├── .gitignore ├── LICENSE ├── README.md ├── __mocks__ ├── fileMock.js └── styleMocks.js ├── __tests__ ├── Events.test.tsx ├── playwright.e2e.ts ├── supertest.js └── utils.test.ts ├── client ├── Types.ts ├── assets │ ├── All-Services.png │ ├── Forward-Success.png │ ├── Home-to-Analysis.gif │ ├── Landing-to-Home.gif │ ├── analysis-icon.png │ ├── close-menu-icon.png │ ├── dashboard-icon.png │ ├── favicon.icns │ ├── favicon.ico │ ├── logo-hat.png │ ├── logo.png │ ├── ns-icon.png │ ├── open-menu-icon.png │ └── stylesheets │ │ ├── Sofia-Pro-Soft.otf │ │ └── style.scss ├── components │ ├── AnalysisPage.tsx │ ├── App.tsx │ ├── ChartGrid.tsx │ ├── ClusterChart.tsx │ ├── ClusterChartCard.tsx │ ├── Events.tsx │ ├── Graph.tsx │ ├── HomePage.tsx │ ├── LandingPage.tsx │ ├── LogCard.tsx │ ├── Modal.tsx │ ├── Sidebar.tsx │ └── Tooltip.tsx ├── global.d.ts ├── index.html └── index.tsx ├── electron ├── main.ts ├── metricsData │ └── formatMatrix.ts ├── preload.ts └── utils.ts ├── jest.config.js ├── package.json ├── playwright.config.ts ├── tsconfig.json ├── webpack.config.js └── webpack.production.js /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | # PromQL queries 107 | QUERIES.TXT 108 | 109 | test.js 110 | 111 | package-lock.json 112 | 113 | # playwright testing reports 114 | __tests__/playwright_reports 115 | 116 | # do not include /build 117 | build 118 | # do not include .DS_STORE 119 | .DS_STORE -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 OSLabs Beta 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |

3 | drawing 4 |

5 | 6 | # Palaemon :fried_shrimp: :fried_shrimp: 7 | 8 | - An Electron based developer tool for Kubernetes cluster monitoring and error analysis 9 | - Palaemon is a Greek, child sea-god who came to aid sailors in distress. He was often depicted as riding a dolphin. Also, a genus of [shrimp](https://en.wikipedia.org/wiki/Palaemon_(crustacean)). 10 | 11 | # Running the Electron App 12 | Prerequisites: 13 | - [ ] kubectl installed 14 | - [ ] Prometheus installed 15 | - [ ] Prometheus port-forwarded to `localhost:9090` 16 | 17 | ## Launching in dev mode with Hot-Module Reload (HMR) 18 | If this is the first time launching the app then run the following commands: 19 | ``` 20 | npm install 21 | npm run build 22 | ``` 23 | This will build your initial `dist` folder. 24 | 25 | 26 | Then on a different terminal run 27 | ``` 28 | npm run electronmon 29 | ``` 30 | 31 | The build command for webpack will run webpack with the --watch flag to watch for any changes in the files, and rebuild the dist folder when any files are changed. Electronmon is watching the dist folder for any changes and will either refresh or relaunch the electron app when it detects any of the dist folder files have been changed. 32 | 33 | 34 | ## After the initial build, you can now run the following command 35 | ``` 36 | npm start 37 | ``` 38 | This which will first delete the old `dist` folder from your app, and concurrently launch the webpack to build and electronmon to wait for the new `dist` folder to be built. 39 | 40 | If the build process was interrupted through `CTRL + C` or other means, then you may receive the following error: 41 | ``` 42 | Error: ENOENT: no such file or directory, stat 'dist' 43 | ``` 44 | In this case, just run the command below, and wait for webpack to finish building. After webpack has finished, you can start the app normally. 45 | ``` 46 | npm run build 47 | ``` 48 | ## Launching in production mode: 49 | The command below will build and bundle files into /dist folder for production and open the electron app based on the bundled files from /dist. 50 | ``` 51 | npm start:production 52 | ``` 53 | 54 | # Using Palaemon 55 | 1. On a successful startup, you will be greeted with a landing page allowing you to select a namespace to be analyzed. 56 | 2. After selecting your namespace, you will be moved to the home page 57 | - The home page is where you can find a list of all events, alerts, and OOMKills 58 | - Events are limited to occurences within the last hour due to a kubectl limitation. 59 | - OOMKill events are **not** limited to the last hour. 60 | 3. The lefthand side will show you all of your pods within the namespace. 61 | - The color of the squares represent the memory usage vs memory limit of the pod, with green pods using less memory than their requested amount. 62 | - Black pods indicate that no data was available for that pod. 63 | 4. When the OOMKill type is seleceted, an `Analyze` button will appear, where you can click that to be taken to the `Analysis` page. 64 | - Here is where you can find all of the information on your node and other pods at the time of death, along with pertinent events. 65 | 66 | 67 |

68 | drawing 69 |

70 | 71 |
72 | 73 |

74 | drawing 75 |

76 | 77 | ## Kubernetes Requirements 78 | Palaemon utilizes the kubernetes command line tool (kubectl) to gather data on your nodes and clusters. Kubectl can be downloaded and installed [here](https://kubernetes.io/docs/tasks/tools/). 79 | 80 | Minikube can also be used to test out Palaemon. More can be found on their official documentation [here](https://minikube.sigs.k8s.io/docs/start/). 81 | 82 | Palaemon has not yet been tested with Amazon Elastic Kubernetes Service (EKS) or Microsoft Azure Kubernetes Service (AKS), but should be compatible provided that kubectl can access all cluster and node information. 83 | ## How to Connect to Google Kubernetes Engine 84 | 85 | 1. Install gcloud CLI on your local machine from [here](https://cloud.google.com/sdk/docs/install) 86 | - If you have having problems with the gcloud CLI, try using the command below in your home directory, and make sure to update your $PATH in the process. 87 | ``` 88 | curl https://sdk.cloud.google.com | bash 89 | ``` 90 | - If you are still having issues, trying restarting your terminal. 91 | 2. Initialize the gcloud CLI following the steps [here](https://cloud.google.com/sdk/docs/initializing) 92 | 3. Connect your gcloud CLI to your GKE cluster [here](https://cloud.google.com/kubernetes-engine/docs/how-to/cluster-access-for-kubectl) 93 | - Make sure to get the credentials of your cluster (see below) or you will not be able to connect to your cluster 94 | ``` 95 | gcloud container clusters get-credentials [CLUSTER_NAME] 96 | ``` 97 | 98 | # Prometheus Requirements 99 | Palaemon utilizes Prometheus to scrape for data on your pods. Therefore, please ensure you have Prometheus installed on your node. 100 | 101 | Cloud hosting platforms such as GCP and AWS already have managed Prometheus services. If you are new to Prometheus, we recommend installing the Prometheus monitoring package, Prometheus Operator. This includes a full monitoring stack: Prometheus, Alert Manager, Node Exporter, Grafana, and Kube State Metrics. Prometheus Operator can be installed onto your cluster via Helm at the link [here](https://artifacthub.io/packages/helm/choerodon/prometheus-operator). 102 | 103 | Once you have Prometheus installed into your Kubernetes cluster, follow the steps below to port-forward your Prometheus operator service. 104 | 105 | ## Connecting Prometheus to Palaemon 106 | 107 | Make sure a Prometheus pod is installed onto your node/cluster, and forward its port (default 9090) to your localhost through the command below. 108 | ``` 109 | kubectl port-forward -n [NAMESPACE] service/[PROMETHEUS] 9090 110 | ``` 111 | 112 | - The -n flag indicates the namespace that the pod is assigned to. 113 | - A list of all available services can be found through typing the command below in the terminal. 114 | ``` 115 | kubectl get services -A 116 | ``` 117 | Find the service with a 9090/TCP Port assigned, and forward that service to your local 9090. 118 | 119 | ![Get All Services](./client/assets/All-Services.png) 120 | 121 | In the example above, the command would be : 122 | ``` 123 | kubectl port-forward -n monitoring service/operator-kube-prometheus-s-prometheus 9090 124 | ``` 125 | 126 | Once you have port-forwarded the service, you should now be able to access the Palaemon application. If you are still getting an error with port 9090 being closed, double-check that you are exposing the correct service: It should be listening on port 9090 (see picture below). 127 | 128 | - A more detailed set of instructions can be found on Google's Documentations [here](https://cloud.google.com/stackdriver/docs/managed-prometheus/query) 129 | 130 | ![Forward Success](./client/assets/Forward-Success.png) 131 | 132 | ### NOTE: While you *can* forward Prometheus to a local port that is **not** 9090, Palaemon is not yet setup to handle any other port besides 9090. 133 | 134 |

