├── .dockerignore ├── .eslintrc.json ├── .github └── workflows │ ├── dev-push.yml │ ├── main-push.yml │ └── other.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── CONTRIBUTING.md ├── Dockerfile ├── Dockerfile-dev ├── Dockerrun.aws.json ├── README.md ├── __tests__ ├── __mocks__ │ └── fileMock.ts ├── jest-setup.js ├── jest-teardown.js ├── supertests.js └── test components │ ├── dashboard.test.js │ ├── login.test.js │ └── signup.test.js ├── babel.config.js ├── client ├── App.tsx ├── assets │ ├── connect-to-db.gif │ ├── db-metrics.gif │ ├── dbhive-logo.png │ ├── dbhive_logo_transparent.png │ ├── icons8-hexagon-office-16.png │ ├── icons8-hexagon-office-32.png │ ├── icons8-hexagon-office-96.png │ ├── postgresql_elephant.png │ ├── toggle-dbs.gif │ └── yellow_hex.png ├── clientMode.ts ├── clientTypes.ts ├── components │ ├── CollapseList.tsx │ ├── ConnectDB.tsx │ ├── DBTab.tsx │ ├── Input.tsx │ ├── LineGraphType1.tsx │ ├── LineGraphType2.tsx │ ├── MetricCard.tsx │ ├── Navbar.tsx │ └── PieGraphType1.tsx ├── import-images.d.ts ├── index.html ├── index.tsx ├── pages │ ├── Dashboard.tsx │ ├── Docs.tsx │ ├── Login.tsx │ ├── Setup.tsx │ └── Signup.tsx ├── store │ ├── appStore.ts │ └── rqHooks.ts └── styles.css ├── db-sample-data ├── adventureworks.readme ├── csv-data.zip ├── erd.pgerd ├── install.sql └── update_csvs.rb ├── dist └── client │ ├── App.d.ts │ ├── clientMode.d.ts │ ├── clientTypes.d.ts │ ├── components │ ├── CollapseList.d.ts │ ├── ConnectDB.d.ts │ ├── DBTab.d.ts │ ├── DropdownMenu.d.ts │ ├── ErrorBoundary.d.ts │ ├── Graph1.d.ts │ ├── Graph2.d.ts │ ├── Input.d.ts │ ├── LineGraphType1.d.ts │ ├── LineGraphType2.d.ts │ ├── MetricCard.d.ts │ ├── Navbar.d.ts │ └── PieGraphType1.d.ts │ ├── index.d.ts │ ├── pages │ ├── Dashboard.d.ts │ ├── Docs.d.ts │ ├── Home.d.ts │ ├── Login.d.ts │ ├── Setup.d.ts │ └── Signup.d.ts │ └── store │ ├── appStore.d.ts │ └── rqHooks.d.ts ├── docker-compose-dev-hot.yml ├── docker-compose-test.yml ├── jest.config.js ├── package-lock.json ├── package.json ├── server ├── controllers │ ├── connectController.ts │ └── databaseController.ts ├── routes │ └── metricApi.ts └── server.ts ├── tsconfig.json └── webpack.config.cjs /.dockerignore: -------------------------------------------------------------------------------- 1 | db-sample-data 2 | __tests__ 3 | node_modules 4 | README.md 5 | .elasticbeanstalk/* 6 | .git 7 | .gitignore 8 | .env -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "node": true 6 | }, 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/eslint-recommended", 10 | "plugin:@typescript-eslint/recommended", 11 | "plugin:import/recommended", 12 | "plugin:import/typescript" 13 | ], 14 | "parser": "@typescript-eslint/parser", 15 | 16 | "settings": { 17 | "import/resolver": { 18 | "typescript": {}, 19 | "node": { 20 | "extensions": [".js", ".jsx", ".ts", ".tsx"] 21 | } 22 | } 23 | } 24 | 25 | } 26 | 27 | -------------------------------------------------------------------------------- /.github/workflows/dev-push.yml: -------------------------------------------------------------------------------- 1 | name: dev-push 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'dev' 7 | 8 | env: 9 | PIPELINE_ID: ${{ github.run_id }}-${{ github.run_number }} 10 | TEST_TAG: dbhive/main-prod:test 11 | LATEST_TAG: dbhive/main-prod:latest 12 | TEST_DB: ${{ secrets.TEST_DB }} 13 | 14 | jobs: 15 | build: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v3 20 | 21 | - name: Set up QEMU 22 | uses: docker/setup-qemu-action@v2 23 | 24 | - name: Set up Docker Buildx 25 | uses: docker/setup-buildx-action@v2 26 | 27 | - name: Login to Docker Hub 28 | uses: docker/login-action@v2 29 | with: 30 | username: ${{ secrets.DOCKERHUB_USERNAME }} 31 | password: ${{ secrets.DOCKERHUB_TOKEN }} 32 | 33 | - name: Configure AWS credentials 34 | uses: aws-actions/configure-aws-credentials@v1 35 | with: 36 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} 37 | aws-secret-access-key: ${{ secrets.AWS_ACCESS_KEY_SECRET }} 38 | aws-region: us-west-2 39 | 40 | - name: Login to Amazon ECR 41 | id: login-ecr 42 | uses: aws-actions/amazon-ecr-login@v1 43 | 44 | - name: Build and export to Docker 45 | uses: docker/build-push-action@v4 46 | with: 47 | context: . 48 | load: true 49 | tags: ${{ env.TEST_TAG }} 50 | 51 | - name: Test 52 | run: docker-compose -f docker-compose-test.yml up --exit-code-from test 53 | 54 | - name: Build and push to DockerHub and ECR 55 | uses: docker/build-push-action@v4 56 | with: 57 | context: . 58 | # platforms: linux/amd64,linux/arm64 59 | push: true 60 | tags: | 61 | ${{ env.LATEST_TAG }} 62 | ${{ steps.login-ecr.outputs.registry }}/dbhive:main-prod 63 | 64 | deploy: 65 | needs: build 66 | runs-on: ubuntu-latest 67 | environment: your-github-environment 68 | steps: 69 | - name: Check out code 70 | uses: actions/checkout@v3 71 | 72 | - name: Deploy to AWS Elastic BeansTalk 73 | uses: einaregilsson/beanstalk-deploy@v20 74 | with: 75 | aws_access_key: ${{ secrets.AWS_ACCESS_KEY_ID }} 76 | aws_secret_key: ${{ secrets.AWS_ACCESS_KEY_SECRET }} 77 | application_name: dbhive-gha 78 | environment_name: dbhive-gha 79 | version_label: ${{ env.PIPELINE_ID }} 80 | use_existing_version_if_available: true 81 | region: us-west-2 82 | deployment_package: Dockerrun.aws.json 83 | -------------------------------------------------------------------------------- /.github/workflows/main-push.yml: -------------------------------------------------------------------------------- 1 | name: main-push 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'main' 7 | 8 | env: 9 | PIPELINE_ID: ${{ github.run_id }}-${{ github.run_number }} 10 | TEST_TAG: dbhive/main-prod:test 11 | LATEST_TAG: dbhive/main-prod:latest 12 | TEST_DB: ${{ secrets.TEST_DB }} 13 | 14 | jobs: 15 | build: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v3 20 | 21 | - name: Set up QEMU 22 | uses: docker/setup-qemu-action@v2 23 | 24 | - name: Set up Docker Buildx 25 | uses: docker/setup-buildx-action@v2 26 | 27 | - name: Login to Docker Hub 28 | uses: docker/login-action@v2 29 | with: 30 | username: ${{ secrets.DOCKERHUB_USERNAME }} 31 | password: ${{ secrets.DOCKERHUB_TOKEN }} 32 | 33 | - name: Configure AWS credentials 34 | uses: aws-actions/configure-aws-credentials@v1 35 | with: 36 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} 37 | aws-secret-access-key: ${{ secrets.AWS_ACCESS_KEY_SECRET }} 38 | aws-region: us-west-2 39 | 40 | - name: Login to Amazon ECR 41 | id: login-ecr 42 | uses: aws-actions/amazon-ecr-login@v1 43 | 44 | - name: Build and export to Docker 45 | uses: docker/build-push-action@v4 46 | with: 47 | context: . 48 | load: true 49 | tags: ${{ env.TEST_TAG }} 50 | 51 | - name: Test 52 | run: docker-compose -f docker-compose-test.yml up --exit-code-from test 53 | 54 | - name: Build and push to DockerHub and ECR 55 | uses: docker/build-push-action@v4 56 | with: 57 | context: . 58 | # platforms: linux/amd64,linux/arm64 59 | push: true 60 | tags: | 61 | ${{ env.LATEST_TAG }} 62 | ${{ steps.login-ecr.outputs.registry }}/dbhive:main-prod 63 | 64 | deploy: 65 | needs: build 66 | runs-on: ubuntu-latest 67 | environment: your-github-environment 68 | steps: 69 | - name: Check out code 70 | uses: actions/checkout@v3 71 | 72 | - name: Deploy to AWS Elastic BeansTalk 73 | uses: einaregilsson/beanstalk-deploy@v20 74 | with: 75 | aws_access_key: ${{ secrets.AWS_ACCESS_KEY_ID }} 76 | aws_secret_key: ${{ secrets.AWS_ACCESS_KEY_SECRET }} 77 | application_name: dbhive-prod2 78 | environment_name: dbhive-prod2 79 | version_label: ${{ env.PIPELINE_ID }} 80 | use_existing_version_if_available: true 81 | region: us-west-2 82 | deployment_package: Dockerrun.aws.json 83 | -------------------------------------------------------------------------------- /.github/workflows/other.yml: -------------------------------------------------------------------------------- 1 | name: other 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - '*' 7 | push: 8 | branches-ignore: 9 | - 'main' 10 | - 'dev' 11 | 12 | env: 13 | TEST_TAG: dbhive/main-prod:test 14 | TEST_DB: ${{ secrets.TEST_DB }} 15 | 16 | jobs: 17 | build: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v3 22 | 23 | - name: Set up QEMU 24 | uses: docker/setup-qemu-action@v2 25 | 26 | - name: Set up Docker Buildx 27 | uses: docker/setup-buildx-action@v2 28 | 29 | - name: Build and export to Docker 30 | uses: docker/build-push-action@v4 31 | with: 32 | context: . 33 | load: true 34 | tags: ${{ env.TEST_TAG }} 35 | 36 | - name: Test 37 | run: docker-compose -f docker-compose-test.yml up --exit-code-from test 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | .DS_Store 4 | build/* 5 | # Elastic Beanstalk Files 6 | .elasticbeanstalk/* 7 | !.elasticbeanstalk/*.cfg.yml 8 | !.elasticbeanstalk/*.global.yml 9 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore artifacts: 2 | build -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to dbHive 2 | 3 | Thank you for contributing! 4 | 5 | ## Pull Requests 6 | 7 | All code changes happen through Github pull requests. To submit your pull request, please follow the steps below: 8 | 9 | 1. Fork and clone the repository. 10 | 2. Create your feature branch. (git checkout -b [my-new-feature]) 11 | 3. Make sure to cover your code with tests and that your code is linted in accordance with our linting specifications (see coding style below). 12 | 4. Commit your changes locally (git commit -m 'Added some feature') and then push to your remote repository. 13 | 5. Submit a pull request to the _dev_ branch, including in your pull request a description of the work done. 14 | 15 | ## Reporting Bugs 16 | 17 | We use GitHub Issues to track public bugs. Please ensure your description is clear and has sufficient instructions to be able to reproduce the issue. 18 | 19 | ## License 20 | 21 | By contributing, you agree that your contributions will be licensed under dbHive's MIT License. 22 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16.13 2 | WORKDIR /usr/src/app 3 | COPY . . 4 | RUN npm ci && npm cache clean --force && npm run build 5 | EXPOSE 3000 6 | CMD [ "npm", "start" ] -------------------------------------------------------------------------------- /Dockerfile-dev: -------------------------------------------------------------------------------- 1 | FROM node:alpine 2 | RUN npm i -g webpack 3 | WORKDIR /usr/src/app 4 | COPY package\*.json . 5 | RUN npm ci && npm cache clean --force 6 | EXPOSE 3000 7 | -------------------------------------------------------------------------------- /Dockerrun.aws.json: -------------------------------------------------------------------------------- 1 | { 2 | "AWSEBDockerrunVersion": "1", 3 | "Image": { 4 | "Name": "096169716422.dkr.ecr.us-west-2.amazonaws.com/dbhive:main-prod", 5 | "Update": "true" 6 | }, 7 | "Ports": [ 8 | { 9 | "ContainerPort": "3000" 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 |

Welcome to dbHive! 🐝

6 |

PostgreSQL Monitoring Tool

7 |
8 | 9 | # Table of Contents 10 | 11 | - [About](#about) 12 | - [Getting Started](#getting-started) 13 | - [Features](#features) 14 | - [Troubleshooting](#troubleshooting) 15 | - [Contributing](#contributing) 16 | 17 | # About 18 | 19 | dbHive offers an interactive dashboard to visualize the performance of one or more PostgreSQL databases. By providing easily accessible information about the health and activity of a database, dbHive enables developers to make informed decisions that optimize the way they store their data. 20 | 21 | # Getting Started 22 | 23 | ### https://dbhive.net/ 24 | 25 | # Features 26 | 27 | ### ➮ Query Execution Times 28 | 29 | Get a broad sense of database performance by viewing average query times across the database, as well as averages for specific types of SQL queries. Pinpoint the slowest queries to gain insight for database improvement. 30 | 31 | ### 32 | 33 | ![Dashboard showing database metrics](client/assets/db-metrics.gif) 34 | 35 | ### ➮ Most Frequent Queries 36 | 37 | To analyze common and recurring database activity, view charts on the most frequent queries within and across all query types. 38 | 39 | ### ➮ Other Key Stats 40 | 41 | At a glance, gather other vital information, including: 42 | 43 | - conflicts 44 | - deadlocks 45 | - rolled back transactions 46 | - cache hit ratio 47 | - block hits 48 | - and more 49 | 50 | ### ➮ Access everything with a secure account 51 | 52 | Maintain privacy and security with dbHive's required secure login. All database information and metrics are protected with encrypted accounts. 53 | 54 | ### ➮ Connect one or more databases 55 | 56 | Easily access all databases and metrics within one place with the ability to toggle between multiple database dashboards. 57 | 58 | ### 59 | 60 | ![Toggle between database dashboards](client/assets/toggle-dbs.gif) 61 | 62 | ### ➮ Customize database metrics dashboard 63 | 64 | Use dropdowns to view more details on a given metric. Expand graphs to fill the screen. Adjust the fetch interval, the frequency at which the dashboard is updated with the latest metrics. 65 | 66 | ### ➮ Delete a database 67 | 68 | Navigate to the Setup page and remove a database when it is no longer in use, keeping your dashboard clean. 69 | 70 | # Privacy and Security 71 | 72 | dbHive does not store any user data, most importantly including database connection information, usernames, and passwords. Data is kept encrypted on the client. 73 | 74 | # Troubleshooting 75 | 76 | If certain database metrics are shown as unavailable in the dashboard, database user permissions may need to be elevated. Necessary user permissions and admin privileges can vary depending on the database hosting service used. 77 | For issues with application accounts, it is recommended to remove problematic users. Follow the directions in the "Remove application users" section of this readme. 78 | 79 | ### ➮ Shared library 80 | 81 | If you receive this error, 'error: pg_stat_statements must be loaded via shared_preload_libraries', in **postgresql.conf** change 82 | 83 | ``` 84 | shared_preload_libraries = '' 85 | ``` 86 | 87 | to 88 | 89 | ``` 90 | shared_preload_libraries = 'pg_stat_statements' 91 | ``` 92 | 93 | and restart the Postgres service. 94 | 95 | # Contributing 96 | 97 | Read our [contribution guide](https://github.com/oslabs-beta/dbhive/blob/main/CONTRIBUTING.md) for more information on how to contribute to dbHive. 98 | 99 | ### Development Mode 100 | 101 | If you would like to participate in developing new features, the app can be launched in development mode: 102 | 103 | ``` 104 | npm run dev 105 | ``` 106 | 107 | or 108 | 109 | ``` 110 | docker build -t dbhive/main-dev -f Dockerfile-dev . 111 | ``` 112 | 113 | ``` 114 | docker-compose -f docker-compose-dev-hot.yml up 115 | ``` 116 | 117 | # Future Enhancements 118 | 119 | - **Search Feature:** Add a search bar to the dashboard that allows users to find data by keywords. 120 | - **Expansion to Other Databases:** Make dbHive available for other databases besides PostgreSQL. 121 | - **Comparing Schemas:** Allow users to compare the performance of alternate database schemas alongside their current schemas. 122 | - **Additional Customization:** Give users more power to customize graphs and dashboard arrangement. 123 | 124 | ### Authors 125 | 126 | - Melanie Forbes [GitHub](https://github.com/mforbz12) | [LinkedIn](https://www.linkedin.com/in/melanie-forbes-/) 127 | - Elise McConnell [GitHub](https://github.com/enmcco) | [LinkedIn](https://www.linkedin.com/in/elisemcconnell/) 128 | - Brandon Miller [GitHub](https://github.com/bmiller1881) | [LinkedIn](https://www.linkedin.com/in/brandon-j-miller/) 129 | - Emily Paine [GitHub](https://github.com/erpaine) | [LinkedIn](https://www.linkedin.com/in/emily-paine1/) 130 | - Jeffery Richardson [GitHub](https://github.com/jrichardson-rn) | [LinkedIn](https://www.linkedin.com/in/jeffery-richardson-ii-2ba819100/) 131 | -------------------------------------------------------------------------------- /__tests__/__mocks__/fileMock.ts: -------------------------------------------------------------------------------- 1 | module.exports = {} -------------------------------------------------------------------------------- /__tests__/jest-setup.js: -------------------------------------------------------------------------------- 1 | //spin up rds instance (if no charge) add to read me how to run tests on client db 2 | // eslint-disable-next-line @typescript-eslint/no-var-requires 3 | const regeneratorRuntime = require('regenerator-runtime'); 4 | 5 | module.exports = () => { 6 | console.log('Test db setup'); 7 | global.testServer = require('../server/server.ts'); 8 | }; 9 | -------------------------------------------------------------------------------- /__tests__/jest-teardown.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | module.exports = async (globalConfig) => { 3 | console.log('Closing down test db'); 4 | testServer.close(); 5 | }; 6 | -------------------------------------------------------------------------------- /__tests__/supertests.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-var-requires 2 | const supertest = require('supertest'); 3 | // eslint-disable-next-line @typescript-eslint/no-var-requires 4 | import { describe, it, expect, xdescribe, test } from '@jest/globals'; 5 | 6 | // eslint-disable-next-line @typescript-eslint/no-var-requires 7 | const pg = require('pg'); 8 | 9 | //Notice: dbtest_url is not a valid url and should be replaced with test db 10 | const dbtest_url = process.env.TEST_DB; 11 | const server = 'http://localhost:3000'; 12 | const pool = new pg.Pool({ 13 | connectionString: dbtest_url, 14 | }); 15 | 16 | //testing the test config and setup 17 | describe('my test', () => { 18 | //test will pass 19 | test('passes', () => { 20 | expect(2).toEqual(2); 21 | }); 22 | //test will fail 23 | test('fails', () => { 24 | expect(3).not.toEqual(2); 25 | }); 26 | }); 27 | 28 | describe('Connecting a database', () => { 29 | describe('connecting a URI key', () => { 30 | it('responds with a true valid URI key value', () => { 31 | const body = { 32 | uri: dbtest_url, 33 | }; 34 | return supertest(server) 35 | .post('/api/uri') 36 | .send(body) 37 | .expect(200) 38 | .expect((res) => { 39 | expect(res.body.result.validURI).toBeTruthy(); 40 | }); 41 | }); 42 | }); 43 | }); 44 | 45 | describe('Database data retrieval', () => { 46 | describe('connecting a valid URI key', () => { 47 | //a valid response would be an object containing lots of data to be rendered 48 | it('responds with a valid response', () => { 49 | const body = { 50 | uri: dbtest_url, 51 | }; 52 | return supertest(server) 53 | .post('/api/queryMetrics') 54 | .send(body) 55 | .expect(200) 56 | .expect('Content-Type', /application\/json/) 57 | .expect((res) => { 58 | expect(typeof res).toEqual('object'); 59 | }); 60 | }); 61 | //the response contains the keys that will be needed to render data 62 | it('responds with data in the expected keys', () => { 63 | const body = { 64 | uri: dbtest_url, 65 | }; 66 | return supertest(server) 67 | .post('/api/queryMetrics') 68 | .send(body) 69 | .expect((res) => { 70 | expect(res.body.allTimes).toBeTruthy(); 71 | expect(res.body.avgTimeTopAllCalls).toBeTruthy(); 72 | expect(res.body.avgTimeTopDeleteCalls).toBeTruthy(); 73 | expect(res.body.avgTimeTopInsertCalls).toBeTruthy(); 74 | expect(res.body.avgTimeTopSelectCalls).toBeTruthy(); 75 | expect(res.body.avgTimeTopUpdateCalls).toBeTruthy(); 76 | expect(res.body.conflicts).toBeTruthy(); 77 | expect(res.body.dbStats).toBeTruthy(); 78 | expect(res.body.deadlocks).toBeTruthy(); 79 | expect(res.body.deleteTimes).toBeTruthy(); 80 | expect(res.body.insertTimes).toBeTruthy(); 81 | expect(res.body.numOfRows).toBeTruthy(); 82 | expect(res.body.rolledBackTransactions).toBeTruthy(); 83 | expect(res.body.selectTimes).toBeTruthy(); 84 | expect(res.body.transactionsCommitted).toBeTruthy(); 85 | expect(res.body.updateTimes).toBeTruthy(); 86 | expect(res.body.cacheHitRatio).toBeTruthy(); 87 | }); 88 | }); 89 | }); 90 | xdescribe('connecting an invalid URI key', () => { 91 | //an invalid key results in a bad request error 92 | it('responds with an error', () => { 93 | const body = { 94 | uri: 'postgres://xxxx:xxxxx@xxxxx.hostname.com:5432/databasename', 95 | }; 96 | return supertest(server).post('/api/queryMetrics').send(body).expect(400); 97 | }); 98 | }); 99 | }); 100 | -------------------------------------------------------------------------------- /__tests__/test components/dashboard.test.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { render, screen, fireEvent } from '@testing-library/react'; 3 | import { BrowserRouter } from 'react-router-dom'; 4 | import Dashboard from '../../client/pages/Dashboard'; 5 | import { 6 | describe, 7 | it, 8 | expect, 9 | xdescribe, 10 | beforeEach, 11 | beforeAll, 12 | test, 13 | } from '@jest/globals'; 14 | 15 | describe('Dashboard Page', () => { 16 | let dashboardComp; 17 | const props = {}; 18 | 19 | beforeEach(async () => { 20 | const loginComp = await render( 21 | 22 | 23 | 24 | ); 25 | }); 26 | test('if no DB has been connected, button prompts connecting one', async () => { 27 | expect(screen.getByText('Please connect a database')).toBeInTheDocument(); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /__tests__/test components/login.test.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { render, screen, fireEvent } from '@testing-library/react'; 3 | import { BrowserRouter } from 'react-router-dom'; 4 | import Login from '../../client/pages/Login'; 5 | import { 6 | describe, 7 | it, 8 | expect, 9 | xdescribe, 10 | beforeEach, 11 | beforeAll, 12 | test, 13 | } from '@jest/globals'; 14 | 15 | describe('Login Page', () => { 16 | let loginComp; 17 | const props = {}; 18 | 19 | beforeEach(async () => { 20 | const loginComp = await render( 21 | 22 | 23 | 24 | ); 25 | }); 26 | test('Submit button rendered', async () => { 27 | const buttons = await screen.getAllByRole('button', { name: 'Submit' }); 28 | expect(buttons.length).toBe(1); 29 | }); 30 | //this value is 2 because it inclues the navbar button and the button on the main component 31 | test('Sign Up button rendered', async () => { 32 | const buttons = await screen.getAllByRole('button', { name: 'Sign Up' }); 33 | expect(buttons.length).toBe(2); 34 | }); 35 | //makes sure inputs are available 36 | test('Username input field to be rendered', async () => { 37 | //only text input is available by textbox role. 38 | expect( 39 | screen.getByRole('textbox', { name: 'Username:' }) 40 | ).toBeInTheDocument(); 41 | }); 42 | test('Login header', async () => { 43 | const header = await screen.getByTestId('login-header'); 44 | expect(header).toBeInTheDocument(); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /__tests__/test components/signup.test.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { render, screen, fireEvent } from '@testing-library/react'; 3 | import { BrowserRouter } from 'react-router-dom'; 4 | import Signup from '../../client/pages/Signup'; 5 | import { 6 | describe, 7 | it, 8 | expect, 9 | xdescribe, 10 | beforeEach, 11 | test, 12 | } from '@jest/globals'; 13 | 14 | describe('Signup Page', () => { 15 | let signupComp; 16 | 17 | beforeEach(async () => { 18 | const signupComp = await render( 19 | 20 | 21 | 22 | ); 23 | }); 24 | 25 | test('Submit button rendered', async () => { 26 | const buttons = await screen.getAllByRole('button', { name: 'Submit' }); 27 | expect(buttons.length).toBe(1); 28 | }); 29 | 30 | test('Username input rendered', () => { 31 | expect( 32 | screen.getByRole('textbox', { name: 'Username:' }) 33 | ).toBeInTheDocument(); 34 | }); 35 | 36 | test('Signup header', async () => { 37 | const header = await screen.getByTestId('signup-header'); 38 | expect(header).toBeInTheDocument(); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = {presets: ['@babel/preset-env', '@babel/preset-react']} -------------------------------------------------------------------------------- /client/App.tsx: -------------------------------------------------------------------------------- 1 | // import dependencies 2 | import * as React from 'react'; 3 | import { createBrowserRouter, RouterProvider } from 'react-router-dom'; 4 | import { QueryClientProvider, QueryClient } from 'react-query'; 5 | import { ReactQueryDevtools } from 'react-query/devtools'; 6 | import { ThemeProvider, createTheme } from '@mui/material/styles'; 7 | import CssBaseline from '@mui/material/CssBaseline'; 8 | 9 | // import react components 10 | import Docs from './pages/Docs'; 11 | import Dashboard from './pages/Dashboard'; 12 | import Login from './pages/Login'; 13 | import Signup from './pages/Signup'; 14 | import Setup from './pages/Setup'; 15 | 16 | // import utilities 17 | import useAppStore from './store/appStore'; 18 | import { UserData } from './clientTypes'; 19 | import { seedDBs } from './clientMode'; 20 | 21 | // custom MUI theme 22 | const darkTheme = createTheme({ 23 | palette: { 24 | mode: 'dark', 25 | primary: { 26 | main: '#ffd900', 27 | }, 28 | secondary: { 29 | main: '#134e00', 30 | }, 31 | }, 32 | typography: { 33 | fontFamily: [ 34 | '-apple-system', 35 | 'BlinkMacSystemFont', 36 | '"Segoe UI"', 37 | 'Roboto', 38 | '"Helvetica Neue"', 39 | 'Arial', 40 | 'sans-serif', 41 | '"Apple Color Emoji"', 42 | '"Segoe UI Emoji"', 43 | '"Segoe UI Symbol"', 44 | ].join(','), 45 | }, 46 | }); 47 | 48 | function App() { 49 | const queryClient = new QueryClient(); 50 | 51 | // update user data with seed data if given 52 | const updateUserData = useAppStore((state) => state.updateUserData); 53 | const initialUserData: UserData = { 54 | decryption: 'isValid', 55 | dbs: seedDBs, 56 | }; 57 | updateUserData(initialUserData); 58 | 59 | const router = createBrowserRouter([ 60 | { 61 | path: '/', 62 | element: , 63 | }, 64 | { 65 | path: '/setup', 66 | element: , 67 | }, 68 | { 69 | path: '/login', 70 | element: , 71 | }, 72 | { 73 | path: '/signup', 74 | element: , 75 | }, 76 | { 77 | path: '/dashboard', 78 | element: , 79 | }, 80 | ]); 81 | 82 | return ( 83 | <> 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | ); 93 | } 94 | 95 | export default App; 96 | -------------------------------------------------------------------------------- /client/assets/connect-to-db.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/dbhive/64f440be09f0b0d0d4b8323f49fbd82294ec0aff/client/assets/connect-to-db.gif -------------------------------------------------------------------------------- /client/assets/db-metrics.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/dbhive/64f440be09f0b0d0d4b8323f49fbd82294ec0aff/client/assets/db-metrics.gif -------------------------------------------------------------------------------- /client/assets/dbhive-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/dbhive/64f440be09f0b0d0d4b8323f49fbd82294ec0aff/client/assets/dbhive-logo.png -------------------------------------------------------------------------------- /client/assets/dbhive_logo_transparent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/dbhive/64f440be09f0b0d0d4b8323f49fbd82294ec0aff/client/assets/dbhive_logo_transparent.png -------------------------------------------------------------------------------- /client/assets/icons8-hexagon-office-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/dbhive/64f440be09f0b0d0d4b8323f49fbd82294ec0aff/client/assets/icons8-hexagon-office-16.png -------------------------------------------------------------------------------- /client/assets/icons8-hexagon-office-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/dbhive/64f440be09f0b0d0d4b8323f49fbd82294ec0aff/client/assets/icons8-hexagon-office-32.png -------------------------------------------------------------------------------- /client/assets/icons8-hexagon-office-96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/dbhive/64f440be09f0b0d0d4b8323f49fbd82294ec0aff/client/assets/icons8-hexagon-office-96.png -------------------------------------------------------------------------------- /client/assets/postgresql_elephant.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/dbhive/64f440be09f0b0d0d4b8323f49fbd82294ec0aff/client/assets/postgresql_elephant.png -------------------------------------------------------------------------------- /client/assets/toggle-dbs.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/dbhive/64f440be09f0b0d0d4b8323f49fbd82294ec0aff/client/assets/toggle-dbs.gif -------------------------------------------------------------------------------- /client/assets/yellow_hex.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/dbhive/64f440be09f0b0d0d4b8323f49fbd82294ec0aff/client/assets/yellow_hex.png -------------------------------------------------------------------------------- /client/clientMode.ts: -------------------------------------------------------------------------------- 1 | import { UserData } from './clientTypes'; 2 | 3 | /* 4 | toggleDashboardAuth default = true, use for production 5 | toggleDashboardAuth = false, deactivates dashboard page authorization 6 | allowing navigation to the page without having to be logged in 7 | */ 8 | const toggleDashboardAuth = true; 9 | 10 | /* 11 | seedDBs default = [], use for production 12 | Seed db data so that developers do not have to login to see data displayed on the dashboard 13 | below is the format for seedDBs: 14 | [ 15 | { 16 | nickname: 'dbNickname', 17 | uri: 'postgres://username:passsord@hostname:5432/databaseName', 18 | }, 19 | ] 20 | */ 21 | 22 | const seedDBs: UserData['dbs'] = []; 23 | 24 | export { toggleDashboardAuth, seedDBs }; 25 | -------------------------------------------------------------------------------- /client/clientTypes.ts: -------------------------------------------------------------------------------- 1 | export type UserData = { 2 | decryption: string; 3 | dbs: { nickname: string; uri: string }[]; 4 | }; 5 | 6 | export type DbData = { 7 | nickname: string; 8 | uri: string; 9 | }; 10 | -------------------------------------------------------------------------------- /client/components/CollapseList.tsx: -------------------------------------------------------------------------------- 1 | // import dependencies 2 | import * as React from 'react'; 3 | import { useState } from 'react'; 4 | import List from '@mui/material/List'; 5 | import ListItemButton from '@mui/material/ListItemButton'; 6 | import ListItemText from '@mui/material/ListItemText'; 7 | import Collapse from '@mui/material/Collapse'; 8 | import ExpandLess from '@mui/icons-material/ExpandLess'; 9 | import ExpandMore from '@mui/icons-material/ExpandMore'; 10 | 11 | type Props = { 12 | label?: string; 13 | content?: JSX.Element | JSX.Element[] | string; 14 | }; 15 | 16 | export default function CollapseList(props: Props) { 17 | const [open, setOpen] = useState(false); 18 | 19 | const handleClick = () => { 20 | setOpen(!open); 21 | }; 22 | 23 | return ( 24 | 25 | 26 | 27 | {open ? : } 28 | 29 | 35 | {props.content} 36 | 37 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /client/components/ConnectDB.tsx: -------------------------------------------------------------------------------- 1 | // import dependencies 2 | import * as React from 'react'; 3 | import { useState } from 'react'; 4 | import { set } from 'idb-keyval'; 5 | import AES from 'crypto-js/aes'; 6 | import { Card, Button, Typography, Divider, Alert } from '@mui/material'; 7 | 8 | // import react components 9 | import Input from './Input'; 10 | 11 | // import utilities 12 | import useAppStore from '../store/appStore'; 13 | 14 | function ConnectDB() { 15 | const username = useAppStore((state) => state.username); 16 | const secret = useAppStore((state) => state.secret); 17 | const userData = useAppStore((state) => state.userData); 18 | const updateUserData = useAppStore((state) => state.updateUserData); 19 | 20 | const [submitAlert, setSubmitAlert] = useState(null); 21 | const [nickname, setNickname] = useState(''); 22 | const [uri, setUri] = useState(''); 23 | const [host, setHost] = useState(''); 24 | const [port, setPort] = useState('5432'); 25 | const [database, setDatabase] = useState(''); 26 | const [dBUsername, setDBUsername] = useState(''); 27 | const [password, setPassword] = useState(''); 28 | 29 | // depending on the button clicked, the data submitted either comes from the complete uri string 30 | // or the separated out version 31 | function submitHandler(type: string) { 32 | const copyUserData = { ...userData }; 33 | if (type === 'uri') { 34 | copyUserData.dbs.push({ 35 | nickname: nickname, 36 | uri: uri, 37 | }); 38 | } else if (type === 'separate') { 39 | copyUserData.dbs.push({ 40 | nickname: nickname, 41 | uri: `postgres://${dBUsername}:${password}@${host}:${port}/${database}`, 42 | }); 43 | } 44 | 45 | updateUserData(copyUserData); 46 | 47 | const ciphertext = AES.encrypt( 48 | JSON.stringify(copyUserData), 49 | secret 50 | ).toString(); 51 | set(username, ciphertext).catch((err) => { 52 | console.log('IndexedDB: set failed', err); 53 | }); 54 | 55 | setNickname(''); 56 | setUri(''); 57 | setHost(''); 58 | setPort('5432'); 59 | setDatabase(''); 60 | setDBUsername(''); 61 | setPassword(''); 62 | setSubmitAlert( 63 | 72 | A new DB has been added 73 | 74 | ); 75 | setTimeout(() => { 76 | setSubmitAlert(null); 77 | }, 5000); 78 | } 79 | 80 | return ( 81 | <> 82 | {submitAlert} 83 | 93 | 98 | Connect to new DB 99 | 100 | 106 | 107 | 113 | 120 | 126 | 132 | 138 | 144 | 151 | 158 | 159 | 160 | ); 161 | } 162 | 163 | export default ConnectDB; 164 | -------------------------------------------------------------------------------- /client/components/DBTab.tsx: -------------------------------------------------------------------------------- 1 | // import dependencies 2 | import * as React from 'react'; 3 | import { useState } from 'react'; 4 | import { 5 | Box, 6 | InputLabel, 7 | Select, 8 | SelectChangeEvent, 9 | MenuItem, 10 | } from '@mui/material'; 11 | 12 | // import react components 13 | import MetricCard from './MetricCard'; 14 | import LineGraphType1 from './LineGraphType1'; 15 | import LineGraphType2 from './LineGraphType2'; 16 | import PieGraphType1 from './PieGraphType1'; 17 | 18 | // import utilities 19 | import { useQueryMetrics } from '../store/rqHooks'; 20 | 21 | type Props = { 22 | dbUri: string; 23 | }; 24 | 25 | function DBTab(props: Props) { 26 | const [refetchInterval, setRefetchInterval] = useState(15000); 27 | 28 | // react-query custom hook for fetching db metrics from backend 29 | const { isLoading, isError, data } = useQueryMetrics( 30 | ['dbMetrics', props.dbUri], 31 | props.dbUri, 32 | refetchInterval 33 | ); 34 | 35 | const handleChangeInterval = (event: SelectChangeEvent) => { 36 | setRefetchInterval(Number(event.target.value)); 37 | }; 38 | 39 | // coniditonal rendering if error received from fetch 40 | if (isError) { 41 | return ( 42 |
43 | 51 | status: request failed! 52 | 53 |
54 | ); 55 | } else if (isLoading) { 56 | // coniditonal rendering if fetch has not returned yet 57 | return ( 58 |
59 | 67 | status: connecting to db... 68 | 69 |
70 | ); 71 | } else { 72 | /* coniditonal rendering if fetch has returned successfully 73 | data sent to child components utilizes optional chaining operators to protect 74 | from fatal errors when nested properties are being accessed in data returned from fetch */ 75 | return ( 76 |
77 | 85 | Fetch Interval 86 | 99 | 100 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 120 | 121 | 122 | 123 | 124 | 125 | 128 | 129 | 130 | 131 | 132 | 133 | 136 | 137 | 138 | 139 | 140 | 141 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | <> 163 | name: {data.dbStats?.[0].datname} 164 |
165 | id: {data.dbStats?.[0].datid} 166 | 167 |
168 | 169 | {data.activeSessions} 170 | 171 | {data.conflicts} 172 | {data.deadlocks} 173 | 174 | {data.rolledBackTransactions} 175 | 176 | 177 | {data.transactionsCommitted} 178 | 179 | 180 | {Number(data.cacheHitRatio?.[0].ratio).toFixed(4)} 181 | 182 | 183 | {data.dbStats?.[0].blk_read_time} 184 | 185 | 186 | {data.dbStats?.[0].blk_write_time} 187 | 188 | 189 | {data.dbStats?.[0].blks_hit} 190 | 191 | 192 | {data.dbStats?.[0].blks_read} 193 | 194 | 195 | {data.dbStats?.[0].checksum_failures} 196 | 197 |
198 |
199 | ); 200 | } 201 | } 202 | 203 | export default DBTab; 204 | -------------------------------------------------------------------------------- /client/components/Input.tsx: -------------------------------------------------------------------------------- 1 | // import dependencies 2 | import * as React from 'react'; 3 | import { Box, TextField } from '@mui/material'; 4 | 5 | type Props = { 6 | label: string; 7 | setInput: (eventTargetValue: string) => void; 8 | inputClass?: string; 9 | inputType?: string; 10 | value?: string; 11 | error?: boolean; 12 | errorText?: string; 13 | }; 14 | 15 | // reusable input component to package typical functionalities 16 | function Input(props: Props) { 17 | return ( 18 |
19 | 20 | { 29 | props.setInput(event.target.value); 30 | }} 31 | /> 32 | 33 |
34 | ); 35 | } 36 | 37 | export default Input; 38 | -------------------------------------------------------------------------------- /client/components/LineGraphType1.tsx: -------------------------------------------------------------------------------- 1 | // import dependencies 2 | import * as React from 'react'; 3 | import { 4 | Chart, 5 | CategoryScale, 6 | LinearScale, 7 | BarElement, 8 | Title, 9 | Tooltip, 10 | Legend, 11 | LogarithmicScale, 12 | } from 'chart.js'; 13 | import { Bar } from 'react-chartjs-2'; 14 | import { Box, ListItemText, Typography, Divider } from '@mui/material'; 15 | 16 | // import react components 17 | import CollapseList from './CollapseList'; 18 | 19 | Chart.register( 20 | CategoryScale, 21 | LinearScale, 22 | BarElement, 23 | Title, 24 | Tooltip, 25 | Legend, 26 | LogarithmicScale 27 | ); 28 | 29 | type Props = { 30 | title?: string; 31 | data?: any; 32 | }; 33 | 34 | type Line = { labels: string[]; data: number[] }; 35 | 36 | function LineGraphType1(props: Props) { 37 | const dataProc: Line = { labels: [], data: [] }; 38 | const detailsProc: JSX.Element[] = []; 39 | 40 | if (props.data) { 41 | props.data.all?.rows?.forEach( 42 | (element: { query: string; mean_exec_time: number }) => { 43 | dataProc.labels.push(element.query); 44 | dataProc.data.push(element.mean_exec_time); 45 | } 46 | ); 47 | 48 | // used to populate collapsing details list 49 | detailsProc.push( 50 | 54 | 55 | mean 56 | 57 | 58 | 59 | {props.data.mean?.rows?.[0]?.averagequerytime?.toFixed(4)} sec 60 | 61 | 62 | ); 63 | detailsProc.push( 64 | 72 | 73 | median 74 | 75 | 76 | 77 | {props.data.median?.rows?.[0]?.median?.toFixed(4)} sec 78 | 79 | 80 | ); 81 | 82 | const slowQueries: JSX.Element[] = []; 83 | slowQueries.push( 84 | 85 | 86 | slowest queries 87 | 88 | 89 | 90 | ); 91 | props.data.slowestQueries.rows.forEach( 92 | (element: { query: string; mean_exec_time: number }, index: number) => { 93 | slowQueries.push( 94 | 102 | 103 | query {index}: {element.query} 104 | 105 | 106 | time: {element.mean_exec_time?.toFixed(4)} sec 107 | 108 | 109 | ); 110 | } 111 | ); 112 | detailsProc.push( 113 | 121 | {slowQueries} 122 | 123 | ); 124 | } 125 | 126 | // configure ChartJS graph options 127 | const options = { 128 | responsive: true, 129 | plugins: { 130 | legend: { 131 | position: 'top' as const, 132 | }, 133 | }, 134 | scales: { 135 | y: { 136 | min: 0, 137 | max: 20, 138 | display: true, 139 | title: { 140 | display: true, 141 | text: 'query times [seconds]', 142 | }, 143 | }, 144 | x: { 145 | display: false, 146 | }, 147 | }, 148 | }; 149 | 150 | const data = { 151 | labels: dataProc.labels, 152 | datasets: [ 153 | { 154 | label: 'queries', 155 | data: dataProc.data, 156 | backgroundColor: 'rgba(255, 99, 132, 0.5)', 157 | }, 158 | ], 159 | }; 160 | 161 | return ( 162 | <> 163 | {' '} 164 | 165 | 166 | ); 167 | } 168 | 169 | export default LineGraphType1; 170 | -------------------------------------------------------------------------------- /client/components/LineGraphType2.tsx: -------------------------------------------------------------------------------- 1 | // import dependencies 2 | import * as React from 'react'; 3 | import { 4 | Chart, 5 | CategoryScale, 6 | LinearScale, 7 | BarElement, 8 | Title, 9 | Tooltip, 10 | Legend, 11 | LogarithmicScale, 12 | } from 'chart.js'; 13 | import { Bar } from 'react-chartjs-2'; 14 | import { Box, ListItemText, Typography } from '@mui/material'; 15 | 16 | // import react components 17 | import CollapseList from './CollapseList'; 18 | 19 | Chart.register( 20 | CategoryScale, 21 | LinearScale, 22 | BarElement, 23 | Title, 24 | Tooltip, 25 | Legend, 26 | LogarithmicScale 27 | ); 28 | 29 | type Props = { 30 | title?: string; 31 | data?: any; 32 | xMax?: number; 33 | }; 34 | 35 | type Line = { labels: string[]; data: number[] }; 36 | 37 | function LineGraphType2(props: Props) { 38 | const dataProc: Line = { labels: [], data: [] }; 39 | const detailsProc: JSX.Element[] = []; 40 | 41 | if (props.data) { 42 | const details: JSX.Element[] = []; 43 | props.data.forEach( 44 | (element: { query: string; mean_exec_time: number }, index: number) => { 45 | dataProc.labels.push(element.query); 46 | dataProc.data.push(element.mean_exec_time); 47 | details.push( 48 | 56 | 57 | query {index}: {element.query} 58 | 59 | 60 | time: {element.mean_exec_time.toFixed(4)} sec 61 | 62 | 63 | ); 64 | } 65 | ); 66 | detailsProc.push( 67 | 75 | {details} 76 | 77 | ); 78 | } 79 | 80 | // configure ChartJS graph options 81 | const options = { 82 | responsive: true, 83 | plugins: { 84 | legend: { 85 | position: 'top' as const, 86 | }, 87 | }, 88 | scales: { 89 | y: { 90 | min: 0, 91 | max: 5, 92 | display: true, 93 | title: { 94 | display: true, 95 | text: 'query times [seconds]', 96 | }, 97 | }, 98 | x: { 99 | display: false, 100 | }, 101 | }, 102 | }; 103 | 104 | const data = { 105 | labels: dataProc.labels, 106 | datasets: [ 107 | { 108 | label: 'queries', 109 | data: dataProc.data, 110 | backgroundColor: 'rgba(255, 99, 132, 0.5)', 111 | }, 112 | ], 113 | }; 114 | 115 | return ( 116 | <> 117 | 118 | 119 | 120 | ); 121 | } 122 | 123 | export default LineGraphType2; 124 | -------------------------------------------------------------------------------- /client/components/MetricCard.tsx: -------------------------------------------------------------------------------- 1 | // import dependencies 2 | import * as React from 'react'; 3 | import { useState } from 'react'; 4 | import { Card, Typography, Box } from '@mui/material'; 5 | import FullscreenIcon from '@mui/icons-material/Fullscreen'; 6 | import FullscreenExitIcon from '@mui/icons-material/FullscreenExit'; 7 | 8 | type Props = { 9 | cardLabel: string; 10 | children?: JSX.Element | string | number; 11 | }; 12 | 13 | // mostly styling wrapper for card contents 14 | function MetricCard(props: Props) { 15 | const initialWidth = '400px'; 16 | const [width, setWidth] = useState(initialWidth); 17 | const [buttonIcon, setButtonIcon] = useState(); 18 | return ( 19 | 29 | { 37 | if (width === initialWidth) { 38 | setWidth(`calc(${window.innerWidth}px - 13.8rem)`); 39 | setButtonIcon(); 40 | } else { 41 | setWidth(initialWidth); 42 | setButtonIcon(); 43 | } 44 | }} 45 | > 46 | {buttonIcon} 47 | 48 |

