├── .DS_Store ├── .babelrc ├── .dockerignore ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── __mocks__ └── fileMock.js ├── __tests__ ├── configController.test.js ├── kubeController.test.js └── prometheusController.test.js ├── babel.config.js ├── bundle.js ├── client ├── assets │ ├── Aldrich │ │ ├── Aldrich-Regular.ttf │ │ └── OFL.txt │ ├── halfLogo.png │ ├── logo.png │ ├── logo2.png │ ├── logoName.png │ └── slogan.png └── components │ ├── Graph.jsx │ ├── GraphsContainer.jsx │ ├── Navbar.jsx │ ├── Parameter.jsx │ ├── ParameterContainer.jsx │ ├── RestartedPodRow.jsx │ ├── RestartedPodTable.jsx │ ├── SavedConfig.jsx │ ├── TimeInput.jsx │ └── ToolTip.jsx ├── deployment.yaml ├── docker-compose-dev-hot.yml ├── electron.js ├── index.html ├── jest.setup.js ├── main.js ├── main.jsx ├── package-lock.json ├── package.json ├── server ├── controllers │ ├── configController.js │ ├── kubeController.js │ └── prometheusController.js ├── router.js └── services │ └── prometheusService.js ├── service.yaml ├── style.css └── webpack.config.js /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/PodMD/56a0593a5c73316d4624976519606d64f8627829/.DS_Store -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env", "@babel/preset-react"], 3 | "plugins": ["@babel/plugin-transform-modules-commonjs"] 4 | } 5 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | node_modules/ 10 | build/ 11 | dist 12 | dist-ssr 13 | *.local 14 | eks-cluster-role-trust-policy.json 15 | .DS_Store 16 | # Editor directories and files 17 | .vscode/* 18 | !.vscode/extensions.json 19 | .idea 20 | .DS_Store 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? 26 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:lts-alpine 2 | RUN npm install -g webpack 3 | WORKDIR /usr/src/app 4 | COPY package*.json ./ 5 | RUN npm install -g 6 | COPY . . 7 | RUN npm run build 8 | EXPOSE 8080 3333 9090 9 | CMD [ "npm", "run", "dev:hot" ] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Open Source Labs Beta 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PodMD 2 | 3 | PodMD is a tool for developers utilizing and maintaining kubernetes clusters.\ 4 | With PodMD, you may set a specific configuration of desired pod metrics\ 5 | you wish to monitor and enable an automatic restart of designated pods based\ 6 | on your specific needs. 7 | 8 | ## Getting Started 9 | 10 | In order to use PodMD, you need to deploy Prometheus on your cluster to\ 11 | monitor pod metrics. You may also wish to install Grafana, but it isn't\ 12 | necessary for PodMD to function. 13 | 14 | Additionally, it is _strongly_ recommended you utilize Helm for installing\ 15 | the following tools. You can find instructions to install Helm below. 16 | 17 | # First: Initial Setup 18 | 19 | You can deploy the application either locally, to Minikube, or on the cloud, to AWS.\ 20 | See below for a detailed walkthrough. 21 | 22 | ## Implementing Monitoring via Minikube 23 | 24 | If using Minikube, perform the following steps to get your Kubernetes cluster\ 25 | running with Prometheus. Continue to last, optional step if you would like to\ 26 | access visualizations with Grafana. 27 | 28 | 1. Ensure that you have the following installed to your computer:\ 29 | [Docker](https://www.docker.com/)\ 30 | [Minikube](https://minikube.sigs.k8s.io/docs/start/)\ 31 | [Helm](https://v3-1-0.helm.sh/docs/intro/install/)\ 32 | [Kubernetes Kubectl](https://kubernetes.io/docs/tasks/tools/)\ 33 | [Node.js](https://nodejs.org/en) 34 | 2. If you already have a Minikube cluster in Docker that is no longer running\ 35 | and you are trying to restart the tool, you must first delete the old cluster\ 36 | by running the following command in your home directory: 37 | 38 | ``` 39 | minikube delete 40 | ``` 41 | 42 | 3. Make sure you have Docker running then start your cluster and install the\ 43 | Prometheus-operator by running the following commands in your home directory: 44 | 45 | ``` 46 | minikube start 47 | ``` 48 | 49 | ``` 50 | helm repo add prometheus-community https://prometheus-community.github.io/helm-charts 51 | helm repo add stable https://charts.helm.sh/stable 52 | helm repo update 53 | ``` 54 | 55 | ``` 56 | helm install prometheus prometheus-community/kube-prometheus-stack 57 | ``` 58 | 59 | 4. Once Prometheus pods and services are running on your cluster, which can take\ 60 | a few minutes, run Prometheus on [PORT 9090](https://localhost:9090/) with the following command: 61 | ``` 62 | kubectl port-forward prometheus-prometheus-kube-prometheus-prometheus-0 9090 63 | ``` 64 | 5. After navigating to [PORT 9090](https://localhost:9090/) you may enter commands in the\ 65 | Prometheus dashboard if you would like to test its functionality. The search\ 66 | bar requires the use of PromQL to gather various metrics. You can read more\ 67 | here: [Prometheus Documentation | Query Examples](https://prometheus.io/docs/prometheus/latest/querying/examples/) 68 | 6. Clone this PodMD repository to your computer. 69 | 7. Install dependencies by running the following command from your new, local repository: 70 | ``` 71 | npm install 72 | ``` 73 | 74 | ## Implementing Monitoring via AWS EKS 75 | 76 | 1. Ensure that you have the following installed to your computer:\ 77 | [AWS Command Line Interface](https://aws.amazon.com/cli/)\ 78 | [AWS EKS Command Line Interface (eksctl)](https://eksctl.io/installation/)\ 79 | [Helm](https://v3-1-0.helm.sh/docs/intro/install/)\ 80 | [Kubernetes Kubectl](https://kubernetes.io/docs/tasks/tools/)\ 81 | [Node.js](https://nodejs.org/en) 82 | 2. Clone this PodMD repository to your computer and run the following command from the\ 83 | resulting directory : 84 | ``` 85 | aws configure 86 | ``` 87 | [AWS User Guide](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html) 88 | 3. Create AWS EKS Clusters, example command is provided below with region set to us-west-1.\ 89 | Clusters must be at least size medium to operate: 90 | ``` 91 | eksctl create cluster --name=prometheus-3 --region=us-west-1 --version=1.31 --nodegroup-name=promnodes --node-type t2.medium --nodes 2 92 | ``` 93 | 4. Once AWS Cluster is running, install the Prometheus-operator by running the following commands in your home directory: 94 | ``` 95 | helm repo add prometheus-community https://prometheus-community.github.io/helm-charts 96 | helm repo add stable https://charts.helm.sh/stable 97 | helm repo update 98 | ``` 99 | 5. Deploy Prometheus on your cluster by running the following command in your home directory: 100 | ``` 101 | helm install prometheus prometheus-community/kube-prometheus-stack 102 | ``` 103 | 6. Once Prometheus pods and services are running on your cluster, which can take\ 104 | a few minutes, run Prometheus on [PORT 9090](https://localhost:9090/) with the following command: 105 | ``` 106 | kubectl port-forward prometheus-prometheus-kube-prometheus-prometheus-0 9090 107 | ``` 108 | 7. After navigating to [PORT 9090](https://localhost:9090/) you may enter commands in the\ 109 | Prometheus dashboard if you would like to test its functionality. The search\ 110 | bar requires the use of PromQL to gather various metrics. You can read more\ 111 | here: [Prometheus Documentation | Query Examples](https://prometheus.io/docs/prometheus/latest/querying/examples/) 112 | 8. Install dependencies by running the following command from your new, local repository: 113 | ``` 114 | npm install 115 | ``` 116 | 117 | # Next: Running Application 118 | 119 | You can run the application either in your browser or in a desktop application. See below for\ 120 | detailed instructions on how to run the application: 121 | 122 | ## Running Application in Browser 123 | 124 | 1. Build the application by running the following command from your new, local repository: 125 | ``` 126 | npm run build 127 | ``` 128 | 2. Start the application by running the following command from your new, local repository: 129 | ``` 130 | npm start 131 | ``` 132 | 133 | ## Launch Desktop Application 134 | 135 | 1. Build and run Electron app by running the following command from your new, local\ 136 | repository: 137 | ``` 138 | npm run electron 139 | ``` 140 | 141 | # Optional: Accessing Grafana Visualizations 142 | 143 | Should you also wish to run Grafana in your browser, this can by done by running the\ 144 | following command from your home directory: 145 | 146 | ``` 147 | kubectl port-forward deployments/prometheus-grafana 3000 148 | ``` 149 | 150 | Navigate to [PORT 3000](https://localhost:3000/). Finally, you will need to login to access visualizations. The default\ 151 | username is `admin` and the default password is `prom-operator`. 152 | 153 | PodMD 154 | -------------------------------------------------------------------------------- /__mocks__/fileMock.js: -------------------------------------------------------------------------------- 1 | // __mocks__/fileMock.js 2 | module.exports = 'test-file-stub'; 3 | -------------------------------------------------------------------------------- /__tests__/configController.test.js: -------------------------------------------------------------------------------- 1 | const { 2 | config, 3 | configController, 4 | } = require('../server/controllers/configController'); 5 | const { queryPrometheus } = require('../server/services/prometheusService'); 6 | 7 | 8 | jest.mock('../server/services/prometheusService', () => ({ 9 | queryPrometheus: jest.fn(), 10 | })); 11 | 12 | describe('configController.saveConfig', () => { 13 | let req, res, next; 14 | 15 | beforeEach(() => { 16 | req = { 17 | body: { 18 | memory: 75, 19 | memTimeFrame: 20, 20 | cpu: 85, 21 | cpuTimeFrame: 15, 22 | }, 23 | }; 24 | res = { locals: {} }; 25 | next = jest.fn(); 26 | queryPrometheus.mockClear(); 27 | }); 28 | 29 | test('should update config and call queryPrometheus with new queries', async () => { 30 | await configController.saveConfig(req, res, next); 31 | 32 | expect(config.cpu.threshold).toBe(85); 33 | expect(config.cpu.minutes).toBe(15); 34 | expect(config.memory.threshold).toBe(75); 35 | expect(config.memory.minutes).toBe(20); 36 | 37 | expect(queryPrometheus).toHaveBeenCalledWith( 38 | expect.stringContaining('[15m]') 39 | ); 40 | expect(queryPrometheus).toHaveBeenCalledWith( 41 | expect.stringContaining('[20m]') 42 | ); 43 | 44 | expect(res.locals.savedConfig).toEqual({ 45 | cpu: { threshold: 85, minutes: 15 }, 46 | memory: { threshold: 75, minutes: 20 }, 47 | }); 48 | 49 | expect(next).toHaveBeenCalled(); 50 | }); 51 | 52 | test('should call next with an error if an exception is thrown', async () => { 53 | const error = new Error('Test error'); 54 | queryPrometheus.mockImplementationOnce(() => { 55 | throw error; 56 | }); 57 | 58 | await configController.saveConfig(req, res, next); 59 | 60 | expect(next).toHaveBeenCalledWith(error); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /__tests__/kubeController.test.js: -------------------------------------------------------------------------------- 1 | const deletePod = require('../server/controllers/kubeController'); 2 | const k8s = require('@kubernetes/client-node'); 3 | 4 | jest.mock('@kubernetes/client-node', () => { 5 | const CoreV1ApiMock = { 6 | deleteNamespacedPod: jest.fn(), 7 | }; 8 | 9 | return { 10 | KubeConfig: jest.fn().mockImplementation(() => ({ 11 | loadFromDefault: jest.fn(), 12 | makeApiClient: jest.fn().mockReturnValue(CoreV1ApiMock), 13 | })), 14 | CoreV1Api: jest.fn(() => CoreV1ApiMock), 15 | }; 16 | }); 17 | 18 | describe('deletePod', () => { 19 | let consoleSpy; 20 | 21 | beforeEach(() => { 22 | consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); 23 | }); 24 | 25 | afterEach(() => { 26 | consoleSpy.mockRestore(); 27 | jest.clearAllMocks(); 28 | }); 29 | 30 | test('should call deleteNamespacedPod with correct arguments and log success on deletion', async () => { 31 | const k8sApi = new k8s.CoreV1Api(); 32 | k8sApi.deleteNamespacedPod.mockResolvedValueOnce({}); 33 | 34 | await deletePod('test-pod', 'default'); 35 | 36 | expect(k8sApi.deleteNamespacedPod).toHaveBeenCalledWith( 37 | 'test-pod', 38 | 'default' 39 | ); 40 | expect(consoleSpy).not.toHaveBeenCalled(); 41 | }); 42 | 43 | test('should log an error message when deletion fails', async () => { 44 | const error = new Error('Failed to delete pod'); 45 | const k8sApi = new k8s.CoreV1Api(); 46 | k8sApi.deleteNamespacedPod.mockRejectedValueOnce(error); 47 | 48 | await deletePod('test-pod', 'default'); 49 | 50 | expect(k8sApi.deleteNamespacedPod).toHaveBeenCalledWith( 51 | 'test-pod', 52 | 'default' 53 | ); 54 | expect(consoleSpy).toHaveBeenCalledWith( 55 | expect.stringContaining('Error deleting pod') 56 | ); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /__tests__/prometheusController.test.js: -------------------------------------------------------------------------------- 1 | const { 2 | prometheusController, 3 | restartedPods, 4 | checkRestart, 5 | } = require('../server/controllers/prometheusController'); 6 | const { config } = require('../server/controllers/configController'); 7 | const { queryPrometheus } = require('../server/services/prometheusService'); 8 | const deletePod = require('../server/controllers/kubeController'); 9 | 10 | jest.mock('../server/services/prometheusService', () => ({ 11 | queryPrometheus: jest.fn(), 12 | runDemo: false, 13 | })); 14 | 15 | jest.mock('../server/controllers/kubeController', () => jest.fn()); 16 | 17 | describe('Prometheus Controller', () => { 18 | afterEach(() => { 19 | jest.clearAllMocks(); 20 | restartedPods.length = 0; 21 | }); 22 | 23 | describe('fetchGraphData', () => { 24 | it('should query CPU data when cpuGraphMinutes is provided', async () => { 25 | const req = { query: { cpuGraphMinutes: '15' } }; 26 | const res = { locals: {} }; 27 | const next = jest.fn(); 28 | const mockCpuData = { 29 | status: 'success', 30 | data: { result: [{ value: [null, 70] }] }, 31 | }; 32 | 33 | queryPrometheus.mockResolvedValueOnce(mockCpuData); 34 | 35 | await prometheusController.fetchGraphData(req, res, next); 36 | 37 | expect(queryPrometheus).toHaveBeenCalledWith( 38 | expect.stringContaining('[15m]'), 39 | config.cpu.threshold 40 | ); 41 | expect(res.locals.data).toEqual({ cpuData: mockCpuData }); 42 | expect(next).toHaveBeenCalled(); 43 | }); 44 | 45 | it('should query Memory data when memoryGraphMinutes is provided', async () => { 46 | const req = { query: { memoryGraphMinutes: '20' } }; 47 | const res = { locals: {} }; 48 | const next = jest.fn(); 49 | const mockMemData = { 50 | status: 'success', 51 | data: { result: [{ value: [null, 60] }] }, 52 | }; 53 | 54 | queryPrometheus.mockResolvedValueOnce(mockMemData); 55 | 56 | await prometheusController.fetchGraphData(req, res, next); 57 | 58 | expect(queryPrometheus).toHaveBeenCalledWith( 59 | expect.stringContaining('[20m]'), 60 | config.memory.threshold 61 | ); 62 | expect(res.locals.data).toEqual({ memData: mockMemData }); 63 | expect(next).toHaveBeenCalled(); 64 | }); 65 | 66 | it('should call next with an error if queryPrometheus fails', async () => { 67 | const req = { query: { cpuGraphMinutes: '15' } }; 68 | const res = { locals: {} }; 69 | const next = jest.fn(); 70 | const error = new Error('Prometheus query failed'); 71 | 72 | queryPrometheus.mockRejectedValueOnce(error); 73 | 74 | await prometheusController.fetchGraphData(req, res, next); 75 | 76 | expect(next).toHaveBeenCalledWith(error); 77 | }); 78 | }); 79 | 80 | describe('checkRestart', () => { 81 | const mockPodData = { 82 | status: 'success', 83 | data: { 84 | result: [ 85 | { 86 | metric: { pod: 'test-pod', namespace: 'default' }, 87 | value: [null, '85'], 88 | }, 89 | ], 90 | }, 91 | }; 92 | 93 | it('should add pod to restartedPods and call deletePod when threshold is exceeded', async () => { 94 | queryPrometheus.mockResolvedValueOnce(mockPodData); 95 | config.cpu.threshold = 80; 96 | 97 | await checkRestart(config.cpu); 98 | 99 | expect(restartedPods.length).toBe(1); 100 | expect(restartedPods[0]).toMatchObject({ 101 | podName: 'test-pod', 102 | namespace: 'default', 103 | label: config.cpu.label, 104 | value: '85', 105 | threshold: 80, 106 | }); 107 | expect(deletePod).toHaveBeenCalledWith('test-pod', 'default'); 108 | }); 109 | 110 | it('should not add pod to restartedPods if threshold is not exceeded', async () => { 111 | const lowPodData = { 112 | ...mockPodData, 113 | data: { 114 | result: [ 115 | { 116 | metric: { pod: 'low-pod', namespace: 'default' }, 117 | value: [null, '75'], 118 | }, 119 | ], 120 | }, 121 | }; 122 | queryPrometheus.mockResolvedValueOnce(lowPodData); 123 | config.cpu.threshold = 80; 124 | 125 | await checkRestart(config.cpu); 126 | 127 | expect(restartedPods.length).toBe(0); 128 | expect(deletePod).not.toHaveBeenCalled(); 129 | }); 130 | }); 131 | }); 132 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ['@babel/preset-env', '@babel/preset-react'], 3 | plugins: ['@babel/plugin-transform-modules-commonjs'], 4 | }; 5 | -------------------------------------------------------------------------------- /bundle.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/PodMD/56a0593a5c73316d4624976519606d64f8627829/bundle.js -------------------------------------------------------------------------------- /client/assets/Aldrich/Aldrich-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/PodMD/56a0593a5c73316d4624976519606d64f8627829/client/assets/Aldrich/Aldrich-Regular.ttf -------------------------------------------------------------------------------- /client/assets/Aldrich/OFL.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011, Matthew Desmond (http://www.madtype.com | mattdesmond@gmail.com),with Reserved Font Name Aldrich. 2 | 3 | This Font Software is licensed under the SIL Open Font License, Version 1.1. 4 | This license is copied below, and is also available with a FAQ at: 5 | https://openfontlicense.org 6 | 7 | 8 | ----------------------------------------------------------- 9 | SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 10 | ----------------------------------------------------------- 11 | 12 | PREAMBLE 13 | The goals of the Open Font License (OFL) are to stimulate worldwide 14 | development of collaborative font projects, to support the font creation 15 | efforts of academic and linguistic communities, and to provide a free and 16 | open framework in which fonts may be shared and improved in partnership 17 | with others. 18 | 19 | The OFL allows the licensed fonts to be used, studied, modified and 20 | redistributed freely as long as they are not sold by themselves. The 21 | fonts, including any derivative works, can be bundled, embedded, 22 | redistributed and/or sold with any software provided that any reserved 23 | names are not used by derivative works. The fonts and derivatives, 24 | however, cannot be released under any other type of license. The 25 | requirement for fonts to remain under this license does not apply 26 | to any document created using the fonts or their derivatives. 27 | 28 | DEFINITIONS 29 | "Font Software" refers to the set of files released by the Copyright 30 | Holder(s) under this license and clearly marked as such. This may 31 | include source files, build scripts and documentation. 32 | 33 | "Reserved Font Name" refers to any names specified as such after the 34 | copyright statement(s). 35 | 36 | "Original Version" refers to the collection of Font Software components as 37 | distributed by the Copyright Holder(s). 38 | 39 | "Modified Version" refers to any derivative made by adding to, deleting, 40 | or substituting -- in part or in whole -- any of the components of the 41 | Original Version, by changing formats or by porting the Font Software to a 42 | new environment. 43 | 44 | "Author" refers to any designer, engineer, programmer, technical 45 | writer or other person who contributed to the Font Software. 46 | 47 | PERMISSION & CONDITIONS 48 | Permission is hereby granted, free of charge, to any person obtaining 49 | a copy of the Font Software, to use, study, copy, merge, embed, modify, 50 | redistribute, and sell modified and unmodified copies of the Font 51 | Software, subject to the following conditions: 52 | 53 | 1) Neither the Font Software nor any of its individual components, 54 | in Original or Modified Versions, may be sold by itself. 55 | 56 | 2) Original or Modified Versions of the Font Software may be bundled, 57 | redistributed and/or sold with any software, provided that each copy 58 | contains the above copyright notice and this license. These can be 59 | included either as stand-alone text files, human-readable headers or 60 | in the appropriate machine-readable metadata fields within text or 61 | binary files as long as those fields can be easily viewed by the user. 62 | 63 | 3) No Modified Version of the Font Software may use the Reserved Font 64 | Name(s) unless explicit written permission is granted by the corresponding 65 | Copyright Holder. This restriction only applies to the primary font name as 66 | presented to the users. 67 | 68 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font 69 | Software shall not be used to promote, endorse or advertise any 70 | Modified Version, except to acknowledge the contribution(s) of the 71 | Copyright Holder(s) and the Author(s) or with their explicit written 72 | permission. 73 | 74 | 5) The Font Software, modified or unmodified, in part or in whole, 75 | must be distributed entirely under this license, and must not be 76 | distributed under any other license. The requirement for fonts to 77 | remain under this license does not apply to any document created 78 | using the Font Software. 79 | 80 | TERMINATION 81 | This license becomes null and void if any of the above conditions are 82 | not met. 83 | 84 | DISCLAIMER 85 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 86 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF 87 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT 88 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE 89 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 90 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL 91 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 92 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM 93 | OTHER DEALINGS IN THE FONT SOFTWARE. 94 | -------------------------------------------------------------------------------- /client/assets/halfLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/PodMD/56a0593a5c73316d4624976519606d64f8627829/client/assets/halfLogo.png -------------------------------------------------------------------------------- /client/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/PodMD/56a0593a5c73316d4624976519606d64f8627829/client/assets/logo.png -------------------------------------------------------------------------------- /client/assets/logo2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/PodMD/56a0593a5c73316d4624976519606d64f8627829/client/assets/logo2.png -------------------------------------------------------------------------------- /client/assets/logoName.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/PodMD/56a0593a5c73316d4624976519606d64f8627829/client/assets/logoName.png -------------------------------------------------------------------------------- /client/assets/slogan.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/PodMD/56a0593a5c73316d4624976519606d64f8627829/client/assets/slogan.png -------------------------------------------------------------------------------- /client/components/Graph.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState, useRef } from 'react'; 2 | import { Chart, registerables } from 'chart.js'; 3 | import { Typography } from '@mui/material'; 4 | 5 | Chart.register(...registerables); 6 | 7 | const Graph = ({ 8 | title, 9 | cpuGraphMinutes, 10 | memoryGraphMinutes, 11 | setCpuGraphMinutes, 12 | setMemoryGraphMinutes, 13 | data, 14 | }) => { 15 | const [graphDisplay, setGraphDisplay] = useState(null); 16 | const [graphTitleDisplay, setGraphTitleDisplay] = useState(''); 17 | 18 | const chartRef = useRef(null); 19 | 20 | const handleSelectDisplay = (mins) => { 21 | 22 | if (title === 'CPU Usage') { 23 | setCpuGraphMinutes(mins); 24 | } else if (title === 'Memory Usage') { 25 | setMemoryGraphMinutes(mins); 26 | } 27 | }; 28 | 29 | useEffect(() => { 30 | if (!data) { 31 | setGraphTitleDisplay('No Data Available'); 32 | return; 33 | } 34 | 35 | const combinedData = data.map((item, index) => ({ 36 | pod: item.metric.pod, 37 | usage: parseFloat(item.value[1]), 38 | })); 39 | const sortedData = combinedData.sort((a, b) => a.pod.localeCompare(b.pod)); 40 | 41 | const labels = sortedData.map((item) => item.pod); 42 | const cpuUsages = sortedData.map((item) => item.usage); 43 | const barColors = cpuUsages.map((value) => { 44 | if (value >= 100) return 'rgba(222, 55, 27, 0.4)'; 45 | else if (value >= 75) return 'rgba(216,190,31,0.4)'; 46 | else return 'rgba(84,171,180,0.4)'; 47 | }); 48 | const borderColors = cpuUsages.map((value) => { 49 | if (value >= 100) return 'rgba(222, 55, 27, 1.0)'; 50 | else if (value >= 75) return 'rgba(216,190,31,1.0)'; 51 | else return 'rgba(84,171,180,1.0)'; 52 | }); 53 | 54 | if (graphDisplay) { 55 | graphDisplay.destroy(); 56 | } 57 | 58 | const newGraphDisplay = new Chart(chartRef.current, { 59 | type: 'bar', 60 | data: { 61 | labels: labels, 62 | datasets: [ 63 | { 64 | label: title, 65 | data: cpuUsages, 66 | backgroundColor: barColors, 67 | borderColor: borderColors, 68 | borderWidth: 1, 69 | }, 70 | ], 71 | }, 72 | options: { 73 | scales: { 74 | x: { 75 | ticks: { 76 | display: false, 77 | }, 78 | }, 79 | y: { 80 | beginAtZero: true, 81 | min: 0, 82 | max: 100, 83 | ticks: { 84 | stepSize: 10, 85 | callback: function (value) { 86 | return value + '%'; 87 | }, 88 | }, 89 | }, 90 | }, 91 | plugins: { 92 | tooltip: { 93 | callbacks: { 94 | label: function (context) { 95 | const roundedValue = Math.round(context.raw); 96 | return `${context.dataset.label}: ${roundedValue}%`; 97 | }, 98 | }, 99 | }, 100 | }, 101 | }, 102 | }); 103 | 104 | setGraphDisplay(newGraphDisplay); 105 | }, [data, title]); 106 | 107 | return ( 108 |
109 |

