├── .gitignore ├── README.md ├── client ├── .eslintrc.json ├── .prettierrc.json ├── index.html ├── package-lock.json ├── package.json ├── src │ ├── App.js │ ├── App.test.js │ ├── App.test.tsx │ ├── App.tsx │ ├── app │ │ ├── createAppSlice.js │ │ ├── createAppSlice.ts │ │ ├── hooks.js │ │ ├── hooks.ts │ │ ├── store.js │ │ └── store.ts │ ├── features │ │ ├── captive-portal │ │ │ ├── ProtectedRoute.js │ │ │ ├── ProtectedRoute.test.tsx │ │ │ ├── ProtectedRoute.tsx │ │ │ ├── captivePortalSlice.js │ │ │ ├── captivePortalSlice.ts │ │ │ ├── components │ │ │ │ ├── CaptiveForm.js │ │ │ │ ├── CaptiveForm.tsx │ │ │ │ ├── CaptiveFormContent.js │ │ │ │ └── CaptiveFormContent.tsx │ │ │ └── containers │ │ │ │ ├── CaptivePortal.js │ │ │ │ ├── CaptivePortal.test.js │ │ │ │ ├── CaptivePortal.test.tsx │ │ │ │ └── CaptivePortal.tsx │ │ ├── cluster-log │ │ │ ├── clusterLogsApiSlice.js │ │ │ ├── clusterLogsApiSlice.ts │ │ │ ├── components │ │ │ │ ├── ClusterLog.js │ │ │ │ ├── ClusterLog.test.js │ │ │ │ ├── ClusterLog.test.tsx │ │ │ │ └── ClusterLog.tsx │ │ │ └── containers │ │ │ │ ├── ClusterLogContainer.js │ │ │ │ ├── ClusterLogContainer.test.js │ │ │ │ ├── ClusterLogContainer.test.tsx │ │ │ │ ├── ClusterLogContainer.tsx │ │ │ │ └── clusterLogContainer.js │ │ ├── cluster-view │ │ │ ├── clusterViewApiSlice.js │ │ │ ├── clusterViewApiSlice.ts │ │ │ ├── components │ │ │ │ ├── CustomNode.js │ │ │ │ └── CustomNode.tsx │ │ │ └── containers │ │ │ │ ├── ClusterViewContainer.js │ │ │ │ └── ClusterViewContainer.tsx │ │ ├── grafana-dashboard │ │ │ ├── Grafana.css │ │ │ ├── GrafanaDashboardApiSlice.js │ │ │ ├── GrafanaDashboardApiSlice.ts │ │ │ ├── GrafanaViewContainer.js │ │ │ └── GrafanaViewContainer.tsx │ │ ├── info-drawer │ │ │ ├── InfoPopover.js │ │ │ └── InfoPopover.tsx │ │ ├── landing-page │ │ │ ├── LandingPage.js │ │ │ ├── LandingPage.test.js │ │ │ ├── LandingPage.test.tsx │ │ │ ├── LandingPage.tsx │ │ │ ├── components │ │ │ │ ├── MeetTheTeam.css │ │ │ │ ├── MeetTheTeam.js │ │ │ │ ├── MeetTheTeam.tsx │ │ │ │ ├── ReadMe.css │ │ │ │ ├── ReadMe.js │ │ │ │ └── ReadMe.tsx │ │ │ └── landingpage.css │ │ ├── mini-drawer │ │ │ ├── MiniDrawer.js │ │ │ └── MiniDrawer.tsx │ │ └── settings │ │ │ ├── settings.js │ │ │ ├── settings.test.js │ │ │ ├── settings.test.tsx │ │ │ └── settings.tsx │ ├── index.css │ ├── main.js │ ├── main.tsx │ ├── public │ │ ├── AlexPFP.jpeg │ │ ├── JonathanPFP.jpeg │ │ ├── MichaelPFP.jpg │ │ ├── VincentPFP.png │ │ ├── logo.png │ │ └── logo.svg │ ├── setupTests.js │ ├── setupTests.ts │ ├── utils │ │ ├── test-utils.js │ │ └── test-utils.tsx │ └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json ├── vite.config.d.ts ├── vite.config.js └── vite.config.ts ├── k8state.png └── server ├── controllers ├── generalController.js ├── generalController.ts ├── kubernetesController.js └── kubernetesController.ts ├── package-lock.json ├── package.json ├── routes ├── kubernetesRouter.js └── kubernetesRouter.ts ├── server.js ├── server.ts ├── services ├── generalService.js ├── generalService.ts ├── kubernetesService.js └── kubernetesService.ts ├── tsconfig.json ├── vite.config.js └── vite.config.ts /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | # Dependency directories 11 | node_modules/ 12 | client/node_modules/ 13 | server/node_modules/ 14 | 15 | # Build outputs 16 | dist/ 17 | client/dist/ 18 | server/dist/ 19 | dist-ssr/ 20 | 21 | # Local environment files 22 | *.local 23 | .env 24 | .env0 25 | 26 | # TypeScript build info files 27 | *.tsbuildinfo 28 | 29 | # Editor directories and files 30 | .vscode/* 31 | !.vscode/extensions.json 32 | .idea/ 33 | .DS_Store 34 | *.suo 35 | *.ntvs* 36 | *.njsproj 37 | *.sln 38 | *.sw? -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # K8State: Kubernetes Dashboard 2 | 3 | K8State is a comprehensive Kubernetes dashboard application designed to provide real-time insights into your Kubernetes clusters. With K8State, you can monitor, manage, and optimize your Kubernetes resources through a user-friendly interface built on modern web technologies. 4 | 5 | ## Features 6 | 7 | • Real-Time Monitoring: View the status of pods, nodes, and services in real-time. 8 | • Resource Visualization: Graphical representation of resource utilization, leveraging Prometheus and Grafana. 9 | • Interactive UI: Built with React, Redux, React-Flow and Material-UI, offering a responsive and intuitive interface. 10 | • Scalability: Designed to scale with your Kubernetes clusters, supporting multiple namespaces and contexts. 11 | • Extensibility: Easily extendable through a modular architecture to accommodate custom metrics and features. 12 | 13 | ## Prerequisites 14 | 15 | • Node.js (v14.x or higher) 16 | • Kubernetes Cluster URL / Bearer Token 17 | • Prometheus and Grafana (Installed on the Kubernetes Cluster) 18 | • Helm (for deploying Grafana on the cluster) 19 | • npm or Yarn 20 | 21 | More information about how to generate a bearer token: 22 | https://kubernetes.io/docs/reference/kubectl/generated/kubectl_create/kubectl_create_token/ 23 | 24 | ## Installation 25 | 26 | 1. Clone the Repository 27 | 28 | ```sh 29 | git clone https://github.com/your-username/k8state.git 30 | cd k8state 31 | ``` 32 | 33 | 2. Install dependencies in both the client and server folders 34 | 35 | Using npm: 36 | 37 | ```sh 38 | cd client 39 | npm install 40 | 41 | cd server 42 | npm install 43 | 44 | ``` 45 | 46 | Using yarn: 47 | 48 | ```sh 49 | yarn install 50 | ``` 51 | 52 | ## Grafana / Prometheus 53 | 54 | In order to import your Grafana dashboard, Grafana must be installed in the 55 | cluster. If you already have a Grafana dashboard, ensure the 'allow_embedding' 56 | property is enabled and restart Grafana. 57 | 58 | Check this documentation to set up grafana: 59 | https://grafana.com/docs/grafana/latest/setup-grafana/installation/kubernetes/ 60 | 61 | After installing Grafana, you can then create and import that dashboard to be 62 | used in the application. This allows you to centralize monitoring and viewing 63 | tools within a single application. 64 | 65 | We recommend also installing Prometheus to automatically scrape 66 | the cluster for necessary metrics. Both Prometheus and Grafana can be installed 67 | using Helm. 68 | 69 | To create your dashboard, log in to Grafana and add your Prometheus URL as a new 70 | data source under 'Connections'. Once saved and tested, you'll be able to 71 | customize a dashboard on Grafana, allowing you to generate a link for embedding. 72 | Input this link in the Grafana Dashboard prompt in the application. 73 | 74 | ## Scripts 75 | 76 | - `dev` - start dev server on port 3000 77 | - `server` - start backend server on port 8080 78 | 79 | ## Running the Application 80 | 81 | 1. Start the Backend 82 | 83 | In the root directory: 84 | 85 | ```sh 86 | cd server 87 | npm run server 88 | ``` 89 | 90 | Starts the server on http://localhost:8080 91 | 92 | 2. Start the Frontend 93 | 94 | In the root directory: 95 | 96 | ```sh 97 | cd client 98 | npm run build 99 | npm run start 100 | ``` 101 | 102 | Starts the client on http://localhost:3000 103 | 104 | ## Technology Stack (This application uses Typescript) 105 | 106 | • Frontend: React, Redux, React-Flow, Material-UI 107 | • Backend: Node.js, Express 108 | • Metrics and Monitoring: Prometheus, Grafana 109 | • Data Management: Kubernetes API 110 | • Deployment: Kubernetes, Helm 111 | 112 | ## Creators 113 | 114 | - [Vincent Collis](https://github.com/VincentCollis) 115 | - [Jonathan Wu](https://github.com/Jon-Wu1) 116 | - [Michael Chen](https://github.com/mochamochaccino) 117 | - [Alex Greenberg](https://github.com/AlexG0718) 118 | -------------------------------------------------------------------------------- /client/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "eslint:recommended", 4 | "react-app", 5 | "plugin:react/jsx-runtime", 6 | "prettier" 7 | ], 8 | "parser": "@typescript-eslint/parser", 9 | "parserOptions": { "project": true, "tsconfigRootDir": "./" }, 10 | "plugins": ["@typescript-eslint"], 11 | "root": true, 12 | "ignorePatterns": ["dist"], 13 | "rules": { 14 | "@typescript-eslint/consistent-type-imports": [ 15 | 2, 16 | { "fixStyle": "separate-type-imports" } 17 | ], 18 | "@typescript-eslint/no-restricted-imports": [ 19 | 2, 20 | { 21 | "paths": [ 22 | { 23 | "name": "react-redux", 24 | "importNames": ["useSelector", "useStore", "useDispatch"], 25 | "message": "Please use pre-typed versions from `src/app/hooks.ts` instead." 26 | } 27 | ] 28 | } 29 | ] 30 | }, 31 | "overrides": [ 32 | { "files": ["*.{c,m,}{t,j}s", "*.{t,j}sx"] }, 33 | { "files": ["*{test,spec}.{t,j}s?(x)"], "env": { "jest": true } } 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /client/.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "arrowParens": "avoid" 4 | } 5 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 16 | 17 | 18 | 22 | 23 | 24 | 25 | K8State 26 | 27 | 28 | 29 |
30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vite-template-redux", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "concurrently \"npm:watch\" \"vite\" ", 8 | "start": "concurrently \"npm:watch\" \"vite\" ", 9 | "build": "concurrently \"npm:watch\" \"vite build\"", 10 | "preview": "concurrently \"npm:watch\" \"vite\" \"preview\"", 11 | "test": "vitest run", 12 | "format": "prettier --write .", 13 | "lint": "eslint .", 14 | "lint:fix": "eslint --fix .", 15 | "type-check": "tsc --noEmit", 16 | "watch": "tsc --watch" 17 | }, 18 | "dependencies": { 19 | "@emotion/react": "^11.13.0", 20 | "@emotion/styled": "^11.13.0", 21 | "@fontsource/roboto": "^5.0.14", 22 | "@mui/icons-material": "^5.16.7", 23 | "@mui/material": "^5.16.7", 24 | "@reduxjs/toolkit": "^2.0.1", 25 | "@xyflow/react": "^12.1.1", 26 | "concurrently": "^8.2.2", 27 | "dotenv": "^16.4.5", 28 | "react": "^18.2.0", 29 | "react-dom": "^18.2.0", 30 | "react-redux": "^9.1.0", 31 | "react-router-dom": "^6.26.1", 32 | "styled-components": "^6.1.13" 33 | }, 34 | "devDependencies": { 35 | "@testing-library/dom": "^9.3.4", 36 | "@testing-library/jest-dom": "^6.5.0", 37 | "@testing-library/react": "^14.1.2", 38 | "@testing-library/user-event": "^14.5.2", 39 | "@types/jest": "^29.5.12", 40 | "@types/react": "^18.2.47", 41 | "@types/react-dom": "^18.2.18", 42 | "@vitejs/plugin-react": "^4.2.1", 43 | "eslint": "^8.56.0", 44 | "eslint-config-prettier": "^9.1.0", 45 | "eslint-config-react-app": "^7.0.1", 46 | "eslint-plugin-prettier": "^5.1.3", 47 | "jsdom": "^23.2.0", 48 | "prettier": "^3.2.1", 49 | "typescript": "^5.3.3", 50 | "vite": "^5.0.11", 51 | "vitest": "^1.2.0" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /client/src/App.js: -------------------------------------------------------------------------------- 1 | import { jsx as _jsx } from "react/jsx-runtime"; 2 | import { Box } from "@mui/material"; 3 | import MiniDrawer from "./features/mini-drawer/MiniDrawer"; 4 | const App = () => { 5 | return (_jsx("div", { className: "App", children: _jsx(Box, { children: _jsx(MiniDrawer, {}) }) })); 6 | }; 7 | export default App; 8 | -------------------------------------------------------------------------------- /client/src/App.test.js: -------------------------------------------------------------------------------- 1 | import { jsx as _jsx } from "react/jsx-runtime"; 2 | import { screen } from "@testing-library/react"; 3 | import App from "./App"; 4 | import { renderWithProviders } from "./utils/test-utils"; 5 | // Testing to ensure App component renders 6 | test("renders MiniDrawer component", () => { 7 | // Render the App component 8 | renderWithProviders(_jsx(App, {})); 9 | // Check if MiniDrawer is in the document 10 | // Check if the menu button (IconButton) is in the document 11 | const menuButton = screen.getByLabelText(/open drawer/i); 12 | expect(menuButton).toBeInTheDocument(); 13 | }); 14 | -------------------------------------------------------------------------------- /client/src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import { screen } from "@testing-library/react" 2 | import App from "./App" 3 | import { renderWithProviders } from "./utils/test-utils" 4 | 5 | // Testing to ensure App component renders 6 | test("renders MiniDrawer component", () => { 7 | // Render the App component 8 | renderWithProviders() 9 | 10 | // Check if MiniDrawer is in the document 11 | // Check if the menu button (IconButton) is in the document 12 | const menuButton = screen.getByLabelText(/open drawer/i) 13 | expect(menuButton).toBeInTheDocument() 14 | }) 15 | -------------------------------------------------------------------------------- /client/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from "@mui/material" 2 | import MiniDrawer from "./features/mini-drawer/MiniDrawer" 3 | 4 | const App = () => { 5 | return ( 6 |
7 | 8 | 9 | 10 |
11 | ) 12 | } 13 | 14 | export default App 15 | -------------------------------------------------------------------------------- /client/src/app/createAppSlice.js: -------------------------------------------------------------------------------- 1 | import { asyncThunkCreator, buildCreateSlice } from "@reduxjs/toolkit"; 2 | // This function creates a slice with async thunks 3 | export const createAppSlice = buildCreateSlice({ 4 | creators: { asyncThunk: asyncThunkCreator }, 5 | }); 6 | -------------------------------------------------------------------------------- /client/src/app/createAppSlice.ts: -------------------------------------------------------------------------------- 1 | import { asyncThunkCreator, buildCreateSlice } from "@reduxjs/toolkit" 2 | 3 | // This function creates a slice with async thunks 4 | export const createAppSlice = buildCreateSlice({ 5 | creators: { asyncThunk: asyncThunkCreator }, 6 | }) 7 | -------------------------------------------------------------------------------- /client/src/app/hooks.js: -------------------------------------------------------------------------------- 1 | // This file serves as a central hub for re-exporting pre-typed Redux hooks. 2 | // These imports are restricted elsewhere to ensure consistent 3 | // usage of typed hooks throughout the application. 4 | // We disable the ESLint rule here because this is the designated place 5 | // for importing and re-exporting the typed versions of hooks. 6 | /* eslint-disable @typescript-eslint/no-restricted-imports */ 7 | import { useDispatch, useSelector } from "react-redux"; 8 | // Use throughout your app instead of plain `useDispatch` and `useSelector` 9 | export const useAppDispatch = useDispatch.withTypes(); 10 | export const useAppSelector = useSelector.withTypes(); 11 | -------------------------------------------------------------------------------- /client/src/app/hooks.ts: -------------------------------------------------------------------------------- 1 | // This file serves as a central hub for re-exporting pre-typed Redux hooks. 2 | // These imports are restricted elsewhere to ensure consistent 3 | // usage of typed hooks throughout the application. 4 | // We disable the ESLint rule here because this is the designated place 5 | // for importing and re-exporting the typed versions of hooks. 6 | /* eslint-disable @typescript-eslint/no-restricted-imports */ 7 | import { useDispatch, useSelector } from "react-redux" 8 | import type { AppDispatch, RootState } from "./store" 9 | 10 | // Use throughout your app instead of plain `useDispatch` and `useSelector` 11 | export const useAppDispatch = useDispatch.withTypes() 12 | export const useAppSelector = useSelector.withTypes() 13 | -------------------------------------------------------------------------------- /client/src/app/store.js: -------------------------------------------------------------------------------- 1 | import { configureStore, combineReducers } from "@reduxjs/toolkit"; 2 | import { setupListeners } from "@reduxjs/toolkit/query/react"; 3 | import { clusterApi } from "../features/cluster-view/clusterViewApiSlice"; 4 | import { clusterLogsApi } from "../features/cluster-log/clusterLogsApiSlice"; 5 | import portalSliceReducer from "../features/captive-portal/captivePortalSlice"; 6 | import clusterViewReducer from "../features/cluster-view/clusterViewApiSlice"; 7 | import clusterLogsReducer from "../features/cluster-log/clusterLogsApiSlice"; 8 | import iframeReducer from "../features/grafana-dashboard/GrafanaDashboardApiSlice"; 9 | // Combine the slices and RTK Query APIs into the root reducer 10 | const rootReducer = combineReducers({ 11 | [clusterApi.reducerPath]: clusterApi.reducer, // Adding the RTK Query reducer 12 | [clusterLogsApi.reducerPath]: clusterLogsApi.reducer, // Adding the RTK Query reducer 13 | clusterView: clusterViewReducer, // Adding the clusterView slice reducer 14 | portalSlice: portalSliceReducer, // Adding the portal slice reducer 15 | clusterLogs: clusterLogsReducer, // Adding the clusterLogs slice reducer 16 | iframe: iframeReducer, 17 | }); 18 | // The store setup is wrapped in `makeStore` to allow reuse 19 | // when setting up tests that need the same store config 20 | export const makeStore = (preloadedState) => { 21 | const store = configureStore({ 22 | reducer: rootReducer, 23 | middleware: getDefaultMiddleware => getDefaultMiddleware().concat(clusterApi.middleware, clusterLogsApi.middleware), // Adding RTK Query middleware 24 | preloadedState, 25 | }); 26 | // configure listeners using the provided defaults 27 | // optional, but required for `refetchOnFocus`/`refetchOnReconnect` behaviors 28 | setupListeners(store.dispatch); 29 | return store; 30 | }; 31 | export const store = makeStore(); 32 | -------------------------------------------------------------------------------- /client/src/app/store.ts: -------------------------------------------------------------------------------- 1 | import type { Action, ThunkAction } from "@reduxjs/toolkit" 2 | import { configureStore, combineReducers } from "@reduxjs/toolkit" 3 | import { setupListeners } from "@reduxjs/toolkit/query/react" 4 | import { clusterApi } from "../features/cluster-view/clusterViewApiSlice" 5 | import { clusterLogsApi } from "../features/cluster-log/clusterLogsApiSlice" 6 | import portalSliceReducer from "../features/captive-portal/captivePortalSlice" 7 | import clusterViewReducer from "../features/cluster-view/clusterViewApiSlice" 8 | import clusterLogsReducer from "../features/cluster-log/clusterLogsApiSlice" 9 | import iframeReducer from "../features/grafana-dashboard/GrafanaDashboardApiSlice" 10 | 11 | // Combine the slices and RTK Query APIs into the root reducer 12 | const rootReducer = combineReducers({ 13 | [clusterApi.reducerPath]: clusterApi.reducer, // Adding the RTK Query reducer 14 | [clusterLogsApi.reducerPath]: clusterLogsApi.reducer, // Adding the RTK Query reducer 15 | clusterView: clusterViewReducer, // Adding the clusterView slice reducer 16 | portalSlice: portalSliceReducer, // Adding the portal slice reducer 17 | clusterLogs: clusterLogsReducer, // Adding the clusterLogs slice reducer 18 | iframe: iframeReducer, 19 | }) 20 | 21 | // Infer the `RootState` type from the root reducer 22 | export type RootState = ReturnType 23 | 24 | // The store setup is wrapped in `makeStore` to allow reuse 25 | // when setting up tests that need the same store config 26 | export const makeStore = (preloadedState?: Partial) => { 27 | const store = configureStore({ 28 | reducer: rootReducer, 29 | middleware: getDefaultMiddleware => 30 | getDefaultMiddleware().concat( 31 | clusterApi.middleware, 32 | clusterLogsApi.middleware, 33 | ), // Adding RTK Query middleware 34 | 35 | preloadedState, 36 | }) 37 | 38 | // configure listeners using the provided defaults 39 | // optional, but required for `refetchOnFocus`/`refetchOnReconnect` behaviors 40 | setupListeners(store.dispatch) 41 | return store 42 | } 43 | 44 | export const store = makeStore() 45 | 46 | // Infer the type of `store` 47 | export type AppStore = ReturnType 48 | // Infer the `AppDispatch` type from the store itself 49 | export type AppDispatch = AppStore["dispatch"] 50 | export type AppThunk = ThunkAction< 51 | ThunkReturnType, 52 | RootState, 53 | unknown, 54 | Action 55 | > 56 | -------------------------------------------------------------------------------- /client/src/features/captive-portal/ProtectedRoute.js: -------------------------------------------------------------------------------- 1 | import { jsx as _jsx } from "react/jsx-runtime"; 2 | import { useState } from "react"; 3 | import { Navigate } from "react-router-dom"; 4 | import { useAppDispatch, useAppSelector } from "../../app/hooks"; 5 | import { setInit, setAddress, setKey } from "./captivePortalSlice"; 6 | export default function ProtectedRoute(props) { 7 | const dispatch = useAppDispatch(); 8 | const [loading, setLoading] = useState(true); 9 | const init = useAppSelector(state => state.portalSlice.init); 10 | //performs a fetch request to see if the environment file has been created. if the file exists and has a key or address, the information is assigned to global state and clusterui is rendered 11 | async function checkENVfileForCredentials() { 12 | const response = await fetch(`http://localhost:8080/api/checkenv`); 13 | const data = await response.json(); 14 | if (data.address && data.key) { 15 | dispatch(setInit(true)); 16 | dispatch(setKey(data.key)); 17 | dispatch(setAddress(data.address)); 18 | } 19 | setLoading(false); 20 | } 21 | checkENVfileForCredentials(); 22 | if (loading) { 23 | return _jsx("div", { children: "Loading..." }); 24 | } 25 | if (init === true) { 26 | return props.element; 27 | } 28 | else { 29 | return _jsx(Navigate, { to: "/portal" }); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /client/src/features/captive-portal/ProtectedRoute.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen, waitFor } from "@testing-library/react"; 2 | import { Provider } from "react-redux"; 3 | import { BrowserRouter } from "react-router-dom"; 4 | import configureStore from "redux-mock-store"; 5 | import thunk from "redux-thunk"; 6 | import ProtectedRoute from "./ProtectedRoute"; 7 | 8 | jest.mock("../../app/hooks", () => ({ 9 | useAppDispatch: () => jest.fn(), 10 | useAppSelector: jest.fn(), 11 | })); 12 | 13 | const mockStore = configureStore([thunk]); 14 | const mockFetch = jest.fn(); 15 | 16 | global.fetch = mockFetch; 17 | 18 | describe("ProtectedRoute", () => { 19 | let store; 20 | 21 | beforeEach(() => { 22 | store = mockStore({ 23 | portalSlice: { init: false }, 24 | }); 25 | mockFetch.mockClear(); 26 | }); 27 | 28 | test("renders loading state initially", async () => { 29 | mockFetch.mockResolvedValueOnce({ 30 | json: jest.fn().mockResolvedValue({}), 31 | }); 32 | 33 | render( 34 | 35 | 36 | ClusterUI} /> 37 | 38 | 39 | ); 40 | 41 | expect(screen.getByText(/loading.../i)).toBeInTheDocument(); 42 | }); 43 | 44 | test("renders the element when credentials exist", async () => { 45 | mockFetch.mockResolvedValueOnce({ 46 | json: jest.fn().mockResolvedValue({ address: "localhost", key: "12345" }), 47 | }); 48 | 49 | render( 50 | 51 | 52 | ClusterUI} /> 53 | 54 | 55 | ); 56 | 57 | await waitFor(() => expect(screen.getByText(/ClusterUI/i)).toBeInTheDocument()); 58 | }); 59 | 60 | test("redirects to /portal when init is false", async () => { 61 | mockFetch.mockResolvedValueOnce({ 62 | json: jest.fn().mockResolvedValue({}), 63 | }); 64 | 65 | render( 66 | 67 | 68 | ClusterUI} /> 69 | 70 | 71 | ); 72 | 73 | await waitFor(() => { 74 | expect(screen.queryByText(/ClusterUI/i)).not.toBeInTheDocument(); 75 | expect(window.location.pathname).toBe("/portal"); 76 | }); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /client/src/features/captive-portal/ProtectedRoute.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react" 2 | import { Navigate } from "react-router-dom" 3 | import { useAppDispatch, useAppSelector } from "../../app/hooks" 4 | import { setInit, setAddress, setKey } from "./captivePortalSlice" 5 | 6 | interface prop { 7 | element: JSX.Element 8 | } 9 | 10 | export default function ProtectedRoute(props: prop) { 11 | const dispatch = useAppDispatch() 12 | 13 | const [loading, setLoading] = useState(true) 14 | const init = useAppSelector(state => state.portalSlice.init) 15 | //performs a fetch request to see if the environment file has been created. if the file exists and has a key or address, the information is assigned to global state and clusterui is rendered 16 | 17 | async function checkENVfileForCredentials() { 18 | const response = await fetch(`http://localhost:8080/api/checkenv`) 19 | const data = await response.json() 20 | 21 | if (data.address && data.key) { 22 | dispatch(setInit(true)) 23 | dispatch(setKey(data.key)) 24 | dispatch(setAddress(data.address)) 25 | } 26 | 27 | setLoading(false) 28 | } 29 | checkENVfileForCredentials() 30 | 31 | if (loading) { 32 | return
Loading...
33 | } 34 | 35 | if (init === true) { 36 | return props.element 37 | } else { 38 | return 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /client/src/features/captive-portal/captivePortalSlice.js: -------------------------------------------------------------------------------- 1 | import { createSlice } from "@reduxjs/toolkit"; 2 | const initialState = { 3 | address: "", 4 | key: "", 5 | init: false, 6 | }; 7 | //Creates a slice in global state for address, key, and init 8 | export const portalSlice = createSlice({ 9 | name: "portalSlice", 10 | initialState, 11 | reducers: { 12 | setAddress: (state, action) => { 13 | state.address = action.payload; 14 | }, 15 | setKey: (state, action) => { 16 | state.key = action.payload; 17 | }, 18 | setInit: (state, action) => { 19 | state.init = action.payload; 20 | }, 21 | }, 22 | }); 23 | export const { setAddress, setKey, setInit } = portalSlice.actions; 24 | export default portalSlice.reducer; 25 | -------------------------------------------------------------------------------- /client/src/features/captive-portal/captivePortalSlice.ts: -------------------------------------------------------------------------------- 1 | import type { PayloadAction } from "@reduxjs/toolkit" 2 | import { createSlice } from "@reduxjs/toolkit" 3 | 4 | interface initial { 5 | address: string 6 | key: string 7 | init: boolean 8 | } 9 | 10 | const initialState: initial = { 11 | address: "", 12 | key: "", 13 | init: false, 14 | } 15 | //Creates a slice in global state for address, key, and init 16 | export const portalSlice = createSlice({ 17 | name: "portalSlice", 18 | initialState, 19 | reducers: { 20 | setAddress: (state, action: PayloadAction) => { 21 | state.address = action.payload 22 | }, 23 | setKey: (state, action: PayloadAction) => { 24 | state.key = action.payload 25 | }, 26 | setInit: (state, action: PayloadAction) => { 27 | state.init = action.payload 28 | }, 29 | }, 30 | }) 31 | export const { setAddress, setKey, setInit } = portalSlice.actions 32 | 33 | export default portalSlice.reducer 34 | -------------------------------------------------------------------------------- /client/src/features/captive-portal/components/CaptiveForm.js: -------------------------------------------------------------------------------- 1 | import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime"; 2 | import { useState } from "react"; 3 | import { useAppDispatch } from "../../../app/hooks"; 4 | import { setAddress, setKey } from "../captivePortalSlice"; 5 | import { TextField, Button } from "@mui/material"; 6 | import { Navigate } from "react-router-dom"; 7 | import Stack from "@mui/material/Stack"; 8 | import MuiCard from "@mui/material/Card"; 9 | import FormLabel from "@mui/material/FormLabel"; 10 | import FormControl from "@mui/material/FormControl"; 11 | import Box from "@mui/material/Box"; 12 | import { styled } from "@mui/material/styles"; 13 | //Card styling for MUI 14 | const Card = styled(MuiCard)(({ theme }) => ({ 15 | display: "flex", 16 | flexDirection: "column", 17 | alignSelf: "center", 18 | width: "100%", 19 | padding: theme.spacing(4), 20 | gap: theme.spacing(2), 21 | boxShadow: "hsla(220, 30%, 5%, 0.05) 0px 5px 15px 0px, hsla(220, 25%, 10%, 0.05) 0px 15px 35px -5px", 22 | [theme.breakpoints.up("sm")]: { 23 | width: "450px", 24 | }, 25 | ...theme.applyStyles("dark", { 26 | boxShadow: "hsla(220, 30%, 5%, 0.5) 0px 5px 15px 0px, hsla(220, 25%, 10%, 0.08) 0px 15px 35px -5px", 27 | }), 28 | })); 29 | export default function CaptiveForm() { 30 | const dispatch = useAppDispatch(); 31 | const [dest, setDest] = useState(""); 32 | const [bearer, setBearer] = useState(""); 33 | const [submit, setSubmit] = useState(false); 34 | const [error, setError] = useState(""); 35 | //sends a fetch request to the backend to check if the API is valid when the submit button is pressed 36 | const submitHandler = (event) => { 37 | event.preventDefault(); 38 | fetch("http://localhost:8080/api/checkAPI", { 39 | method: "POST", 40 | body: JSON.stringify({ 41 | key: bearer, 42 | address: dest, 43 | }), 44 | headers: { 45 | "Content-Type": "application/json", 46 | }, 47 | }) 48 | .then(response => response.json()) 49 | .then(data => { 50 | if (data.message !== "ok") { 51 | setError(JSON.stringify(data)); 52 | } 53 | else { 54 | setSubmit(true); 55 | dispatch(setAddress(dest)); 56 | dispatch(setKey(bearer)); 57 | } 58 | }); 59 | }; 60 | const invalidKeyError = (_jsx("p", { children: "The URL of the Bearer Token entered is invalid. " })); 61 | //Once submitted information returns, users are let into the main clusterui 62 | if (submit === true) { 63 | return _jsx(Navigate, { to: "/clusterui" }); 64 | } 65 | return (_jsx(_Fragment, { children: _jsxs("form", { onSubmit: submitHandler, children: [" ", _jsx(Stack, { direction: { xs: "column-reverse", md: "row" }, sx: { 66 | justifyContent: "center", 67 | gap: { xs: 6, sm: 12 }, 68 | p: 2, 69 | m: "auto", 70 | }, children: _jsxs(Card, { variant: "outlined", children: [_jsxs(FormControl, { children: [_jsx(FormLabel, { htmlFor: "ip_or_url", children: "IP Address or URL" }), _jsx(TextField, { id: "ip_or_url", type: "url", name: "ip_or_url", placeholder: "http://192.168.1.1 or http://yourURL.com", onChange: input => setDest(input.target.value), autoFocus: true, required: true, fullWidth: true, variant: "outlined", error: !!error, sx: { ariaLabel: "ip_or_url" } })] }), _jsxs(FormControl, { children: [_jsx(Box, { sx: { display: "flex", justifyContent: "space-between" }, children: _jsx(FormLabel, { htmlFor: "bearer_token", children: "Bearer Token" }) }), _jsx(TextField, { name: "bearer_token", placeholder: "Bearer Token", type: "text", id: "password", required: true, fullWidth: true, variant: "outlined", error: !!error, onChange: input => setBearer(input.target.value) })] }), _jsx(Button, { type: "submit", fullWidth: true, variant: "contained", color: "violet", children: "View Cluster" }), error && invalidKeyError] }) })] }) })); 71 | } 72 | -------------------------------------------------------------------------------- /client/src/features/captive-portal/components/CaptiveForm.tsx: -------------------------------------------------------------------------------- 1 | import type React from "react" 2 | import { useState } from "react" 3 | import { useAppDispatch } from "../../../app/hooks" 4 | import { setAddress, setKey } from "../captivePortalSlice" 5 | import { TextField, Button } from "@mui/material" 6 | import { Navigate } from "react-router-dom" 7 | 8 | import Stack from "@mui/material/Stack" 9 | import MuiCard from "@mui/material/Card" 10 | import FormLabel from "@mui/material/FormLabel" 11 | import FormControl from "@mui/material/FormControl" 12 | import Box from "@mui/material/Box" 13 | 14 | import { styled } from "@mui/material/styles" 15 | 16 | //Card styling for MUI 17 | const Card = styled(MuiCard)(({ theme }) => ({ 18 | display: "flex", 19 | flexDirection: "column", 20 | alignSelf: "center", 21 | width: "100%", 22 | padding: theme.spacing(4), 23 | gap: theme.spacing(2), 24 | boxShadow: 25 | "hsla(220, 30%, 5%, 0.05) 0px 5px 15px 0px, hsla(220, 25%, 10%, 0.05) 0px 15px 35px -5px", 26 | [theme.breakpoints.up("sm")]: { 27 | width: "450px", 28 | }, 29 | ...theme.applyStyles("dark", { 30 | boxShadow: 31 | "hsla(220, 30%, 5%, 0.5) 0px 5px 15px 0px, hsla(220, 25%, 10%, 0.08) 0px 15px 35px -5px", 32 | }), 33 | })) 34 | 35 | export default function CaptiveForm() { 36 | const dispatch = useAppDispatch() 37 | const [dest, setDest] = useState("") 38 | const [bearer, setBearer] = useState("") 39 | const [submit, setSubmit] = useState(false) 40 | const [error, setError] = useState("") 41 | //sends a fetch request to the backend to check if the API is valid when the submit button is pressed 42 | const submitHandler = (event: React.FormEvent) => { 43 | event.preventDefault() 44 | 45 | fetch("http://localhost:8080/api/checkAPI", { 46 | method: "POST", 47 | body: JSON.stringify({ 48 | key: bearer, 49 | address: dest, 50 | }), 51 | headers: { 52 | "Content-Type": "application/json", 53 | }, 54 | }) 55 | .then(response => response.json()) 56 | .then(data => { 57 | if (data.message !== "ok") { 58 | setError(JSON.stringify(data)) 59 | } else { 60 | setSubmit(true) 61 | dispatch(setAddress(dest)) 62 | dispatch(setKey(bearer)) 63 | } 64 | }) 65 | } 66 | 67 | const invalidKeyError = ( 68 |

The URL of the Bearer Token entered is invalid.

69 | ) 70 | //Once submitted information returns, users are let into the main clusterui 71 | if (submit === true) { 72 | return 73 | } 74 | 75 | return ( 76 | <> 77 |
78 | {" "} 79 | {/* Form wrapper with submit handler */} 80 | 89 | 90 | 91 | IP Address or URL 92 | setDest(input.target.value)} 98 | autoFocus 99 | required 100 | fullWidth 101 | variant="outlined" 102 | error={!!error} 103 | sx={{ ariaLabel: "ip_or_url" }} 104 | /> 105 | 106 | 107 | 108 | 109 | Bearer Token 110 | 111 | setBearer(input.target.value)} 121 | /> 122 | 123 | 124 | 127 | {error && invalidKeyError} 128 | 129 | 130 |
131 | 132 | ) 133 | } 134 | -------------------------------------------------------------------------------- /client/src/features/captive-portal/components/CaptiveFormContent.js: -------------------------------------------------------------------------------- 1 | import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; 2 | import Box from "@mui/material/Box"; 3 | import Stack from "@mui/material/Stack"; 4 | import Typography from "@mui/material/Typography"; 5 | import ThumbUpAltRoundedIcon from "@mui/icons-material/ThumbUpAltRounded"; 6 | import DnsRoundedIcon from "@mui/icons-material/DnsRounded"; 7 | import VpnKeyRoundedIcon from "@mui/icons-material/VpnKeyRounded"; 8 | //Instruction components for filling out the address and key 9 | const items = [ 10 | { 11 | icon: _jsx(DnsRoundedIcon, { sx: { color: "text.secondary" } }), 12 | title: "Cluster IP Address or URL", 13 | description: "Enter the IP address or URL of your Kubernetes cluster. This will allow the tool to connect and retrieve the necessary information to visualize your cluster setup.", 14 | }, 15 | { 16 | icon: _jsx(VpnKeyRoundedIcon, { sx: { color: "text.secondary" } }), 17 | title: "Cluster Credentials", 18 | description: "Provide your Kubernetes credentials to authenticate access. Ensure that the credentials have sufficient permissions to access and interact with cluster resources.", 19 | }, 20 | { 21 | icon: _jsx(ThumbUpAltRoundedIcon, { sx: { color: "text.secondary" } }), 22 | title: "View Clusterview", 23 | description: 'After entering the necessary details, hit the "View Clusterview" button to dynamically render your cluster. Continue exploring for a seamless and interactive visualization experience.', 24 | }, 25 | ]; 26 | export default function CaptiveFormContent() { 27 | return (_jsxs(Stack, { sx: { 28 | flexDirection: "column", 29 | alignSelf: "center", 30 | gap: 4, 31 | maxWidth: 450, 32 | }, children: [_jsx(Box, { sx: { display: { xs: "none", md: "flex" } } }), items.map((item, index) => (_jsxs(Stack, { direction: "row", sx: { gap: 2 }, children: [item.icon, _jsxs("div", { children: [_jsx(Typography, { gutterBottom: true, sx: { fontWeight: "medium" }, children: item.title }), _jsx(Typography, { variant: "body2", sx: { color: "text.secondary" }, children: item.description })] })] }, index)))] })); 33 | } 34 | -------------------------------------------------------------------------------- /client/src/features/captive-portal/components/CaptiveFormContent.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import Box from "@mui/material/Box" 3 | import Stack from "@mui/material/Stack" 4 | import Typography from "@mui/material/Typography" 5 | import ThumbUpAltRoundedIcon from "@mui/icons-material/ThumbUpAltRounded" 6 | import DnsRoundedIcon from "@mui/icons-material/DnsRounded" 7 | import VpnKeyRoundedIcon from "@mui/icons-material/VpnKeyRounded" 8 | //Instruction components for filling out the address and key 9 | const items = [ 10 | { 11 | icon: , 12 | title: "Cluster IP Address or URL", 13 | description: 14 | "Enter the IP address or URL of your Kubernetes cluster. This will allow the tool to connect and retrieve the necessary information to visualize your cluster setup.", 15 | }, 16 | { 17 | icon: , 18 | title: "Cluster Credentials", 19 | description: 20 | "Provide your Kubernetes credentials to authenticate access. Ensure that the credentials have sufficient permissions to access and interact with cluster resources.", 21 | }, 22 | { 23 | icon: , 24 | title: "View Clusterview", 25 | description: 26 | 'After entering the necessary details, hit the "View Clusterview" button to dynamically render your cluster. Continue exploring for a seamless and interactive visualization experience.', 27 | }, 28 | ] 29 | 30 | export default function CaptiveFormContent() { 31 | return ( 32 | 40 | 41 | {items.map((item, index) => ( 42 | 43 | {item.icon} 44 |
45 | 46 | {item.title} 47 | 48 | 49 | {item.description} 50 | 51 |
52 |
53 | ))} 54 |
55 | ) 56 | } 57 | -------------------------------------------------------------------------------- /client/src/features/captive-portal/containers/CaptivePortal.js: -------------------------------------------------------------------------------- 1 | import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; 2 | import Stack from "@mui/material/Stack"; 3 | import CaptiveForm from "../components/CaptiveForm"; 4 | import CaptiveFormContent from "../components/CaptiveFormContent"; 5 | export default function CaptivePortal() { 6 | //Container to render the captive form information 7 | return (_jsxs(Stack, { direction: { xs: "column-reverse", md: "row" }, sx: { 8 | display: "flex", 9 | justifyContent: "center", 10 | alignItems: "center", 11 | height: "100vh", 12 | }, children: [_jsx(CaptiveFormContent, {}), _jsx(CaptiveForm, {})] })); 13 | } 14 | -------------------------------------------------------------------------------- /client/src/features/captive-portal/containers/CaptivePortal.test.js: -------------------------------------------------------------------------------- 1 | import { jsx as _jsx } from "react/jsx-runtime"; 2 | import { screen, fireEvent, cleanup } from "@testing-library/react"; 3 | import { renderWithProviders } from "../../../utils/test-utils"; 4 | import CaptivePortal from "./CaptivePortal"; 5 | import { createTheme, ThemeProvider, alpha, getContrastRatio, } from "@mui/material/styles"; 6 | describe("check if captive portal renders", () => { 7 | const violetBase = "#7F00FF"; 8 | const violetMain = alpha(violetBase, 0.7); 9 | const theme = createTheme({ 10 | palette: { 11 | violet: { 12 | main: violetMain, 13 | light: alpha(violetBase, 0.5), 14 | dark: alpha(violetBase, 0.9), 15 | contrastText: getContrastRatio(violetMain, "#fff") > 4.5 ? "#fff" : "#111", 16 | }, 17 | }, 18 | typography: { 19 | fontFamily: '"Roboto", sans-serif', 20 | h1: { 21 | fontFamily: '"Oswald", sans-serif', 22 | fontWeight: 900, 23 | }, 24 | }, 25 | }); 26 | beforeEach(() => { 27 | renderWithProviders(_jsx(ThemeProvider, { theme: theme, children: _jsx(CaptivePortal, {}) })); 28 | }); 29 | afterEach(() => { 30 | cleanup(); 31 | }); 32 | test("Check if the input boxes and button exist", () => { 33 | const address = screen.getByLabelText(/IP Address or URL/i); 34 | const token = screen.getByPlaceholderText(/Bearer Token/i); 35 | const button = screen.getByRole("button", { 36 | name: /View Cluster/i, 37 | }); 38 | expect(address).toBeInTheDocument(); 39 | expect(token).toBeInTheDocument(); 40 | expect(button).toBeInTheDocument(); 41 | }); 42 | test("Check if the address input accepts only urls", () => { 43 | const address = screen.getByLabelText(/IP Address or URL/i); 44 | fireEvent.change(address, { target: { value: "jahgifjmzxiwek" } }); 45 | expect(address.validity.typeMismatch).toBeTruthy(); 46 | expect(address.validity.valid).toBeFalsy(); 47 | fireEvent.change(address, { target: { value: "https://test.com" } }); 48 | expect(address.validity.typeMismatch).toBeFalsy(); 49 | expect(address.validity.valid).toBeTruthy(); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /client/src/features/captive-portal/containers/CaptivePortal.test.tsx: -------------------------------------------------------------------------------- 1 | import { screen, fireEvent, cleanup } from "@testing-library/react" 2 | import { renderWithProviders } from "../../../utils/test-utils" 3 | import CaptivePortal from "./CaptivePortal" 4 | import { 5 | createTheme, 6 | ThemeProvider, 7 | alpha, 8 | getContrastRatio, 9 | } from "@mui/material/styles" 10 | 11 | describe("check if captive portal renders", () => { 12 | const violetBase = "#7F00FF" 13 | const violetMain = alpha(violetBase, 0.7) 14 | 15 | const theme = createTheme({ 16 | palette: { 17 | violet: { 18 | main: violetMain, 19 | light: alpha(violetBase, 0.5), 20 | dark: alpha(violetBase, 0.9), 21 | contrastText: 22 | getContrastRatio(violetMain, "#fff") > 4.5 ? "#fff" : "#111", 23 | }, 24 | }, 25 | typography: { 26 | fontFamily: '"Roboto", sans-serif', 27 | h1: { 28 | fontFamily: '"Oswald", sans-serif', 29 | fontWeight: 900, 30 | }, 31 | }, 32 | }) 33 | 34 | beforeEach(() => { 35 | renderWithProviders( 36 | 37 | 38 | , 39 | ) 40 | }) 41 | afterEach(() => { 42 | cleanup() 43 | }) 44 | 45 | test("Check if the input boxes and button exist", () => { 46 | const address: HTMLInputElement = screen.getByLabelText( 47 | /IP Address or URL/i, 48 | ) as HTMLInputElement 49 | const token: HTMLElement = screen.getByPlaceholderText(/Bearer Token/i) 50 | const button: HTMLElement = screen.getByRole("button", { 51 | name: /View Cluster/i, 52 | }) 53 | 54 | expect(address).toBeInTheDocument() 55 | expect(token).toBeInTheDocument() 56 | expect(button).toBeInTheDocument() 57 | }) 58 | 59 | test("Check if the address input accepts only urls", () => { 60 | const address: HTMLInputElement = screen.getByLabelText( 61 | /IP Address or URL/i, 62 | ) as HTMLInputElement 63 | 64 | fireEvent.change(address, { target: { value: "jahgifjmzxiwek" } }) 65 | 66 | expect(address.validity.typeMismatch).toBeTruthy() 67 | expect(address.validity.valid).toBeFalsy() 68 | fireEvent.change(address, { target: { value: "https://test.com" } }) 69 | expect(address.validity.typeMismatch).toBeFalsy() 70 | expect(address.validity.valid).toBeTruthy() 71 | }) 72 | }) 73 | -------------------------------------------------------------------------------- /client/src/features/captive-portal/containers/CaptivePortal.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import Stack from "@mui/material/Stack" 3 | 4 | import CaptiveForm from "../components/CaptiveForm" 5 | import CaptiveFormContent from "../components/CaptiveFormContent" 6 | 7 | export default function CaptivePortal() { 8 | //Container to render the captive form information 9 | return ( 10 | 19 | 20 | 21 | 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /client/src/features/cluster-log/clusterLogsApiSlice.js: -------------------------------------------------------------------------------- 1 | import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react"; 2 | import { createSlice } from "@reduxjs/toolkit"; 3 | // Define an API service for the log page 4 | export const clusterLogsApi = createApi({ 5 | reducerPath: "clusterLogsApi", 6 | baseQuery: fetchBaseQuery({ baseUrl: "http://localhost:8080/" }), 7 | endpoints: builder => ({ 8 | getClusterLogs: builder.query({ 9 | query: () => "api/getLogs", 10 | }), 11 | }), 12 | }); 13 | // Auto-generated hooks for the API queries 14 | export const { useGetClusterLogsQuery } = clusterLogsApi; 15 | const initialState = { 16 | logs: ["no logs"], 17 | }; 18 | export const clusterLogsSlice = createSlice({ 19 | name: "clusterLogs", 20 | initialState, 21 | reducers: {}, 22 | }); 23 | // Selectors for any additional state managed in this slice 24 | export const selectClusterLogs = (state) => state.clusterLogs.logs; 25 | export default clusterLogsSlice.reducer; 26 | -------------------------------------------------------------------------------- /client/src/features/cluster-log/clusterLogsApiSlice.ts: -------------------------------------------------------------------------------- 1 | import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react" 2 | import { createSlice } from "@reduxjs/toolkit" 3 | 4 | export interface ClusterLog { 5 | name: string 6 | log: Log[] 7 | } 8 | 9 | export interface Log { 10 | date: string 11 | logs: string 12 | name: string 13 | namespace: string 14 | } 15 | 16 | // Define an API service for the log page 17 | export const clusterLogsApi = createApi({ 18 | reducerPath: "clusterLogsApi", 19 | baseQuery: fetchBaseQuery({ baseUrl: "http://localhost:8080/" }), 20 | endpoints: builder => ({ 21 | getClusterLogs: builder.query({ 22 | query: () => "api/getLogs", 23 | }), 24 | }), 25 | }) 26 | 27 | // Auto-generated hooks for the API queries 28 | export const { useGetClusterLogsQuery } = clusterLogsApi 29 | 30 | export interface ClusterLogsState { 31 | logs: string[] 32 | } 33 | 34 | const initialState: ClusterLogsState = { 35 | logs: ["no logs"], 36 | } 37 | 38 | export const clusterLogsSlice = createSlice({ 39 | name: "clusterLogs", 40 | initialState, 41 | reducers: {}, 42 | }) 43 | 44 | // Selectors for any additional state managed in this slice 45 | export const selectClusterLogs = (state: { clusterLogs: ClusterLogsState }) => 46 | state.clusterLogs.logs 47 | 48 | export default clusterLogsSlice.reducer 49 | -------------------------------------------------------------------------------- /client/src/features/cluster-log/components/ClusterLog.js: -------------------------------------------------------------------------------- 1 | import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; 2 | import * as React from "react"; 3 | import Accordion from "@mui/material/Accordion"; 4 | import AccordionSummary from "@mui/material/AccordionSummary"; 5 | import AccordionDetails from "@mui/material/AccordionDetails"; 6 | import Typography from "@mui/material/Typography"; 7 | import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; 8 | import Button from "@mui/material/Button"; 9 | import { useGetClusterLogsQuery } from "../clusterLogsApiSlice"; 10 | export default function ClusterLog(props) { 11 | const { clusterLog } = props; 12 | const [expanded, setExpanded] = React.useState(false); 13 | const { refetch: refetchClusterLogs } = useGetClusterLogsQuery(); 14 | //opens and closes the accordion 15 | const handleExpansion = () => { 16 | setExpanded(prevExpanded => !prevExpanded); 17 | }; 18 | //requests data from the backend to download the log 19 | const downloadLogHandler = () => { 20 | async function sendDownloadLogRequest() { 21 | try { 22 | const response = await fetch(`http://localhost:8080/api/getDownloadLogs/${clusterLog.name}`); 23 | const blob = await response.blob(); 24 | const url = await window.URL.createObjectURL(new Blob([blob])); 25 | const link = await document.createElement("a"); 26 | link.href = url; 27 | link.setAttribute("download", clusterLog.name); 28 | document.body.appendChild(link); 29 | link.click(); 30 | if (link.parentNode) { 31 | link.parentNode.removeChild(link); 32 | } 33 | window.URL.revokeObjectURL(url); 34 | } 35 | catch (error) { 36 | throw new Error(`Something went wrong: ${error.message}`); 37 | } 38 | } 39 | sendDownloadLogRequest(); 40 | }; 41 | //deletes a log when the user selects delete log 42 | const deleteLogHandler = () => { 43 | async function sendDeleteLogRequest() { 44 | try { 45 | await fetch(`http://localhost:8080/api/deleteLogs/${clusterLog.name}`, { 46 | method: "DELETE", 47 | }); 48 | setExpanded(prevExpanded => !prevExpanded); 49 | await refetchClusterLogs(); 50 | } 51 | catch (error) { 52 | throw new Error(`Something went wrong: ${error.message}`); 53 | } 54 | } 55 | sendDeleteLogRequest(); 56 | }; 57 | return ( 58 | //each log is placed in an accordion and can expand and shrink. 59 | _jsx("div", { children: _jsxs(Accordion, { expanded: expanded, onChange: handleExpansion, style: { 60 | position: "relative", 61 | width: "700px", 62 | top: "120px", 63 | }, sx: [ 64 | expanded 65 | ? { 66 | "& .MuiAccordion-region": { 67 | height: "auto", 68 | }, 69 | "& .MuiAccordionDetails-root": { 70 | display: "block", 71 | }, 72 | } 73 | : { 74 | "& .MuiAccordion-region": { 75 | height: 0, 76 | }, 77 | "& .MuiAccordionDetails-root": { 78 | display: "none", 79 | }, 80 | }, 81 | ], children: [_jsx(AccordionSummary, { expandIcon: _jsx(ExpandMoreIcon, {}), "aria-controls": "panel1-content", id: "panel1-header", children: _jsxs(Typography, { children: [_jsx("strong", { children: "Log Instance:" }), "\u00A0 ", clusterLog.name, " "] }) }), _jsxs(AccordionDetails, { children: [clusterLog.log.map((log, i) => (_jsxs(Typography, { children: [_jsxs("span", { children: [_jsx("strong", { children: "Date:\u00A0" }), log.date] }), _jsx("br", {}), _jsxs("span", { children: [_jsx("strong", { children: "Podname:\u00A0" }), log.name] }), _jsx("br", {}), _jsxs("span", { children: [_jsx("strong", { children: "Log:\u00A0" }), log.logs] }), _jsx("br", {}), _jsx("br", {})] }, i * 938))), _jsx(Button, { "aria-label": "Download", style: { margin: "16px" }, variant: "contained", color: "primary", type: "button", onClick: downloadLogHandler, children: "Download" }), _jsx(Button, { style: { margin: "16px" }, variant: "contained", color: "primary", type: "button", onClick: deleteLogHandler, children: "Delete" })] })] }) })); 82 | } 83 | -------------------------------------------------------------------------------- /client/src/features/cluster-log/components/ClusterLog.test.js: -------------------------------------------------------------------------------- 1 | import { jsx as _jsx } from "react/jsx-runtime"; 2 | import { afterEach, test } from "vitest"; 3 | import { screen, cleanup } from "@testing-library/react"; 4 | import userEvent from "@testing-library/user-event"; 5 | import { renderWithProviders } from "../../../utils/test-utils"; 6 | import ClusterLog from "./ClusterLog"; 7 | describe("if cluster log page renders", () => { 8 | const sampleClusterLog = { 9 | name: "log-2024-9-10-11-57-17.json", 10 | log: [ 11 | { 12 | date: "September 10, 2024", 13 | logs: "\n> mock-server@1.0.0 start /usr/src/app\n> node server/server.js\n\nServer listening on port: 3000\n", 14 | name: "mock-app-deployment-7fdbd7448d-79mtk", 15 | namespace: "default", 16 | }, 17 | { 18 | date: "September 10, 2024", 19 | logs: "\n> mock-server@1.0.0 start /usr/src/app\n> node server/server.js\n\nServer listening on port: 3000\n", 20 | name: "mock-app-deployment-7fdbd7448d-gn7c6", 21 | namespace: "default", 22 | }, 23 | ], 24 | }; 25 | beforeEach(() => { 26 | renderWithProviders(_jsx(ClusterLog, { clusterLog: sampleClusterLog })); 27 | }); 28 | afterEach(() => { 29 | cleanup(); 30 | }); 31 | test("if a cluster log renders with download button", async () => { 32 | // Simulate expanding the accordion to show its content 33 | const accordionToggle = screen.getByRole("button", { 34 | name: /log instance/i, 35 | }); 36 | await userEvent.click(accordionToggle); 37 | // Try to find the "Download" button after expanding the accordion 38 | const downloadLogButton = await screen.findByRole("button", { 39 | name: /Download/i, 40 | }); 41 | expect(downloadLogButton).toBeInTheDocument(); 42 | }); 43 | test("if a cluster log renders with delete buttons", async () => { 44 | // Simulate expanding the accordion to show its content 45 | const accordionToggle = screen.getByRole("button", { 46 | name: /log instance/i, 47 | }); 48 | await userEvent.click(accordionToggle); 49 | const deleteLogButton = await screen.findByRole("button", { 50 | name: /Delete/i, 51 | }); 52 | expect(deleteLogButton).toBeInTheDocument(); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /client/src/features/cluster-log/components/ClusterLog.test.tsx: -------------------------------------------------------------------------------- 1 | import { afterEach, test } from "vitest" 2 | import { screen, cleanup } from "@testing-library/react" 3 | import userEvent from "@testing-library/user-event" 4 | import { renderWithProviders } from "../../../utils/test-utils" 5 | import ClusterLog from "./ClusterLog" 6 | 7 | describe("if cluster log page renders", () => { 8 | const sampleClusterLog = { 9 | name: "log-2024-9-10-11-57-17.json", 10 | log: [ 11 | { 12 | date: "September 10, 2024", 13 | logs: "\n> mock-server@1.0.0 start /usr/src/app\n> node server/server.js\n\nServer listening on port: 3000\n", 14 | name: "mock-app-deployment-7fdbd7448d-79mtk", 15 | namespace: "default", 16 | }, 17 | { 18 | date: "September 10, 2024", 19 | logs: "\n> mock-server@1.0.0 start /usr/src/app\n> node server/server.js\n\nServer listening on port: 3000\n", 20 | name: "mock-app-deployment-7fdbd7448d-gn7c6", 21 | namespace: "default", 22 | }, 23 | ], 24 | } 25 | beforeEach(() => { 26 | renderWithProviders() 27 | }) 28 | afterEach(() => { 29 | cleanup() 30 | }) 31 | 32 | test("if a cluster log renders with download button", async () => { 33 | // Simulate expanding the accordion to show its content 34 | const accordionToggle = screen.getByRole("button", { 35 | name: /log instance/i, 36 | }) 37 | await userEvent.click(accordionToggle) 38 | 39 | // Try to find the "Download" button after expanding the accordion 40 | const downloadLogButton = await screen.findByRole("button", { 41 | name: /Download/i, 42 | }) 43 | expect(downloadLogButton).toBeInTheDocument() 44 | }) 45 | 46 | test("if a cluster log renders with delete buttons", async () => { 47 | // Simulate expanding the accordion to show its content 48 | const accordionToggle = screen.getByRole("button", { 49 | name: /log instance/i, 50 | }) 51 | await userEvent.click(accordionToggle) 52 | 53 | const deleteLogButton = await screen.findByRole("button", { 54 | name: /Delete/i, 55 | }) 56 | 57 | expect(deleteLogButton).toBeInTheDocument() 58 | }) 59 | }) 60 | -------------------------------------------------------------------------------- /client/src/features/cluster-log/components/ClusterLog.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import Accordion from "@mui/material/Accordion" 4 | import AccordionSummary from "@mui/material/AccordionSummary" 5 | import AccordionDetails from "@mui/material/AccordionDetails" 6 | import Typography from "@mui/material/Typography" 7 | import ExpandMoreIcon from "@mui/icons-material/ExpandMore" 8 | import Button from "@mui/material/Button" 9 | 10 | import type { Log, ClusterLog as ClusterLogType } from "../clusterLogsApiSlice" 11 | 12 | import { useGetClusterLogsQuery } from "../clusterLogsApiSlice" 13 | 14 | interface Props { 15 | clusterLog: ClusterLogType 16 | } 17 | 18 | export default function ClusterLog(props: Props) { 19 | const { clusterLog } = props 20 | 21 | const [expanded, setExpanded] = React.useState(false) 22 | 23 | const { refetch: refetchClusterLogs } = useGetClusterLogsQuery() 24 | //opens and closes the accordion 25 | const handleExpansion = () => { 26 | setExpanded(prevExpanded => !prevExpanded) 27 | } 28 | //requests data from the backend to download the log 29 | const downloadLogHandler = (): void => { 30 | async function sendDownloadLogRequest(): Promise { 31 | try { 32 | const response = await fetch( 33 | `http://localhost:8080/api/getDownloadLogs/${clusterLog.name}`, 34 | ) 35 | const blob = await response.blob() 36 | const url = await window.URL.createObjectURL(new Blob([blob])) 37 | const link = await document.createElement("a") 38 | 39 | link.href = url 40 | link.setAttribute("download", clusterLog.name) 41 | document.body.appendChild(link) 42 | link.click() 43 | 44 | if (link.parentNode) { 45 | link.parentNode.removeChild(link) 46 | } 47 | window.URL.revokeObjectURL(url) 48 | } catch (error) { 49 | throw new Error(`Something went wrong: ${(error as Error).message}`) 50 | } 51 | } 52 | 53 | sendDownloadLogRequest() 54 | } 55 | //deletes a log when the user selects delete log 56 | const deleteLogHandler = (): void => { 57 | async function sendDeleteLogRequest() { 58 | try { 59 | await fetch(`http://localhost:8080/api/deleteLogs/${clusterLog.name}`, { 60 | method: "DELETE", 61 | }) 62 | setExpanded(prevExpanded => !prevExpanded) 63 | await refetchClusterLogs() 64 | } catch (error) { 65 | throw new Error(`Something went wrong: ${(error as Error).message}`) 66 | } 67 | } 68 | sendDeleteLogRequest() 69 | } 70 | 71 | return ( 72 | //each log is placed in an accordion and can expand and shrink. 73 |
74 | 102 | } 104 | aria-controls="panel1-content" 105 | id="panel1-header" 106 | > 107 | 108 | Log Instance: 109 |   {clusterLog.name}{" "} 110 | 111 | 112 | 113 | 114 | {clusterLog.log.map((log: Log, i: number) => ( 115 | 116 | 117 | Date:  118 | {log.date} 119 | 120 |
121 | 122 | Podname:  123 | {log.name} 124 | 125 |
126 | 127 | Log:  128 | {log.logs} 129 | 130 |
131 |
132 |
133 | ))} 134 | 135 | 145 | 154 |
155 |
156 |
157 | ) 158 | } 159 | -------------------------------------------------------------------------------- /client/src/features/cluster-log/containers/ClusterLogContainer.js: -------------------------------------------------------------------------------- 1 | import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; 2 | import { useState, useEffect } from "react"; 3 | import { Button, Box } from "@mui/material"; 4 | // import Row from "../Row" 5 | // import {ClusterLog} from "../clusterLogsApiSlice" 6 | import { useGetClusterLogsQuery, } from "../clusterLogsApiSlice"; 7 | import ClusterLog from "../components/ClusterLog"; 8 | export default function LogPage() { 9 | const [dirInfo, setdirInfo] = useState([]); 10 | const [log, setLog] = useState([]); 11 | const [deleted, setDeleted] = useState(""); 12 | useEffect(() => { 13 | fetch("http://localhost:8080/api/getLogs") 14 | .then(process => process.json()) 15 | .then(data => { 16 | //console.log(data); 17 | setdirInfo(data); 18 | }) 19 | .catch(error => { 20 | console.log(error); 21 | }); 22 | }, [log, deleted]); 23 | const createLogHandler = () => { 24 | fetch("http://localhost:8080/api/createLogs", { 25 | method: "POST", 26 | }) 27 | .then(process => process.json()) 28 | .then(data => { 29 | //console.log(data); 30 | setLog(data); 31 | }) 32 | .catch(error => { 33 | console.log(error); 34 | }); 35 | }; 36 | const store = []; 37 | for (let i = dirInfo.length; i > 0; i--) { 38 | if (dirInfo[i] !== (null || undefined)) { 39 | store.push(_jsx(Box, { children: _jsx(ClusterLog, { setDeleted: setDeleted, logName: dirInfo[i] }) }, i * 123)); 40 | } 41 | } 42 | const { data: clusterLog, isLoading: clusterLogIsLoading, isError: clusterLogError, refetch: refetchClusterLogs, } = useGetClusterLogsQuery(); 43 | console.log("useGetClusterLogsQuery.data: ", clusterLog); 44 | return (_jsxs("div", { style: { position: "absolute", left: "250px", top: "100px" }, children: [_jsx("h1", { style: { 45 | textAlign: "center", 46 | marginLeft: "32px", 47 | marginBottom: "16px", 48 | minWidth: "700px", 49 | }, children: "Logs" }), _jsx(Button, { style: { 50 | display: "flex", 51 | left: "290px", 52 | marginLeft: "32px", 53 | marginBottom: "16px", 54 | }, variant: "contained", color: "primary", type: "button", onClick: createLogHandler, children: "Create a Log" }), store] })); 55 | } 56 | -------------------------------------------------------------------------------- /client/src/features/cluster-log/containers/ClusterLogContainer.test.js: -------------------------------------------------------------------------------- 1 | import { jsx as _jsx } from "react/jsx-runtime"; 2 | import { afterEach, test } from "vitest"; 3 | import { screen, cleanup } from "@testing-library/react"; 4 | import { renderWithProviders } from "../../../utils/test-utils"; 5 | import ClusterLogContainer from "../containers/ClusterLogContainer"; 6 | afterEach(() => { 7 | cleanup(); 8 | }); 9 | test("Cluster Log Container should render", () => { 10 | renderWithProviders(_jsx(ClusterLogContainer, {})); 11 | // buttons to load correctly 12 | const createLogButton = screen.getByRole("button", { name: /Create A Log/i }); 13 | const deleteLogButton = screen.getByRole("button", { 14 | name: /Delete All Logs/i, 15 | }); 16 | // Test for Body text 17 | expect(createLogButton).toBeInTheDocument(); 18 | expect(deleteLogButton).toBeInTheDocument(); 19 | }); 20 | -------------------------------------------------------------------------------- /client/src/features/cluster-log/containers/ClusterLogContainer.test.tsx: -------------------------------------------------------------------------------- 1 | import { afterEach, test } from "vitest" 2 | import { screen, cleanup } from "@testing-library/react" 3 | import { renderWithProviders } from "../../../utils/test-utils" 4 | 5 | import ClusterLogContainer from "../containers/ClusterLogContainer" 6 | 7 | afterEach(() => { 8 | cleanup() 9 | }) 10 | 11 | test("Cluster Log Container should render", () => { 12 | renderWithProviders() 13 | 14 | // buttons to load correctly 15 | const createLogButton = screen.getByRole("button", { name: /Create A Log/i }) 16 | const deleteLogButton = screen.getByRole("button", { 17 | name: /Delete All Logs/i, 18 | }) 19 | 20 | // Test for Body text 21 | expect(createLogButton).toBeInTheDocument() 22 | expect(deleteLogButton).toBeInTheDocument() 23 | }) 24 | -------------------------------------------------------------------------------- /client/src/features/cluster-log/containers/ClusterLogContainer.tsx: -------------------------------------------------------------------------------- 1 | // import type React from "react" 2 | import { useState } from "react" 3 | import { Button } from "@mui/material" 4 | 5 | // Alert Dialog imports 6 | import * as React from "react" 7 | import Dialog from "@mui/material/Dialog" 8 | import DialogActions from "@mui/material/DialogActions" 9 | import DialogContent from "@mui/material/DialogContent" 10 | import DialogContentText from "@mui/material/DialogContentText" 11 | import DialogTitle from "@mui/material/DialogTitle" 12 | 13 | import { useGetClusterLogsQuery } from "../clusterLogsApiSlice" 14 | 15 | import ClusterLog from "../components/ClusterLog" 16 | 17 | export default function LogPage() { 18 | const [open, setOpen] = useState(false) 19 | //creates a log in the backend once the create log button is pressed. 20 | const createLogHandler = (): void => { 21 | async function sendCreateLogRequest() { 22 | try { 23 | await fetch("http://localhost:8080/api/createLogs", { 24 | method: "POST", 25 | }) 26 | await refetchClusterLogs() 27 | } catch (error) { 28 | throw new Error(`Something went wrong: ${(error as Error).message}`) 29 | } 30 | } 31 | 32 | sendCreateLogRequest() 33 | } 34 | //RTK Query to grab log info and to refetch if desired 35 | const { data: clusterLogs, refetch: refetchClusterLogs } = 36 | useGetClusterLogsQuery() 37 | //handlers for dialogues and for state modifications. 38 | function AlertDialog() { 39 | const handleClickOpen = () => { 40 | setOpen(true) 41 | } 42 | 43 | const handleClose = () => { 44 | setOpen(false) 45 | } 46 | 47 | const handleCloseCancel = () => { 48 | setOpen(false) 49 | } 50 | 51 | const handleCloseConfirm = () => { 52 | setOpen(false) 53 | deleteLogHandler() 54 | } 55 | 56 | return ( 57 | // delete all confirmation prompt 58 | 59 | 62 | 68 | 69 | {"Are you sure you want to delete all logs?"} 70 | 71 | 72 | 73 | This action is irreversable, please confirm you would like to 74 | delete ALL logs. 75 | 76 | 77 | 78 | 79 | 82 | 83 | 84 | 85 | ) 86 | } 87 | 88 | const confirmDeleteAll = () => { 89 | setOpen(true) 90 | } 91 | //deletes all the logs if the user accepts the confirmation prompt 92 | const deleteLogHandler = async (): Promise => { 93 | if (!clusterLogs || clusterLogs.length === 0) { 94 | return 95 | } 96 | 97 | try { 98 | await Promise.all( 99 | clusterLogs.map(async (log): Promise => { 100 | try { 101 | await fetch(`http://localhost:8080/api/deleteLogs/${log.name}`, { 102 | method: "DELETE", 103 | }) 104 | } catch (error) { 105 | throw new Error(`Something went wrong: ${(error as Error).message}`) 106 | } 107 | }), 108 | ) 109 | await refetchClusterLogs() 110 | } catch (error) { 111 | throw new Error(`Something went wrong: ${(error as Error).message}`) 112 | } 113 | } 114 | 115 | return ( 116 | // holds, styles, and displays the logs and buttons 117 |
126 |