135 | ## How to Run Tests 136 | ### Unit and Integration tests using Jest 137 | The jest testing suite will start with the command below. The `--watch` flag is enabled, which allows for immediate retests upon save. The jest config in `jest.config.js` is set up to only look for and run test files within the `__test__` folder and with file names that include ".test." in them, such as "Events.test.tsx". 138 | ``` 139 | npm run test:watch 140 | ``` 141 | Units tests are set up using Jest testing suite and react-testing-library to test react components in the front end. 142 | 143 | ### End-to-End testing with Playwright Test Runner 144 | `npm run test:e2e` will execute the playwright test runner and run any test files in the `__test__` folder with the name format ".e2e." in them, such as "playwright.e2e.ts". 145 | 146 | There are settings to enable HTML report and video, snapshot, trace recordings that can be configured in the `playwright.config.ts` file is fo desired. 147 | 148 | 149 | ## Features 150 | 151 | * Realtime Pod memory usage, sorted by namespaces 152 | * Connect to locally hosted or cloud hosted Kubernetes clusters and Prometheus monitoring tools 153 | * Filter by namespaces for meaningful reports 154 | * Monitoring for health and resource usage per pod and nodes 155 | * Records and curates reports based on recent out-of-memory kill (OOMKill) errors 156 | * Displays event and alert logs 157 | * Visualize health of pods and nodes by namespace in a Kubernetes cluster 158 | * Execute customized queries based on user-defined time intervals 159 | * Documentation available on the official website 160 | 161 | ## Planned Features 162 | 163 | 1. Provide custom alerts for OOMKill events with specific termination reasons such as “Limit Overcommit” or “Container Limit Reached” 164 | 2. Allow for early, graceful termination of pods 165 | 3. Automatic reconfiguration of YAML files to adjust memory limits and requests 166 | 167 | ## Built With 168 | 169 | 170 | - [Electron](https://www.electronjs.org/) 171 | - [TypeScript](https://www.typescriptlang.org/) 172 | - [React](https://reactjs.org/) 173 | - [React Router](https://reactrouter.com/) 174 | - [Jest](https://jestjs.io/) 175 | - [Playwright](https://playwright.dev/) 176 | - [Node](https://nodejs.org/) 177 | - [Prometheus](https://prometheus.io/) 178 | - [Chart.js](https://www.chartjs.org/) 179 | 180 | 181 | ## The Team 182 | - Si Young Mah [Github](https://github.com/siyoungmah) [LinkedIn](https://www.linkedin.com/in/siyoungmah/) 183 | - Patrick Hu [Github](https://github.com/pathu91) [LinkedIn](https://www.linkedin.com/in/patrickhu91/) 184 | - Thang Thai [Github](https://github.com/thang-thai) [LinkedIn](https://www.linkedin.com/in/thang-thai/) 185 | - Raivyno Lenny Sutrisno [Github](https://github.com/FrozenStove) [LinkedIn](https://www.linkedin.com/in/raivyno-sutrisno/) 186 | -------------------------------------------------------------------------------- /__mocks__/fileMock.js: -------------------------------------------------------------------------------- 1 | module.exports = 'test-file-stub'; -------------------------------------------------------------------------------- /__mocks__/styleMocks.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; -------------------------------------------------------------------------------- /__tests__/Events.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, fireEvent, waitFor, screen } from '@testing-library/react'; 3 | import '@testing-library/jest-dom/extend-expect'; 4 | 5 | import Events from '../client/components/Events'; 6 | import { EventProps, EventObject, oomObject } from '../client/Types'; 7 | 8 | beforeAll(() => { 9 | window.api = { 10 | getNodes: jest.fn(), 11 | getDeployments: jest.fn(), 12 | getServices: jest.fn(), 13 | getPods: jest.fn(), 14 | getLogs: jest.fn(), 15 | getEvents: jest.fn().mockReturnValue(Promise.resolve(mockEvents)), 16 | getNamespaces: jest.fn(), 17 | getMemoryUsageByPods: jest.fn(), 18 | getAlerts: jest.fn().mockReturnValue(Promise.resolve(mockAlerts)), 19 | getAllInfo: jest.fn(), 20 | getOOMKills: jest.fn().mockReturnValue(Promise.resolve(mockOOMKills)), 21 | getUsage: jest.fn(), 22 | getAnalysis: jest.fn(), 23 | }; 24 | }); 25 | 26 | beforeEach(() => { 27 | render( 28 | {}} 30 | analyzedPod={[]} 31 | setAnalyzedData={() => {}} 32 | setShowGraphs={() => {}} 33 | clusterChartData={[]} 34 | /> 35 | ); 36 | }); 37 | 38 | const mockEvents = [ 39 | { 40 | namespace: 'mock_event_namespace', 41 | lastSeen: 'mock_event_lastseen', 42 | severity: 'mock_event_severity', 43 | reason: 'mock_event_reason', 44 | message: 'mock_event_message', 45 | object: 'mock_event_object', 46 | }, 47 | ]; 48 | 49 | const mockAlerts = [ 50 | { 51 | group: 'mock_alerts_group', 52 | state: 'mock_alerts_state', 53 | name: 'mock_alerts_name', 54 | severity: 'mock_alerts_severity', 55 | description: 'mock_alerts_description', 56 | summary: 'mock_alerts_group', 57 | alerts: 'mock_alerts_alerts', 58 | }, 59 | ]; 60 | 61 | // a sample mocked oomObject 62 | const mockOomObject: oomObject = { 63 | namespace: 'mock_namespace', 64 | node: 'mock_node', 65 | podName: 'mock_podname', 66 | laststate: 'mock_state', 67 | restartcount: '4', 68 | reason: 'Fatal testing in jest mocks', 69 | exitcode: 'MOCK_EXITCODE', 70 | started: 'mock_begining', 71 | finished: 'mock_ending', 72 | ready: 'mock_ready', 73 | limits: { 74 | limitCpu: 'mock_cpu_limit', 75 | limitMemory: 'mock_memory_limit', 76 | }, 77 | requests: { 78 | limitCpu: 'mock_cpu_request', 79 | limitMemory: 'mock_memory_request', 80 | }, 81 | }; 82 | 83 | const mockOOMKills = [mockOomObject]; 84 | 85 | const setStateMock = jest.fn(); 86 | const useStateMock: any = (useState: any) => [useState, setStateMock]; 87 | jest.spyOn(React, 'useState').mockImplementation(useStateMock); 88 | 89 | describe('', () => { 90 | test('should render without crashing', () => {}); 91 | test('should render necessary components', () => {}); 92 | test('should render logs cards based on select options', () => {}); 93 | test('should render loading icon only when fetching new data', () => {}); 94 | 95 | describe('The log type selector', () => { 96 | test('should have Events as default log type upon render', () => { 97 | expect( 98 | screen.getByRole('combobox', { name: 'log-type' }) 99 | ).toHaveDisplayValue('Events'); 100 | expect( 101 | screen.getByRole('combobox', { name: 'log-type' }) 102 | ).not.toHaveDisplayValue('Alerts'); 103 | expect( 104 | screen.getByRole('combobox', { name: 'log-type' }) 105 | ).not.toHaveDisplayValue('OOMKills'); 106 | }); 107 | 108 | test('should change displayed value based on user input', () => { 109 | // change the selection to 'alerts' 110 | fireEvent.change(screen.getByRole('combobox', { name: 'log-type' }), { 111 | target: { value: 'alerts' }, 112 | }); 113 | 114 | // the screen should correctly display the newly chosen option 115 | expect( 116 | screen.getByRole('combobox', { name: 'log-type' }) 117 | ).toHaveDisplayValue('Alerts'); 118 | expect( 119 | screen.getByRole('combobox', { name: 'log-type' }) 120 | ).not.toHaveDisplayValue('Events'); 121 | expect( 122 | screen.getByRole('combobox', { name: 'log-type' }) 123 | ).not.toHaveDisplayValue('OOMKills'); 124 | 125 | // change the selection to 'OOMKills' 126 | fireEvent.change(screen.getByRole('combobox', { name: 'log-type' }), { 127 | target: { value: 'oomkills' }, 128 | }); 129 | 130 | // the screen should correctly display the newly chosen option 131 | expect( 132 | screen.getByRole('combobox', { name: 'log-type' }) 133 | ).toHaveDisplayValue('OOMKills'); 134 | expect( 135 | screen.getByRole('combobox', { name: 'log-type' }) 136 | ).not.toHaveDisplayValue('Events'); 137 | expect( 138 | screen.getByRole('combobox', { name: 'log-type' }) 139 | ).not.toHaveDisplayValue('Alerts'); 140 | }); 141 | 142 | test('should filter log card components when the select option has changed', () => {}); 143 | 144 | test('should capitalize select option values', () => {}); 145 | }); 146 | }); 147 | -------------------------------------------------------------------------------- /__tests__/playwright.e2e.ts: -------------------------------------------------------------------------------- 1 | import { ElectronApplication, Page, _electron as electron } from 'playwright' 2 | import { expect, test } from '@playwright/test'; 3 | 4 | let electronApp: ElectronApplication; 5 | let page: Page; 6 | 7 | // Test basic launching of the app 8 | test.describe('01. Launch app', async () => { 9 | test.beforeEach(async () => { 10 | // open the app 11 | electronApp = await electron.launch({ args: ['dist/electron/main.js'] }); 12 | electronApp.on('window', async (page) => { 13 | const filename = page.url()?.split('/').pop(); 14 | console.log(`Window opened: ${filename}`); 15 | 16 | // capture errors 17 | page.on('pageerror', (err) => console.log('page has errors: ', err)); 18 | 19 | // capture console messages 20 | page.on('console', (log) => console.log(log.text())); 21 | }); 22 | page = await electronApp.firstWindow(); 23 | }); 24 | 25 | test.afterEach(async () => { 26 | // close the app 27 | await electronApp.close(); 28 | }); 29 | 30 | test.describe('Rendering landing page:', () => { 31 | test('renders the first page', async () => { 32 | // page = await electronApp.firstWindow(); 33 | 34 | await page.waitForSelector('h1'); 35 | const text = await page.$eval('h1', (el) => el.textContent); 36 | expect(text).toBe('PALAEMON'); 37 | 38 | // const title = await page.title(); 39 | // expect(title).toBe('Palaemon'); 40 | await expect(page).toHaveTitle('Palaemon'); 41 | }); 42 | 43 | test('sidebar for navigation exists', async () => { 44 | await page.waitForSelector('#sidebar'); 45 | expect(await page.$('#sidebar')).toBeTruthy(); 46 | }); 47 | 48 | test('on app load, hash routing is initialized as an empty string', async () => { 49 | // HashRouter keeps track of routing by using # 50 | // when the app loads, the default hash is empty 51 | const hash = await page.evaluate(() => window.location.hash); 52 | expect(hash).toBe(''); 53 | }); 54 | }); 55 | 56 | test.describe('Static routing and navigation around the app:', () => { 57 | 58 | test('clicking on hat logo routes back to landing page', async () => { 59 | await page.click('#sidebar #logo'); 60 | 61 | expect(await page.$('#landing-container')).toBeTruthy(); 62 | const hash = await page.evaluate(() => window.location.hash); 63 | expect(hash).toBe('#/'); 64 | }); 65 | 66 | test('clicking on logo text PALAEMON routes back to landing page', async () => { 67 | await page.click('#company-name'); 68 | 69 | expect(await page.$('#landing-container')).toBeTruthy(); 70 | const hash = await page.evaluate(() => window.location.hash); 71 | expect(hash).toBe('#/'); 72 | }); 73 | 74 | test('clicking on "Namespace" on sidebar routes back to landing page', async () => { 75 | await page.click('#link-namespace'); 76 | await page.waitForSelector('#landing-container'); 77 | 78 | expect(await page.$('#landing-container')).toBeTruthy(); 79 | const hash = await page.evaluate(() => window.location.hash); 80 | expect(hash).toBe('#/'); 81 | }); 82 | 83 | test('clicking on "Dashboard" on sidebar routes to homepage', async () => { 84 | await page.click('#link-dashboard'); 85 | 86 | // wait for react router to load 87 | await page.waitForSelector('#contents'); 88 | 89 | const hash = await page.evaluate(() => window.location.hash); 90 | expect(hash).toBe('#/home'); 91 | 92 | //landing container from previous page should no longer exist 93 | expect(await page.$('#landing-container')).toBeNull(); 94 | expect(await page.$('#contents')).toBeTruthy(); 95 | // form should no longer exist on the page 96 | }); 97 | 98 | test('clicking on "Analysis" on sidebar routes to analysis page', async () => { 99 | await page.click('#link-analysis'); 100 | 101 | // wait for react router to load 102 | await page.waitForSelector('#analysis-container'); 103 | 104 | const hash = await page.evaluate(() => window.location.hash); 105 | expect(hash).toBe('#/analysis'); 106 | 107 | //landing container from previous page should no longer exist 108 | expect(await page.$('#landing-container')).toBeNull(); 109 | expect(await page.$('#analysis-container')).toBeTruthy(); 110 | // form should no longer exist on the page 111 | }); 112 | }); 113 | }); 114 | 115 | // Testing Namespace page 116 | test.describe('02. Namespace page ', async () => { 117 | test.beforeEach(async () => { 118 | // open the app 119 | electronApp = await electron.launch({ args: ['dist/electron/main.js'] }); 120 | electronApp.on('window', async (page) => { 121 | const filename = page.url()?.split('/').pop(); 122 | console.log(`Window opened: ${filename}`); 123 | 124 | // capture errors 125 | page.on('pageerror', (err) => console.log('page has errors: ', err)); 126 | 127 | // capture console messages 128 | page.on('console', (log) => console.log(log.text())); 129 | }); 130 | page = await electronApp.firstWindow(); 131 | }); 132 | 133 | test.afterEach(async () => { 134 | // close the app 135 | await electronApp.close(); 136 | }); 137 | 138 | test('Select menu should be populated based on available namespaces:', async () => { 139 | 140 | }); 141 | }); 142 | 143 | -------------------------------------------------------------------------------- /__tests__/supertest.js: -------------------------------------------------------------------------------- 1 | const request = require("supertest"); 2 | 3 | const server = "http://localhost:9090"; 4 | -------------------------------------------------------------------------------- /__tests__/utils.test.ts: -------------------------------------------------------------------------------- 1 | const globalAny:any = global; 2 | 3 | beforeAll(() => { }); 4 | beforeEach(() => { }); 5 | afterAll(() => { }); 6 | afterEach(() => { }); 7 | 8 | // import { enableFetchMocks } from 'jest-fetch-mock' 9 | // enableFetchMocks() 10 | 11 | // import fetchMock from "jest-fetch-mock" 12 | 13 | // import fetch from 'node-fetch'; 14 | 15 | import { SvgInfoObj } from '../client/Types'; 16 | import { setStartAndEndTime, fetchMem } from '../electron/utils' 17 | 18 | // jest.mock('node-fetch'); 19 | 20 | // let mockedFetch: jest.Mocked; 21 | 22 | 23 | describe('setStartAnEndTime', () => { 24 | test('returns and object with keys startTime and endTime', () => { 25 | const result = setStartAndEndTime(); 26 | expect(typeof result).toBe('object'); 27 | expect(result).toHaveProperty('startTime'); 28 | expect(result).toHaveProperty('endTime'); 29 | }); 30 | 31 | test('sets startTime to an hour before now, and endTime to now', () => { 32 | const now = new Date(); 33 | const endTime = now.toISOString(); 34 | now.setHours(now.getHours() - 24); 35 | const startTime = now.toISOString(); 36 | const result = setStartAndEndTime(); 37 | 38 | expect(result.startTime).toBe(startTime); 39 | expect(result.endTime).toBe(endTime); 40 | }); 41 | 42 | test('Lenny is testing here', () => { 43 | expect('Lenny').toBe('Lenny'); 44 | }); 45 | 46 | }); 47 | 48 | describe('fetchMem should return the memory usage of a pod', () => { 49 | // beforeAll(() => { 50 | const mockObj = { 51 | metadata: { 52 | annotations: { 'cluster-autoscaler.kubernetes.io/safe-to-evict': 'true' }, 53 | creationTimestamp: '2022 - 09 - 08T17: 08: 33.000Z', 54 | generateName: 'mockPodname', 55 | labels: { 56 | app: 'prometheus-node-exporter', 57 | chart: 'prometheus-node-exporter-3.3.1', 58 | 'controller-revision-hash': '7987bcc957', 59 | heritage: 'Helm', 60 | jobLabel: 'node-exporter', 61 | 'pod-template-generation': '1', 62 | release: 'stack' 63 | }, 64 | name: 'mockPodname', 65 | namespace: 'mockNamepsace', 66 | }, 67 | spec: { 68 | nodeName: 'mockNodeName' 69 | }, 70 | } 71 | // limitData.data.result[0].metric.resource 72 | const mockRequest = { 73 | status: 'success', 74 | data: { 75 | resultType: 'matrix', 76 | result: [ // we use this line 77 | { 78 | metric: { 79 | __name__: 'kube_pod_container_resource_requests', 80 | container: 'alertmanager', 81 | endpoint: 'http', 82 | instance: '172.17.0.2:8080', 83 | job: 'kube-state-metrics', 84 | namespace: 'default', 85 | node: 'minikube', 86 | pod: 'alertmanager-stack-kube-prometheus-stac-alertmanager-0', 87 | resource: 'request', // we use this line 88 | service: 'stack-kube-state-metrics', 89 | uid: '863f4c04-580e-4b7f-923b-f1dd52fd1c07', 90 | unit: 'byte' 91 | }, 92 | values: [[1663624864.753, '52428800']] // this string of numbers is what we need 93 | } 94 | ] 95 | }, 96 | } 97 | const mockLimit = { 98 | status: 'success', 99 | data: { 100 | resultType: 'matrix', 101 | result: [ // we use this line 102 | { 103 | metric: { 104 | __name__: 'kube_pod_container_resource_requests', 105 | container: 'alertmanager', 106 | endpoint: 'http', 107 | instance: '172.17.0.2:8080', 108 | job: 'kube-state-metrics', 109 | namespace: 'default', 110 | node: 'minikube', 111 | pod: 'alertmanager-stack-kube-prometheus-stac-alertmanager-0', 112 | resource: 'memory', // we use this line 113 | service: 'stack-kube-state-metrics', 114 | uid: '863f4c04-580e-4b7f-923b-f1dd52fd1c07', 115 | unit: 'byte' 116 | }, 117 | values: [[1663624864.753, '102428800']] // this string of numbers is what we need 118 | } 119 | ] 120 | }, 121 | }; 122 | 123 | 124 | const jsonLimObj = { 125 | json: jest.fn().mockReturnValue(Promise.resolve(mockLimit)) 126 | } 127 | const jsonReqObj = { 128 | json: jest.fn().mockReturnValue(Promise.resolve(mockRequest)) 129 | } 130 | 131 | let returnValue: any; 132 | let returnValueOnce: any; 133 | 134 | 135 | 136 | 137 | // jest.mock('node-fetch', ()=>jest.fn().mockReturnValue(Promise.resolve(jsonReqObj)).mockReturnValueOnce(Promise.resolve(jsonLimObj))) 138 | 139 | afterEach(() => { 140 | // console.log('afterEach') 141 | jest.resetModules() 142 | }) 143 | 144 | beforeEach(() => { 145 | // console.log('beforeEach') 146 | jest.mock('node-fetch', ()=>jest.fn() 147 | .mockReturnValue(Promise.resolve(returnValue)) 148 | .mockReturnValueOnce(Promise.resolve(returnValueOnce)) 149 | ) 150 | }) 151 | 152 | test('It should return a default object with port 9090 closed', async () => { 153 | 154 | const defaultData = { 155 | name: '', 156 | usage: 1, 157 | resource: '', 158 | limit: 1, 159 | request: 1, 160 | parent: '', 161 | namespace: '', 162 | } 163 | 164 | const pod1: SvgInfoObj = await fetchMem(mockObj) 165 | // console.log('mypod9090', pod1) 166 | expect(pod1).toStrictEqual(defaultData) 167 | }) 168 | 169 | 170 | test('It should accept an object with the correct information, or return an error', async () => { 171 | // THIS TEST NEEDS TO BE FINISHED ON THE UTILS.TS SIDE, IT IS SET TO FAIL IF UNCOMMENTED 172 | 173 | const defaultData = { 174 | name: '', 175 | usage: 1, 176 | resource: '', 177 | limit: 1, 178 | request: 1, 179 | parent: '', 180 | namespace: '', 181 | } 182 | 183 | // const pod1: SvgInfoObj = await fetchMem('string') 184 | // console.log('mypod9090', pod1) 185 | // expect(pod1).toStrictEqual(defaultData) 186 | }) 187 | 188 | test('It returns memory usage and data about pods', async () => { 189 | 190 | returnValue = jsonReqObj; 191 | returnValueOnce = jsonLimObj; 192 | 193 | const pod: SvgInfoObj = await fetchMem(mockObj) 194 | 195 | // console.log('mypodTrue', pod) 196 | expect(pod).toHaveProperty('name'); 197 | expect(pod.request).toBe(52.4288); 198 | expect(pod.limit).toBe(102.4288); 199 | expect(pod).toBeInstanceOf(SvgInfoObj); 200 | }) 201 | // test('', () => { 202 | 203 | // }) 204 | 205 | }) 206 | 207 | // test other functions that live in then -------------------------------------------------------------------------------- /client/Types.ts: -------------------------------------------------------------------------------- 1 | //--------------------------------Types for Home Page---------------------------------------------------- 2 | 3 | export type SvgInfo = { 4 | // for properties that dont exist in pod, node, cluster or deployment give it a 0 for num or '' for string 5 | name: string; // name of pod, node, or cluster, or deployment 6 | usage: number; // pods: memory used in bytes -- node: "memory requested" field from k8 (kibibytes) 7 | resource: string; // default should be memory. if a pod tracks cpu it will overwrite 8 | unit?: string; // either megabytes or milicores 9 | request: number; // pods: request memory in bytes -- node: 0 (node does not have same type of "request" memory as pods) 10 | limit: number; // pods: limit memory in bytes -- node: "memory allocatable" field from k8 (kibibytes) 11 | parent: string; // pod: node name -- node: cluster name 12 | namespace: string; // the namespace of a pod, or n/a for node 13 | }; 14 | 15 | export class SvgInfoObj implements SvgInfo { 16 | constructor() { 17 | // set default values for each prop 18 | // number defaults are set to 1 (instead of) to avoid divide by 0 issues 19 | this.name = ''; 20 | this.usage = 1; 21 | this.resource = 'memory'; 22 | this.request = 1; 23 | this.limit = 1; 24 | this.parent = ''; 25 | this.namespace = ''; 26 | } 27 | 28 | name: string; // name of pod, node, or cluster, or deployment 29 | usage: number; // pods: memory used in bytes -- node: "memory requested" field from k8 (kibibytes) 30 | resource: string; // default should be memory. if a pod tracks cpu it will overwrite 31 | unit?: string; // either megabytes or milicores 32 | request: number; // pods: request memory in bytes -- node: 0 (node does not have same type of "request" memory as pods) 33 | limit: number; // pods: limit memory in bytes -- node: "memory allocatable" field from k8 (kibibytes) 34 | parent: string; // pod: node name -- node: cluster name 35 | namespace: string; // the namespace of a pod, or n/a for node 36 | } 37 | 38 | export type ClusterAllInfo = { 39 | Nodes: SvgInfo[]; 40 | Pods: SvgInfo[]; 41 | }; 42 | 43 | export interface ModalProps extends SvgInfo { 44 | position: { left: string; top: string }; 45 | close: () => void; 46 | } 47 | //--------------------------------Types for Cluster Chart---------------------------------------------------- 48 | 49 | export interface ClusterChartProps extends ClusterAllInfo { 50 | close: () => void; 51 | click: (e: any, input: SvgInfo) => void; 52 | } 53 | 54 | export type ClusterChartCardProps = { 55 | title: string; // Cluster, or Pod, or Node, or Deployment 56 | data: SvgInfo[]; 57 | click: (e: any, input: SvgInfo) => void; 58 | close: () => void; 59 | }; 60 | 61 | //--------------------------------Types for the right side and alerts/events ---------------------------------------------------- 62 | 63 | export type EventProps = { 64 | setAnalyzedPod: (input: any) => void; 65 | analyzedPod: any[]; 66 | clusterChartData: any[]; 67 | setAnalyzedData: (input: any) => void; 68 | setShowGraphs: (input: any) => void; 69 | }; 70 | 71 | export type AnalyzeCount = any[]; 72 | 73 | export interface LogCardProps { 74 | eventObj?: EventObject; 75 | alertObj?: AlertObject; 76 | oomObj?: oomObject; 77 | logType: string; 78 | analyzedPod: oomObject; 79 | setAnalyzedPod: (input: any) => void; 80 | clusterChartData: any; 81 | setAnalyzedData: (input: any) => void; 82 | setShowGraphs: (input: any) => void; 83 | setLoading?: (input: any) => void; 84 | } 85 | 86 | export type EventObject = { 87 | namespace: string; 88 | lastSeen: string; 89 | severity: string; 90 | reason: string; 91 | message: string; 92 | object: string; 93 | }; 94 | 95 | export type AlertObject = { 96 | group: string; 97 | state: string; 98 | name: string; 99 | severity: string; 100 | description: string; 101 | summary: string; 102 | alerts: string; 103 | }; 104 | 105 | export type oomObject = { 106 | namespace: string; 107 | node: string; 108 | podName: string; 109 | laststate: string; 110 | restartcount: string; 111 | reason: string; 112 | exitcode: string; 113 | started: string; 114 | finished: string; 115 | ready: string; 116 | limits: LimOrReq; 117 | requests: LimOrReq; 118 | }; 119 | 120 | export type LimOrReq = { 121 | limitCpu: string; 122 | limitMemory: string; 123 | }; 124 | 125 | //--------------------------------Types for Graphs---------------------------------------------------- 126 | export type GraphData = { 127 | [podName: string]: { 128 | times: string[]; 129 | values: number[]; 130 | }; 131 | }[]; 132 | 133 | export type ChartGraphData = { 134 | nodeMem: GraphData; 135 | nodeCPU: GraphData; 136 | podMem: GraphData; 137 | podCPU: GraphData; 138 | netRead: GraphData; 139 | netWrite: GraphData; 140 | }; 141 | 142 | export type GraphableData = { 143 | label: string; 144 | backgroundColor: string; 145 | borderColor: string; 146 | data: number[]; 147 | }; 148 | 149 | //--------------------------------Types for Analysis Page---------------------------------------------------- 150 | 151 | export type AnalysisPage = { 152 | analyzedPod: any[]; 153 | analyzedData: any[]; 154 | setAnalyzedPod: (input: any) => void; 155 | setAnalyzedData: (input: any) => void; 156 | showGraphs: boolean; 157 | setShowGraphs: (input: boolean) => void; 158 | }; 159 | 160 | export type AnalysisPageProps = { 161 | analyzedPod: any[]; 162 | analyzedData: any[]; 163 | setAnalyzedPod: (input: any) => void; 164 | clusterChartData: any; 165 | setAnalyzedData: (input: any) => void; 166 | showGraphs: boolean; 167 | setShowGraphs: (input: boolean) => void; 168 | }; 169 | 170 | export type TooltipProps = { 171 | position: { left: string; top: string }; 172 | }; 173 | 174 | export type SidebarProps = { 175 | menuOpen: string; 176 | setMenuOpen: (input: boolean) => void; 177 | }; -------------------------------------------------------------------------------- /client/assets/All-Services.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/Palaemon/5786859f3f543a3303f379a8dad0ca981f48c885/client/assets/All-Services.png -------------------------------------------------------------------------------- /client/assets/Forward-Success.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/Palaemon/5786859f3f543a3303f379a8dad0ca981f48c885/client/assets/Forward-Success.png -------------------------------------------------------------------------------- /client/assets/Home-to-Analysis.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/Palaemon/5786859f3f543a3303f379a8dad0ca981f48c885/client/assets/Home-to-Analysis.gif -------------------------------------------------------------------------------- /client/assets/Landing-to-Home.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/Palaemon/5786859f3f543a3303f379a8dad0ca981f48c885/client/assets/Landing-to-Home.gif -------------------------------------------------------------------------------- /client/assets/analysis-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/Palaemon/5786859f3f543a3303f379a8dad0ca981f48c885/client/assets/analysis-icon.png -------------------------------------------------------------------------------- /client/assets/close-menu-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/Palaemon/5786859f3f543a3303f379a8dad0ca981f48c885/client/assets/close-menu-icon.png -------------------------------------------------------------------------------- /client/assets/dashboard-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/Palaemon/5786859f3f543a3303f379a8dad0ca981f48c885/client/assets/dashboard-icon.png -------------------------------------------------------------------------------- /client/assets/favicon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/Palaemon/5786859f3f543a3303f379a8dad0ca981f48c885/client/assets/favicon.icns -------------------------------------------------------------------------------- /client/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/Palaemon/5786859f3f543a3303f379a8dad0ca981f48c885/client/assets/favicon.ico -------------------------------------------------------------------------------- /client/assets/logo-hat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/Palaemon/5786859f3f543a3303f379a8dad0ca981f48c885/client/assets/logo-hat.png -------------------------------------------------------------------------------- /client/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/Palaemon/5786859f3f543a3303f379a8dad0ca981f48c885/client/assets/logo.png -------------------------------------------------------------------------------- /client/assets/ns-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/Palaemon/5786859f3f543a3303f379a8dad0ca981f48c885/client/assets/ns-icon.png -------------------------------------------------------------------------------- /client/assets/open-menu-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/Palaemon/5786859f3f543a3303f379a8dad0ca981f48c885/client/assets/open-menu-icon.png -------------------------------------------------------------------------------- /client/assets/stylesheets/Sofia-Pro-Soft.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/Palaemon/5786859f3f543a3303f379a8dad0ca981f48c885/client/assets/stylesheets/Sofia-Pro-Soft.otf -------------------------------------------------------------------------------- /client/assets/stylesheets/style.scss: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Sofia Pro Soft'; 3 | src: url('./Sofia-Pro-Soft.otf') format('opentype'); 4 | } 5 | 6 | $ugly-sewage-green: #00695c; 7 | $p-light-color: #439889; 8 | $p-dark-color: #003d33; 9 | 10 | $font-color: #002a25; 11 | 12 | $secondary-color: #b2dfdb; 13 | $tertiary-color: #d1ece9; 14 | $s-light-color: #e5ffff; 15 | $s-dark-color: #82ada9; 16 | 17 | $sidebar-width: 240px; 18 | $sidebar-width-closed: 80px; 19 | 20 | $header-height: 56px; 21 | 22 | $grey: #ddd; 23 | 24 | html { 25 | height: 100vh; 26 | // prevents the window from over-scrolling and bouncing beyond the 100% screen size 27 | overflow: hidden; 28 | } 29 | 30 | body, 31 | #root, 32 | #sidebar, 33 | #contents, 34 | #landing-container, 35 | #analysis-container { 36 | height: 100%; 37 | } 38 | 39 | body { 40 | margin: 0px; 41 | padding: 0px; 42 | box-sizing: border-box; 43 | } 44 | 45 | * { 46 | // border: 1px black solid; 47 | font-family: Arial, Helvetica, sans-serif; 48 | } 49 | 50 | #logo { 51 | width: 180px; 52 | margin: 8px; 53 | transition: all 0.3s; 54 | } 55 | 56 | #logo-menu-closed { 57 | width: 60px; 58 | margin: 8px; 59 | } 60 | 61 | #root { 62 | height: 100vh; 63 | padding: 0px; 64 | display: flex; 65 | } 66 | 67 | #sidebar { 68 | width: $sidebar-width; 69 | background-color: #004941; 70 | display: flex; 71 | flex-direction: column; 72 | align-items: center; 73 | position: relative; 74 | transition: all 0.3s; 75 | padding: 0.5rem; 76 | 77 | a { 78 | & > img { 79 | margin-top: 50px; 80 | } 81 | 82 | // logo-hat load as "focus-visible" with a yellow outline on app load 83 | // this is to disable that :( 84 | &:focus-visible { 85 | outline: none !important; 86 | } 87 | } 88 | 89 | ul { 90 | list-style-type: none; 91 | padding: 0; 92 | 93 | li { 94 | &, 95 | * { 96 | text-decoration: none; 97 | margin: 0.6rem 0; 98 | font-size: 1.2rem; 99 | color: whitesmoke; 100 | } 101 | } 102 | } 103 | 104 | #close-menu-icon, 105 | #open-menu-icon { 106 | width: 1.2rem; 107 | position: absolute; 108 | top: 0.4rem; 109 | right: 0.4rem; 110 | cursor: pointer; 111 | } 112 | 113 | .sidebar-page-container { 114 | display: flex; 115 | align-items: center; 116 | gap: 1rem; 117 | transition: all 0.3s; 118 | 119 | .sidebar-icon { 120 | width: 2.4rem; 121 | color: white; 122 | transition: all 0.3s; 123 | } 124 | 125 | .sidebar-icon-closed { 126 | @extend .sidebar-icon; 127 | width: 2rem; 128 | transition: all 0.3s; 129 | } 130 | 131 | .sidebar-names { 132 | transition: all 1s; 133 | } 134 | } 135 | } 136 | 137 | #sidebar-closed { 138 | @extend #sidebar; 139 | width: $sidebar-width-closed; 140 | transition: all 0.3s; 141 | } 142 | 143 | #page { 144 | flex-grow: 1; 145 | display: flex; 146 | flex-direction: column; 147 | background-color: $secondary-color; 148 | height: 100vh; 149 | } 150 | 151 | #header { 152 | display: flex; 153 | justify-content: flex-end; 154 | background-color: $ugly-sewage-green; 155 | 156 | img { 157 | display: block; 158 | } 159 | 160 | a { 161 | text-decoration: none; 162 | } 163 | 164 | h1 { 165 | display: block; 166 | height: max-content; 167 | color: whitesmoke; 168 | padding-right: 12px; 169 | margin: 12px; 170 | font-size: 1.7rem; 171 | font-family: "Sofia Pro Soft"; 172 | } 173 | } 174 | 175 | #contents { 176 | flex-grow: 1; 177 | display: flex; 178 | justify-content: space-between; 179 | background-color: #b2dfdb; 180 | // transition: all 0.3s; 181 | 182 | & #left-side, 183 | #right-side { 184 | height: 100%; 185 | width: calc((100vw - $sidebar-width) / 2); 186 | transition: all 0.3s; 187 | } 188 | 189 | & #left-side-closed, 190 | #right-side-closed { 191 | height: 100%; 192 | width: calc((100vw - $sidebar-width-closed) / 2); 193 | transition: all 0.3s; 194 | } 195 | } 196 | 197 | #left-side { 198 | display: flex; 199 | flex-direction: column; 200 | justify-content: flex-start; 201 | } 202 | 203 | #tagline { 204 | display: block; 205 | margin: 0; 206 | text-align: right; 207 | background-color: $ugly-sewage-green; 208 | font-weight: 100; 209 | font-size: 1rem; 210 | font-style: italic; 211 | color: whitesmoke; 212 | padding-right: 12px; 213 | } 214 | 215 | footer { 216 | font-size: 0.75rem; 217 | text-align: center; 218 | color: $ugly-sewage-green; 219 | padding: 4px 0; 220 | } 221 | 222 | // left side styling 223 | .cluster-chart-card { 224 | background-color: white; 225 | box-shadow: 5px 10px 10px rgb(64 0 0 / 20%); 226 | border-radius: 10px; 227 | margin: 1rem 2rem; 228 | padding: 0.6rem; 229 | display: flex; 230 | align-items: center; 231 | flex-wrap: wrap; 232 | font-weight: bold; 233 | color: $font-color; 234 | 235 | .cluster-chart-names { 236 | display: inline-block; 237 | width: 100px; 238 | } 239 | 240 | svg { 241 | margin: 0px 5px; 242 | // border: black 2px solid; 243 | } 244 | 245 | svg:hover { 246 | filter: drop-shadow(2px 2px 3px $font-color); 247 | } 248 | 249 | .health-bar { 250 | cursor: pointer; 251 | } 252 | } 253 | 254 | .cluster-btns { 255 | display: flex; 256 | gap: 0.6rem; 257 | margin: 1rem 2rem; 258 | 259 | .select-mem, 260 | .select-cpu { 261 | font-size: 0.7rem; 262 | border-radius: 10px; 263 | border: none; 264 | background-color: $ugly-sewage-green; 265 | color: white; 266 | min-width: 2.4rem; 267 | padding: 0.4rem 0.8rem; 268 | font-weight: 500; 269 | transition: all 0.2s; 270 | } 271 | 272 | .select-mem { 273 | top: 0.4rem; 274 | left: 4.3rem; 275 | } 276 | 277 | .select-cpu { 278 | top: 1.8rem; 279 | left: 4.3rem; 280 | } 281 | 282 | .select-mem:hover, 283 | .select-cpu:hover { 284 | background-color: white; 285 | color: $font-color; 286 | cursor: pointer; 287 | filter: drop-shadow(2px 2px 3px $font-color); 288 | } 289 | } 290 | 291 | .modal { 292 | animation-name: opac; 293 | animation-fill-mode: both; 294 | animation-duration: 0.5s; 295 | 296 | position: absolute; 297 | color: white; 298 | background-color: $ugly-sewage-green; 299 | 300 | box-shadow: 10px 10px 10px 6px rgb(64 0 0 / 20%); 301 | margin: 16px 20px 16px 20px; 302 | border-radius: 10px; 303 | padding: 0.4rem 1rem; 304 | z-index: 999; 305 | 306 | .modalClose { 307 | position: relative; 308 | display: flex; 309 | justify-content: flex-end; 310 | 311 | #modalCloseBtn { 312 | cursor: pointer; 313 | } 314 | } 315 | 316 | .modalList { 317 | list-style: none; 318 | padding-left: 0; 319 | } 320 | } 321 | 322 | @keyframes opac { 323 | from { 324 | opacity: 0; 325 | } 326 | 327 | to { 328 | opacity: 1; 329 | } 330 | } 331 | 332 | // Right side event styling 333 | #container-event { 334 | display: flex; 335 | flex-direction: column; 336 | height: 100%; 337 | 338 | // background-color: $tertiary-color; 339 | 340 | // & > * { 341 | // padding: 0.8rem; 342 | // margin: 0.5rem; 343 | // } 344 | } 345 | 346 | // Right side of event styling 347 | #container-event-logs { 348 | flex-grow: 1; 349 | overflow-y: auto; 350 | overflow-x: hidden; 351 | // I thiiink flex-grow maxes out the height, but a fixed height is needed to make 352 | // overflow-y scrolling feature working. I don't know why. 353 | height: 100px; 354 | color: $font-color; 355 | background-color: whitesmoke; 356 | // border: 4px solid $s-dark-color; 357 | box-shadow: 5px 10px 10px rgb(64 0 0 / 20%); 358 | margin: 0rem 2rem 1rem 1rem; 359 | border-radius: 10px; 360 | padding: 1rem; 361 | 362 | .event-card { 363 | position: relative; 364 | margin-bottom: 1rem; 365 | padding: 1rem 1.5rem; 366 | box-shadow: 5px 10px 10px rgb(64 0 0 / 20%); 367 | background-color: white; 368 | border-radius: 10px; 369 | word-wrap: break-word; 370 | 371 | &-header { 372 | display: flex; 373 | justify-content: space-between; 374 | } 375 | 376 | p { 377 | margin: 0.5rem 0; 378 | } 379 | } 380 | 381 | .no-logs-msg { 382 | font-size: 1rem; 383 | text-align: center; 384 | background-color: $grey; 385 | border-radius: 10px; 386 | padding: 1rem; 387 | color: $font-color; 388 | } 389 | } 390 | 391 | #container-select { 392 | display: flex; 393 | justify-content: flex-start; 394 | align-items: center; 395 | gap: 24px; 396 | height: 50px; 397 | margin-top: 0.4rem; 398 | margin-left: 1rem; 399 | 400 | & > select { 401 | width: 140px; 402 | font-size: 1.1rem; 403 | padding: 0.25rem 0.5rem; 404 | cursor: pointer; 405 | border-radius: 10px; 406 | border: none; 407 | } 408 | } 409 | 410 | .analyze { 411 | position: absolute; 412 | top: 10px; 413 | right: 10px; 414 | box-shadow: 0px 2px 3px rgb(64 0 0 / 20%); 415 | border-radius: 10px; 416 | border: none; 417 | padding: 0.3rem 0.6rem; 418 | font-size: 0.8rem; 419 | background-color: $ugly-sewage-green; 420 | color: white; 421 | transition: all 0.2s; 422 | text-transform: uppercase; 423 | } 424 | 425 | .analyze:hover { 426 | background-color: white; 427 | color: $font-color; 428 | cursor: pointer; 429 | } 430 | 431 | #graph { 432 | color: $font-color; 433 | background-color: white; 434 | box-shadow: 5px 10px 10px rgb(64 0 0 / 20%); 435 | border-radius: 10px; 436 | display: flex; 437 | flex-direction: column; 438 | gap: 1rem; 439 | margin: 2rem 2rem; 440 | padding: 0.5rem; 441 | 442 | .legend-btn-main { 443 | background-color: $ugly-sewage-green; 444 | color: white; 445 | box-shadow: 2px 2px 6px rgb(64 0 0 / 20%); 446 | border-radius: 10px; 447 | border: none; 448 | padding: 0.4rem 0.8rem; 449 | border-radius: 10px; 450 | width: fit-content; 451 | align-self: center; 452 | transition: all 0.2s; 453 | } 454 | 455 | .legend-btn-main:hover { 456 | background-color: white; 457 | color: $font-color; 458 | cursor: pointer; 459 | border: none; 460 | // filter: drop-shadow(2px 2px 3px $font-color); 461 | } 462 | } 463 | 464 | .loader { 465 | border: 6px solid $s-dark-color; 466 | /* Light grey */ 467 | border-top: 6px solid $p-dark-color; 468 | /*#3498db; /* Blue */ 469 | border-radius: 50%; 470 | width: 16px; 471 | height: 16px; 472 | margin: 0px; 473 | animation: spin 1s linear infinite; 474 | } 475 | 476 | @keyframes spin { 477 | 0% { 478 | transform: rotate(0deg); 479 | } 480 | 481 | 100% { 482 | transform: rotate(360deg); 483 | } 484 | } 485 | 486 | // This is styling for Landing page 487 | #landing-container { 488 | background-color: $secondary-color; 489 | width: 100%; 490 | display: flex; 491 | flex-direction: column; 492 | justify-content: flex-start; 493 | align-items: center; 494 | 495 | #logo-large { 496 | height: 48%; 497 | width: auto; 498 | } 499 | 500 | #namespace-selector-form { 501 | margin-top: 15vh; 502 | height: fit-content; 503 | width: 400px; 504 | padding: 10px 30px 30px 30px; 505 | 506 | // the code below will center the form absolutely 507 | // position: absolute; 508 | // margin-left: auto; 509 | // margin-right: auto; 510 | // left: 0; 511 | // right: 0; 512 | 513 | background-color: white; 514 | border-radius: 10px; 515 | box-shadow: 5px 10px 10px rgba(rgb(64, 0, 0), 0.2); 516 | 517 | text-align: center; 518 | 519 | #ns-error { 520 | margin-top: 1rem; 521 | display: flex; 522 | align-items: center; 523 | justify-content: center; 524 | } 525 | 526 | #error { 527 | color: red; 528 | font-weight: bold; 529 | font-size: 0.75rem; 530 | } 531 | 532 | h2 { 533 | color: $font-color; 534 | font-size: 1.25rem; 535 | } 536 | 537 | #selector-namespace { 538 | width: 320px; 539 | height: 3rem; 540 | padding: 10px; 541 | font-size: 1.2rem; 542 | border-radius: 5px; 543 | } 544 | } 545 | } 546 | 547 | select:hover, 548 | select:focus-visible, 549 | button:hover, 550 | button:focus-visible { 551 | outline: 2px solid $p-light-color; 552 | } 553 | 554 | // ANALYSIS PAGE ------------------------------------------------------------------------------------------>> 555 | 556 | #analysis-container { 557 | display: flex; 558 | flex-direction: column; 559 | // align-items: center; 560 | background-color: $secondary-color; 561 | 562 | // height: calc(100vh - $header-height); 563 | .analysis-nav { 564 | display: flex; 565 | justify-content: flex-start; 566 | align-items: center; 567 | margin: 1rem 2rem; 568 | color: $font-color; 569 | 570 | .analysis-oomkill-data { 571 | display: flex; 572 | flex-direction: column; 573 | justify-content: center; 574 | align-items: center; 575 | flex: 1; 576 | min-width: 30vw; 577 | min-height: 5.4rem; 578 | background-color: white; 579 | box-shadow: 5px 10px 10px rgb(64 0 0 / 20%); 580 | border-radius: 10px; 581 | padding: 0rem 1rem; 582 | font-size: 0.4rem; 583 | max-width: auto; 584 | 585 | .oomkilled-pod-data { 586 | margin-top: 0.4rem; 587 | text-transform: uppercase; 588 | font-size: 0.8rem; 589 | color: $font-color; 590 | font-weight: 700; 591 | text-align: center; 592 | } 593 | 594 | &-container { 595 | display: flex; 596 | font-size: 1rem; 597 | justify-content: center; 598 | gap: 3rem; 599 | } 600 | } 601 | 602 | &-left { 603 | display: flex; 604 | justify-content: flex-start; 605 | gap: 2rem; 606 | 607 | .loading-bar { 608 | display: flex; 609 | align-items: center; 610 | justify-content: space-between; 611 | 612 | #loadname { 613 | padding-right: 1rem; 614 | } 615 | } 616 | 617 | .analysis-form { 618 | display: grid; 619 | grid-template-columns: repeat(5, auto); 620 | width: fit-content; 621 | 622 | flex: 1; 623 | justify-content: center; 624 | align-items: center; 625 | gap: 1rem; 626 | min-height: 4rem; 627 | 628 | #oomkill-selector { 629 | font-size: 1rem; 630 | padding: 0.25rem 0.5rem; 631 | cursor: pointer; 632 | box-shadow: 2px 2px 6px rgb(64 0 0 / 20%); 633 | border-radius: 10px; 634 | border: none; 635 | height: fit-content; 636 | width: 380px; 637 | } 638 | 639 | .analysis-interval { 640 | font-size: 1rem; 641 | padding: 0.25rem 0.5rem; 642 | cursor: pointer; 643 | box-shadow: 2px 2px 6px rgb(64 0 0 / 20%); 644 | border-radius: 10px; 645 | border: none; 646 | height: fit-content; 647 | width: 3.5rem; 648 | } 649 | 650 | .interval-unit { 651 | font-size: 1rem; 652 | padding: 0.25rem 0.5rem; 653 | cursor: pointer; 654 | box-shadow: 2px 2px 6px rgb(64 0 0 / 20%); 655 | border-radius: 10px; 656 | border: none; 657 | height: fit-content; 658 | } 659 | 660 | .query-btn { 661 | font-size: 1rem; 662 | padding: 0.25rem 0.5rem; 663 | cursor: pointer; 664 | box-shadow: 2px 2px 6px rgb(64 0 0 / 20%); 665 | border-radius: 10px; 666 | border: none; 667 | background-color: $ugly-sewage-green; 668 | color: white; 669 | transition: all 0.2s; 670 | height: fit-content; 671 | } 672 | 673 | .query-btn:hover { 674 | background-color: white; 675 | color: $font-color; 676 | cursor: pointer; 677 | } 678 | 679 | .tooltip { 680 | cursor: pointer; 681 | } 682 | } 683 | } 684 | } 685 | 686 | .no-data-msg { 687 | flex-wrap: nowrap; 688 | font-size: 0.8rem; 689 | text-align: center; 690 | background-color: #ddd; 691 | border-radius: 10px; 692 | padding: 1rem; 693 | color: $font-color; 694 | } 695 | 696 | .analysis-oomkill-data-msg { 697 | font-size: 0.9rem; 698 | } 699 | 700 | .analysis-main { 701 | display: flex; 702 | width: 100%; 703 | height: 78vh; 704 | flex-grow: 1; 705 | 706 | & > div { 707 | margin: 1rem 2rem; 708 | } 709 | 710 | .pod-overview { 711 | display: flex; 712 | flex-direction: column; 713 | padding: 1rem 1.5rem; 714 | box-shadow: 5px 10px 10px rgb(64 0 0 / 20%); 715 | background-color: white; 716 | border-radius: 10px; 717 | height: 14rem; 718 | margin: 0rem 1rem 2rem 1rem; 719 | 720 | .summary { 721 | text-transform: uppercase; 722 | font-size: 0.8rem; 723 | color: $font-color; 724 | font-weight: 700; 725 | text-align: center; 726 | margin-bottom: 1rem; 727 | } 728 | } 729 | 730 | .analysis-oomkill-data { 731 | display: flex; 732 | flex-direction: column; 733 | justify-content: center; 734 | align-items: stretch; 735 | min-height: 5.4rem; 736 | height: fit-content; 737 | background-color: whitesmoke; 738 | box-shadow: 5px 10px 10px rgb(64 0 0 / 20%); 739 | border-radius: 10px; 740 | padding: 1rem 24px; 741 | font-size: 0.4rem; 742 | 743 | &-container { 744 | background-color: white; 745 | box-shadow: 5px 10px 10px rgb(64 0 0 / 20%); 746 | border-radius: 10px; 747 | // margin-right: 8px; 748 | padding: 16px 24px; 749 | font-size: 1rem; 750 | 751 | & > p { 752 | margin: 0.5rem; 753 | } 754 | } 755 | 756 | .oomkilled-pod-data { 757 | margin-bottom: 0.6rem; 758 | text-transform: uppercase; 759 | font-size: 0.8rem; 760 | color: $font-color; 761 | font-weight: 700; 762 | text-align: center; 763 | } 764 | } 765 | 766 | .filtered-log-container { 767 | display: flex; 768 | flex-direction: column; 769 | padding: 1rem 1.5rem; 770 | // adjustment for scroll bar 771 | padding-right: 0.5rem; 772 | box-shadow: 5px 10px 10px rgb(64 0 0 / 20%); 773 | background-color: whitesmoke; 774 | border-radius: 10px; 775 | // height: 30rem; // use this height for when the summary works 776 | overflow-y: auto; 777 | flex: 1; 778 | 779 | .no-data-msg { 780 | // also adjustment for scrollbar 781 | margin-right: calc(24px - 1rem); 782 | } 783 | 784 | .filtered-events-heading { 785 | text-transform: uppercase; 786 | font-size: 0.8rem; 787 | color: $font-color; 788 | font-weight: 700; 789 | text-align: center; 790 | margin-bottom: 1rem; 791 | margin-right: calc(24px - 0.5rem); 792 | } 793 | 794 | .filtered-events-container { 795 | padding-right: 0.5rem; 796 | overflow-y: auto; 797 | overflow-x: hidden; 798 | } 799 | 800 | .event-card { 801 | position: relative; 802 | font-size: 0.6rem; 803 | margin-bottom: 1rem; 804 | padding: 1rem 1.5rem; 805 | box-shadow: 5px 10px 10px rgb(64 0 0 / 20%); 806 | background-color: white; 807 | border-radius: 10px; 808 | 809 | & > * { 810 | word-wrap: break-word; 811 | } 812 | } 813 | } 814 | 815 | #left-side { 816 | width: 500px; 817 | gap: 16px; 818 | } 819 | 820 | // chart styling 821 | #chartarea { 822 | @extend .filtered-log-container; 823 | background-color: whitesmoke; 824 | 825 | // adjust for non-overlapping margins 826 | margin-left: 0; 827 | 828 | .chartarea-container { 829 | box-shadow: 5px 10px 10px rgb(64 0 0 / 20%); 830 | background-color: white; 831 | border-radius: 10px; 832 | margin-right: 1rem; 833 | } 834 | 835 | .chartarea-heading { 836 | text-transform: uppercase; 837 | font-size: 0.8rem; 838 | color: $font-color; 839 | font-weight: 700; 840 | text-align: center; 841 | margin-bottom: 0.6rem; 842 | margin-right: calc(24px - 0.5rem); 843 | } 844 | 845 | .graph-msg { 846 | margin-right: calc(24px - 0.5rem); 847 | } 848 | 849 | .chartarea-container { 850 | overflow-y: scroll; 851 | overflow-x: hidden; 852 | display: flex; 853 | flex-wrap: wrap; 854 | gap: 2rem; 855 | justify-content: space-around; 856 | } 857 | } 858 | 859 | .line-chart-div { 860 | display: flex; 861 | // border: 1px solid black; 862 | flex-direction: column; 863 | margin: 2rem; 864 | padding: 0.4rem; 865 | min-width: 0; 866 | min-width: 20rem; 867 | 868 | // chart height is fixed to prevent infinite height increase 869 | max-height: 20rem; 870 | flex: 0 1 calc(40% - 2rem); 871 | } 872 | 873 | .legend-btn-grid { 874 | background-color: $ugly-sewage-green; 875 | color: white; 876 | box-shadow: 2px 2px 6px rgb(64 0 0 / 20%); 877 | border-radius: 10px; 878 | border: none; 879 | padding: 0.4rem 0.8rem; 880 | border-radius: 10px; 881 | width: fit-content; 882 | align-self: center; 883 | transition: all 0.2s; 884 | font-size: 0.6rem; 885 | margin-top: 1rem; 886 | } 887 | 888 | .legend-btn-grid:hover { 889 | background-color: white; 890 | color: $font-color; 891 | cursor: pointer; 892 | border: none; 893 | // filter: drop-shadow(2px 2px 3px $font-color); 894 | } 895 | } 896 | } 897 | 898 | // MEDIA QUERIES 899 | @media only screen and (max-width: 800px) { 900 | #logo { 901 | width: 10rem; 902 | } 903 | 904 | #sidebar { 905 | flex: 1; 906 | 907 | .sidebar-page-container { 908 | .sidebar-icon { 909 | width: 1.2rem; 910 | } 911 | 912 | // .sidebar-names { 913 | // font-size: 0.8rem; 914 | // } 915 | } 916 | } 917 | 918 | .interval-unit, 919 | .query-btn, 920 | .tooltip-container { 921 | grid-row: 2; 922 | margin: 0; 923 | padding: 0; 924 | } 925 | } 926 | -------------------------------------------------------------------------------- /client/components/AnalysisPage.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import ChartGrid from './ChartGrid'; 3 | import { AnalysisPageProps, ChartGraphData } from '../Types'; 4 | import LogCard from './LogCard'; 5 | import Tooltip from './Tooltip'; 6 | 7 | const AnalysisPage = (props: AnalysisPageProps) => { 8 | const [OOMKillsList, setOOMKillsList]: any = useState([]); 9 | const [allOOMKills, setAllOOMKills]: any = useState([]); 10 | const [filteredLogs, setFilteredLogs]: any = useState([]); 11 | const [logType, setLogType]: any = useState('events'); 12 | const [tooltipState, setTooltipState]: any = useState(false); 13 | const [tooltip, setTooltip]: any = useState(<>); 14 | const [hiddenInputs, setHiddenInputs]: any = useState([]) 15 | const [loading, setLoading]: any = useState(false); 16 | const { 17 | analyzedPod, 18 | setAnalyzedPod, 19 | setAnalyzedData, 20 | analyzedData, 21 | showGraphs, 22 | setShowGraphs, 23 | }: any = props; 24 | 25 | const handleQuery = async (e: any) => { 26 | setLoading(true) 27 | let timeInterval = e.target["analysis-interval"].value + e.target['interval-unit'].value 28 | const podName = e.target['oomkill-selector'].value; 29 | if (podName === 'default') { 30 | setLoading(false) 31 | return 32 | } 33 | if (timeInterval === 'default' || !e.target["analysis-interval"].value) timeInterval = '5m' 34 | 35 | const nodeName = e.target[podName].value; 36 | const timeOfDeath = new Date(analyzedPod.started).toISOString(); 37 | 38 | try { 39 | // analyzeData1 is object with all chart data. see type 40 | const analyzeData1: ChartGraphData = await window.api.getAnalysis(nodeName, timeInterval, timeOfDeath) 41 | 42 | props.setAnalyzedData(analyzeData1); 43 | setShowGraphs(true) 44 | setLoading(false) 45 | } catch (err) { 46 | setLoading(false) 47 | return err 48 | } 49 | } 50 | 51 | // displays analyzed pod 52 | const updateAnalyzedPod = (e: any) => { 53 | // sets podName as node name. we deliver all pod data based on parent node, so we extract the pod's parent (node) 54 | const podName = e.target.value; 55 | const newAnalysis = allOOMKills.filter( 56 | (oomkill: any) => oomkill.podName === podName 57 | ); 58 | setAnalyzedPod({ ...newAnalysis[0] }); 59 | }; 60 | 61 | const openTooltip = (e: any) => { 62 | const position = { 63 | top: e.pageY.toString() + 'px', 64 | left: e.pageX.toString() + 'px', 65 | }; 66 | setTooltip(); 67 | setTooltipState(true); 68 | }; 69 | 70 | const closeTooltip = () => { 71 | setTooltipState(false); 72 | }; 73 | 74 | useEffect(() => { 75 | // Queries for all OOMKilled pods and stores in state variables 76 | // 1) oomKillOptions - array of pod names used for drop down list 77 | // 2) allOomKills - array of oomkilled objects 78 | const renderOOMKills = async () => { 79 | const hiddenInps: any[] = [] 80 | const oomkillData = await window.api.getOOMKills(); 81 | const oomKillOptions: JSX.Element[] = oomkillData.map( 82 | (oomkill: any, i: number): JSX.Element => { 83 | hiddenInps.push() 84 | return ( 85 | 88 | ); 89 | } 90 | ); 91 | setOOMKillsList([...oomKillOptions]); 92 | setAllOOMKills([...oomkillData]); 93 | setHiddenInputs([...hiddenInps]); 94 | }; 95 | 96 | // Queries and generates filtered logs of events for pod being analyzed 97 | const createLogs = async () => { 98 | const logCards: JSX.Element[] = []; 99 | const logsData = await window.api.getEvents(); 100 | const filtered = logsData.filter( 101 | (log: any) => log.object.slice(4) === analyzedPod.podName 102 | ); 103 | for (let i = 0; i < filtered.length; i++) { 104 | logCards.push( 105 | 117 | ); 118 | } 119 | setFilteredLogs([...logCards]); 120 | }; 121 | 122 | // onChange, match the selected option pod with the pod in the allOOMKills then set analyzedPod to be that pod 123 | renderOOMKills(); 124 | createLogs(); 125 | 126 | }, [analyzedPod]); 127 | 128 | return ( 129 |
130 | 186 |
187 | {/* -------------------- START OF LEFT AREA -------------------- */} 188 |
189 | {/* -------------------- START OF OOM KILL DATA -------------------- */} 190 |
191 | OOMKilled Pod Data 192 | {analyzedPod.podName ? ( 193 |
194 |

195 | Pod: {analyzedPod.podName} 196 |

197 |

198 | Restarts: {analyzedPod.restartcount} 199 |

200 |

201 | Terminated at:{' '} 202 | {analyzedPod.started.slice(0, -6)} 203 |

204 |

205 | Restarted at:{' '} 206 | {analyzedPod.finished.slice(0, -6)} 207 |

208 |
209 | ) : ( 210 |

Select OOMKilled Pod to Display Data

211 | )} 212 |
213 | {/* -------------------- END OF OOMKILL Data -------------------- */} 214 | 215 |
216 |
Events
217 |
218 | {analyzedPod.podName && filteredLogs.length > 0 ? ( 219 | filteredLogs 220 | ) : analyzedPod.podName ? ( 221 |

No Events to Display

222 | ) : ( 223 |

224 | Select OOMKilled Pod to Display Data 225 |

226 | )} 227 | 228 |
229 |
230 |
231 | {/* -------------------- CHART AREA -------------------- */} 232 |
233 |
Metrics
234 | {showGraphs ? ( 235 | 236 |
237 | 238 |
239 | ) : ( 240 |

241 | Please Query an OOMKilled Pod to Display Charts 242 |

243 | )} 244 |
245 |
246 |
247 | ); 248 | }; 249 | 250 | export default AnalysisPage; 251 | -------------------------------------------------------------------------------- /client/components/App.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { HashRouter, Link, Route, Routes } from 'react-router-dom'; 3 | 4 | // page containers for React Router 5 | import HomePage from './HomePage'; 6 | import LandingPage from './LandingPage'; 7 | import AnalysisPage from './AnalysisPage'; 8 | 9 | // import styles sheet here 10 | import '../assets/stylesheets/style.scss'; 11 | 12 | // initial data for charts. app breaks if there's no initial data 13 | const initData: any = [ 14 | { 15 | Port9090isClosed: { 16 | times: ['a', 'b', 'c'], 17 | values: [1, 2, 3], 18 | }, 19 | }, 20 | { 21 | Port9090isClosedOpenIt: { 22 | times: ['a', 'b', 'c'], 23 | values: [3, 2, 1], 24 | }, 25 | }, 26 | ]; 27 | 28 | import Sidebar from './Sidebar'; 29 | 30 | const App = () => { 31 | const [analyzedPod, setAnalyzedPod]: any = React.useState({}); 32 | const [resourceError, setResourceError]: any = React.useState(''); 33 | const [menuOpen, setMenuOpen]: any = React.useState(true); 34 | const [showGraphs, setShowGraphs]: any = React.useState(false); 35 | const [analyzedData, setAnalyzedData]: any = React.useState({ 36 | podMem: initData, 37 | podCPU: initData, 38 | nodeMem: initData, 39 | nodeCPU: initData, 40 | netRead: initData, 41 | netWrite: initData, 42 | }); 43 | 44 | return ( 45 | 46 | 47 |
48 | 53 | 54 | 61 | } 62 | /> 63 | 75 | } 76 | /> 77 | 89 | } 90 | /> 91 | 92 |
93 |
94 | ); 95 | }; 96 | 97 | export default App; 98 | -------------------------------------------------------------------------------- /client/components/ChartGrid.tsx: -------------------------------------------------------------------------------- 1 | import { Line } from 'react-chartjs-2'; 2 | import { useState, useEffect } from 'react'; 3 | import { GraphData, ChartGraphData, GraphableData } from '../Types'; 4 | import { 5 | Chart as ChartJS, 6 | CategoryScale, 7 | LinearScale, 8 | PointElement, 9 | LineElement, 10 | Title, 11 | Tooltip, 12 | Legend, 13 | } from 'chart.js'; 14 | import { watchPlugins } from '../../jest.config'; 15 | 16 | ChartJS.register( 17 | CategoryScale, 18 | LinearScale, 19 | PointElement, 20 | LineElement, 21 | Title, 22 | Tooltip, 23 | Legend 24 | ); 25 | 26 | // populates charts on analysis page 27 | const ChartGrid = (props: any) => { 28 | // show/hide legend buttons 29 | const [buttonClicked, setButtonClicked] = useState({ 30 | podMem: false, 31 | podCPU: false, 32 | nodeMem: false, 33 | nodeCPU: false, 34 | netRead: false, 35 | netWrite: false, 36 | }); 37 | const { analyzedData } = props; 38 | const initData: GraphData = [ 39 | { 40 | Port9090isClosed: { 41 | times: ['a', 'b', 'c'], 42 | values: [1, 2, 3], 43 | }, 44 | }, 45 | { 46 | Port9090isClosedOpenIt: { 47 | times: ['a', 'b', 'c'], 48 | values: [3, 2, 1], 49 | }, 50 | }, 51 | ]; 52 | // initData so app doesn't crash on load 53 | const [graphState, setGraphState] = useState({ 54 | podCPU: initData, 55 | podMem: initData, 56 | nodeMem: initData, 57 | nodeCPU: initData, 58 | netRead: initData, 59 | netWrite: initData, 60 | }); 61 | 62 | useEffect(() => { 63 | // set state with the data received from props object 64 | setGraphState(analyzedData); 65 | }, [props.analyzedData]); 66 | 67 | const colorArray: string[] = [ 68 | 'red', 69 | 'blue', 70 | 'green', 71 | 'black', 72 | 'purple', 73 | 'cyan', 74 | 'yellow', 75 | 'orange', 76 | ]; 77 | 78 | const xLabels: string[] = 79 | graphState.nodeMem[0][Object.keys(graphState.nodeMem[0])[0]].times; 80 | 81 | let options: string = JSON.stringify({ 82 | responsive: true, 83 | responsiveAnimationDuration: 1000, 84 | maintainAspectRatio: false, 85 | pointRadius: 0, 86 | indexAxis: 'x', 87 | plugins: { 88 | legend: { 89 | display: false, 90 | position: 'bottom' as const, 91 | }, 92 | title: { 93 | display: true, 94 | text: 'working title', 95 | }, 96 | }, 97 | scales: { 98 | x: { 99 | grid: { 100 | color: 'rgb(240, 240, 240)', 101 | }, 102 | ticks: { 103 | color: '#797676', 104 | }, 105 | title: { 106 | display: true, 107 | text: new Date().toDateString(), 108 | }, 109 | }, 110 | y: { 111 | grid: { 112 | color: 'rgb(240, 240, 240)', 113 | }, 114 | ticks: { 115 | color: '#797676', 116 | }, 117 | title: { 118 | display: true, 119 | text: 'Mibibytes', 120 | }, 121 | }, 122 | }, 123 | }); 124 | 125 | const multiOptions = { 126 | nodeMem: JSON.parse(options), 127 | nodeCPU: JSON.parse(options), 128 | podMem: JSON.parse(options), 129 | podCPU: JSON.parse(options), 130 | netRead: JSON.parse(options), 131 | netWrite: JSON.parse(options), 132 | }; 133 | 134 | const charts: JSX.Element[] = []; 135 | let datasetData = [] as GraphableData[]; 136 | let keyCounter: number = 0; 137 | 138 | // handles open legend for specific graph instead of all of them 139 | const handleLegendClick = ( 140 | keyName: 141 | | 'podCPU' 142 | | 'podMem' 143 | | 'nodeMem' 144 | | 'nodeCPU' 145 | | 'netRead' 146 | | 'netWrite' 147 | ) => { 148 | const newButton = { ...buttonClicked }; 149 | newButton[keyName] = !newButton[keyName]; 150 | setButtonClicked(newButton); 151 | }; 152 | 153 | // first we iterate of the total number of graphs we want 154 | (Object.keys(graphState) as (keyof typeof graphState)[]).forEach(key => { 155 | // then we iterate over all of the lines in that graph 156 | for (let i = 0; i < graphState[key].length; i++) { 157 | const podName: string = Object.keys(graphState[key][i])[0]; 158 | if (!colorArray[i]) 159 | colorArray.push( 160 | '#' + Math.floor(Math.random() * 16777215).toString(16) 161 | ); 162 | datasetData.push({ 163 | label: podName, 164 | backgroundColor: colorArray[i], 165 | borderColor: colorArray[i], 166 | data: graphState[key][i][podName].values, 167 | }); 168 | } 169 | multiOptions[key].plugins.legend.display = buttonClicked[key]; 170 | 171 | // this is part of the each individual graphs 172 | // multiOptions[key].scales.y.title.text = 'y-axis label'; 173 | switch (key) { 174 | case 'nodeMem': 175 | multiOptions[key].scales.y.title.text = 'MegaBytes'; 176 | multiOptions[key].plugins.title.text = 'Node Memory Usage'; 177 | break; 178 | case 'nodeCPU': 179 | multiOptions[key].scales.y.title.text = 'Milicores'; 180 | multiOptions[key].plugins.title.text = 'Node CPU Usage'; 181 | break; 182 | case 'podMem': 183 | multiOptions[key].scales.y.title.text = 'MegaBytes'; 184 | multiOptions[key].plugins.title.text = 'Pod Memory Usage'; 185 | break; 186 | case 'podCPU': 187 | multiOptions[key].scales.y.title.text = 'Milicores'; 188 | multiOptions[key].plugins.title.text = 'Pod CPU Usage'; 189 | break; 190 | case 'netRead': 191 | multiOptions[key].scales.y.title.text = 'KiloBytes'; 192 | multiOptions[key].plugins.title.text = 'Network Read'; 193 | break; 194 | case 'netWrite': 195 | multiOptions[key].scales.y.title.text = 'KiloBytes'; 196 | multiOptions[key].plugins.title.text = 'Network Write'; 197 | break; 198 | default: 199 | console.log('Default Case Hit'); 200 | break; 201 | } 202 | 203 | charts.push( 204 |
205 | 213 | 219 |
220 | ); 221 | datasetData = [] as GraphableData[]; 222 | }); 223 | 224 | return <>{charts}; 225 | }; 226 | 227 | export default ChartGrid; 228 | -------------------------------------------------------------------------------- /client/components/ClusterChart.tsx: -------------------------------------------------------------------------------- 1 | import { ClusterChartProps } from '../Types'; 2 | import ClusterChartCard from './ClusterChartCard'; 3 | 4 | const ClusterChart = (props: ClusterChartProps): JSX.Element => { 5 | 6 | // do not change order of this. 7 | const names: ["Pods", "Nodes"] = ["Pods", "Nodes"] 8 | const clusterCards: any = []; 9 | 10 | // async function to update each card's usage with query to prom via main process 11 | const getData = async (name: string, resource: string, obj: any) => { 12 | try { 13 | const usageData = await window.api.getUsage(name, resource); 14 | if (usageData) obj.usage = usageData[0] 15 | } catch(err) { 16 | console.log('Error', err) 17 | } 18 | } 19 | 20 | if (props.click) 21 | for (let i = 0; i < names.length; i++) { 22 | clusterCards.push( 23 | 30 | ); 31 | } 32 | 33 | if (clusterCards.length > 0) { 34 | for (let i = 0; i < clusterCards.length; i++) { 35 | for (let j = 0; j < clusterCards[i].props.data.length; j++) { 36 | const card = clusterCards[i].props.data[j] 37 | // excluding the temp cards in initial state 38 | if (card.resource !== 'hello') { 39 | getData(card.name, card.resource, card); 40 | } 41 | } 42 | } 43 | } 44 | 45 | return ( 46 |
47 | {clusterCards} 48 |
49 | ) 50 | }; 51 | 52 | export default ClusterChart; 53 | -------------------------------------------------------------------------------- /client/components/ClusterChartCard.tsx: -------------------------------------------------------------------------------- 1 | import { ClusterChartCardProps } from '../Types'; 2 | 3 | const ClusterChartCard = (props: ClusterChartCardProps): JSX.Element => { 4 | const squares: JSX.Element[] = []; 5 | const colors: string[] = [ 6 | '#96ca82', 7 | '#cdd185', 8 | '#FAB733', 9 | '#FF8E15', 10 | '#FF4E11', 11 | '#FF0D0D', 12 | '#363636', 13 | ]; 14 | 15 | let index: number = 0; 16 | // checks usage of cluster chard cards. if usage is over a certain limit, it will color differently 17 | for (let i = 0; i < props.data.length; i++) { 18 | let usage: number = props.data[i].usage - props.data[i].request; 19 | let limit: number = props.data[i].limit - props.data[i].request; 20 | if (usage < 0) index = 0; 21 | else if (usage < 0.2 * limit) index = 1; 22 | else if (usage < 0.4 * limit) index = 2; 23 | else if (usage < 0.6 * limit) index = 3; 24 | else if (usage < 0.8 * limit) index = 4; 25 | else if (props.data[i].unit === undefined || props.data[i].unit === null) 26 | index = 6; 27 | else index = 5; 28 | squares.push( 29 | props.click(e, props.data[i])} 31 | width={30} 32 | height={30} 33 | key={i + 1000} 34 | > 35 | 42 | 43 | ); 44 | } 45 | 46 | return ( 47 |
48 | {props.title} 49 |
{squares}
50 |
51 | ); 52 | }; 53 | 54 | export default ClusterChartCard; 55 | -------------------------------------------------------------------------------- /client/components/Events.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import LogCard from './LogCard'; 3 | import { EventProps } from '../Types'; 4 | import { capitalize } from '../../electron/utils'; 5 | 6 | const Events = (props: EventProps): JSX.Element => { 7 | const [logs, setLogs]: any = useState([]); 8 | const [logType, setLogType]: any = useState('events'); 9 | const [severityType, setSeverityType]: any = useState('Default'); 10 | const [loading, setLoading]: any = useState(true); 11 | const { analyzedPod, setAnalyzedPod, setShowGraphs }: any = props; 12 | 13 | const handleLogTypeChange = (e: any) => { 14 | const logTypeStr = e.target.value; 15 | setLogType(logTypeStr); 16 | }; 17 | 18 | const handleSeverityChange = (e: any) => { 19 | const severity = capitalize(e.target.value); 20 | setSeverityType(severity); 21 | }; 22 | 23 | useEffect(() => { 24 | // populate and set logCards according to what type of logs is requested. 25 | // this is a helper function as typescript was not playing nicely with useEffect as an async function 26 | const createLogs = async () => { 27 | const logCards: JSX.Element[] = []; 28 | let logsData; 29 | if (logType === 'events') { 30 | // events is an object with prop "formatttedEvents", which is an array of objects 31 | // each obj in array has the following keys: namespace, lastSeen, severity, reason, message, object 32 | logsData = await window.api.getEvents(); 33 | } else if (logType === 'alerts') { 34 | logsData = await window.api.getAlerts(); 35 | } else if (logType === 'oomkills') { 36 | logsData = await window.api.getOOMKills(); 37 | } 38 | setLoading(false); 39 | for (let i = 0; i < logsData.length; i++) { 40 | logCards.push( 41 | 54 | ); 55 | } 56 | 57 | if (severityType !== 'Default') { 58 | const filteredLogs = logCards.filter((log: any) => { 59 | if ( 60 | logType === 'events' && 61 | log.props.eventObj.severity === severityType 62 | ) 63 | return log; 64 | else if ( 65 | logType === 'alerts' && 66 | log.props.alertObj.severity === severityType 67 | ) 68 | return log; 69 | }); 70 | setLogs(filteredLogs); 71 | } else setLogs(logCards); 72 | }; 73 | 74 | createLogs(); 75 | }, [logType, severityType]); 76 | 77 | return ( 78 |
79 | 122 |
123 | {logs.length ? ( 124 | logs 125 | ) : ( 126 |

127 | No {logType[0].toUpperCase() + logType.slice(1)} in Current 128 | Namespace 129 |

130 | )} 131 |
132 |
133 | ); 134 | }; 135 | 136 | export default Events; 137 | -------------------------------------------------------------------------------- /client/components/Graph.tsx: -------------------------------------------------------------------------------- 1 | import { GraphData } from '../Types'; 2 | import { Line } from 'react-chartjs-2'; 3 | import { useState, useEffect } from 'react'; 4 | import { useNavigate } from 'react-router-dom'; 5 | // I dont know why, but you need all this ChartJS stuff to make the react-chartjs-2 to work 6 | import { 7 | Chart as ChartJS, 8 | CategoryScale, 9 | LinearScale, 10 | PointElement, 11 | LineElement, 12 | Title, 13 | Tooltip, 14 | Legend, 15 | } from 'chart.js'; 16 | 17 | ChartJS.register( 18 | CategoryScale, 19 | LinearScale, 20 | PointElement, 21 | LineElement, 22 | Title, 23 | Tooltip, 24 | Legend 25 | ); 26 | 27 | const Graph = (props: any): JSX.Element => { 28 | const [portOpen, setPortOpen] = useState(false); 29 | const [buttonClicked, setButtonClicked] = useState(false); 30 | const [graphState, setGraphState] = useState([ 31 | { 32 | Port9090isClosed: { 33 | times: ['a', 'b', 'c'], 34 | values: [1, 2, 3], 35 | }, 36 | }, 37 | { 38 | Port9090isClosed: { 39 | times: ['a', 'b', 'c'], 40 | values: [3, 2, 1], 41 | }, 42 | }, 43 | ]); 44 | 45 | const navigate = useNavigate(); 46 | 47 | if (!portOpen) 48 | fetch('http://localhost:9090/').then(response => { 49 | if (response.status === 200) { 50 | console.log('Port 9090 is Open'); 51 | setPortOpen(true); 52 | } else { 53 | //optional place to throw error when port 9090 is closed 54 | } 55 | }); 56 | 57 | useEffect(() => { 58 | refreshGraphData(); 59 | }, [portOpen]); 60 | 61 | const refreshGraphData = () => { 62 | if (portOpen) { 63 | window.api 64 | .getMemoryUsageByPods() 65 | .then((output: any) => { 66 | if (output.length < 1) { 67 | return navigate('/'); 68 | } else if (!output.err) setGraphState(output); 69 | }) 70 | .catch((err: any) => { 71 | return { err: err }; 72 | }); 73 | setTimeout(refreshGraphData, 5000); 74 | } 75 | }; 76 | 77 | const datasetData = []; 78 | const colorArray = [ 79 | 'red', 80 | 'blue', 81 | 'green', 82 | 'black', 83 | 'purple', 84 | 'cyan', 85 | 'yellow', 86 | 'orange', 87 | '#003d33', 88 | ]; 89 | 90 | const xLabels: string[] = graphState[0][Object.keys(graphState[0])[0]].times; 91 | 92 | for (let i = 0; i < graphState.length; i++) { 93 | const podName: string = Object.keys(graphState[i])[0]; 94 | datasetData.push({ 95 | label: podName, 96 | backgroundColor: colorArray[i], 97 | borderColor: colorArray[i], 98 | data: graphState[i][podName].values, 99 | }); 100 | } 101 | 102 | const options: any = { 103 | responsive: true, 104 | responsiveAnimationDuration: 1000, 105 | pointRadius: 0, 106 | indexAxis: 'x', 107 | plugins: { 108 | legend: { 109 | display: buttonClicked, 110 | position: 'bottom' as const, 111 | }, 112 | title: { 113 | display: true, 114 | text: 'Current Memory Usage by Pods', 115 | }, 116 | }, 117 | scales: { 118 | x: { 119 | grid: { 120 | color: 'rgb(240, 240, 240)', 121 | }, 122 | ticks: { 123 | color: '#797676', 124 | }, 125 | title: { 126 | display: true, 127 | text: new Date().toDateString(), 128 | }, 129 | }, 130 | y: { 131 | grid: { 132 | color: 'rgb(240, 240, 240)', 133 | }, 134 | ticks: { 135 | color: '#797676', 136 | }, 137 | title: { 138 | display: true, 139 | text: 'Megabytes', 140 | }, 141 | }, 142 | }, 143 | }; 144 | 145 | const handleLegendClick = () => { 146 | setButtonClicked(prevCheck => !prevCheck); 147 | }; 148 | 149 | const data = { 150 | labels: xLabels, 151 | datasets: datasetData, 152 | }; 153 | 154 | return ( 155 | <> 156 | 157 | 160 | 161 | ); 162 | }; 163 | 164 | export default Graph; 165 | -------------------------------------------------------------------------------- /client/components/HomePage.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, MouseEvent } from 'react'; 2 | import ClusterChart from './ClusterChart'; 3 | import Events from './Events'; 4 | import Graph from './Graph'; 5 | import DetailsModal from './Modal'; 6 | 7 | import { 8 | ClusterChartProps, 9 | SvgInfo, 10 | ModalProps, 11 | ClusterAllInfo, 12 | } from '../Types'; 13 | import { useNavigate } from 'react-router-dom'; 14 | 15 | const tempData: SvgInfo[] = [ 16 | { 17 | name: 'string', 18 | usage: 1, 19 | resource: 'hello', 20 | request: 0.9, 21 | limit: Math.random() + 1, 22 | parent: 'string', 23 | namespace: 'string', 24 | }, 25 | ]; 26 | 27 | const initalClusterChartData: ClusterAllInfo = { 28 | Nodes: tempData, 29 | Pods: tempData, 30 | }; 31 | 32 | const HomePage = (props: any): JSX.Element => { 33 | const [pods, setPods]: any = useState([]); 34 | const [nodes, setNodes]: any = useState(['node1']); 35 | const [portOpen, setPortOpen]: any = useState(false); 36 | const [resource, setResource]: any = useState(''); 37 | const [clusterChartData, setClusterChartData]: any = useState( 38 | initalClusterChartData 39 | ); 40 | const navigate = useNavigate(); 41 | const { menuOpen, setShowGraphs } = props; 42 | 43 | // Ways to clean up the modal: 44 | // the modal is split into two states. the modalState could probably accept the JSX component as a key value 45 | const [modalState, setModalState] = useState(false); 46 | const [theModal, setTheModal] = useState(

No Modal Info

); 47 | 48 | const openModal = (e: MouseEvent, data: SvgInfo) => { 49 | console.log('Opening Modal'); 50 | const position = { 51 | top: e.pageY.toString() + 'px', 52 | left: e.pageX.toString() + 'px', 53 | }; 54 | const propData: ModalProps = { 55 | ...data, 56 | position: position, 57 | close: closeModal, 58 | }; 59 | setTheModal(); 60 | setModalState(true); 61 | }; 62 | 63 | const closeModal = (): void => { 64 | setModalState(false); 65 | }; 66 | 67 | const closeModalFromAnywhere = (e: MouseEvent): void => { 68 | if (e.target instanceof HTMLDivElement) setModalState(false); 69 | }; 70 | 71 | const gke: ClusterChartProps = { 72 | ...clusterChartData, 73 | click: openModal, 74 | close: closeModal, 75 | }; 76 | 77 | // loads all cluster chart info. number of nodes, pods and names, etc. 78 | const renderData = async () => { 79 | const allTheInfo = await window.api.getAllInfo(); 80 | 81 | if (resource === 'memory' || resource === 'cpu') { 82 | allTheInfo.Pods = allTheInfo.Pods.filter( 83 | (info: any) => info.resource === resource 84 | ); 85 | setClusterChartData(allTheInfo); 86 | } else { 87 | setClusterChartData(allTheInfo); 88 | } 89 | }; 90 | 91 | const handleResourceChange = (e: any) => { 92 | setResource(e.target.value); 93 | }; 94 | 95 | useEffect(() => { 96 | renderData(); 97 | }, [resource]); 98 | 99 | return ( 100 |
101 |
102 |
103 |
104 | 111 | 118 |
119 | 125 |
126 |
127 | 128 |
129 |
130 |
131 | 138 |
139 | {modalState && theModal} 140 |
141 | ); 142 | }; 143 | 144 | export default HomePage; 145 | -------------------------------------------------------------------------------- /client/components/LandingPage.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import { useNavigate } from "react-router-dom"; 3 | 4 | const LandingPage = (props: any): JSX.Element => { 5 | const [namespaces, setNamespaces] = useState([]); 6 | const navigate = useNavigate(); 7 | let j = 150; 8 | 9 | const error = props.resourceError; 10 | 11 | useEffect(() => { 12 | window.api 13 | .getNamespaces() 14 | .then((data) => { 15 | setNamespaces(data); 16 | }) 17 | .catch((err: Error) => { 18 | console.log( 19 | "An error occurred in LandingPage in function useEffect() for namespaces" 20 | ); 21 | }); 22 | }, [namespaces]); 23 | 24 | const handleNamespaceChange = (e: React.ChangeEvent) => { 25 | localStorage.setItem("namespace", e.target.value); 26 | // the below fetch can end up being slow due to the time length 27 | props.setResourceError(

); 28 | 29 | window.api.getMemoryUsageByPods().then((output: any) => { 30 | if (output.length < 1) { 31 | // console.log("returning out without setGraphState"); 32 | props.setResourceError(

No resources found in this namespace

); 33 | return navigate("/"); 34 | } else { 35 | props.setResourceError(<>); 36 | navigate("/home"); 37 | } 38 | }); 39 | }; 40 | 41 | return ( 42 |
43 |
44 |

Choose a namespace to get started

45 | 60 |
{error}
61 |
62 | 63 |
64 | ); 65 | }; 66 | 67 | export default LandingPage; 68 | -------------------------------------------------------------------------------- /client/components/LogCard.tsx: -------------------------------------------------------------------------------- 1 | import { LogCardProps } from '../Types'; 2 | import { useState } from 'react'; 3 | import { useNavigate } from 'react-router-dom'; 4 | 5 | const LogCard = (props: LogCardProps): JSX.Element => { 6 | const navigate = useNavigate(); 7 | const {setLoading, setAnalyzedPod, setShowGraphs }: any = props; 8 | 9 | const handleAnalyze = async () => { 10 | setLoading(true) 11 | setAnalyzedPod({ ...props.oomObj }); 12 | try { 13 | if (props.oomObj) { 14 | // sets end time as the time of death to query and get data leading up to pod's termination time 15 | const timeOfDeath = new Date(props.oomObj.started).toISOString(); 16 | const analyzeData = await window.api.getAnalysis(props.oomObj.node, '15s', timeOfDeath); 17 | //access to cluster chart data which shows limits/requests 18 | setShowGraphs(true); 19 | // sets state of analyzeData so analysispage can preload with selected pod 20 | props.setAnalyzedData(analyzeData); 21 | } 22 | setLoading(false) 23 | navigate('/analysis'); 24 | } catch (err) { 25 | return console.log('error: ', err); 26 | } 27 | }; 28 | 29 | // create the header elements 30 | let headerObj: { [key: string]: string } = {}; 31 | let bodyObj: { [key: string]: string } = {}; 32 | if (props.logType === 'events' && props.eventObj) { 33 | // part 1: anonymous function with input and output (take in args, returns and object) 34 | // (({namespace, severity}) => ({namespace, severity})) 35 | // part 2: invoke the function on props.eventObj 36 | headerObj = (({ namespace, severity }) => ({ namespace, severity }))( 37 | props.eventObj 38 | ); 39 | bodyObj = (({ reason, message, object, lastSeen }) => ({ 40 | reason, 41 | message, 42 | object, 43 | lastSeen, 44 | }))(props.eventObj); 45 | } else if (props.logType === 'alerts' && props.alertObj) { 46 | headerObj = (({ group, severity }) => ({ group, severity }))( 47 | props.alertObj 48 | ); 49 | bodyObj = (({ name, state, description, summary }) => ({ 50 | name, 51 | state, 52 | description, 53 | summary, 54 | }))(props.alertObj); 55 | } else if (props.logType === 'oomkills' && props.oomObj) { 56 | // the line below is an IIFE, which returns a single object with a key-value pair namaespace: namespace, taken from the props.oomObj object 57 | // we do this so we can extract only the data we want à la GraphQL style 58 | headerObj = (({ namespace }) => ({ namespace }))(props.oomObj); 59 | bodyObj = (({ 60 | podName, 61 | restartcount, 62 | laststate, 63 | reason, 64 | exitcode, 65 | started, 66 | finished, 67 | }) => ({ 68 | podName, 69 | restartcount, 70 | laststate, 71 | reason, 72 | exitcode, 73 | started, 74 | finished, 75 | }))(props.oomObj); 76 | } 77 | 78 | const header: JSX.Element[] = []; 79 | const body: JSX.Element[] = []; 80 | // need to make sure order is perserved in objects!!! 81 | let k = 500; 82 | for (const x in headerObj) { 83 | const label: string = x[0].toUpperCase() + x.slice(1) + ':'; 84 | 85 | header.push( 86 |
87 |

88 | {label} {headerObj[x]} 89 |

90 |
91 | ); 92 | } 93 | for (const x in bodyObj) { 94 | let label: string = x[0].toUpperCase() + x.slice(1) + ':'; 95 | 96 | // Fixes the capitalization of body labels 97 | switch (label) { 98 | case 'PodName:': 99 | label = 'Pod Name:'; 100 | break; 101 | case 'Restartcount:': 102 | label = 'Restart Count:'; 103 | break; 104 | case 'Laststate:': 105 | label = 'Last State:'; 106 | break; 107 | case 'Exitcode:': 108 | label = 'Exit Code:'; 109 | break; 110 | case 'LastSeen:': 111 | label = 'Last Seen:'; 112 | break; 113 | default: 114 | break; 115 | } 116 | 117 | body.push( 118 |
119 |

120 | {label} {bodyObj[x]} 121 |

122 |
123 | ); 124 | } 125 | 126 | return ( 127 |
128 | {props.logType === 'oomkills' && ( 129 |
130 | 133 |
134 | )} 135 |
{header}
136 | {body} 137 |
138 | ); 139 | }; 140 | 141 | export default LogCard; 142 | -------------------------------------------------------------------------------- /client/components/Modal.tsx: -------------------------------------------------------------------------------- 1 | import { ModalProps } from '../Types'; 2 | 3 | const DetailsModal = (props: ModalProps) => { 4 | const { 5 | name, 6 | usage, 7 | request, 8 | resource, 9 | unit, 10 | limit, 11 | parent, 12 | namespace, 13 | position, 14 | close, 15 | } = props; 16 | 17 | return ( 18 |
19 |
20 |
21 | 31 | 32 | 33 | 34 |
35 |
    36 |
  • 37 | Name: {name} 38 |
  • 39 |
  • 40 | Namespace: {namespace} 41 |
  • 42 |
  • 43 | Parent: {parent} 44 |
  • 45 |
  • 46 | Usage: {usage} 47 |
  • 48 |
  • 49 | Request: {request} 50 |
  • 51 |
  • 52 | Limit: {limit} 53 |
  • 54 |
  • 55 | Unit: {unit} 56 |
  • 57 |
  • 58 | Resource: {resource} 59 |
  • 60 |
61 |
62 |
63 | ); 64 | }; 65 | 66 | export default DetailsModal; 67 | -------------------------------------------------------------------------------- /client/components/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from 'react-router-dom'; 2 | import { SidebarProps } from '../Types'; 3 | 4 | const Sidebar = (props: SidebarProps) => { 5 | const { menuOpen, setMenuOpen } = props; 6 | 7 | const toggleMenu = () => { 8 | setMenuOpen(menuOpen ? false : true); 9 | }; 10 | 11 | return ( 12 | 77 | ); 78 | }; 79 | 80 | export default Sidebar; 81 | -------------------------------------------------------------------------------- /client/components/Tooltip.tsx: -------------------------------------------------------------------------------- 1 | import { TooltipProps } from '../Types'; 2 | 3 | const Tooltip = (props: TooltipProps) => { 4 | const { position } = props; 5 | 6 | return ( 7 |
8 |
9 |
    10 |

    1. Select an OOMKilled Pod to begin analysis

    11 |

    12 | 2. By default, the time interval will be set to 5 minutes before the 13 | OOMKill event 14 |

    15 |

    16 | 3. To adjust the time, input desired time interval, select a unit of 17 | time, and click "Query" 18 |

    19 |
20 |
21 |
22 | ); 23 | }; 24 | 25 | export default Tooltip; 26 | -------------------------------------------------------------------------------- /client/global.d.ts: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom/extend-expect"; 2 | 3 | declare global { 4 | /** 5 | * We define all IPC APIs here to give devs auto-complete 6 | * use window.electron anywhere in app 7 | * Also note the capital "Window" here. When accessing window object, the "w" is not capitalized. 8 | */ 9 | interface Window { 10 | api: { 11 | getEvents: () => Promise; 12 | getNodes: () => Promise; 13 | getLogs: () => Promise; 14 | getDeployments: () => Promise; 15 | getMemoryUsageByPods: () => Promise; 16 | getServices: () => Promise; 17 | getPods: () => Promise; 18 | getNamespaces: () => Promise; 19 | getAlerts: () => Promise; 20 | getAllInfo: () => Promise; 21 | getOOMKills: () => Promise; 22 | getUsage: (name: string, resource: string) => Promise; 23 | getAnalysis: (nodeName: string, timeInterval?: string, timeOfDeath?: string) => Promise; 24 | }; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Palaemon 8 | 9 | 10 |
11 | 12 | -------------------------------------------------------------------------------- /client/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot, Root } from 'react-dom/client'; 2 | import HomePage from './components/HomePage'; 3 | import App from './components/App'; 4 | 5 | const rootElement: HTMLElement | null = document.getElementById('root'); 6 | 7 | if (!rootElement) throw new Error('Fail to get root element in index.ts'); 8 | 9 | const root: Root = createRoot(rootElement); 10 | 11 | root.render(); -------------------------------------------------------------------------------- /electron/main.ts: -------------------------------------------------------------------------------- 1 | import { 2 | app, 3 | BrowserWindow, 4 | ipcMain, 5 | dialog, 6 | } from 'electron'; 7 | import { ClusterAllInfo } from '../client/Types'; 8 | import path from 'path'; 9 | // import installExtension, { 10 | // REACT_DEVELOPER_TOOLS, 11 | // REDUX_DEVTOOLS, 12 | // } from 'electron-devtools-installer'; 13 | 14 | import * as k8s from '@kubernetes/client-node'; 15 | import * as cp from 'child_process'; 16 | const fetch: any = (...args: any) => 17 | import('node-fetch').then(({ default: fetch }: any) => fetch(...args)); 18 | 19 | import { 20 | setStartAndEndTime, 21 | formatEvents, 22 | formatAlerts, 23 | parseNode, 24 | fetchMem, 25 | fetchCPU, 26 | formatOOMKills, 27 | } from './utils'; 28 | 29 | // metrics modules 30 | import { 31 | formatMatrix, 32 | formatUsage, 33 | formatAnalysis, 34 | } from './metricsData/formatMatrix'; 35 | import { SvgInfo, SvgInfoObj } from '../client/Types'; 36 | 37 | // K8S API BOILERPLATE 38 | const kc = new k8s.KubeConfig(); 39 | kc.loadFromDefault(); 40 | const k8sApiCore = kc.makeApiClient(k8s.CoreV1Api); 41 | const k8sApiApps = kc.makeApiClient(k8s.AppsV1Api); 42 | 43 | const PROM_URL = 'http://127.0.0.1:9090/api/v1/'; 44 | 45 | const isDev: boolean = process.env.NODE_ENV === 'development'; 46 | // const PORT: string | number = process.env.PORT || 8080; 47 | 48 | // this is to allow the BrowserWindow object to be referrable globally 49 | // however, BrowserWindow cannot be created before app is 'ready' 50 | let mainWindow: any = null; 51 | 52 | const loadMainWindow = () => { 53 | mainWindow = new BrowserWindow({ 54 | width: 1200, 55 | height: 800, 56 | show: false, 57 | icon: path.resolve(__dirname, '../client/assets/logo_hat.ico'), 58 | webPreferences: { 59 | nodeIntegration: true, 60 | // contextIsolation: false, 61 | devTools: isDev, //whether to enable DevTools 62 | preload: path.join(__dirname, 'preload.js'), 63 | }, 64 | }); 65 | 66 | mainWindow.loadFile(path.join(__dirname, '../client/index.html')); 67 | console.log('Main Window loaded file index.html'); 68 | 69 | // check to see if port 9090 is open 70 | const checkPort = () => { 71 | fetch('http://localhost:9090/') 72 | .then((res: any) => { 73 | console.log('status code in loadMainWindow is ', res.status); 74 | mainWindow.show(); 75 | }) 76 | .catch((err: Error) => { 77 | console.log('fetch to 9090 has failed in main.ts in loadMainWindow'); 78 | const num = dialog.showMessageBoxSync({ 79 | message: 80 | 'PALAEMON ERROR: 9090', 81 | type: 'warning', 82 | // Cancel returns 0, OK returns 1 83 | buttons: ['Cancel', 'OK'], 84 | title: 'PALAEMON', 85 | detail: 'Port-forward Prometheus service to port 9090, then press OK.\n \nVisit palaemon.io for more information.', 86 | }); 87 | if (num === 1) checkPort(); 88 | else if (num === 0) app.quit(); 89 | }); 90 | }; 91 | checkPort(); 92 | }; 93 | 94 | app.on('ready', async () => { 95 | // if(isDev){ 96 | // const forceDownload = !!process.env.UPGRADE_EXTENSIONS; 97 | // const extensions = [REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS]; 98 | // installExtension( 99 | // extensions, 100 | // {loadExtensionOptions: {allowFileAccess: true}, forceDownload: forceDownload} 101 | // ).then((name:string) => {console.log(`Added Extension: ${name}`)}) 102 | // .then(loadMainWindow) 103 | // // .catch((err: Error) => {console.log('There was an Error: ', err)}) 104 | // } 105 | // else loadMainWindow(); 106 | loadMainWindow(); 107 | }); 108 | 109 | app.on('window-all-closed', () => { 110 | if (process.platform !== 'darwin') { 111 | app.quit(); 112 | } 113 | }); 114 | 115 | // K8S API // 116 | 117 | // populates cluster chart cards 118 | 119 | ipcMain.handle('getAllInfo', async (): Promise => { 120 | // nodes 121 | 122 | const tempData: SvgInfo = { 123 | name: 'deploy', 124 | usage: 1, 125 | resource: 'deploy', 126 | request: 0.9, 127 | limit: Math.random() + 1, 128 | parent: 'deploy', 129 | namespace: 'deploy', 130 | }; 131 | 132 | try { 133 | const nsSelect = await mainWindow.webContents 134 | .executeJavaScript('({...localStorage});', true) 135 | /* check what type this is with team */ /* check what type this is with team */ 136 | .then((localStorage: any) => { 137 | return localStorage.namespace; 138 | }); 139 | const getNodes = await k8sApiCore.listNode(`${nsSelect}`); 140 | const nodeData = getNodes.body.items.map(node => { 141 | return parseNode(node); 142 | }); // end of nodeData 143 | 144 | const getPods = await k8sApiCore.listNamespacedPod(`${nsSelect}`); 145 | // console.log('this is getPods: ',getPods.body.items[0]); 146 | 147 | const memData = await Promise.all( 148 | getPods.body.items.map(pod => { 149 | // console.log('this is all pods fom k8s', pod) 150 | return fetchMem(pod); 151 | }) 152 | ); 153 | const cpuData = await Promise.all( 154 | getPods.body.items.map(pod => fetchCPU(pod)) 155 | ); 156 | 157 | const filteredMem = memData; 158 | // const filteredMem = memData.filter(el => el.request > 1) 159 | const filteredCPU = cpuData.filter(el => el.resource === 'cpu'); 160 | const filteredPods = filteredMem; 161 | 162 | for (let i = 0; i < filteredCPU.length; i++) { 163 | filteredPods.push(filteredCPU[i]); 164 | } 165 | 166 | if (filteredPods) { 167 | const newObj: ClusterAllInfo = { 168 | Nodes: nodeData, 169 | Pods: filteredPods, 170 | }; 171 | return newObj; 172 | } 173 | } catch (error) { 174 | return [tempData]; 175 | } 176 | }); 177 | 178 | // get names of nodes in cluster related to selected namespace 179 | ipcMain.handle('getNodes', async (): Promise => { 180 | // dynamically get this from frontend later 181 | try { 182 | const nsSelect = await mainWindow.webContents 183 | .executeJavaScript('({...localStorage});', true) 184 | /* check what type this is with team */ /* check what type this is with team */ 185 | .then((localStorage: any) => { 186 | return localStorage.namespace; 187 | }); 188 | const data = await k8sApiCore.listNode(`${nsSelect}`); 189 | 190 | return data.body.items; 191 | } catch (error) { 192 | return console.log(`Error in getNodes function: ERROR: ${error}`); 193 | } 194 | }); 195 | 196 | // get namespaces in cluster. used for multiple queries and to display relevant data 197 | ipcMain.handle('getNamespaces', async () => { 198 | try { 199 | const data = await k8sApiCore.listNamespace(); 200 | const formattedData: any = data.body.items.map(pod => pod?.metadata?.name); 201 | return formattedData; 202 | } catch (error) { 203 | console.log(`Error in getNamespaces function: ERROR: ${error}`); 204 | } 205 | }); 206 | 207 | // COMMAND LINE // 208 | // get all events in all namespaces 209 | ipcMain.handle('getEvents', async () => { 210 | try { 211 | const response: string = cp 212 | .execSync('kubectl get events --all-namespaces', { 213 | encoding: 'utf-8', 214 | }) 215 | .toString(); 216 | return formatEvents(response); 217 | } catch (error) { 218 | return console.log(`Error in getEvents function: ERROR: ${error}`); // add return statement to make async () => on line 112 happy 219 | } 220 | }); 221 | 222 | // HOMEPAGE CHART QUERY FOR MEMORY 223 | 224 | ipcMain.handle('getMemoryUsageByPods', async () => { 225 | const { startTime, endTime } = setStartAndEndTime(); 226 | 227 | const interval = '15s'; 228 | try { 229 | const nsSelect = await mainWindow.webContents 230 | .executeJavaScript('({...localStorage});', true) 231 | .then((localStorage: any) => { 232 | return localStorage.namespace; 233 | }); 234 | // fetch time series data from prom api 235 | const query = `${PROM_URL}query_range?query=container_memory_working_set_bytes{namespace="${nsSelect}",image=""}&start=${startTime}&end=${endTime}&step=${interval}`; 236 | // fetch request 237 | const res = await fetch(query); 238 | const data = await res.json(); 239 | 240 | return formatMatrix(data.data); 241 | } catch (error) { 242 | console.log(`Error in getMemoryUsageByPod function: ERROR: ${error}`); 243 | return { err: error }; 244 | } 245 | }); 246 | 247 | // get alerts from alert manager 248 | ipcMain.handle('getAlerts', async (): Promise => { 249 | try { 250 | const data: any = await fetch(`${PROM_URL}rules`); 251 | const alerts: any = await data.json(); 252 | return formatAlerts(alerts); 253 | } catch (error) { 254 | console.log(`Error in getAlerts function: ERROR: ${error}`); 255 | } 256 | }); 257 | 258 | ipcMain.handle('getOOMKills', async (): Promise => { 259 | try { 260 | // query for pods' last terminated reasons 261 | const response = await fetch( 262 | `${PROM_URL}query?query=kube_pod_container_status_last_terminated_reason` 263 | ); 264 | const data = await response.json(); 265 | const pods = data.data.result; 266 | 267 | // filters for OOMKilledPods and returns an array of names 268 | const OOMKilledPods = pods 269 | .filter((pod: any) => pod.metric.reason === 'OOMKilled') 270 | .map((pod: any) => pod.metric.pod); 271 | 272 | return formatOOMKills(OOMKilledPods); 273 | } catch (error) { 274 | console.log(`Error in getOOMKills function: ERROR: ${error}`); 275 | } 276 | }); 277 | 278 | // TEST FOR USAGE ON HOMEPAGE AND ANALYSIS PAGE 279 | 280 | ipcMain.handle('getUsage', async (event, ...args) => { 281 | const time = new Date().toISOString(); 282 | const interval = '15s'; 283 | const podName = args[0]; 284 | const resource = args[1]; 285 | let namespace; 286 | try { 287 | await mainWindow.webContents 288 | .executeJavaScript('({...localStorage});', true) 289 | .then((localStorage: any) => { 290 | namespace = localStorage.namespace; 291 | }); 292 | 293 | // fetch time series matrix both cpu and mem usage filtered by pod 294 | 295 | const query = 296 | resource === 'memory' 297 | ? `${PROM_URL}query_range?query= 298 | container_memory_working_set_bytes{namespace="${namespace}",pod="${podName}",image=""} 299 | &start=${time}&end=${time}&step=${interval}` 300 | : `${PROM_URL}query_range?query= 301 | sum(rate(container_cpu_usage_seconds_total{namespace="${namespace}",pod="${podName}",image=""}[5m])) 302 | &start=${time}&end=${time}&step=${interval}`; 303 | 304 | const res = await fetch(query); 305 | const data = await res.json(); 306 | 307 | // based on second argument, different calculations for units will take place in formatUsage 308 | return resource === 'memory' 309 | ? formatUsage(data.data, 'megabytes') 310 | : formatUsage(data.data, 'milicores'); 311 | } catch (error) { 312 | console.log(`Error in getUSAGE function: ERROR: ${error}`); 313 | return { err: error }; 314 | } 315 | }); 316 | 317 | /* -------------- Analysis Page -------------- */ 318 | 319 | ipcMain.handle("getAnalysis", async (event, parentNode, interval = '5m', timeOfDeath) => { 320 | 321 | const endTime = timeOfDeath; 322 | const now = new Date(timeOfDeath); 323 | const copyNow = new Date(now.getTime()); 324 | // convert to ISO String for promQL 325 | copyNow.setHours(copyNow.getHours() - 1); 326 | const startTime: string = copyNow.toISOString(); 327 | 328 | try { 329 | // build mem usage by PODS graph 330 | const podMEMQuery = `${PROM_URL}query_range?query= 331 | container_memory_working_set_bytes{node="${parentNode}",image=""} 332 | &start=${startTime}&end=${endTime}&step=${interval}`; 333 | const podMEMRes = await fetch(podMEMQuery); 334 | const podMEMData = await podMEMRes.json(); 335 | const podMem = await formatAnalysis(podMEMData.data, "megabytes", startTime, endTime); 336 | 337 | // build CPU usage by PODS graph 338 | const podCPUQuery = `${PROM_URL}query_range?query= 339 | rate(container_cpu_usage_seconds_total{node="${parentNode}",image=""}[5m]) 340 | &start=${startTime}&end=${endTime}&step=${interval}`; 341 | const podCPURes = await fetch(podCPUQuery); 342 | const podCPUData = await podCPURes.json(); 343 | const podCPU = await formatAnalysis(podCPUData.data, 'milicores'); 344 | 345 | // build sum of mem usage by node from start to end 346 | const nodeMEMQuery = `${PROM_URL}query_range?query= 347 | sum(container_memory_working_set_bytes{node="${parentNode}",image=""}) by (node) 348 | &start=${startTime}&end=${endTime}&step=${interval}`; 349 | const nodeMEMRes = await fetch(nodeMEMQuery); 350 | const nodeMEMData = await nodeMEMRes.json(); 351 | const nodeMem = await formatAnalysis(nodeMEMData.data, 'megabytes'); 352 | 353 | // build sum of CPU usage by node from start to end 354 | const nodeCPUQuery = `${PROM_URL}query_range?query= 355 | sum(rate(container_cpu_usage_seconds_total{node="${parentNode}",image=""}[5m])) by (node) 356 | &start=${startTime}&end=${endTime}&step=${interval}`; 357 | const nodeCPURes = await fetch(nodeCPUQuery); 358 | const nodeCPUData = await nodeCPURes.json(); 359 | const nodeCPU = await formatAnalysis(nodeCPUData.data, 'milicores'); 360 | 361 | // build network bytes read graph 362 | const networkReadQuery = `${PROM_URL}query_range?query= 363 | rate(container_network_receive_bytes_total{node="${parentNode}"}[5m]) 364 | &start=${startTime}&end=${endTime}&step=${interval}` 365 | const networkReadRes = await fetch(networkReadQuery); 366 | const networkReadData = await networkReadRes.json(); 367 | const netRead = await formatAnalysis(networkReadData.data, 'bytes') 368 | 369 | // build network bytes write graph 370 | const networkWriteQuery = `${PROM_URL}query_range?query= 371 | rate(container_network_transmit_bytes_total{node="${parentNode}"}[5m]) 372 | &start=${startTime}&end=${endTime}&step=${interval}` 373 | const networkWriteRes = await fetch(networkWriteQuery); 374 | const networkWriteData = await networkWriteRes.json(); 375 | const netWrite = await formatAnalysis(networkWriteData.data, 'kilobytes') 376 | 377 | return { 378 | podMem, 379 | podCPU, 380 | nodeMem, 381 | nodeCPU, 382 | netRead, 383 | netWrite 384 | }; 385 | } catch (error) { 386 | console.log(`Error in getAnalysis function: ERROR: ${error}`); 387 | return { err: error }; 388 | } 389 | }); 390 | -------------------------------------------------------------------------------- /electron/metricsData/formatMatrix.ts: -------------------------------------------------------------------------------- 1 | import { Metrics } from "@kubernetes/client-node"; 2 | 3 | import { SvgInfo, ClusterChartProps } from "../../client/Types"; 4 | 5 | const fetch: any = (...args: any) => 6 | import("node-fetch").then(({ default: fetch }: any) => fetch(...args)); 7 | 8 | interface matrix { 9 | resultType?: string; 10 | result: [{ metric: {}; values: Array<[number, string]> }]; 11 | } 12 | 13 | interface graph { 14 | [name: string]: { 15 | times: string[]; 16 | values: number[]; 17 | limits?: number; 18 | requests?: number; 19 | // units?: string; 20 | }; 21 | } 22 | 23 | export function formatMatrix(matrix: matrix, unitType?: string) { 24 | const arr: any = []; 25 | // console.log('matrix THIS IS ', matrix) 26 | 27 | const dateOptions: any = { 28 | // dateStyle: "full", 29 | timeStyle: "short", 30 | }; 31 | 32 | matrix.result.forEach((obj: any) => { 33 | const output: graph = {}; 34 | 35 | const podName: string = obj.metric.pod; 36 | output[podName] = { 37 | times: [], 38 | values: [], 39 | // units: '', 40 | }; 41 | 42 | output[podName].times = obj.values.map((el: [number, number]) => { 43 | // time value 44 | return new Date(el[0] * 1000).toLocaleTimeString("en-US", dateOptions); 45 | }); 46 | //this is bytes/units - convert bytes to GB when unit type is bytes 47 | 48 | output[podName].values = obj.values.map((el: [number, number]) => { 49 | // change to megabytes 50 | 51 | return Number(el[1] / 1000000); 52 | }); 53 | arr.push(output); 54 | }); 55 | 56 | return arr; 57 | } 58 | 59 | export function formatUsage(matrix: matrix, unitType?: string) { 60 | let output; 61 | 62 | matrix.result.forEach((obj: any) => { 63 | output = obj.values.map((el: [number, number]) => { 64 | // change to megabytes 65 | 66 | if (unitType === "megabytes") { 67 | return Number(el[1] / 1000000); 68 | } else { 69 | return Number(el[1] * 1000); 70 | } 71 | }); 72 | }); 73 | return output; 74 | } 75 | 76 | export async function getPodReqLimits(podName: string, startTime?: string, endTime?: string) { 77 | const limitsQuery = `http://127.0.0.1:9090/api/v1/query_range?query=kube_pod_container_resource_limits{pod="${podName}",resource="memory"}&start=${startTime}&end=${endTime}&step=24h`; 78 | const requestsQuery = `http://127.0.0.1:9090/api/v1/query_range?query=kube_pod_container_resource_requests{pod="${podName}",resource="memory"}&start=${startTime}&end=${endTime}&step=24h`; 79 | const limit = await fetch(limitsQuery); 80 | const request = await fetch(requestsQuery); 81 | const limits: any = await limit.json(); 82 | const requests: any = await request.json(); 83 | let limitData:any; 84 | let requestData:any; 85 | 86 | limits.data.result.forEach((obj: any) => { 87 | limitData = obj.values.map((entry: [number, number]) => { 88 | return Number(entry[1] / 1000000) 89 | }) 90 | 91 | 92 | requests.data.result.forEach((obj: any) => { 93 | requestData = obj.values.map((entry: [number, number]):any => { 94 | return Number(entry[1] / 1000000) 95 | }) 96 | }) 97 | 98 | // console.log('limitdata', limitData) 99 | // console.log('requestdata', requestData) 100 | }) 101 | return { 102 | limitData, 103 | requestData 104 | } 105 | } 106 | 107 | export async function formatAnalysis(matrix: matrix, unitType?: string, startTime?: string, endTime?: string) { 108 | const arr: any = []; 109 | let reqObj: any = { 110 | limitData: [], 111 | requestData: [] 112 | } 113 | 114 | const dateOptions: any = { 115 | timeStyle: "short", 116 | }; 117 | 118 | await matrix.result.forEach(async (obj: any) => { 119 | 120 | const output: graph = {}; 121 | let name: string = 'n/a'; 122 | if (obj.metric.pod) { 123 | name = obj.metric.pod; 124 | if (unitType === "megabytes") { 125 | reqObj = await getPodReqLimits(name, startTime, endTime) 126 | } 127 | } 128 | // if theres no metric.pod, then the object being passed in is a node 129 | else if (!obj.metric.pod) { 130 | name = obj.metric.node; 131 | } 132 | if (!reqObj.limitData){ 133 | 134 | reqObj = { 135 | limitData: [], 136 | requestData: [] 137 | } 138 | } 139 | output[name] = { 140 | times: [], 141 | values: [], 142 | limits: reqObj.limitData, 143 | requests: reqObj.requestData 144 | }; 145 | 146 | output[name].times = obj.values.map((el: [number, number]) => { 147 | // time value 148 | return new Date(el[0] * 1000).toLocaleTimeString("en-US", dateOptions); 149 | }); 150 | //this is bytes/units - convert bytes to GB when unit type is bytes 151 | 152 | output[name].values = obj.values.map((el: [number, number]) => { 153 | // change to megabytes 154 | 155 | if (unitType === "megabytes") return Number(el[1] / 1000000); 156 | else if (unitType === "milicores") return Number(el[1]*1000) 157 | else if (unitType === "kilobytes") return Number(el[1]/1000) 158 | else if (unitType === "bytes") return Number(el[1]) 159 | return; 160 | }); 161 | 162 | arr.push(output); 163 | }); 164 | 165 | return arr; 166 | } -------------------------------------------------------------------------------- /electron/preload.ts: -------------------------------------------------------------------------------- 1 | import { ipcRenderer, contextBridge } from 'electron'; 2 | 3 | // contextBridge allows for functions in main.ts to be available in the frontend 4 | 5 | // Create window API with events at specific channels 6 | 7 | // Stretch goals: Accept namespace and pass in namespace to invoke method as 2nd arg 8 | const WINDOW_API = { 9 | // getNamespaces: async () => ipcRenderer.invoke('getNamespaces'), 10 | getNodes: async () => ipcRenderer.invoke('getNodes'), 11 | getDeployments: async () => ipcRenderer.invoke('getDeployments'), 12 | getServices: async () => ipcRenderer.invoke('getServices'), 13 | getPods: async () => ipcRenderer.invoke('getPods'), 14 | getLogs: async () => ipcRenderer.invoke('getLogs'), 15 | getEvents: async () => ipcRenderer.invoke('getEvents'), 16 | getNamespaces: async () => ipcRenderer.invoke('getNamespaces'), 17 | getMemoryUsageByPods: async () => ipcRenderer.invoke('getMemoryUsageByPods'), 18 | getCPUsage: async () => ipcRenderer.invoke('getCPUUsageByPods'), 19 | getAlerts: async () => ipcRenderer.invoke('getAlerts'), 20 | getLimits: async () => ipcRenderer.invoke('getLimits'), 21 | getAllInfo: async () => ipcRenderer.invoke('getAllInfo'), 22 | getOOMKills: async () => ipcRenderer.invoke('getOOMKills'), 23 | getUsage: async (...args: any) => ipcRenderer.invoke('getUsage', ...args), 24 | getAnalysis: async (...args: any) => 25 | ipcRenderer.invoke('getAnalysis', ...args), 26 | }; 27 | 28 | // exposes WINDOW_API methods to the frontend under "window.api" object 29 | contextBridge.exposeInMainWorld('api', WINDOW_API); 30 | -------------------------------------------------------------------------------- /electron/utils.ts: -------------------------------------------------------------------------------- 1 | import { SvgInfo, SvgInfoObj, oomObject } from '../client/Types'; 2 | import * as cp from 'child_process'; 3 | 4 | const fetch: any = (...args: any) => 5 | import('node-fetch').then(({ default: fetch }: any) => fetch(...args)); 6 | 7 | // utilized for start and end times when querying for metrics 8 | export const setStartAndEndTime = () => { 9 | var now = new Date(); 10 | var copyNow = new Date(now.getTime()); 11 | copyNow.setHours(copyNow.getHours() - 1); 12 | var startTime = copyNow.toISOString(); 13 | var endTime = new Date().toISOString(); 14 | return { 15 | startTime: startTime, 16 | endTime: endTime, 17 | }; 18 | }; 19 | 20 | export const formatClusterData = (data: any): string[] => { 21 | const formattedData: string[] = data.body.items.map( 22 | (pod: any) => pod?.metadata?.name 23 | ); 24 | return formattedData; 25 | }; 26 | 27 | export const formatEvents = (data: any): {}[] => { 28 | const dataArr: string[] = data.split('\n'); 29 | const trimmed: string[] = dataArr.map((el: any) => el.split(/[ ]{2,}/)); 30 | const formattedEvents: {}[] = trimmed.map((event: any) => { 31 | return { 32 | namespace: event[0], 33 | lastSeen: event[1], 34 | severity: event[2], 35 | reason: event[3], 36 | message: event[5], 37 | object: event[4], 38 | }; 39 | }); 40 | return formattedEvents.slice(1, -1); 41 | }; 42 | 43 | export const formatAlerts = (data: any): {}[] => { 44 | const formattedAlerts: object[] = []; 45 | data.data.groups.forEach((group: any) => { 46 | group.rules.forEach((rule: any) => { 47 | if (rule.state) { 48 | rule.labels.severity = capitalize(rule.labels.severity); 49 | rule.state = capitalize(rule.state); 50 | 51 | const alert: {} = { 52 | group: group.name, 53 | state: rule.state, 54 | name: rule.name, 55 | severity: rule.labels.severity, 56 | description: rule.annotations.description, 57 | summary: rule.annotations.summary, 58 | alerts: rule.alerts, 59 | }; 60 | formattedAlerts.push(alert); 61 | } 62 | }); 63 | }); 64 | return formattedAlerts; 65 | }; 66 | 67 | export function capitalize(data: string) { 68 | return data[0].toUpperCase() + data.slice(1); 69 | } 70 | 71 | export function parseMem(entry: string) { 72 | // if dealing with memory (ki, mb, mi, etc.) 73 | 74 | return parseInt(entry.slice(0, entry.length - 2)); 75 | } 76 | 77 | export function parseNode(obj: any) { 78 | // for each node from query we spit back this object 79 | 80 | // using Type Assertion to create an empty object for the typed variable 81 | // this could potentially create inconsistencies. 82 | // const output: SvgInfo = {} as SvgInfo; 83 | 84 | // best practice might be to create a new class object with default values and set 85 | const output: SvgInfo = new SvgInfoObj(); 86 | 87 | if (obj.status?.allocatable !== undefined) { 88 | const memRequest: number = parseMem(obj.status.allocatable.memory); 89 | output.request = memRequest / 1000; 90 | output.unit = 'megabytes' 91 | } 92 | if (obj.status?.capacity !== undefined) { 93 | const memLimit: number = parseMem(obj.status.capacity.memory); 94 | output.limit = memLimit / 1000; 95 | output.unit = 'megabytes' 96 | } 97 | 98 | // (if node is truthy, and if node.metadata is truthy, and if node.metadat.name is truthy) 99 | if (obj?.metadata?.name) output.name = obj.metadata.name; 100 | return output; 101 | } 102 | 103 | export async function fetchMem(obj: any) { 104 | const output: SvgInfo = new SvgInfoObj(); 105 | const podName = obj.metadata.name; 106 | const { startTime, endTime } = setStartAndEndTime(); 107 | 108 | if (obj?.metadata?.name) { 109 | output.name = obj.metadata.name; 110 | output.parent = obj.spec.nodeName; 111 | output.namespace = obj.metadata.namespace; 112 | } 113 | 114 | try { 115 | // PromQL to query resource limits for pods in all namespaces 116 | const limitsQuery = `http://127.0.0.1:9090/api/v1/query_range?query=kube_pod_container_resource_limits{pod="${podName}",resource="memory"}&start=${startTime}&end=${endTime}&step=24h`; 117 | const requestsQuery = `http://127.0.0.1:9090/api/v1/query_range?query=kube_pod_container_resource_requests{pod="${podName}",resource="memory"}&start=${startTime}&end=${endTime}&step=24h`; 118 | const limit = await fetch(limitsQuery); 119 | const request = await fetch(requestsQuery); 120 | const limitData: any = await limit.json(); 121 | const requestData: any = await request.json(); 122 | 123 | if (limitData.data.result[0]) { 124 | if (limitData.data.result[0].metric.resource === 'memory') { 125 | output.resource = 'memory'; 126 | output.limit = 127 | parseInt(limitData.data.result[0].values[0][1]) / 1000000; 128 | output.request = 129 | parseInt(requestData.data.result[0].values[0][1]) / 1000000; 130 | output.unit = 'megabytes'; 131 | } 132 | } 133 | return output; 134 | } catch (err) { 135 | return { 136 | name: '', 137 | usage: 1, 138 | resource: '', 139 | limit: 1, 140 | request: 1, 141 | parent: '', 142 | namespace: '', 143 | }; 144 | } 145 | } 146 | 147 | export async function fetchCPU(obj: any) { 148 | const output: SvgInfo = new SvgInfoObj(); 149 | const podName = obj.metadata.name; 150 | const { startTime, endTime } = setStartAndEndTime(); 151 | // console.log('I AM POD NAME', podName) 152 | if (obj?.metadata?.name) { 153 | output.name = obj.metadata.name; 154 | output.parent = obj.spec.nodeName; 155 | output.namespace = obj.metadata.namespace; 156 | } 157 | 158 | try { 159 | const limitsQuery = `http://127.0.0.1:9090/api/v1/query_range?query=kube_pod_container_resource_limits{pod="${podName}",resource="cpu"}&start=${startTime}&end=${endTime}&step=24h`; 160 | const requestsQuery = `http://127.0.0.1:9090/api/v1/query_range?query=kube_pod_container_resource_requests{pod="${podName}",resource="cpu"}&start=${startTime}&end=${endTime}&step=24h`; 161 | const limit = await fetch(limitsQuery); 162 | const limitData: any = await limit.json(); 163 | const request = await fetch(requestsQuery); 164 | const requestData: any = await request.json(); 165 | 166 | if (limitData.data.result[0]) { 167 | if (limitData.data.result[0].metric.resource === 'cpu') { 168 | output.resource = 'cpu'; 169 | output.limit = limitData.data.result[0].values[0][1] * 1000; 170 | output.request = requestData.data.result[0].values[0][1] * 1000; 171 | output.unit = 'milicores'; 172 | } 173 | } 174 | return output; 175 | } catch (error) { 176 | return { 177 | name: '', 178 | usage: 1, 179 | request: 1, 180 | resource: '', 181 | limit: 1, 182 | parent: '', 183 | namespace: '', 184 | }; 185 | } 186 | } 187 | 188 | export const formatOOMKills = (data: string[]) => { 189 | const OOMKills: {}[] = []; 190 | 191 | data.forEach((el: any) => { 192 | const podDesc = cp.execSync(`kubectl describe pod ${el}`).toString(); 193 | const podData = podDesc.split('\n'); 194 | const updatedPodData = podData.map(pod => 195 | pod.replace(/^\s+|\s+$|\s+(?=\s)/g, '') 196 | ); 197 | // console.log("this is updated pod data", updatedPodData); 198 | const indexOfTerm = updatedPodData.indexOf('Last State: Terminated'); 199 | // console.log(indexOfTerm); 200 | const filteredPodData: string[] = updatedPodData.slice( 201 | indexOfTerm, 202 | indexOfTerm + 13 203 | ); 204 | // console.log(filteredPodData); 205 | 206 | const oomObject: { [index: string]: any } = {}; 207 | 208 | const namespaceStr: string = updatedPodData[1]; 209 | const nsColonIdx: any = namespaceStr.indexOf(':'); 210 | const namespace: string = namespaceStr.slice(nsColonIdx + 1).trim(); 211 | 212 | const nodeStr: any = updatedPodData.filter(str => str.includes('Node:'))[0]; 213 | const nodeColonIdx: any = nodeStr.indexOf(':'); 214 | const nodeSlashIdx: any = nodeStr.indexOf('/'); 215 | const node: string = nodeStr.slice(nodeColonIdx + 1, nodeSlashIdx).trim(); 216 | 217 | const limitIdx: any = filteredPodData.indexOf('Limits:'); 218 | const limitCpu = filteredPodData[limitIdx + 1]; 219 | const limitMemory = filteredPodData[limitIdx + 2]; 220 | const limits = { 221 | limitCpu, 222 | limitMemory, 223 | }; 224 | 225 | const requestIdx = filteredPodData.indexOf('Requests:'); 226 | const requestCpu = filteredPodData[requestIdx + 1]; 227 | const requestMemory = filteredPodData[requestIdx + 2]; 228 | const requests = { 229 | requestCpu, 230 | requestMemory, 231 | }; 232 | 233 | oomObject.namespace = namespace; 234 | oomObject.podName = el; 235 | oomObject[filteredPodData[limitIdx]] = limits; 236 | oomObject[filteredPodData[requestIdx]] = requests; 237 | oomObject.node = node; 238 | filteredPodData.slice(0, 7).forEach((el: any) => { 239 | const colon: any = el.indexOf(':'); 240 | // Extracts key from the left of colon and lowercases to send properly to frontend 241 | const key: keyof oomObject = el 242 | .slice(0, colon) 243 | .toLowerCase() 244 | .split(' ') 245 | .join(''); 246 | // Extracts value from the right of colon and removes white space 247 | oomObject[key] = el.slice(colon + 2); 248 | }); 249 | 250 | OOMKills.push(oomObject); 251 | }); 252 | 253 | return OOMKills; 254 | }; 255 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | 5 | // jsdom is used for testing frontend, will need to use 'node' for backend server side testing 6 | testEnvironment: 'jsdom', 7 | 8 | // Test spec file resolution pattern 9 | // Matches parent folder `__tests__` and filename 10 | // should contain `test` or `spec`. 11 | testRegex: "(/__tests__/.*\\.(test|spec))\\.tsx?$", 12 | 13 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "palaemon", 3 | "version": "1.0.0", 4 | "description": "- A gentle, euthanization and diagnosis tool for out-of-memory (OOM) kubernetes pods. - Palaemon is a Greek, childe sea-god who came to aid sailors in distress. He was often depicted as riding a dolphin. Also, a genus of shrimp.", 5 | "scripts": { 6 | "build": "webpack --watch --config ./webpack.config.js", 7 | "build:production": "webpack --config ./webpack.production.js", 8 | "rmdir": "node -e \"var fs = require('fs'); process.argv.slice(1).map((fpath) => fs.rmdirSync(fpath, { recursive: true })); process.exit(0);\"", 9 | "clean:prebuild": "npm run rmdir -- dist", 10 | "dev": "concurrently \"webpack --watch --config ./webpack.config.js\" \"wait-on ./dist/electron/main.js && electronmon ./dist/electron/main.js\"", 11 | "electronmon": "electronmon ./dist/electron/main.js", 12 | "start": "npm run clean:prebuild && npm run dev", 13 | "start:production": "npm run build:production && electron ./dist/electron/main.js", 14 | "test": "jest", 15 | "test:watch": "jest --watch", 16 | "test:e2e": "playwright test" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/oslabs-beta/Palaemon.git" 21 | }, 22 | "keywords": [], 23 | "author": "", 24 | "license": "ISC", 25 | "bugs": { 26 | "url": "https://github.com/oslabs-beta/Palaemon/issues" 27 | }, 28 | "homepage": "https://github.com/oslabs-beta/Palaemon#readme", 29 | "browser": { 30 | "child_process": false 31 | }, 32 | "dependencies": { 33 | "@kubernetes/client-node": "^0.17.0", 34 | "ajv": "^7.2.4", 35 | "chart.js": "^3.9.1", 36 | "child_process": "^1.0.2", 37 | "node-fetch": "^2.2.10", 38 | "react": "^18.2.0", 39 | "react-chartjs-2": "^4.3.1", 40 | "react-dom": "^18.2.0", 41 | "react-router-dom": "^6.3.0", 42 | "sass": "^1.54.8", 43 | "typescript": "^4.8.3" 44 | }, 45 | "devDependencies": { 46 | "@playwright/test": "^1.25.2", 47 | "@testing-library/jest-dom": "^5.16.5", 48 | "@testing-library/react": "^13.4.0", 49 | "@types/jest": "^29.0.3", 50 | "@types/node-fetch": "^2.6.2", 51 | "@types/react": "^18.0.17", 52 | "@types/react-dom": "^18.0.6", 53 | "concurrently": "^7.3.0", 54 | "copy-webpack-plugin": "^11.0.0", 55 | "css-loader": "^6.7.1", 56 | "electron": "^20.1.0", 57 | "electron-builder": "^23.3.3", 58 | "electron-devtools-installer": "^3.2.0", 59 | "electronmon": "^2.0.2", 60 | "html-webpack-plugin": "^5.5.0", 61 | "image-webpack-loader": "^8.1.0", 62 | "jest": "^29.0.3", 63 | "jest-dom": "^4.0.0", 64 | "jest-environment-jsdom": "^29.0.3", 65 | "playwright": "^1.25.2", 66 | "postcss-loader": "^7.0.1", 67 | "sass-loader": "^13.0.2", 68 | "style-loader": "^3.3.1", 69 | "ts-jest": "^29.0.1", 70 | "ts-loader": "^9.3.1", 71 | "wait-on": "^6.0.1", 72 | "webpack": "^5.74.0", 73 | "webpack-cli": "^4.10.0" 74 | }, 75 | "electronmon": { 76 | "patterns": [ 77 | "dist/**/*", 78 | "!client/**/*", 79 | "!electron/**/*", 80 | "!__tests__/**/*", 81 | "!__mocks__/**/*", 82 | "!journal/**/*", 83 | "!node_modules/**/*" 84 | ] 85 | } 86 | } -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import type { PlaywrightTestConfig } from '@playwright/test'; 2 | const config: PlaywrightTestConfig = { 3 | use: { 4 | headless: false, 5 | viewport: { width: 1280, height: 720 }, 6 | ignoreHTTPSErrors: true, 7 | trace: 'on', 8 | video: 'on-first-retry', 9 | }, 10 | // 11 | reporter:[ 12 | ['list'], 13 | ['html', {open: 'never', outputFolder:'./__tests__/playwright_reports'}] 14 | ], 15 | 16 | // Each test is given 15 seconds 17 | timeout: 15 * 1000, 18 | 19 | // two retries for each test 20 | retries: 2, 21 | 22 | // only the files matching one of these patterns are executed as test files 23 | // default is .*(test|spec)\.(js|ts|mjs) 24 | testMatch: /.*\.e2e\.(js|ts|jsx|tsx)/, 25 | 26 | // directroy to be scanned for test files. Defaults to directory of the config file 27 | testDir: './__tests__', 28 | // outputDir: './__tests__/playwright_reports' 29 | }; 30 | export default config; -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "types": ["node", "jest", "@testing-library/jest-dom"], 5 | "module": "commonjs", 6 | "lib": ["dom", "es2015", "es2016", "es2017"], 7 | "allowJs": true, 8 | "jsx": "react-jsx", 9 | "sourceMap": true, 10 | "outDir": "dist", 11 | "strict": true, 12 | "esModuleInterop": true, 13 | "noImplicitAny": true, 14 | "noImplicitReturns": true, 15 | "strictNullChecks": true, 16 | "forceConsistentCasingInFileNames": true 17 | }, 18 | "exclude": ["node_modules", "dist"] 19 | } 20 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 2 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 3 | // const NormalModuleReplacementPlugin = require("module-replace-webpack-plugin"); 4 | const path = require('path'); 5 | 6 | module.exports = [ 7 | { 8 | mode: 'development', 9 | entry: { 10 | main: './electron/main.ts', 11 | preload: './electron/preload.ts', 12 | }, 13 | target: 'electron-main', 14 | module: { 15 | rules: [ 16 | { 17 | test: /\.ts(x?)$/, 18 | exclude: /node-modules/, 19 | use: 'ts-loader', 20 | }, 21 | ], 22 | }, 23 | resolve: { 24 | extensions: ['.ts', '.tsx', '.js', '.jsx'], 25 | }, 26 | output: { 27 | path: path.resolve(__dirname, './dist/electron/'), 28 | clean: true, 29 | }, 30 | externals: [ 31 | { 32 | 'utf-8-validate': 'commonjs utf-8-validate', 33 | bufferutil: 'commonjs bufferutil', 34 | fsevents: "require('fsevents')", 35 | }, 36 | ], 37 | }, 38 | { 39 | mode: 'development', 40 | entry: './client/index.tsx', 41 | devtool: 'inline-source-map', 42 | module: { 43 | rules: [ 44 | { 45 | test: /\.ts(x?)$/, 46 | exclude: /node_modules/, 47 | use: 'ts-loader', 48 | }, 49 | { 50 | test: /\.s?[ac]ss$/, 51 | use: ['style-loader', 'css-loader', 'sass-loader'], 52 | }, 53 | { 54 | test: /\.png/, 55 | type: 'asset/resource', 56 | }, 57 | ], 58 | }, 59 | resolve: { 60 | extensions: ['.ts', '.tsx', '.js', '.jsx'], 61 | }, 62 | output: { 63 | path: path.resolve(__dirname, './dist/client'), 64 | filename: 'index.js', 65 | clean: true, 66 | }, 67 | // devServer: { 68 | // host: 'localhost', 69 | // port: 8080, 70 | // hot: true, 71 | // static: { 72 | // directory: path.resolve(__dirname, './client/assets'), 73 | // publicPath: '/assets', 74 | // }, 75 | // }, 76 | plugins: [ 77 | new HtmlWebpackPlugin({ 78 | template: 'client/index.html', 79 | }), 80 | new CopyWebpackPlugin({ 81 | patterns: [ 82 | { 83 | from: path.resolve(__dirname, 'client/assets'), 84 | to: 'assets/', 85 | }, 86 | ], 87 | }), 88 | // new webpack.NormalModuleReplacementPlugin(/node:/, (resource) => { 89 | // resource.request = resource.request.replace(/^node:/, ""); 90 | // }), 91 | ], 92 | }, 93 | ]; 94 | -------------------------------------------------------------------------------- /webpack.production.js: -------------------------------------------------------------------------------- 1 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 2 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 3 | const path = require('path'); 4 | 5 | module.exports = [ 6 | { 7 | mode: 'production', 8 | entry: { 9 | main: './electron/main.ts', 10 | preload: './electron/preload.ts' 11 | }, 12 | target: 'electron-main', 13 | module: { 14 | rules: [ 15 | { 16 | test: /\.ts(x?)$/, 17 | exclude: /node-modules/, 18 | use: 'ts-loader', 19 | }, 20 | ], 21 | }, 22 | resolve: { 23 | extensions: ['.ts', '.tsx', '.js', '.jsx'], 24 | }, 25 | output: { 26 | path: path.resolve(__dirname, 'dist/electron'), 27 | clean: true 28 | }, 29 | externals: [ 30 | { 31 | 'utf-8-validate': 'commonjs utf-8-validate', 32 | bufferutil: 'commonjs bufferutil', 33 | fsevents: "require('fsevents')", 34 | }, 35 | ], 36 | }, 37 | { 38 | mode: 'production', 39 | entry: './client/index.tsx', 40 | devtool: false, 41 | module: { 42 | rules: [ 43 | { 44 | test: /\.ts(x?)$/, 45 | exclude: /node_modules/, 46 | use: 'ts-loader', 47 | }, 48 | { 49 | test: /\.s?[ac]ss$/, 50 | use: ['style-loader', 'css-loader', 'sass-loader'], 51 | }, 52 | { 53 | test: /\.png/, 54 | type: 'asset/resource', 55 | }, 56 | ], 57 | }, 58 | resolve: { 59 | modules: [__dirname, 'client', 'node_modules'], 60 | extensions: ['*', '.ts', '.tsx', '.js', '.jsx'], 61 | }, 62 | output: { 63 | path: path.resolve(__dirname, 'dist/client'), 64 | filename: 'index.js', 65 | clean: true, 66 | }, 67 | plugins: [ 68 | new HtmlWebpackPlugin({ 69 | template: '/client/index.html', 70 | }), 71 | new CopyWebpackPlugin({ 72 | patterns: [ 73 | { 74 | from: path.resolve(__dirname, 'client/assets'), 75 | to: 'assets/', 76 | }, 77 | ], 78 | }), 79 | ], 80 | }, 81 | ]; 82 | --------------------------------------------------------------------------------