{`Average ${title}`}

110 |
111 |
112 | {[1440, 60, 10].map((mins) => ( 113 |
114 | handleSelectDisplay(mins)} 125 | /> 126 | 133 |
134 | ))} 135 |
136 | 152 |
153 | {graphTitleDisplay} 154 | 155 |
156 | ); 157 | }; 158 | 159 | export default Graph; 160 | -------------------------------------------------------------------------------- /client/components/GraphsContainer.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import Graph from './Graph'; 3 | import { Refresh } from '@mui/icons-material'; 4 | import manualGraphRefresh from '../../main.jsx'; 5 | // client/components/GraphsContainer.jsx 6 | // main.jsx 7 | const GraphsContainer = ({ 8 | cpuGraphMinutes, 9 | manualGraphRefresh, 10 | memoryGraphMinutes, 11 | setCpuGraphMinutes, 12 | setMemoryGraphMinutes, 13 | cpuData, 14 | memoryData, 15 | queryCpuData, 16 | queryMemoryData, 17 | }) => { 18 | 19 | const [refreshHover, setRefreshHover] = useState(false); 20 | 21 | const handleCpuSliderChange = (mins) => { 22 | setCpuGraphMinutes(mins); 23 | queryCpuData(mins); 24 | }; 25 | 26 | const handleMemorySliderChange = (mins) => { 27 | setMemoryGraphMinutes(mins); 28 | queryMemoryData(mins); 29 | }; 30 | 31 | const handleRefreshHover = () => { 32 | setRefreshHover(true); 33 | }; 34 | 35 | const handleRefreshLeave = () => { 36 | setRefreshHover(false); 37 | }; 38 | 39 | const handleRefreshClick = () => { 40 | manualGraphRefresh(); 41 | }; 42 | 43 | return ( 44 |
45 |
46 | {(refreshHover ? () : ())} 47 |
48 | 54 | 60 |
61 | ); 62 | }; 63 | 64 | export default GraphsContainer; 65 | -------------------------------------------------------------------------------- /client/components/Navbar.jsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import AppBar from '@mui/material/AppBar'; 3 | import Box from '@mui/material/Box'; 4 | import Toolbar from '@mui/material/Toolbar'; 5 | import logo from '../assets/logo.png'; 6 | import logoName from '../assets/logoName.png'; 7 | import slogan from '../assets/slogan.png'; 8 | 9 | function Navbar() { 10 | return ( 11 | 12 | 13 | 19 |
20 | PodMD Logo 32 | PodMD 44 |
45 | PodMD - Cluster Monitoring for Developers 51 |
52 |
53 | 70 | 71 |
72 | ); 73 | } 74 | 75 | export default Navbar; 76 | -------------------------------------------------------------------------------- /client/components/Parameter.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Slider from '@mui/material/Slider'; 3 | import Box from '@mui/material/Box'; 4 | import TimeInput from './TimeInput'; 5 | 6 | const Parameter = ({ metric, onChange, value, timeFrame, onTimeChange }) => { 7 | return ( 8 | 9 | 10 |

{`${metric} Usage (%)`}

11 |
12 | onChange(e.target.value)} 21 | /> 22 | 27 |
28 | ); 29 | }; 30 | 31 | export default Parameter; 32 | -------------------------------------------------------------------------------- /client/components/ParameterContainer.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import Box from '@mui/material/Box'; 3 | import Button from '@mui/material/Button'; 4 | import Parameters from './Parameter'; 5 | import SavedConfig from './SavedConfig'; 6 | import DataSaverOnIcon from '@mui/icons-material/DataSaverOn'; 7 | import Alert from '@mui/material/Alert'; 8 | import Collapse from '@mui/material/Collapse'; 9 | 10 | const ParameterContainer = ({ 11 | handleSubmit, 12 | memory, 13 | setMemory, 14 | memTimeFrame, 15 | setMemTimeFrame, 16 | cpu, 17 | setCpu, 18 | cpuTimeFrame, 19 | setCpuTimeFrame, 20 | savedConfiguration, 21 | }) => { 22 | const [showAlert, setShowAlert] = useState(false); 23 | 24 | const handleSaveAndSubmit = () => { 25 | handleSubmit(); 26 | setShowAlert(true); 27 | setTimeout(() => setShowAlert(false), 3000); 28 | }; 29 | 30 | return ( 31 | <> 32 |
33 | 34 |
35 | 42 | 49 |
50 | 51 | 66 | 67 |
68 | 69 | 70 | 71 | 72 | Settings successfully saved. 73 | 74 | 75 | 76 |
77 | 78 | ); 79 | }; 80 | 81 | export default ParameterContainer; 82 | -------------------------------------------------------------------------------- /client/components/RestartedPodRow.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const RestartedPodRow = ({ timestamp, namespace, podName, label, value, threshold }) => { 4 | function formatDate(date) { 5 | const now = new Date(); 6 | const isToday = (date.getDate() === now.getDate() && 7 | date.getMonth() === now.getMonth() && 8 | date.getFullYear() === now.getFullYear()); 9 | const time = date.toLocaleTimeString('en-US', { 10 | hour: '2-digit', 11 | minute: '2-digit', 12 | hour12: true 13 | }); 14 | if (isToday) { 15 | return time; 16 | } else { 17 | const date = date.toLocaleDateString('en-US', { 18 | year: '2-digit', 19 | month: 'numeric', 20 | day: 'numeric', 21 | }); 22 | return `${date} ${time}`; 23 | } 24 | } 25 | const formattedDate = formatDate(timestamp); 26 | 27 | return ( 28 | <> 29 | 30 | {podName} 31 | {formattedDate} 32 | {namespace} 33 | {label} 34 | {Math.round(value*100)/100}% 35 | {threshold}% 36 | 37 | 38 | ); 39 | }; 40 | 41 | export default RestartedPodRow; -------------------------------------------------------------------------------- /client/components/RestartedPodTable.jsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Table from '@mui/material/Table'; 3 | import TableBody from '@mui/material/TableBody'; 4 | import TableCell, { tableCellClasses } from '@mui/material/TableCell'; 5 | import TableContainer from '@mui/material/TableContainer'; 6 | import TableHead from '@mui/material/TableHead'; 7 | import TableRow from '@mui/material/TableRow'; 8 | import Paper from '@mui/material/Paper'; 9 | import RestartedPodRow from './RestartedPodRow'; 10 | import { styled } from '@mui/material/styles'; 11 | 12 | const StyledTableCell = styled(TableCell)(({ theme }) => ({ 13 | [`&.${tableCellClasses.head}`]: { 14 | color: theme.palette.common.white, 15 | }, 16 | [`&.${tableCellClasses.body}`]: { 17 | fontSize: 14, 18 | }, 19 | })); 20 | 21 | const StyledTableRow = styled(TableRow)(({ theme }) => ({ 22 | '&:nth-of-type(odd)': { 23 | backgroundColor: theme.palette.action.hover, 24 | }, 25 | '&:last-child td, &:last-child th': { 26 | border: 0, 27 | }, 28 | })); 29 | 30 | const RestartedPodTable = ({ restartedPods }) => { 31 | const rows = []; 32 | restartedPods.sort((a, b) => (a.timestamp < b.timestamp) ? 1 : ((b.timestamp < a.timestamp) ? -1 : 0)); 33 | for (let i = 0; i < Math.min(restartedPods.length, 10); i++) { 34 | let { timestamp, podName, namespace, label, value, threshold } = restartedPods[i]; 35 | rows.push(); 36 | } 37 | return ( 38 | <> 39 | 40 | 41 | 42 | 43 | Restarted Pod 44 | Time Deleted 45 | Namespace 46 | Metric 47 | Restart 48 | Threshold 49 | 50 | 51 | 52 | {rows} 53 | 54 |
55 |
56 | 57 | ); 58 | }; 59 | 60 | export default RestartedPodTable; -------------------------------------------------------------------------------- /client/components/SavedConfig.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Table from '@mui/material/Table'; 3 | import TableBody from '@mui/material/TableBody'; 4 | import TableCell from '@mui/material/TableCell'; 5 | import TableContainer from '@mui/material/TableContainer'; 6 | import TableHead from '@mui/material/TableHead'; 7 | import TableRow from '@mui/material/TableRow'; 8 | import { styled } from '@mui/material/styles'; 9 | 10 | const StyledTableCell = styled(TableCell)(({ theme }) => ({ 11 | backgroundColor: '#363637', 12 | color: '#adadad', 13 | fontFamily: 'Aldrich', 14 | padding: '10px', 15 | border: 'none', 16 | marginBottom: '20px' 17 | })); 18 | 19 | const StyledTableRow = styled(TableRow)(({ theme }) => ({ 20 | backgroundColor: '#363637', 21 | color: '#adadad', 22 | fontFamily: 'Aldrich', 23 | padding: 0, 24 | border: 'none', 25 | marginBottom: '10px' 26 | })); 27 | 28 | const SavedConfig = ({ savedConfiguration }) => { 29 | const { savedMemoryThreshold, savedMemTimeFrame, savedCpuThreshold, savedCpuTimeFrame, savedSettings } = savedConfiguration; 30 | 31 | function createData(resource, threshold, time) { 32 | return { resource, threshold, time }; 33 | } 34 | 35 | const rows = [ 36 | createData('Memory', `${savedMemoryThreshold}%`, `${savedMemTimeFrame} min`), 37 | createData('CPU', `${savedCpuThreshold}%`, `${savedCpuTimeFrame} min`), 38 | ]; 39 | 40 | return ( 41 | <> 42 |

43 | Current Saved Settings 44 |

45 | 53 | 54 | 55 | 56 | Resource 57 | Threshold 58 | Time Frame 59 | 60 | 61 | 62 | {rows.map((row) => ( 63 | 68 | 69 | {row.resource} 70 | 71 | {row.threshold} 72 | {row.time} 73 | 74 | ))} 75 | 76 |
77 |
78 | 79 | ); 80 | }; 81 | 82 | export default SavedConfig; -------------------------------------------------------------------------------- /client/components/TimeInput.jsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import TimeToolTip from './ToolTip'; 3 | 4 | const TimeInput = React.forwardRef(function CustomNumberInput( 5 | { timeFrame, onTimeChange, ...props }, 6 | ref 7 | ) { 8 | return ( 9 |
10 |
Refresh window (min)
11 | onTimeChange(e.target.value)} 18 | aria-label='Refresh window in minutes' 19 | /> 20 | 21 |
22 | ); 23 | }); 24 | 25 | export default TimeInput; 26 | -------------------------------------------------------------------------------- /client/components/ToolTip.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import QuestionMarkIcon from '@mui/icons-material/QuestionMark'; 3 | import IconButton from '@mui/material/IconButton'; 4 | import Tooltip from '@mui/material/Tooltip'; 5 | 6 | const timeDescription = ` 7 | Choose what time frame to record average resource usage. 8 | E.g., 30 min will record average memory or CPU usage of pods over the past 30 minutes. 9 | `; 10 | 11 | export default function TimeToolTip() { 12 | return ( 13 | 14 | 15 | 16 | 17 | 18 | ); 19 | } 20 | 21 | // const newDescription = ` 22 | // Enter new description for PodMD component tooltip here. 23 | // `; 24 | 25 | // export default function NewToolTip() { 26 | // return ( 27 | // 28 | // 29 | // 30 | // 31 | // 32 | // ); 33 | // } -------------------------------------------------------------------------------- /deployment.yaml: -------------------------------------------------------------------------------- 1 | # Replace lines marked '#' with your preferences. 2 | 3 | apiVersion: apps/v1 4 | kind: Deployment 5 | metadata: 6 | name: # 7 | spec: 8 | replicas: 1 9 | selector: 10 | matchLabels: 11 | app: # 12 | template: 13 | metadata: 14 | labels: 15 | app: # 16 | spec: 17 | containers: 18 | - name: # 19 | image: # 20 | ports: 21 | - containerPort: # 22 | - containerPort: # 23 | -------------------------------------------------------------------------------- /docker-compose-dev-hot.yml: -------------------------------------------------------------------------------- 1 | # Replace '#' with your preferences. 2 | 3 | services: 4 | dev: 5 | image: # 6 | container_name: # 7 | ports: # 8 | volumes: [ ./:/usr/src/app, node_modules:/usr/src/app/node_modules ] 9 | command: # 10 | volumes: 11 | node_modules: -------------------------------------------------------------------------------- /electron.js: -------------------------------------------------------------------------------- 1 | const { spawn } = require('child_process'); 2 | const waitOn = require('wait-on'); 3 | const http = require('http'); 4 | 5 | const options = { 6 | resources: ['http://localhost:3333'], 7 | timeout: 60000, 8 | }; 9 | 10 | function isServerReady(url) { 11 | return new Promise((resolve, reject) => { 12 | http 13 | .get(url, (res) => { 14 | if (res.statusCode === 200) { 15 | resolve(); 16 | } else { 17 | reject(new Error(`Server not ready: ${res.statusCode}`)); 18 | } 19 | }) 20 | .on('error', reject); 21 | }); 22 | } 23 | 24 | const server = spawn('node', ['./server/router.js'], { 25 | stdio: 'inherit', 26 | shell: true, 27 | }); 28 | 29 | waitOn(options) 30 | .then(() => { 31 | return isServerReady('http://localhost:3333'); 32 | }) 33 | .then(() => { 34 | const electron = spawn('electron', ['.'], { 35 | stdio: 'inherit', 36 | shell: true, 37 | }); 38 | 39 | electron.on('exit', (code) => { 40 | server.kill(); 41 | }); 42 | }) 43 | .catch((err) => { 44 | console.error('Error waiting for server:', err); 45 | server.kill(); 46 | }); 47 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | Pod Health Configuration 13 | 14 | 15 |
16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | setupFilesAfterEnv: ['/jest.setup.js'], 3 | }; 4 | -------------------------------------------------------------------------------- /main.js: -------------------------------------------------------------------------------- 1 | const { app, BrowserWindow } = require('electron'); 2 | const { spawn } = require('child_process'); 3 | const path = require('path'); 4 | 5 | let mainWindow; 6 | let serverProcess; 7 | 8 | function createWindow() { 9 | mainWindow = new BrowserWindow({ 10 | width: 1200, 11 | height: 1200, 12 | webPreferences: { 13 | nodeIntegration: true, 14 | contextIsolation: false, 15 | }, 16 | }); 17 | 18 | mainWindow.loadFile('./build/index.html'); 19 | 20 | mainWindow.on('closed', () => { 21 | mainWindow = null; 22 | }); 23 | } 24 | 25 | app.on('ready', () => { 26 | serverProcess = spawn('node', [path.join(__dirname, 'server', 'router.js')], { 27 | stdio: 'inherit', 28 | }); 29 | 30 | serverProcess.on('error', (error) => { 31 | console.error(`Failed to start server: ${error.message}`); 32 | }); 33 | 34 | createWindow(); 35 | }); 36 | 37 | app.on('window-all-closed', () => { 38 | if (process.platform !== 'darwin') { 39 | if (serverProcess) serverProcess.kill(); 40 | app.quit(); 41 | } 42 | }); 43 | 44 | app.on('activate', () => { 45 | if (mainWindow === null) { 46 | createWindow(); 47 | } 48 | }); 49 | -------------------------------------------------------------------------------- /main.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import Navbar from './client/components/Navbar'; 4 | import './style.css'; 5 | import ParameterContainer from './client/components/ParameterContainer'; 6 | import GraphsContainer from './client/components/GraphsContainer'; 7 | import RestartedPodTable from './client/components/RestartedPodTable'; 8 | 9 | const App = () => { 10 | const [memory, setMemory] = useState(80); 11 | const [memTimeFrame, setMemTimeFrame] = useState(30); 12 | const [cpu, setCpu] = useState(80); 13 | const [cpuTimeFrame, setCpuTimeFrame] = useState(30); 14 | const [savedConfiguration, setSavedConfiguration] = useState({ 15 | savedMemoryThreshold: 80, 16 | savedMemTimeFrame: 30, 17 | savedCpuThreshold: 80, 18 | savedCpuTimeFrame: 30, 19 | }); 20 | 21 | const [memoryData, setMemoryData] = useState([]); 22 | const [cpuData, setCpuData] = useState([]); 23 | const [cpuGraphMinutes, setCpuGraphMinutes] = useState(60); 24 | const [memoryGraphMinutes, setMemoryGraphMinutes] = useState(60); 25 | const [restartedPods, setRestartedPods] = useState([]); 26 | 27 | const queryCpuData = async (minutes) => { 28 | try { 29 | const response = await fetch( 30 | `http://127.0.0.1:3333/graphData?cpuGraphMinutes=${minutes}`, 31 | { 32 | method: 'GET', 33 | headers: { 34 | 'Content-Type': 'application/json', 35 | }, 36 | } 37 | ); 38 | if (!response.ok) { 39 | throw new Error('Failed to fetch graph data'); 40 | } 41 | const result = await response.json(); 42 | setCpuData(result.cpuData.data.result); 43 | } catch (error) { 44 | console.error('Error fetching graph data:', error); 45 | } 46 | }; 47 | 48 | const queryMemoryData = async (minutes) => { 49 | try { 50 | const response = await fetch( 51 | `http://127.0.0.1:3333/graphData?memoryGraphMinutes=${minutes}`, 52 | { 53 | method: 'GET', 54 | headers: { 55 | 'Content-Type': 'application/json', 56 | }, 57 | } 58 | ); 59 | if (!response.ok) { 60 | throw new Error('Failed to fetch graph data'); 61 | } 62 | const result = await response.json(); 63 | setMemoryData(result.memData.data.result); 64 | } catch (error) { 65 | console.error('Error fetching graph data:', error); 66 | } 67 | }; 68 | 69 | const fetchRestartedPods = async () => { 70 | const res = await fetch('http://127.0.0.1:3333/restarted'); 71 | const restartedPods = await res.json(); 72 | setRestartedPods(restartedPods); 73 | }; 74 | 75 | useEffect(() => { 76 | const restartedPodIntervalId = setInterval(fetchRestartedPods, 10000); 77 | return () => clearInterval(restartedPodIntervalId); 78 | }, []); 79 | 80 | const cpuGraphMinutesRef = useRef(cpuGraphMinutes); 81 | const memoryGraphMinutesRef = useRef(memoryGraphMinutes); 82 | 83 | useEffect(() => { 84 | cpuGraphMinutesRef.current = cpuGraphMinutes; 85 | memoryGraphMinutesRef.current = memoryGraphMinutes; 86 | }, [cpuGraphMinutes, memoryGraphMinutes]); 87 | 88 | useEffect(() => { 89 | queryCpuData(cpuGraphMinutes); 90 | queryMemoryData(memoryGraphMinutes); 91 | 92 | const intervalId = setInterval(() => { 93 | queryCpuData(cpuGraphMinutesRef.current); 94 | queryMemoryData(memoryGraphMinutesRef.current); 95 | }, 60000); 96 | 97 | return () => clearInterval(intervalId); 98 | }, []); 99 | 100 | useEffect(() => { 101 | queryMemoryData(memoryGraphMinutes); 102 | }, [memoryGraphMinutes]); 103 | 104 | useEffect(() => { 105 | queryCpuData(cpuGraphMinutes); 106 | }, [cpuGraphMinutes]); 107 | 108 | const setConfiguration = async (memory, memTimeFrame, cpu, cpuTimeFrame) => { 109 | try { 110 | const config = { 111 | memory, 112 | memTimeFrame, 113 | cpu, 114 | cpuTimeFrame, 115 | }; 116 | const response = await fetch('http://localhost:3333/config', { 117 | method: 'POST', 118 | headers: { 119 | 'Content-Type': 'application/json', 120 | }, 121 | body: JSON.stringify(config), 122 | }); 123 | if (!response.ok) { 124 | throw new Error('Failed to send configuration'); 125 | } 126 | const result = await response.json(); 127 | setSavedConfiguration({ 128 | savedMemoryThreshold: result.memory.threshold, 129 | savedMemTimeFrame: result.memory.minutes, 130 | savedCpuThreshold: result.cpu.threshold, 131 | savedCpuTimeFrame: result.cpu.minutes, 132 | }); 133 | } catch (error) { 134 | console.error('Error sending configuration:', error); 135 | } 136 | }; 137 | 138 | const manualGraphRefresh = async () => { 139 | queryCpuData(cpuGraphMinutes); 140 | await queryMemoryData(memoryGraphMinutes); 141 | fetchRestartedPods(); 142 | }; 143 | 144 | const handleSubmit = () => { 145 | setConfiguration(memory, memTimeFrame, cpu, cpuTimeFrame); 146 | }; 147 | 148 | return ( 149 |
150 | 151 | 163 | 175 | {restartedPods.length > 0 ? ( 176 | 177 | ) : null} 178 |
179 | ); 180 | }; 181 | 182 | const root = createRoot(document.getElementById('app')); 183 | root.render(); 184 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "podmd", 3 | "productName": "PodMD", 4 | "private": true, 5 | "version": "0.0.0", 6 | "type": "commonjs", 7 | "main": "main.js", 8 | "scripts": { 9 | "start": "NODE_ENV=development concurrently \"nodemon ./server/router.js\" \"webpack serve --open\"", 10 | "electron": "node ./electron.js", 11 | "server": "nodemon ./server/router.js", 12 | "dev": "webpack serve", 13 | "build": "NODE_ENV=production webpack --config webpack.config.js", 14 | "test": "jest", 15 | "dist": "npm run build && electron-builder" 16 | }, 17 | "build": { 18 | "appId": "com.yourapp.podmd", 19 | "files": [ 20 | "build/**/*", 21 | "server/**/*", 22 | "main.js" 23 | ], 24 | "directories": { 25 | "buildResources": "assets" 26 | } 27 | }, 28 | "devDependencies": { 29 | "@babel/core": "^7.25.9", 30 | "@babel/plugin-transform-modules-commonjs": "^7.25.9", 31 | "@babel/preset-env": "^7.25.9", 32 | "@babel/preset-react": "^7.25.9", 33 | "babel-loader": "^9.2.1", 34 | "concurrently": "^9.0.1", 35 | "electron": "^33.0.2", 36 | "electron-builder": "^25.1.8", 37 | "file-loader": "^6.2.0", 38 | "html-webpack-plugin": "^5.6.3", 39 | "identity-obj-proxy": "^3.0.0", 40 | "jest": "^29.7.0", 41 | "jest-environment-jsdom": "^29.7.0", 42 | "nodemon": "^3.1.7", 43 | "supertest": "^7.0.0", 44 | "webpack": "^5.95.0", 45 | "webpack-cli": "^5.1.4", 46 | "webpack-dev-server": "^5.1.0" 47 | }, 48 | "dependencies": { 49 | "@babel/preset-env": "^7.25.9", 50 | "@emotion/react": "^11.13.3", 51 | "@emotion/styled": "^11.13.0", 52 | "@fontsource/roboto": "^5.1.0", 53 | "@kubernetes/client-node": "^0.22.0", 54 | "@mui/base": "^5.0.0-beta.59", 55 | "@mui/icons-material": "^6.1.5", 56 | "@mui/material": "^6.1.4", 57 | "@mui/system": "^6.1.4", 58 | "chart.js": "^4.4.4", 59 | "cors": "^2.8.5", 60 | "css-loader": "^7.1.2", 61 | "dotenv": "^16.4.5", 62 | "dotenv-webpack": "^8.1.0", 63 | "express": "^4.21.1", 64 | "express-prom-bundle": "^7.0.0", 65 | "html-webpack-plugin": "^5.6.3", 66 | "node-fetch": "^2.7.0", 67 | "prom-client": "^15.1.3", 68 | "react": "^18.3.1", 69 | "react-dom": "^18.3.1", 70 | "style-loader": "^4.0.0", 71 | "wait-on": "^8.0.1" 72 | }, 73 | "jest": { 74 | "moduleNameMapper": { 75 | "\\.(jpg|jpeg|png|gif|svg)$": "/__mocks__/fileMock.js", 76 | "\\.(css|scss)$": "identity-obj-proxy" 77 | }, 78 | "transform": { 79 | "^.+\\.[tj]sx?$": "babel-jest" 80 | }, 81 | "testEnvironment": "jest-environment-jsdom", 82 | "setupFilesAfterEnv": [ 83 | "/jest.setup.js" 84 | ], 85 | "transformIgnorePatterns": [ 86 | "/node_modules/(?!(@kubernetes/client-node|jsonpath-plus)/)" 87 | ] 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /server/controllers/configController.js: -------------------------------------------------------------------------------- 1 | const { queryPrometheus } = require('../services/prometheusService'); 2 | 3 | let config = { 4 | cpu: { 5 | label: 'cpu', 6 | threshold: 80, 7 | minutes: 30, 8 | queryString: ` 9 | avg(rate(container_cpu_usage_seconds_total[30m])) by (pod, namespace)/ 10 | sum(kube_pod_container_resource_requests{resource="cpu"}) by (pod, namespace) * 100 11 | `, 12 | }, 13 | memory: { 14 | label: 'memory', 15 | threshold: 80, 16 | minutes: 30, 17 | queryString: `sum(avg_over_time(container_memory_usage_bytes[30m])) by (pod, namespace) 18 | / 19 | sum(kube_pod_container_resource_requests{resource="memory"}) by (pod, namespace) * 100 20 | `, 21 | }, 22 | }; 23 | 24 | const updateQueries = () => { 25 | queryPrometheus(config.cpu.queryString); 26 | queryPrometheus(config.memory.queryString); 27 | }; 28 | 29 | const configController = {}; 30 | configController.saveConfig = async (req, res, next) => { 31 | try { 32 | const { memory, memTimeFrame, cpu, cpuTimeFrame } = req.body; 33 | config.cpu.threshold = cpu; 34 | config.cpu.minutes = cpuTimeFrame; 35 | config.cpu.queryString = ` 36 | avg(rate(container_cpu_usage_seconds_total[${cpuTimeFrame}m])) by (pod, namespace)/ 37 | sum(kube_pod_container_resource_requests{resource="cpu"}) by (pod, namespace) * 100 38 | `; 39 | config.memory.threshold = memory; 40 | config.memory.minutes = memTimeFrame; 41 | config.memory.queryString = `sum(avg_over_time(container_memory_usage_bytes[${memTimeFrame}m])) by (pod, namespace) 42 | / 43 | sum(kube_pod_container_resource_requests{resource="memory"}) by (pod, namespace) * 100 44 | `; 45 | 46 | res.locals.savedConfig = { 47 | cpu: { threshold: config.cpu.threshold, minutes: config.cpu.minutes }, 48 | memory: { 49 | threshold: config.memory.threshold, 50 | minutes: config.memory.minutes, 51 | }, 52 | }; 53 | await updateQueries(); 54 | return next(); 55 | } catch (err) { 56 | return next(err); 57 | } 58 | }; 59 | 60 | module.exports = { config, configController }; 61 | -------------------------------------------------------------------------------- /server/controllers/kubeController.js: -------------------------------------------------------------------------------- 1 | const k8s = require('@kubernetes/client-node'); 2 | 3 | const kubeConfigFile = new k8s.KubeConfig(); 4 | kubeConfigFile.loadFromDefault(); 5 | 6 | const k8sApi = kubeConfigFile.makeApiClient(k8s.CoreV1Api); 7 | 8 | async function getPods() { 9 | try { 10 | const res = await k8sApi.listPodForAllNamespaces(); 11 | res.body.items.forEach((pod) => { 12 | console.log(`${pod.metadata.namespace} - ${pod.metadata.name}`); 13 | }); 14 | } catch (err) { 15 | console.error(`Error getting pods: ${err}`); 16 | } 17 | } 18 | 19 | async function deletePod(podName, podNamespace) { 20 | try { 21 | const res = await k8sApi.deleteNamespacedPod(podName, podNamespace); 22 | } catch (err) { 23 | console.error(`Error deleting pod: ${JSON.stringify(err)}`); 24 | } 25 | } 26 | 27 | module.exports = deletePod; 28 | -------------------------------------------------------------------------------- /server/controllers/prometheusController.js: -------------------------------------------------------------------------------- 1 | const { config } = require('./configController'); 2 | const { queryPrometheus, runDemo } = require('../services/prometheusService'); 3 | const deletePod = require('./kubeController'); 4 | console.log('Prometheus Controller Running!'); 5 | 6 | const callInterval = 0.3; 7 | 8 | const restartedPods = []; 9 | 10 | const prometheusController = {}; 11 | 12 | prometheusController.fetchGraphData = async (req, res, next) => { 13 | try { 14 | if (runDemo) { 15 | checkRestart(config.cpu); 16 | checkRestart(config.memory); 17 | } 18 | const cpuGraphMinutes = req.query.cpuGraphMinutes; 19 | const memoryGraphMinutes = req.query.memoryGraphMinutes; 20 | 21 | let cpuData, memData; 22 | 23 | if (cpuGraphMinutes) { 24 | const cpuQuery = ` 25 | avg(rate(container_cpu_usage_seconds_total[${cpuGraphMinutes}m])) by (pod, namespace)/ 26 | sum(kube_pod_container_resource_requests{resource="cpu"}) by (pod, namespace) * 100 27 | `; 28 | cpuData = await queryPrometheus(cpuQuery, config.cpu.threshold); 29 | res.locals.data = { cpuData }; 30 | } 31 | if (memoryGraphMinutes) { 32 | const memQuery = ` 33 | sum(avg_over_time(container_memory_usage_bytes[${memoryGraphMinutes}m])) by (pod, namespace) / 34 | sum(kube_pod_container_resource_requests{resource="memory"}) by (pod, namespace) * 100 35 | `; 36 | memData = await queryPrometheus(memQuery, config.memory.threshold); 37 | res.locals.data = { memData }; 38 | } 39 | 40 | return next(); 41 | } catch (err) { 42 | return next(err); 43 | } 44 | }; 45 | 46 | const checkRestart = async (obj) => { 47 | const { threshold, queryString, label } = obj; 48 | const data = await queryPrometheus(queryString, threshold); 49 | if (data.status === 'success') { 50 | const results = data.data.result; 51 | results.forEach((pod) => { 52 | if ( 53 | pod.value[1] > threshold && 54 | pod.metric.pod !== 'prometheus-prometheus-kube-prometheus-prometheus-0' 55 | ) { 56 | restartedPods.push({ 57 | timestamp: new Date(), 58 | namespace: pod.metric.namespace, 59 | podName: pod.metric.pod, 60 | label, 61 | value: pod.value[1], 62 | threshold, 63 | }); 64 | deletePod(pod.metric.pod, pod.metric.namespace); 65 | } 66 | }); 67 | } else { 68 | console.error(`PromQL ${label} query failed:`, data.error); 69 | } 70 | }; 71 | 72 | const restartChecks = async (config) => { 73 | // invoke Promise.all and pass in the function invocations with arguments in the order it needs to be run in an array 74 | await Promise.all([checkRestart(config.cpu), checkRestart(config.memory)]); 75 | }; 76 | setInterval(() => restartChecks(config), 1000 * 60 * callInterval); 77 | module.exports = { restartedPods, prometheusController, checkRestart }; 78 | -------------------------------------------------------------------------------- /server/router.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const path = require('path'); 3 | const app = express(); 4 | const PORT = 3333; 5 | const cors = require('cors'); 6 | const { 7 | restartedPods, 8 | prometheusController, 9 | } = require('./controllers/prometheusController'); 10 | 11 | const { configController } = require('./controllers/configController'); 12 | 13 | app.use(express.json()); 14 | app.use(express.urlencoded({ extended: true })); 15 | app.use(express.static(path.resolve(__dirname, '../client/assets'))); 16 | app.use(cors()); 17 | 18 | app.get('/', (req, res) => { 19 | return res.status(200).sendFile(path.resolve(__dirname, '../index.html')); 20 | }); 21 | 22 | app.get('/restarted', (req, res) => { 23 | res.status(200).json(restartedPods); 24 | }); 25 | 26 | app.get('/graphData', prometheusController.fetchGraphData, (req, res) => { 27 | res.status(200).json(res.locals.data); 28 | }); 29 | 30 | app.post('/config', configController.saveConfig, (req, res) => { 31 | res.status(201).json(res.locals.savedConfig); 32 | }); 33 | 34 | app.use('*', (req, res) => { 35 | res.status(404).send('Page not found'); 36 | }); 37 | 38 | app.use((err, req, res, next) => { 39 | const defaultErr = { 40 | log: 'App caught unknown middleware error.', 41 | status: 500, 42 | message: { 43 | err: 'An error occurred, please try again.', 44 | }, 45 | }; 46 | const errObj = Object.assign({}, defaultErr, err); 47 | console.error(errObj.log); 48 | res.status(errObj.status).json(errObj.message); 49 | }); 50 | 51 | 52 | if (require.main === module) { 53 | app.listen(PORT, () => { 54 | console.log(`Server listening on port ${PORT}`); 55 | }); 56 | } 57 | 58 | module.exports = app; 59 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /server/services/prometheusService.js: -------------------------------------------------------------------------------- 1 | const fetch = require('node-fetch'); 2 | 3 | const prometheusUrl = 'localhost'; 4 | const docker = 'docker.host.internal'; 5 | 6 | const runDemo = true; 7 | 8 | let demoHasRun = false; 9 | 10 | const demoPod = 'kube-apiserver-minikube'; 11 | 12 | const queryPrometheus = async (queryStr, threshold) => { 13 | try { 14 | const encodedUrl = `http://localhost:9090/api/v1/query?query=${encodeURIComponent( 15 | queryStr 16 | )}`; 17 | const response = await fetch(encodedUrl); 18 | const data = await response.json(); 19 | if (runDemo === true) { 20 | if (demoPod.length === 0) 21 | throw new Error('ERROR: app set to demo-mode but no pod name entered'); 22 | for (const pod of data.data.result) { 23 | if (pod.metric.pod === demoPod && demoHasRun === false) { 24 | pod.value[1] = 75 + Math.random() * 5; 25 | if (pod.value[1] > threshold) demoHasRun = true; 26 | } else { 27 | pod.value[1] = 40 + Math.random() * 10; 28 | } 29 | } 30 | } 31 | return data; 32 | } catch (err) { 33 | console.error(err); 34 | return err; 35 | } 36 | }; 37 | 38 | module.exports = { queryPrometheus, runDemo }; 39 | -------------------------------------------------------------------------------- /service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: pod-dev-service 5 | spec: 6 | type: NodePort 7 | ports: 8 | - name: front 9 | port: 8080 10 | targetPort: 8080 11 | nodePort: 30001 12 | - name: back 13 | port: 3333 14 | targetPort: 3333 15 | nodePort: 30002 16 | selector: 17 | app: pod-dev-hot 18 | -------------------------------------------------------------------------------- /style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; 3 | line-height: 1.5; 4 | font-weight: 400; 5 | 6 | color-scheme: light dark; 7 | color: rgba(255, 255, 255, 0.87); 8 | background-color: #242424; 9 | 10 | font-synthesis: none; 11 | text-rendering: optimizeLegibility; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | } 15 | 16 | @font-face { 17 | font-family: 'Aldrich', sans-serif; 18 | src: url('./client/assets/Aldrich/Aldrich-Regular.ttf'); 19 | font-weight: normal; 20 | font-style: normal; 21 | } 22 | 23 | a { 24 | font-weight: 500; 25 | color: #646cff; 26 | text-decoration: inherit; 27 | } 28 | a:hover { 29 | color: #535bf2; 30 | } 31 | 32 | .OuterContainer { 33 | margin-top: 80px; 34 | } 35 | 36 | body { 37 | margin: 0; 38 | display: flex; 39 | place-items: center; 40 | min-width: 320px; 41 | min-height: 100vh; 42 | } 43 | 44 | h1 { 45 | font-size: 3.2em; 46 | line-height: 1.1; 47 | color: #adadad; 48 | } 49 | 50 | h2 { 51 | font-family: 'Aldrich', sans-serif; 52 | color: #adadad; 53 | padding: 3px; 54 | margin-bottom: 10px; 55 | margin-top: 10px; 56 | } 57 | 58 | h3 { 59 | font-family: 'Aldrich', sans-serif; 60 | color: #adadad; 61 | margin: 0; 62 | } 63 | 64 | h3 { 65 | font-family: 'Aldrich', sans-serif; 66 | color: #adadad; 67 | } 68 | 69 | h4 { 70 | font-family: 'Aldrich', sans-serif; 71 | color: #adadad; 72 | } 73 | 74 | #app { 75 | max-width: 1280px; 76 | margin: 0 auto; 77 | padding: 2rem; 78 | text-align: center; 79 | } 80 | 81 | #toolbar { 82 | display: flex; 83 | justify-content: space-between; 84 | } 85 | 86 | #sliderTitle { 87 | display: flex; 88 | flex-direction: row; 89 | align-items: flex-end; 90 | margin: 0; 91 | } 92 | 93 | canvas { 94 | margin-top: 10px; 95 | margin-bottom: 10px; 96 | } 97 | 98 | .sliderBox { 99 | margin-top: 20px; 100 | margin-left: 20px; 101 | padding: 5px; 102 | display: flex; 103 | flex-direction: column; 104 | align-items: flex-start; 105 | } 106 | 107 | .logo:hover { 108 | filter: drop-shadow(0 0 2em #646cffaa); 109 | } 110 | .logo.vanilla:hover { 111 | filter: drop-shadow(0 0 2em #f7df1eaa); 112 | } 113 | 114 | #paramBox { 115 | display: flex; 116 | flex-direction: row; 117 | justify-content: space-around; 118 | align-items: flex-start; 119 | border-radius: 8px; 120 | width: 470px; 121 | background-color: #363637; 122 | filter: drop-shadow(1px 1px 1px #1e1d1d); 123 | } 124 | 125 | li { 126 | text-align: left; 127 | margin-left: 25px; 128 | font-family: 'Aldrich'; 129 | color: #adadad; 130 | } 131 | 132 | .card { 133 | padding: 2em; 134 | } 135 | 136 | .read-the-docs { 137 | color: #888; 138 | } 139 | 140 | #buttonForm { 141 | display: flex; 142 | justify-content: flex-end; 143 | gap: 25px; 144 | } 145 | 146 | button { 147 | margin: 10px; 148 | border-radius: 8px; 149 | border: 1px solid transparent; 150 | padding: 0.6em 1.2em; 151 | font-size: 1em; 152 | font-weight: 500; 153 | font-family: inherit; 154 | background-color: #e8e3e3; 155 | cursor: pointer; 156 | transition: border-color 0.25s; 157 | } 158 | button:hover { 159 | border-color: #646cff; 160 | } 161 | 162 | button:focus, 163 | button:focus-visible { 164 | outline: 4px auto -webkit-focus-ring-color; 165 | } 166 | 167 | #settingsCard { 168 | display: flex; 169 | flex-direction: column; 170 | align-items: center; 171 | border: 0; 172 | width: 470px; 173 | border-radius: 8px; 174 | background-color: #363637; 175 | filter: drop-shadow(1px 1px 1px #1e1d1d); 176 | } 177 | 178 | #outerBox { 179 | margin-top: 20px; 180 | display: flex; 181 | justify-content: space-between; 182 | height: 300px; 183 | } 184 | 185 | #configButton { 186 | display: flex; 187 | flex-direction: column; 188 | align-self: flex-end; 189 | padding: 20px; 190 | } 191 | 192 | #timeInputLabel { 193 | font-family: 'Aldrich', sans-serif; 194 | color: #adadad; 195 | } 196 | 197 | #timeInput { 198 | border-radius: 4px; 199 | height: 25px; 200 | width: 45px; 201 | text-align: center; 202 | border: 2px solid #adadad; 203 | color: #adadad; 204 | background-color: transparent; 205 | font-family: 'Aldrich', sans-serif; 206 | margin-left: 10px; 207 | margin-right: 10px; 208 | } 209 | 210 | 211 | #timeInputField { 212 | display: flex; 213 | justify-content: space-between; 214 | align-items: center; 215 | } 216 | 217 | #timeTool { 218 | margin-left: 10px; 219 | color: #adadad; 220 | } 221 | 222 | #configContainer { 223 | display: flex; 224 | } 225 | 226 | .graphs { 227 | position: relative; 228 | width: 60em; 229 | height: 620px; 230 | border-radius: 8px; 231 | margin-top: 20px; 232 | display: flex; 233 | flex-direction: row; 234 | justify-content: space-between; 235 | align-items: space-between; 236 | margin-bottom: 20px; 237 | background-color: #363637; 238 | filter: drop-shadow(1px 1px 1px #1e1d1d); 239 | } 240 | 241 | #refresh-icon { 242 | position: absolute; 243 | top: 10px; 244 | right: 10px; 245 | } 246 | 247 | #graphContain { 248 | border-radius: 8px; 249 | } 250 | 251 | table { 252 | background-color: #363637; 253 | border: 0; 254 | border-radius: 8px; 255 | } 256 | 257 | th, 258 | td { 259 | color: #adadad; 260 | border: none; 261 | } 262 | 263 | tr:nth-child(odd) { 264 | background-color: #242424; 265 | } 266 | 267 | .heartbeat { 268 | animation: heartbeat 1.5s infinite; 269 | } 270 | 271 | @keyframes heartbeat { 272 | 0%, 273 | 100% { 274 | transform: scale(0.9); 275 | } 276 | 15% { 277 | transform: scale(1.1); 278 | } 279 | 30% { 280 | transform: scale(1); 281 | } 282 | 45% { 283 | transform: scale(1.1); 284 | } 285 | 60% { 286 | transform: scale(0.9); 287 | } 288 | } 289 | 290 | #innerGraphBox { 291 | display: flex; 292 | flex-direction: column; 293 | align-items: center; 294 | width: 470px; 295 | } 296 | 297 | .sliderContainer { 298 | position: relative; 299 | width: 350px; 300 | height: 60px; 301 | display: flex; 302 | align-items: center; 303 | flex-direction: row-reverse; 304 | justify-content: center; 305 | box-sizing: border-box; 306 | } 307 | 308 | .tabs { 309 | display: flex; 310 | flex-shrink: 0; 311 | justify-content: space-between; 312 | min-width: 300px; 313 | position: absolute; 314 | background: #242424; 315 | padding: 8px; 316 | border-radius: 40px; 317 | box-shadow: 0 6px 12px 0 #2a2a2b; 318 | } 319 | 320 | .tabs * { 321 | z-index: 4; 322 | } 323 | 324 | .radio { 325 | display: none; 326 | } 327 | 328 | .tab { 329 | display: flex; 330 | flex-shrink: 0; 331 | align-items: center; 332 | justify-content: center; 333 | min-height: 40px; 334 | min-width: 75px; 335 | font-size: 14px; 336 | font-weight: 600; 337 | font-family: 'Aldrich', sans-serif; 338 | border-radius: 40px; 339 | cursor: pointer; 340 | transition: color 0.15s ease-in; 341 | } 342 | 343 | .radio:checked + label { 344 | color: #042100; 345 | } 346 | 347 | input[id='radio-1']:checked ~ .graphSlider { 348 | transform: translateX(0); 349 | } 350 | 351 | input[id='radio-2']:checked ~ .graphSlider { 352 | transform: translateX(80px); 353 | } 354 | 355 | input[id='radio-3']:checked ~ .graphSlider { 356 | transform: translateX(160px); 357 | } 358 | 359 | .graphSlider { 360 | position: relative; 361 | display: flex; 362 | height: 40px; 363 | width: 75px; 364 | background: #54abb4; 365 | z-index: 1; 366 | border-radius: 40px; 367 | transition: 0.25s ease-out; 368 | } 369 | 370 | th { 371 | text-align: left; 372 | } 373 | 374 | #firstHeader { 375 | color: #adadad; 376 | text-align: left; 377 | } 378 | 379 | .MuiTableContainer-root.settingContain { 380 | color: red; 381 | } 382 | 383 | .MuiTableCell-root.MuiTableCell-head.tableHeader { 384 | font-family: 'Aldrich', sans-serif; 385 | font-size: 14px; 386 | text-align: center; 387 | color: #adadad; 388 | } 389 | 390 | .MuiButtonBase-root .MuiSvgIcon-root path { 391 | stroke: #adadad; 392 | } 393 | 394 | .MuiButtonBase-root:focus, 395 | .MuiButtonBase-root:active path { 396 | border: none; 397 | outline: none; 398 | } 399 | 400 | #tblhd .MuiTableCell-root { 401 | padding: 5px; 402 | text-align: center; 403 | font-size: 15px; 404 | color: #adadad; 405 | } 406 | #tblhd #firstHeader { 407 | text-align: left; 408 | } 409 | 410 | .MuiTableBody-root { 411 | display: flex; 412 | justify-content: space-around; 413 | font-family: 'Roboto', sans-serif; 414 | font-size: 14px; 415 | text-align: center; 416 | color: #adadad; 417 | } 418 | 419 | @media (max-width: 760px) { 420 | .tabs { 421 | transform: scale(0.6); 422 | } 423 | } 424 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const path = require('path'); 3 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 4 | 5 | module.exports = { 6 | watchOptions: { 7 | poll: 1000 8 | }, 9 | entry: './main.jsx', 10 | output: { 11 | filename: 'bundle.js', 12 | publicPath: './', 13 | path: path.resolve(__dirname, 'build'), 14 | }, 15 | mode: process.env.NODE_ENV === 'production' ? 'production' : 'development', 16 | devtool: 17 | process.env.NODE_ENV === 'production' ? 'source-map' : 'eval-source-map', 18 | devServer: { 19 | compress: false, 20 | host: 'localhost', 21 | port: 8080, 22 | hot: true, 23 | static: { 24 | directory: path.resolve(__dirname, 'build'), 25 | }, 26 | 27 | }, 28 | module: { 29 | rules: [ 30 | { 31 | test: /\.(png|jpeg|gif|svg)$/i, 32 | type: 'asset/resource', 33 | }, 34 | { 35 | test: /.(js|jsx)$/, 36 | exclude: /node_modules/, 37 | use: [ 38 | { 39 | loader: 'babel-loader', 40 | options: { 41 | targets: 'defaults', 42 | presets: ['@babel/preset-env', '@babel/preset-react'], 43 | }, 44 | }, 45 | ], 46 | }, 47 | { 48 | test: /.(css|scss)$/, 49 | exclude: /node_modules/, 50 | use: ['style-loader', 'css-loader'], 51 | }, 52 | { 53 | test: /\.(ttf|eot|woff|woff2)$/, 54 | use: { 55 | loader: 'file-loader', 56 | options: { 57 | name: '[name].[ext]', 58 | outputPath: 'assets/', 59 | }, 60 | }, 61 | }, 62 | ], 63 | }, 64 | plugins: [ 65 | new HtmlWebpackPlugin({ 66 | template: './index.html', 67 | favicon: './client/assets/logo2.png', 68 | }), 69 | ], 70 | resolve: { 71 | extensions: ['.js', '.jsx'], 72 | }, 73 | }; 74 | --------------------------------------------------------------------------------