137 | Logs 138 |

139 |
140 | 155 | 172 |
173 | {clusterLogs?.map((clusterLog, i) => ( 174 | 175 | ))} 176 |
186 | {open === true && } 187 |
188 |
189 | ) 190 | } 191 | -------------------------------------------------------------------------------- /client/src/features/cluster-log/containers/clusterLogContainer.js: -------------------------------------------------------------------------------- 1 | import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; 2 | // import type React from "react" 3 | import { useState } from "react"; 4 | import { Button } from "@mui/material"; 5 | // Alert Dialog imports 6 | import * as React from "react"; 7 | import Dialog from "@mui/material/Dialog"; 8 | import DialogActions from "@mui/material/DialogActions"; 9 | import DialogContent from "@mui/material/DialogContent"; 10 | import DialogContentText from "@mui/material/DialogContentText"; 11 | import DialogTitle from "@mui/material/DialogTitle"; 12 | import { useGetClusterLogsQuery } from "../clusterLogsApiSlice"; 13 | import ClusterLog from "../components/ClusterLog"; 14 | export default function LogPage() { 15 | const [open, setOpen] = useState(false); 16 | //creates a log in the backend once the create log button is pressed. 17 | const createLogHandler = () => { 18 | async function sendCreateLogRequest() { 19 | try { 20 | await fetch("http://localhost:8080/api/createLogs", { 21 | method: "POST", 22 | }); 23 | await refetchClusterLogs(); 24 | } 25 | catch (error) { 26 | throw new Error(`Something went wrong: ${error.message}`); 27 | } 28 | } 29 | sendCreateLogRequest(); 30 | }; 31 | //RTK Query to grab log info and to refetch if desired 32 | const { data: clusterLogs, refetch: refetchClusterLogs } = useGetClusterLogsQuery(); 33 | //handlers for dialogues and for state modifications. 34 | function AlertDialog() { 35 | const handleClickOpen = () => { 36 | setOpen(true); 37 | }; 38 | const handleClose = () => { 39 | setOpen(false); 40 | }; 41 | const handleCloseCancel = () => { 42 | setOpen(false); 43 | }; 44 | const handleCloseConfirm = () => { 45 | setOpen(false); 46 | deleteLogHandler(); 47 | }; 48 | return ( 49 | // delete all confirmation prompt 50 | _jsxs(React.Fragment, { children: [_jsx(Button, { variant: "outlined", onClick: handleClickOpen, children: "Delete Logs" }), _jsxs(Dialog, { open: open, onClose: handleClose, "aria-labelledby": "alert-dialog-title", "aria-describedby": "alert-dialog-description", children: [_jsx(DialogTitle, { id: "alert-dialog-title", children: "Are you sure you want to delete all logs?" }), _jsx(DialogContent, { children: _jsx(DialogContentText, { id: "alert-dialog-description", children: "This action is irreversable, please confirm you would like to delete ALL logs." }) }), _jsxs(DialogActions, { children: [_jsx(Button, { onClick: handleCloseCancel, children: "Cancel" }), _jsx(Button, { onClick: handleCloseConfirm, autoFocus: true, children: "Confirm" })] })] })] })); 51 | } 52 | const confirmDeleteAll = () => { 53 | setOpen(true); 54 | }; 55 | //deletes all the logs if the user accepts the confirmation prompt 56 | const deleteLogHandler = async () => { 57 | if (!clusterLogs || clusterLogs.length === 0) { 58 | return; 59 | } 60 | try { 61 | await Promise.all(clusterLogs.map(async (log) => { 62 | try { 63 | await fetch(`http://localhost:8080/api/deleteLogs/${log.name}`, { 64 | method: "DELETE", 65 | }); 66 | } 67 | catch (error) { 68 | throw new Error(`Something went wrong: ${error.message}`); 69 | } 70 | })); 71 | await refetchClusterLogs(); 72 | } 73 | catch (error) { 74 | throw new Error(`Something went wrong: ${error.message}`); 75 | } 76 | }; 77 | return ( 78 | // holds, styles, and displays the logs and buttons 79 | _jsxs("div", { style: { 80 | position: "relative", 81 | left: "225px", 82 | top: "90px", 83 | width: "100vw", 84 | height: "100vh", 85 | }, children: [_jsx("h1", { style: { 86 | position: "absolute", 87 | textAlign: "center", 88 | marginLeft: "32px", 89 | marginBottom: "16px", 90 | minWidth: "700px", 91 | left: "-50px", 92 | top: "35px", 93 | }, children: "Logs" }), _jsxs("div", { className: "log-button-container", children: [_jsx(Button, { style: { 94 | display: "absolute", 95 | left: "130px", 96 | top: "100px", 97 | marginLeft: "32px", 98 | marginBottom: "16px", 99 | }, variant: "contained", color: "secondary", type: "button", onClick: createLogHandler, children: "Create a Log" }), _jsx(Button, { id: "delete-all-logs-button", style: { 100 | display: "absolute", 101 | left: "190px", 102 | top: "100px", 103 | marginLeft: "32px", 104 | marginBottom: "16px", 105 | }, variant: "contained", color: "error", type: "button", onClick: confirmDeleteAll, disabled: !clusterLogs || clusterLogs.length === 0 ? true : false, children: "Delete all Logs" })] }), clusterLogs?.map((clusterLog, i) => (_jsx(ClusterLog, { clusterLog: clusterLog }, `clusterLog:${i}`))), _jsx("div", { className: "alert-dialog", style: { 106 | display: "flex", 107 | justifyContent: "center", 108 | marginTop: "16px", 109 | }, children: open === true && _jsx(AlertDialog, {}) })] })); 110 | } 111 | -------------------------------------------------------------------------------- /client/src/features/cluster-view/clusterViewApiSlice.js: -------------------------------------------------------------------------------- 1 | import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react"; 2 | import { createSlice } from "@reduxjs/toolkit"; 3 | // Define an API service for the cluster view 4 | export const clusterApi = createApi({ 5 | reducerPath: "clusterApi", 6 | baseQuery: fetchBaseQuery({ baseUrl: "http://localhost:8080/" }), 7 | endpoints: builder => ({ 8 | getKubernetesNodes: builder.query({ 9 | query: () => "api/nodes", 10 | }), 11 | getKubernetesPods: builder.query({ 12 | query: () => "api/pods", 13 | }), 14 | getKubernetesServices: builder.query({ 15 | query: () => "api/services", 16 | }), 17 | }), 18 | }); 19 | // Auto-generated hooks for the API queries 20 | export const { useGetKubernetesNodesQuery, useGetKubernetesPodsQuery, useGetKubernetesServicesQuery, } = clusterApi; 21 | const initialState = { 22 | pods: ["pod_1"], 23 | }; 24 | export const clusterViewSlice = createSlice({ 25 | name: "clusterView", 26 | initialState, 27 | reducers: {}, 28 | }); 29 | // Selectors for any additional state managed in this slice 30 | export const selectPods = (state) => state.clusterView.pods; 31 | export default clusterViewSlice.reducer; 32 | -------------------------------------------------------------------------------- /client/src/features/cluster-view/clusterViewApiSlice.ts: -------------------------------------------------------------------------------- 1 | import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react" 2 | import { createSlice } from "@reduxjs/toolkit" 3 | 4 | // Define the structure of the Node object 5 | export interface KubernetesNode { 6 | creationTimestamp: string 7 | name: string 8 | labels: { [key: string]: string } 9 | podCIDR: string 10 | addresses: { type: string; address: string }[] 11 | allocatable: { [key: string]: string } 12 | capacity: { 13 | [key: string]: string 14 | } 15 | conditions: { type: string; status: string }[] 16 | } 17 | 18 | export interface KubernetesPod { 19 | name: string 20 | creationTimestamp: string 21 | namespace: string 22 | labels: { [key: string]: string } 23 | nodeName: string 24 | restartPolicy: string 25 | hostIP: string 26 | podIP: string 27 | phase: string 28 | startTime: string 29 | uid: string 30 | conditions: Conditions[] 31 | } 32 | 33 | export interface KubernetesServices { 34 | name: String 35 | namespace: String 36 | labels: { [key: string]: string } | undefined 37 | creationTimestamp: Date | undefined 38 | clusterIP: String | undefined 39 | ports: string[] | undefined 40 | selector: { [key: string]: string } | undefined 41 | type: String | undefined 42 | } 43 | 44 | interface Conditions { 45 | lastProbeTime: string | null 46 | lastTransitionTime: Date 47 | status: Boolean 48 | type: string 49 | } 50 | 51 | // Define an API service for the cluster view 52 | export const clusterApi = createApi({ 53 | reducerPath: "clusterApi", 54 | baseQuery: fetchBaseQuery({ baseUrl: "http://localhost:8080/" }), 55 | endpoints: builder => ({ 56 | getKubernetesNodes: builder.query({ 57 | query: () => "api/nodes", 58 | }), 59 | getKubernetesPods: builder.query({ 60 | query: () => "api/pods", 61 | }), 62 | getKubernetesServices: builder.query({ 63 | query: () => "api/services", 64 | }), 65 | }), 66 | }) 67 | 68 | // Auto-generated hooks for the API queries 69 | export const { 70 | useGetKubernetesNodesQuery, 71 | useGetKubernetesPodsQuery, 72 | useGetKubernetesServicesQuery, 73 | } = clusterApi 74 | 75 | export interface ClusterViewState { 76 | pods: string[] 77 | } 78 | 79 | const initialState: ClusterViewState = { 80 | pods: ["pod_1"], 81 | } 82 | 83 | export const clusterViewSlice = createSlice({ 84 | name: "clusterView", 85 | initialState, 86 | reducers: {}, 87 | }) 88 | 89 | // Selectors for any additional state managed in this slice 90 | export const selectPods = (state: { clusterView: ClusterViewState }) => 91 | state.clusterView.pods 92 | 93 | export default clusterViewSlice.reducer 94 | -------------------------------------------------------------------------------- /client/src/features/cluster-view/components/CustomNode.js: -------------------------------------------------------------------------------- 1 | import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime"; 2 | import { useState, useEffect } from "react"; 3 | import { Handle, Position } from "@xyflow/react"; 4 | import Popover from "@mui/material/Popover"; 5 | import Typography from "@mui/material/Typography"; 6 | import styled from "styled-components"; 7 | const Cluster = styled.div ` 8 | background: white; 9 | border-radius: 50%; 10 | height: 150px; 11 | width: 150px; 12 | border: 5px solid #ad97d0; 13 | box-shadow: 0 0 40px #ad97d0; 14 | color: black; 15 | text-align: center; 16 | align-content: center; 17 | `; 18 | const Node = styled.div ` 19 | background: white; 20 | border-radius: 50%; 21 | height: 150px; 22 | width: 150px; 23 | border: 5px solid #ad97d0; 24 | box-shadow: 0 0 40px #ad97d0; 25 | color: black; 26 | text-align: center; 27 | align-content: center; 28 | `; 29 | const Pod = styled.div ` 30 | background: white; 31 | border-radius: 50%; 32 | height: 150px; 33 | width: 150px; 34 | color: black; 35 | text-align: center; 36 | align-content: center; 37 | `; 38 | export const KubeNode = ({ data }) => { 39 | // ** set local state ** 40 | const [anchorEl, setAnchorEl] = useState(null); 41 | // Define fuctions to open and close popover element 42 | const handleClick = (event) => { 43 | event.preventDefault(); 44 | setAnchorEl(event.currentTarget); 45 | }; 46 | const handleClose = () => { 47 | setAnchorEl(null); 48 | }; 49 | const open = Boolean(anchorEl); 50 | const id = open ? "simple-popover" : undefined; 51 | return (_jsxs(_Fragment, { children: [_jsxs(Node, { onClick: handleClick, children: [_jsx("div", { children: data.name.length > 10 ? `${data.name.slice(0, 20)}...` : data.name }), _jsx(Handle, { type: "target", position: Position.Top, style: { borderRadius: 100, width: 20 } }), _jsx(Handle, { type: "source", position: Position.Bottom, style: { borderRadius: 100, width: 20 } })] }), _jsx(Popover, { id: id, open: open, anchorEl: anchorEl, onClose: handleClose, anchorOrigin: { 52 | vertical: "top", 53 | horizontal: "right", 54 | }, children: _jsxs(Typography, { sx: { p: 2 }, children: [_jsx("strong", { children: "Name:" }), " ", data.name, _jsx("br", {}), _jsx("strong", { children: "Time Created:" }), data["creationTimestamp"] ? data.creationTimestamp : null, _jsx("br", {}), _jsx("strong", { children: "Capacity:" }), _jsx("br", {}), _jsx("strong", { children: _jsx("span", { style: { marginLeft: "20px" }, children: " CPU: " }) }), data.capacity["cpu"].toString(), _jsx("br", {}), _jsx("strong", { children: _jsx("span", { style: { marginLeft: "20px" }, children: " ephemeral-storage: " }) }), data.capacity["ephemeral-storage"], _jsx("br", {}), _jsx("strong", { children: _jsx("span", { style: { marginLeft: "20px" }, children: " hugepages-1Gi: " }) }), data.capacity["hugepages-1Gi"], _jsx("br", {}), _jsx("strong", { children: _jsx("span", { style: { marginLeft: "20px" }, children: " hugepages-2Mi: " }) }), data.capacity["hugepages-2Mi"], _jsx("br", {}), _jsx("strong", { children: _jsx("span", { style: { marginLeft: "20px" }, children: " memory: " }) }), data.capacity["memory"], _jsx("br", {}), _jsx("strong", { children: _jsx("span", { style: { marginLeft: "20px" }, children: " pods: " }) }), data.capacity["pods"]] }) })] })); 55 | }; 56 | export const KubePod = ({ data }) => { 57 | // ** set local state ** 58 | const [anchorEl, setAnchorEl] = useState(null); 59 | const [status, setStatus] = useState(data.conditions[2].status); 60 | // Monitors changes in pod status and updates state accordingly 61 | useEffect(() => { 62 | setStatus(data.conditions[2].status); 63 | }, [data.conditions]); 64 | // Define fuctions to open and close popover element 65 | const handleClick = (event) => { 66 | event.preventDefault(); 67 | setAnchorEl(event.currentTarget); 68 | }; 69 | const handleClose = () => { 70 | setAnchorEl(null); 71 | }; 72 | const open = Boolean(anchorEl); 73 | const id = open ? "simple-popover" : undefined; 74 | const styles = { 75 | border: `5px solid ${status ? "rgb(46, 226, 88)" : "red"}`, 76 | boxShadow: `0 0 40px ${status ? "rgb(46, 226, 88)" : "red"}`, 77 | }; 78 | return (_jsxs(_Fragment, { children: [_jsxs(Pod, { onClick: handleClick, style: styles, children: [_jsx("div", { children: data.name.length > 10 ? `${data.name.slice(0, 20)}...` : data.name }), _jsx(Handle, { type: "target", position: Position.Top, style: { borderRadius: 100, width: 20 } })] }), _jsx(Popover, { id: id, open: open, anchorEl: anchorEl, onClose: handleClose, anchorOrigin: { 79 | vertical: "top", 80 | horizontal: "right", 81 | }, children: _jsxs(Typography, { sx: { p: 2 }, children: [_jsx("strong", { children: "Name:" }), " ", data.name, _jsx("br", {}), _jsx("strong", { children: "Time Created: " }), data.creationTimestamp ? data.creationTimestamp : null, _jsx("br", {}), _jsx("strong", { children: "phase:" }), " ", data.phase, _jsx("br", {}), _jsx("strong", { children: "restartPolicy:" }), " ", data.restartPolicy, _jsx("br", {}), _jsx("strong", { children: "uid:" }), " ", data.uid] }) })] })); 82 | }; 83 | export const KubeCluster = ({ data }) => { 84 | return (_jsx(_Fragment, { children: _jsxs(Cluster, { children: [_jsx("div", { children: data.name.length > 10 ? `${data.name.slice(0, 20)}...` : data.name }), _jsx(Handle, { type: "source", position: Position.Bottom, style: { borderRadius: 100, width: 20 } })] }) })); 85 | }; 86 | -------------------------------------------------------------------------------- /client/src/features/cluster-view/components/CustomNode.tsx: -------------------------------------------------------------------------------- 1 | import type React from "react" 2 | import { useState, useEffect } from "react" 3 | import { Handle, Position } from "@xyflow/react" 4 | import Popover from "@mui/material/Popover" 5 | import Typography from "@mui/material/Typography" 6 | import styled from "styled-components" 7 | import type { KubernetesNode, KubernetesPod } from "../clusterViewApiSlice" 8 | 9 | // **************************** 10 | // ** Create Interface's ** 11 | // **************************** 12 | 13 | interface ReactFlowNodeData { 14 | data: KubernetesNode 15 | } 16 | 17 | interface ReactFlowPodData { 18 | data: KubernetesPod 19 | } 20 | 21 | interface ReactFlowClusterData { 22 | data: { name: string } 23 | } 24 | 25 | const Cluster = styled.div` 26 | background: white; 27 | border-radius: 50%; 28 | height: 150px; 29 | width: 150px; 30 | border: 5px solid #ad97d0; 31 | box-shadow: 0 0 40px #ad97d0; 32 | color: black; 33 | text-align: center; 34 | align-content: center; 35 | ` 36 | 37 | const Node = styled.div` 38 | background: white; 39 | border-radius: 50%; 40 | height: 150px; 41 | width: 150px; 42 | border: 5px solid #ad97d0; 43 | box-shadow: 0 0 40px #ad97d0; 44 | color: black; 45 | text-align: center; 46 | align-content: center; 47 | ` 48 | 49 | const Pod = styled.div` 50 | background: white; 51 | border-radius: 50%; 52 | height: 150px; 53 | width: 150px; 54 | color: black; 55 | text-align: center; 56 | align-content: center; 57 | ` 58 | 59 | export const KubeNode = ({ data }: ReactFlowNodeData) => { 60 | // ** set local state ** 61 | const [anchorEl, setAnchorEl] = useState(null) 62 | 63 | // Define fuctions to open and close popover element 64 | const handleClick = (event: React.MouseEvent) => { 65 | event.preventDefault() 66 | setAnchorEl(event.currentTarget) 67 | } 68 | 69 | const handleClose = () => { 70 | setAnchorEl(null) 71 | } 72 | 73 | const open = Boolean(anchorEl) 74 | const id = open ? "simple-popover" : undefined 75 | 76 | return ( 77 | <> 78 | 79 |
80 | {data.name.length > 10 ? `${data.name.slice(0, 20)}...` : data.name} 81 |
82 | 83 | 88 | 89 | 94 |
95 | 96 | 106 | 107 | Name: {data.name} 108 |
109 | Time Created: 110 | {data["creationTimestamp"] ? data.creationTimestamp : null} 111 |
112 | Capacity: 113 |
114 | 115 | CPU: 116 | 117 | {data.capacity["cpu"].toString()} 118 |
119 | 120 | ephemeral-storage: 121 | 122 | {data.capacity["ephemeral-storage"]} 123 |
124 | 125 | hugepages-1Gi: 126 | 127 | {data.capacity["hugepages-1Gi"]} 128 |
129 | 130 | hugepages-2Mi: 131 | 132 | {data.capacity["hugepages-2Mi"]} 133 |
134 | 135 | memory: 136 | 137 | {data.capacity["memory"]} 138 |
139 | 140 | pods: 141 | 142 | {data.capacity["pods"]} 143 |
144 |
145 | 146 | ) 147 | } 148 | 149 | export const KubePod = ({ data }: ReactFlowPodData) => { 150 | // ** set local state ** 151 | const [anchorEl, setAnchorEl] = useState(null) 152 | const [status, setStatus] = useState(data.conditions[2].status) 153 | 154 | // Monitors changes in pod status and updates state accordingly 155 | useEffect(() => { 156 | setStatus(data.conditions[2].status) 157 | }, [data.conditions]) 158 | 159 | // Define fuctions to open and close popover element 160 | const handleClick = (event: React.MouseEvent) => { 161 | event.preventDefault() 162 | setAnchorEl(event.currentTarget) 163 | } 164 | 165 | const handleClose = () => { 166 | setAnchorEl(null) 167 | } 168 | 169 | const open = Boolean(anchorEl) 170 | const id = open ? "simple-popover" : undefined 171 | 172 | const styles = { 173 | border: `5px solid ${status ? "rgb(46, 226, 88)" : "red"}`, 174 | boxShadow: `0 0 40px ${status ? "rgb(46, 226, 88)" : "red"}`, 175 | } 176 | 177 | return ( 178 | <> 179 | 180 |
181 | {data.name.length > 10 ? `${data.name.slice(0, 20)}...` : data.name} 182 |
183 | 184 | 189 |
190 | 200 | 201 | Name: {data.name} 202 |
203 | Time Created: 204 | {data.creationTimestamp ? data.creationTimestamp : null} 205 |
206 | phase: {data.phase} 207 |
208 | restartPolicy: {data.restartPolicy} 209 |
210 | uid: {data.uid} 211 |
212 |
213 | 214 | ) 215 | } 216 | 217 | export const KubeCluster = ({ data }: ReactFlowClusterData) => { 218 | return ( 219 | <> 220 | 221 |
222 | {data.name.length > 10 ? `${data.name.slice(0, 20)}...` : data.name} 223 |
224 | 229 |
230 | 231 | ) 232 | } 233 | -------------------------------------------------------------------------------- /client/src/features/cluster-view/containers/ClusterViewContainer.js: -------------------------------------------------------------------------------- 1 | import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; 2 | import { useState, useEffect, useMemo } from "react"; 3 | import "@xyflow/react/dist/style.css"; 4 | import { ReactFlow, Background, Controls } from "@xyflow/react"; 5 | import { MiniMap } from "@xyflow/react"; 6 | import { useGetKubernetesNodesQuery, useGetKubernetesPodsQuery, } from "../clusterViewApiSlice"; 7 | import { KubeNode, KubePod, KubeCluster } from "../components/CustomNode"; 8 | // ******************* 9 | // ** Component ** 10 | // ******************* 11 | export default function ClusterViewContainer() { 12 | // **** Global State **** 13 | // Hooks into Kubernets Cluster Data via RTK Query 14 | const { data: kubernetesNodes = [] } = useGetKubernetesNodesQuery(); 15 | const { data: kubernetesPods = [] } = useGetKubernetesPodsQuery(); 16 | // Create object to pass into type property of React Flow Nodes. 17 | // This enables the usage of a React Component to be the structure of a ReactFlow Node. 18 | const nodeTypes = useMemo(() => ({ kubeNode: KubeNode, kubePod: KubePod, kubeCluster: KubeCluster }), []); 19 | // **** Manage Side Effect **** 20 | // Ensure mappedNodes is always up to date when new data is recieved from Kubernetes Cluster 21 | useEffect(() => { 22 | if (kubernetesNodes.length > 0 && kubernetesPods.length > 0) { 23 | // Creates pod arrays for each node 24 | const initializedNodes = initializeNodes(kubernetesNodes); 25 | // Calls node mapping function and stores in a temp variable 26 | const tempMappedNodes = mapPodsToNodes(initializedNodes, kubernetesPods); 27 | // Sets the mappedNodes state to the newly mapped nodes 28 | setMappedNodes(tempMappedNodes); 29 | } 30 | }, [kubernetesNodes, kubernetesPods]); 31 | // **** Local State **** 32 | // Sets state for nodes array **** 33 | const [mappedNodes, setMappedNodes] = useState([]); 34 | // **** Helper Functions **** 35 | // mapPodsToNodes, maps the pods array to the corresponding node 36 | function mapPodsToNodes(nodes, pods) { 37 | const nodeMap = {}; 38 | nodes.forEach(node => { 39 | nodeMap[node.name] = node; 40 | }); 41 | pods.forEach(pod => { 42 | if (nodeMap[pod.nodeName]) { 43 | nodeMap[pod.nodeName].pods?.push(pod); 44 | } 45 | }); 46 | return nodes; 47 | } 48 | // Adds a 'pods' array to each node in the nodes array 49 | // This array stores the corresponding pods for that node 50 | function initializeNodes(nodes) { 51 | return nodes.map(node => ({ name: node.name, data: node, pods: [] })); 52 | } 53 | // ********************************** 54 | // ** Renders React Flow Nodes ** 55 | // ********************************** 56 | const reactFlowNodes = () => { 57 | // Adds Kubernetes Cluster as the first node by default 58 | const reactFlowNodeArray = [ 59 | { 60 | id: "Cluster", 61 | position: { x: (mappedNodes.length / 2 + 1) * 750, y: 0 }, 62 | data: { name: "Cluster" }, 63 | type: "kubeCluster", 64 | draggable: true, 65 | }, 66 | ]; 67 | // Iterates through the mapped nodes state array 68 | mappedNodes.forEach((node, index) => { 69 | // Adds the node to the output array first 70 | const startingXPos = (index + 1) * 1000; 71 | reactFlowNodeArray.push({ 72 | id: node.name, 73 | position: { x: startingXPos, y: 300 }, 74 | data: { ...node.data }, 75 | type: "kubeNode", 76 | draggable: true, 77 | }); 78 | const podsPerRow = 3; 79 | let podValueY = 600; 80 | let podCurrentIndex = 0; 81 | // Conditional wrapper to start iteration of pods array for current node 82 | while (podCurrentIndex < node.pods.length) { 83 | const startX = startingXPos - 200; 84 | // Iterates through the pods array for this node 85 | for (let i = 0; i < podsPerRow && podCurrentIndex < node.pods.length; i++) { 86 | // Adds pod to reactFlowNodeArray 87 | reactFlowNodeArray.push({ 88 | id: node.pods[podCurrentIndex].uid.toString(), 89 | position: { x: startX + i * 200, y: podValueY }, 90 | data: { ...node.pods[podCurrentIndex] }, 91 | type: "kubePod", 92 | draggable: true, 93 | }); 94 | podCurrentIndex++; 95 | } 96 | // Increments variables for new row 97 | podValueY += 200; 98 | } 99 | }); 100 | return reactFlowNodeArray; 101 | }; 102 | // ********************************** 103 | // ** Renders React Flow Edges ** 104 | // ********************************** 105 | const reactFlowEdges = () => { 106 | const reactFlowEdgeArray = []; 107 | // Adds React Flow Edges connections between nodes and the cluster 108 | mappedNodes.forEach(node => { 109 | reactFlowEdgeArray.push({ 110 | id: `Cluster-${node.name}`, 111 | source: "Cluster", 112 | target: `${node.name}`, 113 | animated: true, 114 | }); 115 | }); 116 | // Adds React Flow Edges connections between pods and nodes 117 | kubernetesPods.forEach(pod => { 118 | reactFlowEdgeArray.push({ 119 | id: `${pod.nodeName}-${pod.name}`, 120 | source: `${pod.nodeName}`, 121 | target: `${pod.uid}`, 122 | animated: true, 123 | }); 124 | }); 125 | return reactFlowEdgeArray; 126 | }; 127 | const nodes = reactFlowNodes(); 128 | const edges = reactFlowEdges(); 129 | // **** ClusterViewContainer Function Return **** 130 | return (_jsx("div", { id: "clusterview-container", className: "container", children: _jsx("div", { style: { width: "100vw", height: "100vh" }, children: _jsxs(ReactFlow, { nodes: nodes, edges: edges, nodeTypes: nodeTypes, children: [_jsx(Background, {}), _jsx(Controls, {}), _jsx(MiniMap, { style: { backgroundColor: "gray" } })] }) }) })); 131 | } 132 | -------------------------------------------------------------------------------- /client/src/features/cluster-view/containers/ClusterViewContainer.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useMemo } from "react" 2 | import "@xyflow/react/dist/style.css" 3 | import { ReactFlow, Background, Controls } from "@xyflow/react" 4 | import type { Node, Edge } from "@xyflow/react" 5 | import { MiniMap } from "@xyflow/react" 6 | import { 7 | useGetKubernetesNodesQuery, 8 | useGetKubernetesPodsQuery, 9 | } from "../clusterViewApiSlice" 10 | import { KubeNode, KubePod, KubeCluster } from "../components/CustomNode" 11 | import type { KubernetesNode, KubernetesPod } from "../clusterViewApiSlice" 12 | 13 | // **************************** 14 | // ** Create Interface's ** 15 | // **************************** 16 | 17 | interface MiddlewareNodes { 18 | name: string 19 | data: KubernetesNode 20 | pods: KubernetesPod[] 21 | } 22 | 23 | // ******************* 24 | // ** Component ** 25 | // ******************* 26 | 27 | export default function ClusterViewContainer() { 28 | // **** Global State **** 29 | // Hooks into Kubernets Cluster Data via RTK Query 30 | const { data: kubernetesNodes = [] } = useGetKubernetesNodesQuery() 31 | 32 | const { data: kubernetesPods = [] } = useGetKubernetesPodsQuery() 33 | 34 | // Create object to pass into type property of React Flow Nodes. 35 | // This enables the usage of a React Component to be the structure of a ReactFlow Node. 36 | const nodeTypes = useMemo( 37 | () => ({ kubeNode: KubeNode, kubePod: KubePod, kubeCluster: KubeCluster }), 38 | [], 39 | ) 40 | 41 | // **** Manage Side Effect **** 42 | // Ensure mappedNodes is always up to date when new data is recieved from Kubernetes Cluster 43 | useEffect(() => { 44 | if (kubernetesNodes.length > 0 && kubernetesPods.length > 0) { 45 | // Creates pod arrays for each node 46 | const initializedNodes = initializeNodes(kubernetesNodes) 47 | // Calls node mapping function and stores in a temp variable 48 | const tempMappedNodes = mapPodsToNodes(initializedNodes, kubernetesPods) 49 | // Sets the mappedNodes state to the newly mapped nodes 50 | setMappedNodes(tempMappedNodes) 51 | } 52 | }, [kubernetesNodes, kubernetesPods]) 53 | 54 | // **** Local State **** 55 | // Sets state for nodes array **** 56 | const [mappedNodes, setMappedNodes] = useState([]) 57 | 58 | // **** Helper Functions **** 59 | // mapPodsToNodes, maps the pods array to the corresponding node 60 | function mapPodsToNodes( 61 | nodes: MiddlewareNodes[], 62 | pods: KubernetesPod[], 63 | ): MiddlewareNodes[] { 64 | const nodeMap: { [key: string]: MiddlewareNodes } = {} 65 | 66 | nodes.forEach(node => { 67 | nodeMap[node.name] = node 68 | }) 69 | 70 | pods.forEach(pod => { 71 | if (nodeMap[pod.nodeName]) { 72 | nodeMap[pod.nodeName].pods?.push(pod) 73 | } 74 | }) 75 | 76 | return nodes 77 | } 78 | 79 | // Adds a 'pods' array to each node in the nodes array 80 | // This array stores the corresponding pods for that node 81 | function initializeNodes(nodes: KubernetesNode[]): MiddlewareNodes[] { 82 | return nodes.map(node => ({ name: node.name, data: node, pods: [] })) 83 | } 84 | 85 | // ********************************** 86 | // ** Renders React Flow Nodes ** 87 | // ********************************** 88 | const reactFlowNodes = (): Node[] => { 89 | // Adds Kubernetes Cluster as the first node by default 90 | const reactFlowNodeArray: Node[] = [ 91 | { 92 | id: "Cluster", 93 | position: { x: (mappedNodes.length / 2 + 1) * 750, y: 0 }, 94 | data: { name: "Cluster" }, 95 | type: "kubeCluster", 96 | draggable: true, 97 | }, 98 | ] 99 | 100 | // Iterates through the mapped nodes state array 101 | mappedNodes.forEach((node, index) => { 102 | // Adds the node to the output array first 103 | const startingXPos = (index + 1) * 1000 104 | reactFlowNodeArray.push({ 105 | id: node.name, 106 | position: { x: startingXPos, y: 300 }, 107 | data: { ...node.data }, 108 | type: "kubeNode", 109 | draggable: true, 110 | }) 111 | 112 | const podsPerRow = 3 113 | let podValueY = 600 114 | let podCurrentIndex = 0 115 | 116 | // Conditional wrapper to start iteration of pods array for current node 117 | while (podCurrentIndex < node.pods.length) { 118 | const startX = startingXPos - 200 119 | 120 | // Iterates through the pods array for this node 121 | for ( 122 | let i = 0; 123 | i < podsPerRow && podCurrentIndex < node.pods.length; 124 | i++ 125 | ) { 126 | // Adds pod to reactFlowNodeArray 127 | reactFlowNodeArray.push({ 128 | id: node.pods[podCurrentIndex].uid.toString(), 129 | position: { x: startX + i * 200, y: podValueY }, 130 | data: { ...node.pods[podCurrentIndex] }, 131 | type: "kubePod", 132 | draggable: true, 133 | }) 134 | podCurrentIndex++ 135 | } 136 | 137 | // Increments variables for new row 138 | podValueY += 200 139 | } 140 | }) 141 | return reactFlowNodeArray 142 | } 143 | 144 | // ********************************** 145 | // ** Renders React Flow Edges ** 146 | // ********************************** 147 | 148 | const reactFlowEdges = (): Edge[] => { 149 | const reactFlowEdgeArray: Edge[] = [] 150 | 151 | // Adds React Flow Edges connections between nodes and the cluster 152 | mappedNodes.forEach(node => { 153 | reactFlowEdgeArray.push({ 154 | id: `Cluster-${node.name}`, 155 | source: "Cluster", 156 | target: `${node.name}`, 157 | animated: true, 158 | }) 159 | }) 160 | 161 | // Adds React Flow Edges connections between pods and nodes 162 | kubernetesPods.forEach(pod => { 163 | reactFlowEdgeArray.push({ 164 | id: `${pod.nodeName}-${pod.name}`, 165 | source: `${pod.nodeName}`, 166 | target: `${pod.uid}`, 167 | animated: true, 168 | }) 169 | }) 170 | return reactFlowEdgeArray 171 | } 172 | 173 | const nodes = reactFlowNodes() 174 | const edges = reactFlowEdges() 175 | 176 | // **** ClusterViewContainer Function Return **** 177 | return ( 178 |
179 |
180 | 181 | 182 | 183 | 184 | 185 |
186 |
187 | ) 188 | } 189 | -------------------------------------------------------------------------------- /client/src/features/grafana-dashboard/Grafana.css: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0; 3 | padding: 0; 4 | box-sizing: border-box; 5 | } 6 | 7 | body { 8 | display: flex; 9 | justify-content: center; 10 | align-items: center; 11 | min-height: 100vh; 12 | } 13 | 14 | .wrapper { 15 | display: flex; 16 | flex-direction: column; 17 | justify-content: center; 18 | align-items: center; 19 | border: 1px solid rgb(229, 209, 255); 20 | padding: 20px; 21 | gap: 20px; 22 | width: 100%; 23 | max-width: 600px; 24 | margin: 0 auto; 25 | box-shadow: 0 5px 15px rgba(0, 0, 0, 0.241); 26 | } 27 | 28 | .wrapper form { 29 | display: flex; 30 | flex-direction: column; 31 | width: 100%; 32 | } 33 | 34 | .wrapper h2 { 35 | margin-top: 15px; 36 | font-size: 1.8rem; 37 | margin-bottom: 20px; 38 | text-align: center; 39 | } 40 | 41 | .wrapper input[type="text"] { 42 | width: 100%; 43 | padding: 12px 16px; 44 | border-radius: 12px; 45 | /* border: 1px solid #ccc; */ 46 | font-size: 1rem; 47 | outline: none; 48 | transition: border-color 0.3s ease; 49 | } 50 | 51 | .wrapper input[type="text"]:focus { 52 | border-color: #6200ea; 53 | box-shadow: 0 0 8px rgba(98, 0, 234, 0.2); 54 | } 55 | 56 | .wrapper button { 57 | padding: 12px 24px; 58 | background-color: #7f00ff; 59 | color: white; 60 | border: none; 61 | border-radius: 12px; 62 | font-size: 1rem; 63 | cursor: pointer; 64 | transition: 65 | background-color 0.3s ease, 66 | transform 0.3s ease; 67 | width: 100%; 68 | margin-top: 20px; 69 | } 70 | 71 | .wrapper button:hover { 72 | background-color: #4a00b4; 73 | } 74 | 75 | .wrapper button:active { 76 | background-color: #3a008c; 77 | transform: translateY(2px); 78 | } 79 | 80 | @media (max-width: 768px) { 81 | .wrapper { 82 | width: 90%; 83 | padding: 15px; 84 | } 85 | 86 | .wrapper h2 { 87 | font-size: 1.5rem; 88 | } 89 | 90 | .wrapper button { 91 | font-size: 0.9rem; 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /client/src/features/grafana-dashboard/GrafanaDashboardApiSlice.js: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit'; 2 | const initialState = { 3 | src: '' 4 | }; 5 | const iframeSlice = createSlice({ 6 | name: 'iframe', 7 | initialState, 8 | reducers: { 9 | setIframeSrc: (state, action) => { 10 | state.src = action.payload; 11 | }, 12 | }, 13 | }); 14 | export const { setIframeSrc } = iframeSlice.actions; 15 | export default iframeSlice.reducer; 16 | -------------------------------------------------------------------------------- /client/src/features/grafana-dashboard/GrafanaDashboardApiSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit'; 2 | import type { PayloadAction } from '@reduxjs/toolkit' 3 | 4 | // Define the initial state for the iframe URL 5 | interface IframeState { 6 | src: string; 7 | } 8 | 9 | const initialState: IframeState = { 10 | src: '' 11 | }; 12 | 13 | const iframeSlice = createSlice({ 14 | name: 'iframe', 15 | initialState, 16 | reducers: { 17 | setIframeSrc: (state, action: PayloadAction) => { 18 | state.src = action.payload; 19 | }, 20 | }, 21 | }); 22 | 23 | export const { setIframeSrc } = iframeSlice.actions; 24 | export default iframeSlice.reducer; -------------------------------------------------------------------------------- /client/src/features/grafana-dashboard/GrafanaViewContainer.js: -------------------------------------------------------------------------------- 1 | import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime"; 2 | import { useState } from "react"; 3 | import { setIframeSrc } from "./GrafanaDashboardApiSlice"; 4 | import { useAppDispatch, useAppSelector } from "../../app/hooks"; 5 | import "./Grafana.css"; 6 | import { TextField, Button } from "@mui/material"; 7 | export default function GrafanaViewContainer() { 8 | const iframeURL = useAppSelector((state) => state.iframe.src); 9 | const Form = () => { 10 | const [inputValue, setInputValue] = useState(""); 11 | const dispatch = useAppDispatch(); 12 | const handleInputChange = (e) => { 13 | setInputValue(e.target.value); 14 | }; 15 | const handleSubmit = (e) => { 16 | e.preventDefault(); 17 | dispatch(setIframeSrc(inputValue)); 18 | }; 19 | return (_jsxs("div", { className: "wrapper", style: { position: "relative", left: "-45px", top: "-75px" }, children: [_jsx("h2", { children: "Connect Your Grafana Dashboard" }), _jsxs("form", { onSubmit: handleSubmit, children: [_jsx(TextField, { label: "Grafana URL", color: "primary", variant: "outlined", placeholder: "http://your-grafana-instance/d/your-dashboard-id", focused: true, value: inputValue, onChange: handleInputChange }), _jsx(Button, { type: "button", children: "Connect" })] })] })); 20 | }; 21 | const Dashboard = () => { 22 | return (_jsx("iframe", { title: "Grafana Dashboard", src: iframeURL, style: { 23 | width: "100vw", 24 | height: "100vh", 25 | border: "none", 26 | position: "relative", 27 | marginTop: "30px", 28 | } })); 29 | }; 30 | return _jsx(_Fragment, { children: iframeURL !== "" ? _jsx(Dashboard, {}) : _jsx(Form, {}) }); 31 | } 32 | -------------------------------------------------------------------------------- /client/src/features/grafana-dashboard/GrafanaViewContainer.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react" 2 | import { setIframeSrc } from "./GrafanaDashboardApiSlice" 3 | import { useAppDispatch, useAppSelector } from "../../app/hooks" 4 | import type React from "react" 5 | import "./Grafana.css" 6 | import type { RootState } from "../../app/store" 7 | import { TextField, Button } from "@mui/material" 8 | 9 | export default function GrafanaViewContainer() { 10 | const iframeURL = useAppSelector((state: RootState) => state.iframe.src) 11 | 12 | const Form = () => { 13 | const [inputValue, setInputValue] = useState("") 14 | const dispatch = useAppDispatch() 15 | 16 | const handleInputChange = (e: React.ChangeEvent) => { 17 | setInputValue(e.target.value) 18 | } 19 | 20 | const handleSubmit = (e: React.FormEvent) => { 21 | e.preventDefault() 22 | dispatch(setIframeSrc(inputValue)) 23 | } 24 | 25 | return ( 26 |
30 |

Connect Your Grafana Dashboard

31 |
32 | 41 | 42 | 43 |
44 | ) 45 | } 46 | 47 | const Dashboard = () => { 48 | return ( 49 |