{props.cardLabel}

49 | 54 | {props.children} 55 | 56 |
57 | ); 58 | } 59 | 60 | export default MetricCard; 61 | -------------------------------------------------------------------------------- /client/components/Navbar.tsx: -------------------------------------------------------------------------------- 1 | // import dependencies 2 | import * as React from 'react'; 3 | import { useNavigate, useLocation } from 'react-router-dom'; 4 | import { 5 | Box, 6 | Drawer, 7 | AppBar, 8 | Toolbar, 9 | List, 10 | Typography, 11 | Divider, 12 | ListItem, 13 | ListItemButton, 14 | ListItemIcon, 15 | ListItemText, 16 | } from '@mui/material'; 17 | import LoginIcon from '@mui/icons-material/Login'; 18 | import PersonAddIcon from '@mui/icons-material/PersonAdd'; 19 | import InfoIcon from '@mui/icons-material/Info'; 20 | import TuneIcon from '@mui/icons-material/Tune'; 21 | import LogoutIcon from '@mui/icons-material/Logout'; 22 | import MonitorHeartIcon from '@mui/icons-material/MonitorHeart'; 23 | import AccountCircleIcon from '@mui/icons-material/AccountCircle'; 24 | 25 | // import utilities 26 | import useAppStore from '../store/appStore'; 27 | import postgresql_elephant from '../assets/postgresql_elephant.png'; 28 | import yellow_hex from '../assets/yellow_hex.png'; 29 | 30 | function Navbar() { 31 | const navigate = useNavigate(); 32 | const { pathname } = useLocation(); 33 | 34 | const isLoggedIn = useAppStore((state) => state.isLoggedIn); 35 | const username = useAppStore((state) => state.username); 36 | const logOutUser = useAppStore((state) => state.logOutUser); 37 | 38 | const drawerWidth = '11rem'; 39 | return ( 40 | 192 | ); 193 | } 194 | 195 | export default Navbar; 196 | -------------------------------------------------------------------------------- /client/components/PieGraphType1.tsx: -------------------------------------------------------------------------------- 1 | // import dependencies 2 | import * as React from 'react'; 3 | import { Chart, ArcElement, Tooltip, Legend } from 'chart.js'; 4 | import { Pie } from 'react-chartjs-2'; 5 | 6 | Chart.register(ArcElement, Tooltip, Legend); 7 | 8 | type Props = { 9 | data?: any; 10 | }; 11 | 12 | type Pie = { 13 | 'time < .1s'?: number; 14 | '.1s > time < .5s'?: number; 15 | '.5s > time < 1s'?: number; 16 | '1s < time'?: number; 17 | }; 18 | 19 | function PieGraphType1(props: Props) { 20 | // sort data into intervals 21 | const dataProc: Pie = { 22 | 'time < .1s': 0, 23 | '.1s > time < .5s': 0, 24 | '.5s > time < 1s': 0, 25 | '1s < time': 0, 26 | }; 27 | 28 | if (props.data) { 29 | props.data.forEach((element: { query: string; mean_exec_time: number }) => { 30 | if (element.mean_exec_time < 0.1) { 31 | dataProc['time < .1s']++; 32 | } else if (element.mean_exec_time > 0.1 && element.mean_exec_time < 0.5) { 33 | dataProc['.1s > time < .5s']++; 34 | } else if (element.mean_exec_time > 0.5 && element.mean_exec_time < 1) { 35 | dataProc['.5s > time < 1s']++; 36 | } else if (element.mean_exec_time > 1) { 37 | dataProc['1s < time']++; 38 | } 39 | }); 40 | } 41 | 42 | const data = { 43 | labels: Object.keys(dataProc), 44 | datasets: [ 45 | { 46 | label: 'Dataset 1', 47 | data: Object.values(dataProc), 48 | backgroundColor: [ 49 | 'rgba(255, 99, 132, 0.2)', 50 | 'rgba(54, 162, 235, 0.2)', 51 | 'rgba(255, 206, 86, 0.2)', 52 | 'rgba(75, 192, 192, 0.2)', 53 | 'rgba(153, 102, 255, 0.2)', 54 | 'rgba(255, 159, 64, 0.2)', 55 | ], 56 | borderColor: [ 57 | 'rgba(255, 99, 132, 1)', 58 | 'rgba(54, 162, 235, 1)', 59 | 'rgba(255, 206, 86, 1)', 60 | 'rgba(75, 192, 192, 1)', 61 | 'rgba(153, 102, 255, 1)', 62 | 'rgba(255, 159, 64, 1)', 63 | ], 64 | borderWidth: 1, 65 | }, 66 | ], 67 | }; 68 | 69 | return ; 70 | } 71 | 72 | export default PieGraphType1; 73 | -------------------------------------------------------------------------------- /client/import-images.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.png'; 2 | declare module '*.jpg'; 3 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | dbHive 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /client/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import App from './App'; 4 | import './styles.css'; 5 | 6 | const root = createRoot(document.getElementById('root')); 7 | root.render(); 8 | -------------------------------------------------------------------------------- /client/pages/Dashboard.tsx: -------------------------------------------------------------------------------- 1 | // import dependencies 2 | import * as React from 'react'; 3 | import { useState, useEffect } from 'react'; 4 | import { useNavigate } from 'react-router-dom'; 5 | import { Box, Card, Tabs, Tab, Typography, Button } from '@mui/material'; 6 | 7 | // import react components 8 | import Navbar from '../components/Navbar'; 9 | import DBTab from '../components/DBTab'; 10 | 11 | // import utilities 12 | import { toggleDashboardAuth } from '../clientMode'; 13 | import useAppStore from '../store/appStore'; 14 | 15 | function Dashboard() { 16 | const navigate = useNavigate(); 17 | 18 | const isLoggedIn = useAppStore((state) => state.isLoggedIn); 19 | const userData = useAppStore((state) => state.userData); 20 | 21 | const [activeTab, setActiveTab] = useState(0); 22 | 23 | // check user authorization 24 | useEffect(() => { 25 | if (!isLoggedIn && toggleDashboardAuth) navigate('/login'); 26 | }, []); 27 | 28 | // conditional rendering for when user has not added any databases 29 | if (userData.dbs[0] === undefined) { 30 | return ( 31 |
32 | 33 | 42 | 43 | 44 | 53 | 54 | Please connect a database 55 | 56 | 63 | 64 |
65 | ); 66 | } 67 | // default rendering for when user has already added databases 68 | else { 69 | type TabList = JSX.Element[]; 70 | const tabList: TabList = []; 71 | const tabPanel: TabList = []; 72 | userData.dbs.forEach((db, index) => { 73 | tabPanel.push( 74 | 77 | ); 78 | tabList.push( 79 | 83 | 84 | 91 | 92 | 93 | 94 | 95 | 96 | {db.nickname} 97 | 98 | 99 | } 100 | onClick={() => { 101 | setActiveTab(index); 102 | }} 103 | /> 104 | ); 105 | }); 106 | 107 | return ( 108 |
109 | 110 | 119 | 120 | {tabList} 121 | 122 | 123 | {tabPanel} 124 |
125 | ); 126 | } 127 | } 128 | 129 | export default Dashboard; 130 | -------------------------------------------------------------------------------- /client/pages/Docs.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Navbar from '../components/Navbar'; 3 | import { 4 | Button, 5 | Box, 6 | Card, 7 | Typography, 8 | Divider, 9 | Stepper, 10 | Step, 11 | StepLabel, 12 | StepContent, 13 | } from '@mui/material'; 14 | import dbhive_logo_transparent from '../assets/dbhive_logo_transparent.png'; 15 | 16 | function Docs() { 17 | return ( 18 | <> 19 | 20 | 25 | 37 | 45 | 55 | Welcome to dbHive 56 | 69 | 70 | 71 | dbHive offers an interactive dashboard to visualize the performance 72 | of one or more PostgreSQL databases. By providing easily accessible 73 | information about the health and activity of a database, dbHive 74 | enables developers to make informed decisions that optimize the way 75 | they store their data. 76 | 77 | 78 | 81 | 82 | 83 | 93 | 102 | Get Started 103 | 104 | 105 | 106 | 107 | 108 | Sign Up 109 | 110 | 113 | 114 | Create an account that can be utilized by one or more users. 115 | 116 | 117 | 118 | 119 | 120 | Login 121 | 122 | 125 | 126 | Login with your username and password information. 127 | 128 | 129 | 130 | 131 | 132 | Connect Databases 133 | 134 | 137 | 138 | Connect one or more PostgreSQL databases using either the URI 139 | string or individual credentials. 140 | 141 | 142 | 143 | 144 | 145 | 146 | Use the Dashboard to monitor your databases! 147 | 148 | 149 | 152 | 153 | Access query execution times, most frequent queries, 154 | conflicts, deadlocks, rolled back transactions, cache hit 155 | ratio, block hits, and more. 156 | 157 | 158 | 159 | 160 | 161 | 171 | 180 | Troubleshooting 181 | 182 | 183 | 184 | If certain database metrics are not showing up in the dashboard, 185 | look into the database user's permissions. User permissions and 186 | admin privileges can vary depending on the database hosting service 187 | used. 188 | 189 | 190 | 191 | 192 | ); 193 | } 194 | 195 | export default Docs; 196 | -------------------------------------------------------------------------------- /client/pages/Login.tsx: -------------------------------------------------------------------------------- 1 | // import dependencies 2 | import * as React from 'react'; 3 | import { useState } from 'react'; 4 | import { useNavigate } from 'react-router-dom'; 5 | import { get } from 'idb-keyval'; 6 | import CryptoJS from 'crypto-js'; 7 | import AES from 'crypto-js/aes'; 8 | import { Card, Button, Typography, Box } from '@mui/material'; 9 | 10 | // import react components 11 | import Navbar from '../components/Navbar'; 12 | import Input from '../components/Input'; 13 | 14 | // import utilities 15 | import useAppStore from '../store/appStore'; 16 | 17 | function Login() { 18 | const navigate = useNavigate(); 19 | 20 | const logInUser = useAppStore((state) => state.logInUser); 21 | 22 | const [usernameInput, setUsernameInput] = useState(''); 23 | const [secretInput, setSecretInput] = useState(''); 24 | const [loginErrorText, setLoginErrorText] = useState(null); 25 | 26 | function submitHandler() { 27 | /* authenticate by checking for a username key within IndexedDB 28 | if there is a user, use the password as the AES encryption secret, 29 | and look for a verifiable property on the value object 30 | */ 31 | get(usernameInput) 32 | .then((data) => { 33 | const bytes = AES.decrypt(data, secretInput); 34 | const decryptResponse = bytes.toString(CryptoJS.enc.Utf8); 35 | const originalText = JSON.parse(decryptResponse); 36 | if (originalText.decryption === 'isValid') { 37 | // populate global state store with decrypted IDB value, which holds user data 38 | logInUser(usernameInput, secretInput, originalText); 39 | setUsernameInput(''); 40 | setSecretInput(''); 41 | setLoginErrorText(null); 42 | navigate('/dashboard'); 43 | } else { 44 | setLoginErrorText('incorrect username or password'); 45 | } 46 | }) 47 | .catch(() => { 48 | setLoginErrorText('incorrect username or password'); 49 | }); 50 | } 51 | 52 | return ( 53 |
54 | 55 | 60 | 69 | 75 | Login 76 | 77 | 84 | 93 | 100 | 107 | 108 | 109 |
110 | ); 111 | } 112 | 113 | export default Login; 114 | -------------------------------------------------------------------------------- /client/pages/Setup.tsx: -------------------------------------------------------------------------------- 1 | // import dependencies 2 | import * as React from 'react'; 3 | import { useEffect } from 'react'; 4 | import { useNavigate } from 'react-router-dom'; 5 | import { set } from 'idb-keyval'; 6 | import AES from 'crypto-js/aes'; 7 | import { Card, Typography, ListItemText, List, Box } from '@mui/material'; 8 | import DeleteForeverIcon from '@mui/icons-material/DeleteForever'; 9 | 10 | // import react components 11 | import Navbar from '../components/Navbar'; 12 | import ConnectDB from '../components/ConnectDB'; 13 | 14 | // import utilities 15 | import useAppStore from '../store/appStore'; 16 | import { UserData } from '../clientTypes'; 17 | 18 | function Setup() { 19 | const navigate = useNavigate(); 20 | 21 | const username = useAppStore((state) => state.username); 22 | const secret = useAppStore((state) => state.secret); 23 | const isLoggedIn = useAppStore((state) => state.isLoggedIn); 24 | const userData = useAppStore((state) => state.userData); 25 | const updateUserData = useAppStore((state) => state.updateUserData); 26 | 27 | // check user authorization 28 | useEffect(() => { 29 | if (!isLoggedIn) navigate('/login'); 30 | }, []); 31 | 32 | function deleteHandler(dbName: string) { 33 | const copyUserData = { ...userData }; 34 | copyUserData.dbs = copyUserData.dbs.filter((db) => db.nickname !== dbName); 35 | updateUserData(copyUserData); 36 | storeDelete(copyUserData); 37 | } 38 | 39 | function storeDelete(userData: UserData) { 40 | const ciphertext = AES.encrypt(JSON.stringify(userData), secret).toString(); 41 | set(username, ciphertext).catch((err) => { 42 | console.log('IndexedDB: set failed', err); 43 | }); 44 | } 45 | 46 | // render a list of databases associated with the user 47 | const connectedDBs: JSX.Element[] = []; 48 | userData.dbs.reverse().forEach((db) => { 49 | connectedDBs.push( 50 | 60 | { 68 | deleteHandler(db.nickname); 69 | }} 70 | > 71 | 72 | 73 | 80 | 81 | 82 | 83 | 84 | 85 | {db.nickname} 86 | 87 | ); 88 | }); 89 | 90 | return ( 91 | 96 | 97 | 98 | 107 | 108 | Connected Databases ({userData.dbs.length}) 109 | 110 | {connectedDBs} 111 | 112 | 113 | ); 114 | } 115 | 116 | export default Setup; 117 | -------------------------------------------------------------------------------- /client/pages/Signup.tsx: -------------------------------------------------------------------------------- 1 | // import dependencies 2 | import * as React from 'react'; 3 | import { useState } from 'react'; 4 | import { useNavigate } from 'react-router-dom'; 5 | import { set, get } from 'idb-keyval'; 6 | import AES from 'crypto-js/aes'; 7 | import { Card, Button, Typography, Box } from '@mui/material'; 8 | 9 | // import react components 10 | import Navbar from '../components/Navbar'; 11 | import Input from '../components/Input'; 12 | 13 | // import utilities 14 | import { UserData } from '../clientTypes'; 15 | 16 | function Signup() { 17 | const navigate = useNavigate(); 18 | 19 | const [usernameInput, setUsernameInput] = useState(''); 20 | const [secretInput, setSecretInput] = useState(''); 21 | const [signupErrorText, setSignupErrorText] = useState(null); 22 | 23 | function submitHandler() { 24 | /* check to see if the username is available by searching IndexedDB 25 | if available, add username as key to IndexedDB and populate value with an encrypted 26 | JSON object using the password as the AES secret 27 | */ 28 | const initialUserData: UserData = { decryption: 'isValid', dbs: [] }; 29 | const ciphertext = AES.encrypt( 30 | JSON.stringify(initialUserData), 31 | secretInput 32 | ).toString(); 33 | 34 | get(usernameInput) 35 | .then((data) => { 36 | if (data === undefined) { 37 | set(usernameInput, ciphertext) 38 | .then(() => { 39 | navigate('/login'); 40 | }) 41 | .catch((err) => { 42 | console.log('IndexedDB: set failed', err); 43 | }); 44 | } else { 45 | setSignupErrorText('incorrect username or password'); 46 | } 47 | }) 48 | .catch((err) => { 49 | console.log('IndexedDB: get failed', err); 50 | }); 51 | 52 | setUsernameInput(''); 53 | setSecretInput(''); 54 | setSignupErrorText(null); 55 | } 56 | 57 | return ( 58 |
59 | 60 | 65 | 74 | 80 | Sign Up 81 | 82 | 89 | 98 | 105 | 106 | 107 |
108 | ); 109 | } 110 | 111 | export default Signup; 112 | -------------------------------------------------------------------------------- /client/store/appStore.ts: -------------------------------------------------------------------------------- 1 | // zustand store for global state 2 | import create from 'zustand'; 3 | import { UserData } from '../clientTypes'; 4 | 5 | interface AppState { 6 | username: string; 7 | secret: string; 8 | isLoggedIn: boolean; 9 | logInUser: (username: string, secret: string, userData: UserData) => void; 10 | logOutUser: () => void; 11 | userData: UserData; 12 | updateUserData: (userData: UserData) => void; 13 | } 14 | 15 | const useAppStore = create()((set) => ({ 16 | username: '', 17 | secret: '', 18 | isLoggedIn: false, 19 | logInUser: (username, secret, userData) => 20 | set(() => ({ 21 | username: username, 22 | secret: secret, 23 | isLoggedIn: true, 24 | userData: userData, 25 | })), 26 | logOutUser: () => 27 | set(() => ({ 28 | username: '', 29 | secret: '', 30 | isLoggedIn: false, 31 | userData: { 32 | decryption: 'isValid', 33 | dbs: [], 34 | }, 35 | })), 36 | userData: { 37 | decryption: 'isValid', 38 | dbs: [], 39 | }, 40 | updateUserData: (userData) => 41 | set(() => ({ 42 | userData: userData, 43 | })), 44 | })); 45 | 46 | export default useAppStore; 47 | -------------------------------------------------------------------------------- /client/store/rqHooks.ts: -------------------------------------------------------------------------------- 1 | // react-query custom hooks 2 | import { useQuery } from 'react-query'; 3 | 4 | // custom hook to fetch metrics from the backend and handle state 5 | export function useQueryMetrics(key: string[], uri: string, interval: number) { 6 | const { isLoading, isError, data } = useQuery( 7 | key, 8 | async () => { 9 | const res = await fetch('/api/queryMetrics', { 10 | method: 'POST', 11 | headers: { 12 | 'Content-Type': 'Application/JSON', 13 | }, 14 | body: JSON.stringify({ uri: uri }), 15 | }); 16 | if (!res.ok) { 17 | throw new Error('Network response was not ok'); 18 | } 19 | return res.json(); 20 | }, 21 | { refetchInterval: interval, refetchIntervalInBackground: true } 22 | ); 23 | return { isLoading, isError, data }; 24 | } 25 | -------------------------------------------------------------------------------- /client/styles.css: -------------------------------------------------------------------------------- 1 | /* ANIMATED BEE LOGO */ 2 | .pulse-animation { 3 | animation: createBox 1s infinite; 4 | } 5 | 6 | @keyframes createBox { 7 | from { 8 | transform: scale(1); 9 | } 10 | to { 11 | transform: scale(1.25); 12 | } 13 | } 14 | 15 | /* SCROLLBAR */ 16 | /* width */ 17 | ::-webkit-scrollbar { 18 | width: 10px; 19 | height: 10px; 20 | } 21 | 22 | /* Track */ 23 | ::-webkit-scrollbar-track { 24 | background: rgb(255, 255, 255, 0.1); 25 | } 26 | 27 | /* Handle */ 28 | ::-webkit-scrollbar-thumb { 29 | background: rgb(255, 255, 255, 0.5); 30 | } 31 | 32 | /* Handle on hover */ 33 | ::-webkit-scrollbar-thumb:hover { 34 | background: rgb(255, 255, 255, 0.8); 35 | } 36 | -------------------------------------------------------------------------------- /db-sample-data/adventureworks.readme: -------------------------------------------------------------------------------- 1 | Instructions: 2 | 3 | - Within database, create public schema => CREATE SCHEMA public; 4 | - Navigate to db-sample-data dir 5 | - Unzip csv-data and place all csv files inside same dir as .rb and .sql scripts 6 | - Inside terminal run => psql -h [HOST ADDRESS] -p [PORT] -d [DB NAME] -U [USERNAME] < install.sql -------------------------------------------------------------------------------- /db-sample-data/csv-data.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/dbhive/64f440be09f0b0d0d4b8323f49fbd82294ec0aff/db-sample-data/csv-data.zip -------------------------------------------------------------------------------- /db-sample-data/update_csvs.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | # AdventureWorks for Postgres 4 | # by Lorin Thwaits 5 | 6 | # How to use this file: 7 | 8 | # Download "Adventure Works 2014 OLTP Script" from: 9 | # https://msftdbprodsamples.codeplex.com/downloads/get/880662 10 | 11 | # Extract the .zip and copy all of the CSV files into the same folder containing 12 | # this update_csvs.rb file and the install.sql file. 13 | 14 | # Modify the CSVs to work with Postgres by running: 15 | # ruby update_csvs.rb 16 | 17 | # Create the database and tables, import the data, and set up the views and keys with: 18 | # psql -c "CREATE DATABASE \"Adventureworks\";" 19 | # psql -d Adventureworks < install.sql 20 | 21 | # (you may need to also add: -U postgres to the above two commands) 22 | 23 | # All 68 tables are properly set up. 24 | # All 20 views are established. 25 | # 68 additional convenience views are added which: 26 | # * Provide a shorthand to refer to tables. 27 | # * Add an "id" column to a primary key or primary-ish key if it makes sense. 28 | 29 | # For example, with the convenience views you can simply do: 30 | # SELECT pe.p.firstname, hr.e.jobtitle 31 | # FROM pe.p 32 | # INNER JOIN hr.e ON pe.p.id = hr.e.id; 33 | # Instead of: 34 | # SELECT p.firstname, e.jobtitle 35 | # FROM person.person AS p 36 | # INNER JOIN humanresources.employee AS e ON p.businessentityid = e.businessentityid; 37 | 38 | # Schemas for these views: 39 | # pe = person 40 | # hr = humanresources 41 | # pr = production 42 | # pu = purchasing 43 | # sa = sales 44 | # Easily get a list of all of these in psql with: \dv (pe|hr|pr|pu|sa).* 45 | 46 | # Enjoy! 47 | 48 | 49 | Dir.glob('./*.csv') do |csv_file| 50 | f = File.open(csv_file, "rb:UTF-16LE:utf-8") 51 | output = "" 52 | text = "" 53 | is_needed=false 54 | is_first = true 55 | is_pipes = false 56 | begin 57 | f.each do |line| 58 | if is_first 59 | if line.include?("+|") 60 | is_pipes = true 61 | end 62 | if line[0] == "\uFEFF" 63 | line = line[1..-1] 64 | is_needed = true 65 | end 66 | end 67 | is_first = false 68 | break if !is_needed 69 | if is_pipes 70 | if line.strip.end_with?("&|") 71 | text << line.gsub(/\"/, "\"\"").strip[0..-3] 72 | output << text.split("+|").map { |part| 73 | (part[1] == "<" && part[-1] == ">") ? '"' + part[1..-1] + '"' : 74 | (part.include?("\t") ? '"' + part + '"' : part) 75 | }.join("\t") 76 | output << "\n" 77 | text = "" 78 | else 79 | text << line.gsub(/\"/, "\"\"").gsub("\r\n", "\\n") 80 | end 81 | else 82 | # The last gsub makes everything compatible with Windows -- change \r\n into just \n 83 | output << line.gsub(/\"/, "\"\"").gsub(/\&\|\n/, "\n").gsub(/\&\|\r\n/, "\n").gsub(/\r\n/, "\n") 84 | end 85 | end 86 | if is_needed 87 | puts "Processing #{csv_file}" 88 | f.close 89 | w = File.open(csv_file + ".xyz", "w") 90 | w.write(output) 91 | w.close 92 | File.delete(csv_file) 93 | File.rename(csv_file + ".xyz", csv_file) 94 | end 95 | 96 | # Here's a list of files that get snagged here: 97 | # Address.csv 98 | # AWBuildVersion.csv 99 | # CreditCard.csv 100 | # Culture.csv 101 | # Currency.csv 102 | # Department.csv 103 | # EmployeeDepartmentHistory.csv 104 | # EmployeePayHistory.csv 105 | # Product.csv 106 | # ProductCostHistory.csv 107 | # ProductModelIllustration.csv 108 | # ProductReview.csv 109 | # SalesOrderDetail.csv 110 | # SalesTerritory.csv 111 | # Shift.csv 112 | # ShipMethod.csv 113 | # ShoppingCartItem.csv 114 | # SpecialOffer.csv 115 | # Vendor.csv 116 | # WorkOrder.csv 117 | rescue Encoding::InvalidByteSequenceError 118 | f.close 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /dist/client/App.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | declare function App(): JSX.Element; 3 | export default App; 4 | -------------------------------------------------------------------------------- /dist/client/clientMode.d.ts: -------------------------------------------------------------------------------- 1 | import { UserData } from './clientTypes'; 2 | declare const toggleDashboardAuth = true; 3 | declare const seedDBs: UserData['dbs']; 4 | export { toggleDashboardAuth, seedDBs }; 5 | -------------------------------------------------------------------------------- /dist/client/clientTypes.d.ts: -------------------------------------------------------------------------------- 1 | export type UserData = { 2 | decryption: string; 3 | dbs: { 4 | nickname: string; 5 | uri: string; 6 | }[]; 7 | }; 8 | export type DbData = { 9 | nickname: string; 10 | uri: string; 11 | }; 12 | -------------------------------------------------------------------------------- /dist/client/components/CollapseList.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | type Props = { 3 | label?: string; 4 | content?: JSX.Element | JSX.Element[] | string; 5 | }; 6 | export default function CollapseList(props: Props): JSX.Element; 7 | export {}; 8 | -------------------------------------------------------------------------------- /dist/client/components/ConnectDB.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | declare function ConnectDB(): JSX.Element; 3 | export default ConnectDB; 4 | -------------------------------------------------------------------------------- /dist/client/components/DBTab.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | type Props = { 3 | dbUri: string; 4 | }; 5 | declare function DBTab(props: Props): JSX.Element; 6 | export default DBTab; 7 | -------------------------------------------------------------------------------- /dist/client/components/DropdownMenu.d.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/dbhive/64f440be09f0b0d0d4b8323f49fbd82294ec0aff/dist/client/components/DropdownMenu.d.ts -------------------------------------------------------------------------------- /dist/client/components/ErrorBoundary.d.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Component, ErrorInfo, ReactNode } from 'react'; 3 | interface Props { 4 | children?: ReactNode; 5 | } 6 | interface State { 7 | hasError: boolean; 8 | } 9 | declare class ErrorBoundary extends Component { 10 | state: State; 11 | static getDerivedStateFromError(_: Error): State; 12 | componentDidCatch(error: Error, errorInfo: ErrorInfo): void; 13 | render(): string | number | boolean | React.ReactFragment | JSX.Element; 14 | } 15 | export default ErrorBoundary; 16 | -------------------------------------------------------------------------------- /dist/client/components/Graph1.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | type Props = { 3 | labels?: string[]; 4 | data?: number[]; 5 | }; 6 | declare function Graph1(props: Props): JSX.Element; 7 | export default Graph1; 8 | -------------------------------------------------------------------------------- /dist/client/components/Graph2.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | type Props = { 3 | labels?: string[]; 4 | data?: number[]; 5 | }; 6 | declare function Graph2(props: Props): JSX.Element; 7 | export default Graph2; 8 | -------------------------------------------------------------------------------- /dist/client/components/Input.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | type Props = { 3 | label: string; 4 | setInput: (eventTargetValue: string) => void; 5 | inputClass?: string; 6 | inputType?: string; 7 | value?: string; 8 | error?: boolean; 9 | errorText?: string; 10 | }; 11 | declare function Input(props: Props): JSX.Element; 12 | export default Input; 13 | -------------------------------------------------------------------------------- /dist/client/components/LineGraphType1.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | type Props = { 3 | title?: string; 4 | data?: any; 5 | }; 6 | declare function LineGraphType1(props: Props): JSX.Element; 7 | export default LineGraphType1; 8 | -------------------------------------------------------------------------------- /dist/client/components/LineGraphType2.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | type Props = { 3 | title?: string; 4 | data?: any; 5 | xMax?: number; 6 | }; 7 | declare function LineGraphType2(props: Props): JSX.Element; 8 | export default LineGraphType2; 9 | -------------------------------------------------------------------------------- /dist/client/components/MetricCard.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | type Props = { 3 | cardLabel: string; 4 | children?: JSX.Element | string | number; 5 | }; 6 | declare function MetricCard(props: Props): JSX.Element; 7 | export default MetricCard; 8 | -------------------------------------------------------------------------------- /dist/client/components/Navbar.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | declare function Navbar(): JSX.Element; 3 | export default Navbar; 4 | -------------------------------------------------------------------------------- /dist/client/components/PieGraphType1.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | type Props = { 3 | data?: any; 4 | }; 5 | declare function PieGraphType1(props: Props): JSX.Element; 6 | export default PieGraphType1; 7 | -------------------------------------------------------------------------------- /dist/client/index.d.ts: -------------------------------------------------------------------------------- 1 | import './styles.css'; 2 | -------------------------------------------------------------------------------- /dist/client/pages/Dashboard.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | declare function Dashboard(): JSX.Element; 3 | export default Dashboard; 4 | -------------------------------------------------------------------------------- /dist/client/pages/Docs.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | declare function Docs(): JSX.Element; 3 | export default Docs; 4 | -------------------------------------------------------------------------------- /dist/client/pages/Home.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | declare function Home(): JSX.Element; 3 | export default Home; 4 | -------------------------------------------------------------------------------- /dist/client/pages/Login.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | declare function Login(): JSX.Element; 3 | export default Login; 4 | -------------------------------------------------------------------------------- /dist/client/pages/Setup.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | declare function Setup(): JSX.Element; 3 | export default Setup; 4 | -------------------------------------------------------------------------------- /dist/client/pages/Signup.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | declare function Signup(): JSX.Element; 3 | export default Signup; 4 | -------------------------------------------------------------------------------- /dist/client/store/appStore.d.ts: -------------------------------------------------------------------------------- 1 | import { UserData } from '../clientTypes'; 2 | interface AppState { 3 | username: string; 4 | secret: string; 5 | isLoggedIn: boolean; 6 | logInUser: (username: string, secret: string, userData: UserData) => void; 7 | logOutUser: () => void; 8 | userData: UserData; 9 | updateUserData: (userData: UserData) => void; 10 | } 11 | declare const useAppStore: import("zustand").UseBoundStore>; 12 | export default useAppStore; 13 | -------------------------------------------------------------------------------- /dist/client/store/rqHooks.d.ts: -------------------------------------------------------------------------------- 1 | export declare function useQueryMetrics(key: string[], uri: string, interval: number): { 2 | isLoading: boolean; 3 | isError: boolean; 4 | data: any; 5 | }; 6 | -------------------------------------------------------------------------------- /docker-compose-dev-hot.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | dev: 4 | image: dbhive/main-dev 5 | container_name: main-dev 6 | ports: 7 | - 8080:8080 8 | volumes: 9 | - .:/usr/src/app 10 | - node_modules:/usr/src/app/node_modules 11 | command: npm run dev 12 | volumes: 13 | node_modules: 14 | -------------------------------------------------------------------------------- /docker-compose-test.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | test: 4 | image: dbhive/main-prod 5 | environment: 6 | - TEST_DB=${TEST_DB} 7 | container_name: main-test 8 | ports: 9 | - 3000:3000 10 | volumes: 11 | - .:/usr/src/app 12 | - node_modules:/usr/src/app/node_modules 13 | command: npm run test 14 | volumes: 15 | node_modules: 16 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | automock: false, 3 | //ts jest or jest 4 | preset: 'ts-jest', 5 | testEnvironment: 'jest-environment-jsdom', 6 | transform: { 7 | '^.+\\.(ts|tsx)?$': 'ts-jest', 8 | '^.+\\.(js|jsx)$': 'babel-jest', 9 | }, 10 | setupFilesAfterEnv: ['@testing-library/jest-dom/extend-expect'], 11 | moduleNameMapper: { 12 | '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': 13 | '/__tests__/__mocks__/fileMock.ts', 14 | //if this doesn't work, go to filemock 15 | '\\.(css|less|scss|sass)$': 'identity-obj-proxy', 16 | }, 17 | testPathIgnorePatterns: [ 18 | './__tests__/__mocks__', 19 | './__tests__/jest-setup.js', 20 | './__tests__/jest-teardown.js', 21 | ], 22 | globalSetup: './__tests__/jest-setup.js', 23 | globalTeardown: './__tests__/jest-teardown.js', 24 | }; 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dbhive", 3 | "version": "1.0.0", 4 | "description": "postgres performance analysis tool", 5 | "main": "index.tsx", 6 | "scripts": { 7 | "start": "cross-env NODE_ENV=production ts-node --transpile-only server/server.ts", 8 | "build": "cross-env NODE_ENV=production webpack", 9 | "dev": "concurrently \"cross-env NODE_ENV=development webpack-dev-server --open --hot\" \"cross-env NODE_ENV=development nodemon server/server.ts\"", 10 | "test": "jest --config jest.config.js --detectOpenHandles" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/oslabs-beta/dbhive.git" 15 | }, 16 | "keywords": [], 17 | "author": "", 18 | "license": "ISC", 19 | "bugs": { 20 | "url": "https://github.com/oslabs-beta/dbhive/issues" 21 | }, 22 | "homepage": "https://github.com/oslabs-beta/dbhive#readme", 23 | "dependencies": { 24 | "@emotion/react": "^11.10.5", 25 | "@emotion/styled": "^11.10.5", 26 | "@mui/icons-material": "^5.10.16", 27 | "@mui/material": "^5.10.16", 28 | "@types/chart.js": "^2.9.37", 29 | "@types/crypto-js": "^4.1.1", 30 | "@types/eslint": "^8.4.10", 31 | "@types/express": "^4.17.14", 32 | "@types/jest": "^29.2.3", 33 | "@types/node": "^18.11.9", 34 | "@types/pg": "^8.6.5", 35 | "@types/react": "^18.0.25", 36 | "@types/react-dom": "^18.0.9", 37 | "@types/react-is": "^17.0.3", 38 | "@types/react-router": "^5.1.19", 39 | "@types/regenerator-runtime": "^0.13.1", 40 | "babel-jest": "^29.3.1", 41 | "bcryptjs": "^2.4.3", 42 | "chart.js": "3.9.1", 43 | "cross-env": "^7.0.3", 44 | "crypto-js": "^4.1.1", 45 | "express": "^4.18.2", 46 | "idb-keyval": "^6.2.0", 47 | "jest-environment-jsdom": "^29.3.1", 48 | "material-ui-popup-state": "^4.1.0", 49 | "path": "^0.12.7", 50 | "pg": "^8.8.0", 51 | "react": "^18.2.0", 52 | "react-chartjs-2": "4.3.1", 53 | "react-dom": "^18.2.0", 54 | "react-query": "^3.39.2", 55 | "react-router": "^6.4.3", 56 | "react-router-dom": "^6.4.3", 57 | "regenerator-runtime": "^0.13.11", 58 | "ts-jest": "^29.0.3", 59 | "ts-node": "^10.9.1", 60 | "typescript": "^4.9.4", 61 | "zustand": "^4.1.5" 62 | }, 63 | "devDependencies": { 64 | "@babel/core": "^7.19.6", 65 | "@babel/preset-env": "^7.20.2", 66 | "@babel/preset-react": "^7.18.6", 67 | "@testing-library/jest-dom": "^5.16.5", 68 | "@testing-library/react": "^13.4.0", 69 | "@testing-library/user-event": "^14.4.3", 70 | "@types/express": "^4.17.14", 71 | "@types/node": "^18.11.9", 72 | "@typescript-eslint/eslint-plugin": "^5.43.0", 73 | "@typescript-eslint/parser": "^5.43.0", 74 | "babel-loader": "^9.0.0", 75 | "concurrently": "^7.6.0", 76 | "css-loader": "^6.7.2", 77 | "eslint": "^8.27.0", 78 | "eslint-import-resolver-node": "^0.3.6", 79 | "eslint-import-resolver-typescript": "^3.5.2", 80 | "eslint-plugin-import": "^2.26.0", 81 | "file-loader": "^6.2.0", 82 | "html-webpack-plugin": "^5.5.0", 83 | "install": "^0.13.0", 84 | "isomorphic-fetch": "^3.0.0", 85 | "jest": "^29.3.1", 86 | "node-loader": "^2.0.0", 87 | "nodemon": "^2.0.20", 88 | "npm": "^9.1.2", 89 | "prettier": "2.8.0", 90 | "resolve-url-loader": "^5.0.0", 91 | "sass": "^1.55.0", 92 | "sass-loader": "^13.1.0", 93 | "style-loader": "^3.3.1", 94 | "supertest": "^6.3.1", 95 | "ts-loader": "^9.4.1", 96 | "url-loader": "^4.1.1", 97 | "webpack": "^5.74.0", 98 | "webpack-cli": "^4.10.0", 99 | "webpack-dev-server": "^4.11.1" 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /server/controllers/connectController.ts: -------------------------------------------------------------------------------- 1 | import { RequestHandler } from 'express'; 2 | import { Pool } from 'pg'; 3 | 4 | type ConnectController = { 5 | connectDB: RequestHandler; 6 | createExtension: RequestHandler; 7 | }; 8 | 9 | // on request, connect to user's database and return query pool on res.locals 10 | const connectController: ConnectController = { 11 | connectDB: (req, res, next) => { 12 | const uri_string = req.body.uri; 13 | const pool = new Pool({ 14 | connectionString: uri_string, 15 | }); 16 | const db = { 17 | query: (text: string, params?: Array) => { 18 | return pool.query(text, params); 19 | }, 20 | }; 21 | res.locals.dbConnection = db; 22 | res.locals.result = {}; 23 | return next(); 24 | }, 25 | 26 | // initializes pg_stat_statements if not already initialized 27 | // first controller to stop response cycle and return an error if connection fails 28 | createExtension: async (req, res, next) => { 29 | const db = res.locals.dbConnection; 30 | const queryString = 'CREATE EXTENSION IF NOT EXISTS pg_stat_statements'; 31 | try { 32 | await db.query(queryString); 33 | res.locals.result.validURI = true; 34 | return next(); 35 | } catch (error) { 36 | return next({ 37 | log: `ERROR caught in connectController.createExtension: ${error}`, 38 | status: 400, 39 | message: 40 | 'ERROR: error has occured in connectController.createExtension', 41 | }); 42 | } 43 | }, 44 | }; 45 | 46 | export default connectController; 47 | -------------------------------------------------------------------------------- /server/controllers/databaseController.ts: -------------------------------------------------------------------------------- 1 | import { RequestHandler } from 'express'; 2 | 3 | type DatabaseController = { 4 | queryTimes: RequestHandler; 5 | numOfRows: RequestHandler; 6 | topCalls: RequestHandler; 7 | dbStats: RequestHandler; 8 | cacheHitRatio: RequestHandler; 9 | statActivity: RequestHandler; 10 | }; 11 | type queryData = { 12 | all: any[]; 13 | mean: number; 14 | median: number; 15 | slowestQueries: any[]; 16 | } | null; 17 | 18 | const databaseController: DatabaseController = { 19 | // this controller returns mean and median times for different types of queries 20 | queryTimes: async (req, res, next) => { 21 | const db = res.locals.dbConnection; 22 | // WIP: this number will come from the front end and currently defaults to 10 23 | const userNumberOfQueries: number = req.body.slowQueries; 24 | const slowQueryNumber = userNumberOfQueries || 10; 25 | 26 | try { 27 | const allQueries: queryData = { 28 | all: await db.query( 29 | 'SELECT * FROM pg_stat_statements ORDER BY mean_exec_time;' 30 | ), 31 | median: await db.query( 32 | 'SELECT PERCENTILE_CONT(0.5) WITHIN GROUP(ORDER BY mean_exec_time) AS median FROM pg_stat_statements;' 33 | ), 34 | mean: await db.query( 35 | 'SELECT avg(mean_exec_time) AS averageQueryTime FROM pg_stat_statements;' 36 | ), 37 | slowestQueries: await db.query( 38 | 'SELECT query, mean_exec_time FROM pg_stat_statements ORDER BY mean_exec_time DESC LIMIT $1;', 39 | [slowQueryNumber] 40 | ), 41 | }; 42 | res.locals.result.allTimes = allQueries; 43 | 44 | const selectQueries: queryData = { 45 | all: await db.query( 46 | "SELECT * FROM pg_stat_statements WHERE query LIKE '%SELECT%' ORDER BY mean_exec_time;" 47 | ), 48 | median: await db.query( 49 | "SELECT PERCENTILE_CONT(0.5) WITHIN GROUP(ORDER BY mean_exec_time) AS median FROM pg_stat_statements WHERE query LIKE '%SELECT%';" 50 | ), 51 | mean: await db.query( 52 | "SELECT avg(mean_exec_time) AS averageQueryTime FROM pg_stat_statements WHERE query LIKE '%SELECT%';" 53 | ), 54 | slowestQueries: await db.query( 55 | "SELECT query, mean_exec_time FROM pg_stat_statements WHERE query LIKE '%SELECT%' ORDER BY mean_exec_time DESC LIMIT $1;", 56 | [slowQueryNumber] 57 | ), 58 | }; 59 | res.locals.result.selectTimes = selectQueries; 60 | 61 | const insertQueries: queryData = { 62 | all: await db.query( 63 | "SELECT * FROM pg_stat_statements WHERE query LIKE '%INSERT%' ORDER BY mean_exec_time;" 64 | ), 65 | median: await db.query( 66 | "SELECT PERCENTILE_CONT(0.5) WITHIN GROUP(ORDER BY mean_exec_time) AS median FROM pg_stat_statements WHERE query LIKE '%INSERT%';" 67 | ), 68 | mean: await db.query( 69 | "SELECT avg(mean_exec_time) AS averageQueryTime FROM pg_stat_statements WHERE query LIKE '%INSERT%';" 70 | ), 71 | slowestQueries: await db.query( 72 | "SELECT query, mean_exec_time FROM pg_stat_statements WHERE query LIKE '%INSERT%' ORDER BY mean_exec_time DESC LIMIT $1;", 73 | [slowQueryNumber] 74 | ), 75 | }; 76 | res.locals.result.insertTimes = insertQueries; 77 | 78 | const updateQueries: queryData = { 79 | all: await db.query( 80 | "SELECT * FROM pg_stat_statements WHERE query LIKE '%UPDATE%' ORDER BY mean_exec_time;" 81 | ), 82 | median: await db.query( 83 | "SELECT PERCENTILE_CONT(0.5) WITHIN GROUP(ORDER BY mean_exec_time) AS median FROM pg_stat_statements WHERE query LIKE '%UPDATE%';" 84 | ), 85 | mean: await db.query( 86 | "SELECT avg(mean_exec_time) AS averageQueryTime FROM pg_stat_statements WHERE query LIKE '%UPDATE%';" 87 | ), 88 | slowestQueries: await db.query( 89 | "SELECT query, mean_exec_time FROM pg_stat_statements WHERE query LIKE '%UPDATE%' ORDER BY mean_exec_time DESC LIMIT $1;", 90 | [slowQueryNumber] 91 | ), 92 | }; 93 | res.locals.result.updateTimes = updateQueries; 94 | 95 | const deleteQueries: queryData = { 96 | all: await db.query( 97 | "SELECT * FROM pg_stat_statements WHERE query LIKE '%DELETE%' ORDER BY mean_exec_time;" 98 | ), 99 | median: await db.query( 100 | "SELECT PERCENTILE_CONT(0.5) WITHIN GROUP(ORDER BY mean_exec_time) AS median FROM pg_stat_statements WHERE query LIKE '%DELETE%';" 101 | ), 102 | mean: await db.query( 103 | "SELECT avg(mean_exec_time) AS averageQueryTime FROM pg_stat_statements WHERE query LIKE '%DELETE%';" 104 | ), 105 | slowestQueries: await db.query( 106 | "SELECT query, mean_exec_time FROM pg_stat_statements WHERE query LIKE '%DELETE%' ORDER BY mean_exec_time DESC LIMIT $1;", 107 | [slowQueryNumber] 108 | ), 109 | }; 110 | res.locals.result.deleteTimes = deleteQueries; 111 | 112 | return next(); 113 | } catch (error) { 114 | console.log('ERROR in databaseController.queryTimes: ', error); 115 | res.locals.result.allTimes = null; 116 | res.locals.result.selectTimes = null; 117 | res.locals.result.insertTimes = null; 118 | res.locals.result.updateTimes = null; 119 | res.locals.result.deleteTimes = null; 120 | return next(); 121 | } 122 | }, 123 | 124 | // this controller returns the number of rows that a given query touches, where the number is greater than 10 125 | numOfRows: async (req, res, next) => { 126 | const db = res.locals.dbConnection; 127 | // WIP: this number will come from the front end and currently defaults to 5 128 | const userProvided = req.body.numOfRowsNumber; 129 | const rowsNumber = userProvided || 10; 130 | try { 131 | const quantOfRows = await db.query( 132 | 'SELECT query, rows FROM pg_stat_statements WHERE rows > $1;', 133 | [rowsNumber] 134 | ); 135 | res.locals.result.numOfRows = quantOfRows.rows; 136 | return next(); 137 | } catch (error) { 138 | console.log('ERROR in databaseController.numOfRows: ', error); 139 | res.locals.result.numOfRows = null; 140 | return next(); 141 | } 142 | }, 143 | 144 | // this controller returns mean execution time for top queries of each query type 145 | topCalls: async (req, res, next) => { 146 | const db = res.locals.dbConnection; 147 | // WIP: this number will come from the front end and currently defaults to 5 148 | const userProvided = req.body.topCallsNumber; 149 | const callsNumber = userProvided || 5; 150 | try { 151 | const topAllCalls = await db.query( 152 | 'SELECT query, mean_exec_time FROM pg_stat_statements ORDER BY calls DESC LIMIT $1;', 153 | [callsNumber] 154 | ); 155 | const topSelectCalls = await db.query( 156 | "SELECT query, mean_exec_time FROM pg_stat_statements WHERE query LIKE '%SELECT %' ORDER BY calls DESC LIMIT $1;", 157 | [callsNumber] 158 | ); 159 | const topInsertCalls = await db.query( 160 | "SELECT query, mean_exec_time FROM pg_stat_statements WHERE query LIKE '%INSERT %' ORDER BY calls DESC LIMIT $1;", 161 | [callsNumber] 162 | ); 163 | const topDeleteCalls = await db.query( 164 | "SELECT query, mean_exec_time FROM pg_stat_statements WHERE query LIKE '%DELETE %' ORDER BY calls DESC LIMIT $1;", 165 | [callsNumber] 166 | ); 167 | const topUpdateCalls = await db.query( 168 | "SELECT query, mean_exec_time FROM pg_stat_statements WHERE query LIKE '%UPDATE %' ORDER BY calls DESC LIMIT $1;", 169 | [callsNumber] 170 | ); 171 | res.locals.result.avgTimeTopAllCalls = topAllCalls.rows; 172 | res.locals.result.avgTimeTopSelectCalls = topSelectCalls.rows; 173 | res.locals.result.avgTimeTopInsertCalls = topInsertCalls.rows; 174 | res.locals.result.avgTimeTopDeleteCalls = topDeleteCalls.rows; 175 | res.locals.result.avgTimeTopUpdateCalls = topUpdateCalls.rows; 176 | return next(); 177 | } catch (error) { 178 | console.log('ERROR in databaseController.topCalls: ', error); 179 | res.locals.result.averageQueryTime = null; 180 | res.locals.result.avgTimeTopSelectCalls = null; 181 | res.locals.result.avgTimeTopInsertCalls = null; 182 | res.locals.result.avgTimeTopDeleteCalls = null; 183 | res.locals.result.avgTimeTopUpdateCalls = null; 184 | return next(); 185 | } 186 | }, 187 | 188 | // this controller pulls the database-wide statistics for a specified database 189 | dbStats: async (req, res, next) => { 190 | const db = res.locals.dbConnection; 191 | const data = req.body.uri; 192 | // grab database name from req.body string, database assignment contigent upon db setup via uri || manual input 193 | const dataBase = 194 | data.split('.com/')[1] || 195 | data.split('5432/').pop().split('/')[0].replace(/\s/g, ''); 196 | try { 197 | const dbOverview = await db.query( 198 | 'SELECT * FROM pg_stat_database WHERE datname = $1;', 199 | [dataBase] 200 | ); 201 | res.locals.result.dbStats = dbOverview.rows; 202 | res.locals.result.conflicts = dbOverview.rows[0].conflicts; 203 | res.locals.result.deadlocks = dbOverview.rows[0].deadlocks; 204 | res.locals.result.transactionsCommitted = dbOverview.rows[0].xact_commit; 205 | res.locals.result.rolledBackTransactions = 206 | dbOverview.rows[0].xact_rollback; 207 | return next(); 208 | } catch (error) { 209 | console.log('ERROR in databaseController.dbStats: ', error); 210 | res.locals.result.dbStats = null; 211 | res.locals.result.conflicts = null; 212 | res.locals.result.deadlocks = null; 213 | res.locals.result.transactionsCommitted = null; 214 | res.locals.result.rolledBackTransactions = null; 215 | return next(); 216 | } 217 | }, 218 | 219 | // this controller calculates and returns the cache hit ratio for the database 220 | cacheHitRatio: async (req, res, next) => { 221 | const db = res.locals.dbConnection; 222 | const queryString = 223 | 'SELECT sum(heap_blks_read) AS heap_read, sum(heap_blks_hit) AS heap_hit, sum(heap_blks_hit) / (sum(heap_blks_hit) + sum(heap_blks_read)) AS ratio FROM pg_statio_user_tables;'; 224 | try { 225 | const cacheHitRate = await db.query(queryString); 226 | res.locals.result.cacheHitRatio = cacheHitRate.rows; 227 | return next(); 228 | } catch (error) { 229 | console.log('ERROR in databaseController.cacheHitRatio: ', error); 230 | res.locals.result.cacheHitRatio = null; 231 | return next(); 232 | } 233 | }, 234 | 235 | // this controller interfaces with db_stat_activity 236 | statActivity: async (req, res, next) => { 237 | const db = res.locals.dbConnection; 238 | const data = req.body.uri; 239 | // grab database name from req.body string, database assignment contigent upon db setup via uri || manual input 240 | const dataBase = 241 | data.split('.com/')[1] || 242 | data.split('5432/').pop().split('/')[0].replace(/\s/g, ''); 243 | try { 244 | const dbOverview = await db.query( 245 | "SELECT * FROM pg_stat_activity WHERE datname = $1 and state = 'active';", 246 | [dataBase] 247 | ); 248 | res.locals.result.activeSessions = dbOverview.rowCount; 249 | return next(); 250 | } catch (error) { 251 | console.log('ERROR in databaseController.statActivity: ', error); 252 | res.locals.result.activeSessions = null; 253 | return next(); 254 | } 255 | }, 256 | }; 257 | 258 | //SELECT sum(numbackends) FROM pg_stat_database; 259 | 260 | export default databaseController; 261 | -------------------------------------------------------------------------------- /server/routes/metricApi.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import connectController from '../controllers/connectController'; 3 | import databaseController from '../controllers/databaseController'; 4 | 5 | const router = Router(); 6 | 7 | // check if database being added is valid 8 | // WIP 9 | router.post( 10 | '/uri', 11 | connectController.connectDB, 12 | connectController.createExtension, 13 | databaseController.dbStats, 14 | (req, res) => { 15 | console.log(res.locals); 16 | return res.status(200).json(res.locals); 17 | } 18 | ); 19 | 20 | // delivers metrics from database 21 | // notice: the database controllers will console log, but not return errors so that individual charts can render 22 | router.post( 23 | '/queryMetrics', 24 | connectController.connectDB, 25 | connectController.createExtension, 26 | databaseController.queryTimes, 27 | databaseController.numOfRows, 28 | databaseController.topCalls, 29 | databaseController.dbStats, 30 | databaseController.cacheHitRatio, 31 | databaseController.statActivity, 32 | (req, res) => { 33 | return res.status(200).json(res.locals.result); 34 | } 35 | ); 36 | 37 | // this should reflect any method that would need to update based on user input 38 | // WIP: not currently in use but ready to be connected to front end based on user selection 39 | router.put( 40 | '/queryMetrics', 41 | databaseController.numOfRows, 42 | databaseController.queryTimes, 43 | databaseController.topCalls, 44 | (req, res) => { 45 | return res.status(200).json(res.locals.result); 46 | } 47 | ); 48 | 49 | export default router; 50 | -------------------------------------------------------------------------------- /server/server.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import metricAPI from './routes/metricApi'; 3 | import express, { 4 | json, 5 | urlencoded, 6 | Request, 7 | Response, 8 | NextFunction, 9 | } from 'express'; 10 | 11 | const app = express(); 12 | 13 | const PORT = process.env.PORT || '3000'; 14 | 15 | app.use(json()); 16 | app.use(urlencoded({ extended: true })); 17 | 18 | // route for all Postgres Metrics 19 | app.use('/api', metricAPI); 20 | 21 | // serves static files in production mode 22 | if (process.env.NODE_ENV !== 'development') { 23 | app.use('/build', express.static(path.join(__dirname, '../build'))); 24 | app.get('/*', (req, res) => { 25 | return res 26 | .status(200) 27 | .sendFile(path.join(__dirname, '../build/index.html')); 28 | }); 29 | } 30 | 31 | app.use((err: Error, req: Request, res: Response, next: NextFunction) => { 32 | const defaultError = { 33 | log: 'Express error handler caught unknown middleware error', 34 | status: 500, 35 | message: { Error: 'An error occurred' }, 36 | }; 37 | 38 | const errorObj = Object.assign({}, defaultError, err); 39 | return res.status(errorObj.status).json(errorObj.message); 40 | }); 41 | 42 | const server = app.listen(PORT, () => { 43 | console.log(`Server started on ${PORT}...`); 44 | }); 45 | 46 | module.exports = server; 47 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react", 4 | "module": "commonjs", 5 | "noImplicitAny": true, 6 | "removeComments": true, 7 | "preserveConstEnums": true, 8 | "sourceMap": true, 9 | "declaration": true, 10 | "moduleResolution": "node", 11 | "esModuleInterop": true, 12 | "outDir": "./dist", 13 | "rootDir": ".", 14 | "allowJs": true, 15 | "lib": ["DOM"] 16 | }, 17 | "include": ["client", "import-images.d.ts"] 18 | } 19 | -------------------------------------------------------------------------------- /webpack.config.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | const path = require('path'); 3 | const HTMLWebpackPlugin = require('html-webpack-plugin'); 4 | 5 | module.exports = { 6 | // dictates development or production environment 7 | mode: process.env.NODE_ENV, 8 | // where Webpack starts constructing bundle.js 9 | entry: './client/index.tsx', 10 | // where bundle.js will be placed after compiling 11 | output: { 12 | path: path.resolve(__dirname, 'build'), 13 | filename: 'bundle.js', 14 | publicPath: '/build', 15 | }, 16 | // used to make a copy of index.html file for serving in dev mode 17 | plugins: [ 18 | new HTMLWebpackPlugin({ 19 | template: './client/index.html', 20 | favicon: './client/assets/icons8-hexagon-office-32.png', 21 | }), 22 | ], 23 | // details for bundle transpiling 24 | module: { 25 | rules: [ 26 | { 27 | // transpile js or jsx files 28 | test: /\.jsx?$/, 29 | // node_modules already transpiled 30 | exclude: /node_modules/, 31 | use: { 32 | loader: 'babel-loader', 33 | options: { 34 | // env is ES6 to ES5, react is jsx to js (order matters) 35 | presets: ['@babel/preset-env', '@babel/preset-react'], 36 | }, 37 | }, 38 | }, 39 | { 40 | test: /\.tsx?$/, 41 | use: 'ts-loader', 42 | exclude: /node_modules/, 43 | }, 44 | { 45 | // transpile scss or sass 46 | test: /\.s[ac]ss$/i, 47 | // creates style nodes from JS strings, translates CSS into Common JS, Compiles SASS to CSS (order matters) 48 | use: ['style-loader', 'css-loader', 'sass-loader'], 49 | }, 50 | { 51 | // transpile css 52 | test: /\.css$/i, 53 | use: ['style-loader', 'css-loader'], 54 | }, 55 | { 56 | test: /\.(jpe?g|png|gif|svg)$/i, 57 | use: [ 58 | { 59 | loader: 'url-loader', 60 | options: { 61 | name: '[name].[ext]', 62 | }, 63 | }, 64 | ], 65 | }, 66 | ], 67 | }, 68 | resolve: { 69 | extensions: ['.tsx', '.ts', '.js', '.jsx'], 70 | modules: [path.join(__dirname, './node_modules')], 71 | }, 72 | // server allows for hot module reloading (HMR) in dev mode 73 | devServer: { 74 | static: { 75 | // tells webpack dev server where to serve static content 76 | directory: path.resolve(__dirname, 'build'), 77 | // path must have wildcard so that all routes are served 78 | publicPath: '/*', 79 | }, 80 | // allows backend requests to 8080 to be routed to 3000 automatically 81 | proxy: { 82 | '/api': 'http://localhost:3000', 83 | }, 84 | }, 85 | }; 86 | --------------------------------------------------------------------------------