├── .gitignore ├── README.md ├── client ├── App.tsx ├── components │ ├── ChartTable.tsx │ ├── ClusterInput.tsx │ ├── GuageChart.tsx │ ├── GuageChart2.tsx │ ├── LineChart2.tsx │ ├── Navbar.tsx │ └── SnapshotButton.tsx ├── index.tsx ├── pages │ ├── Dashboard.tsx │ ├── History.tsx │ ├── NotFoundPage.tsx │ ├── SignIn.tsx │ ├── Signup.tsx │ ├── SnapshotPage.tsx │ ├── SplashPage.tsx │ └── WelcomePage.tsx ├── public │ ├── Assets │ │ ├── GPU image.jpg:Zone.Identifier │ │ ├── GPU-image.jpg │ │ ├── Ismael LinkedIn Headshot.jpg:Zone.Identifier │ │ ├── Ismael-Headshot.jpg │ │ ├── Jeff Headshot.jpg:Zone.Identifier │ │ ├── Jeff-Headshot.jpg │ │ ├── Jin Headshot.jpg:Zone.Identifier │ │ ├── Jin-Headshot.jpg │ │ ├── Sonia-Headshot.jpg │ │ ├── demo │ │ │ ├── auto_refresh.gif │ │ │ ├── input_url.gif │ │ │ ├── save_snapshot.gif │ │ │ └── signup.gif │ │ ├── favicon.png │ │ ├── kale logo.png:Zone.Identifier │ │ ├── kale-logo.png │ │ ├── kubernetes illustration.jpg:Zone.Identifier │ │ ├── kubernetes-illustration.jpg │ │ └── welcome-page.png │ └── index.html ├── slices │ ├── metricsApi.ts │ ├── snapshotsApi.ts │ ├── store.ts │ ├── uiSlice.ts │ ├── userApi.ts │ └── userSlice.ts └── stylesheets │ └── styles.css ├── cypress.config.ts ├── cypress ├── e2e │ ├── auth.cy.ts │ ├── dashboardPage.cy.ts │ ├── historyPage.cy.ts │ ├── navigation.cy.ts │ ├── splashPage.cy.ts │ └── welcomePage.cy.ts └── support │ ├── commands.ts │ ├── component-index.html │ ├── component.ts │ └── e2e.ts ├── package.json ├── server ├── Models │ ├── snapshotModel.ts │ └── userModel.ts ├── controllers │ ├── apiController.ts │ ├── authController.ts │ └── dbController.ts ├── router │ ├── authRouter.ts │ └── dbRouter.ts └── server.ts ├── tailwind.config.js ├── tsconfig.json ├── types.d.ts └── webpack.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_STORE 2 | node_modules 3 | package-lock.json 4 | dist 5 | .env 6 | secret*.json -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

3 | kale logo 4 |

kale

5 |

6 | 7 |

kale is an open-source Kubernetes tool for monitoring and autoscaling machine learning workloads, specializing in GPU metrics for optimized scaling decisions.

8 |
9 | 10 | welcome to kale 11 |

12 |

13 | GitHub commit activity 14 | Github Last Commit 15 | Github Created At 16 |

17 | 18 | ## Features 19 | 20 | - 📊 **Real-time GPU Monitoring**: Get live updates on utilization, temperature, power draw, and other essential GPU metrics with kale. 21 | - 🗃️ **Metric Snapshots**: kale captures historical data points beyond real-time monitoring. These snapshots allow for in-depth analysis and performance optimization over time. 22 | - 🧠 **Smart Scaling**: Optimize resource allocation for machine learning workloads with GPU-driven HPA configuration. 23 | 24 | ## Getting Started 25 | 26 | The simplest and most efficient way to dive into kale is through [our official hosted service](https://openkale.com/). 27 | 28 | ### Prerequisites 29 | 30 | Here's what you need to be able to run kale: 31 | 32 | - GPU-enabled Kubernetes cluster 33 | - Prometheus server integrated with cluster 34 | - NVIDIA DCGM-exporter deployed in cluster 35 | 36 | ## Demo 37 | 38 | 1. 📝 Sign up for kale. 39 | 40 | demo gif 41 | 42 | 2. 📈 Enter Prometheus Server URL and Pod Name you would like to monitor. 43 | 44 | demo gif 45 | 46 | 3. 📸 Take a snapshot of your pod metrics and view your saved snapshots in your 'History' tab. Click on a saved snapshot to open up its metrics. 47 | 48 | demo gif 49 | 50 | 4. 🔄 Dashoboard updates metrics every 30 seconds. 51 | 52 | demo gif 53 | 54 | ## Run kale locally 55 | 56 | ⚠️ NOTE: You won't be able to save snapshots if you run kale locally. 57 | 58 | ### 1. Clone the repository 59 | 60 | ```shell 61 | git clone https://github.com/oslabs-beta/kale.git 62 | cd kale 63 | ``` 64 | 65 | ### 2. Install npm dependencies 66 | 67 | ```shell 68 | npm install 69 | ``` 70 | 71 | ### 3. Run the dev server 72 | 73 | ```shell 74 | npm run dev 75 | ``` 76 | 77 | ### 4. Open the app in your browser 78 | 79 | Visit [http://localhost:8080](http://localhost:8080) in your browser. 80 | 81 | ## Beta Limitations and Exciting Future 82 | 83 | CPU-Based Proof of Concept: Currently, kale uses CPU metrics as a proxy for GPU utilization. This provides a valuable proof of concept. 84 | 85 | **Roadmap: We envision exciting features including:** 86 | 87 | - **GPU Metric Integration**: Replace CPU monitoring with direct GPU metric monitoring for enhanced scaling accuracy tailored to ML workloads. 88 | - **Multi-Pod Monitoring**: Monitor resource consumption trends across multiple pods. 89 | - **Snapshot Customization**: Allow users to rename snapshots for better organization. 90 | - **Autoscaling Integration**: Directly trigger Kubernetes cluster scaling actions based on in-app analysis. 91 | 92 | ## Contributing 93 | 94 | kale is open-source and welcomes your contributions! Here's how to get involved: 95 | 96 | 1. Fork the repository. 97 | 2. Make your desired changes. 98 | 3. Submit a pull request for review. 99 | 100 | ## Our Contributors 101 | 102 | Sonia Han | [LinkedIn](https://www.linkedin.com/in/soheunhan/) | [Github](https://github.com/soheunhan) 103 | 104 | Jeffrey Chao | [LinkedIn](https://www.linkedin.com/in/jeffrey-chao-9479142a/) | [Github](https://github.com/jeffplv) 105 | 106 | Jinseong Nam | [LinkedIn](https://www.linkedin.com/in/jinseong-nam-8b6815212/) | [Github](https://github.com/thejinnam) 107 | 108 | Ismael Boussatta | [LinkedIn](https://www.linkedin.com/in/ismael-boussatta-2493b2126/) | [Github](https://github.com/iboussat) 109 | -------------------------------------------------------------------------------- /client/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './stylesheets/styles.css'; 3 | import { createBrowserRouter, RouterProvider } from 'react-router-dom'; 4 | import NotFoundPage from './pages/NotFoundPage'; 5 | import WelcomePage from './pages/WelcomePage'; 6 | import Dashboard from './pages/Dashboard'; 7 | import History from './pages/History'; 8 | import SnapshotPage from './pages/SnapshotPage'; 9 | import SignInContainer from './pages/SignIn'; 10 | import SignupContainer from './pages/Signup'; 11 | import SplashPage from './pages/SplashPage'; 12 | 13 | const router = createBrowserRouter([ 14 | { 15 | path: '/welcome', 16 | element: , 17 | errorElement: , 18 | }, 19 | { 20 | path: '/', 21 | element: , 22 | errorElement: , 23 | }, 24 | { 25 | path: '/dashboard', 26 | element: , 27 | errorElement: , 28 | }, 29 | { 30 | path: '/history', 31 | element: , 32 | errorElement: , 33 | }, 34 | { 35 | path: '/history/:snapshotId', 36 | element: , 37 | errorElement: , 38 | }, 39 | { 40 | path: '/signin', 41 | element: , 42 | errorElement: , 43 | }, 44 | { 45 | path: '/signup', 46 | element: , 47 | errorElement: , 48 | }, 49 | ]); 50 | 51 | export default function App() { 52 | return ; 53 | } 54 | -------------------------------------------------------------------------------- /client/components/ChartTable.tsx: -------------------------------------------------------------------------------- 1 | import React, { lazy } from 'react'; 2 | import GuageChart from './GuageChart'; 3 | import type { MetricsData } from '../../types'; 4 | import LineChart2 from './LineChart2'; 5 | 6 | type ChartTableProps = { 7 | metrics: { [key: string]: MetricsData }; 8 | }; 9 | 10 | export default function ChartTable({ metrics }: ChartTableProps) { 11 | const lineChartArr: JSX.Element[] = []; 12 | const gaugeChartArr: JSX.Element[] = []; 13 | 14 | for (let i = 0; i < Object.keys(metrics).length; i++) { 15 | lineChartArr.push( 16 | 23 | ); 24 | } 25 | for (let i = 0; i < Object.keys(metrics).length; i++) { 26 | const val = 27 | metrics[Object.keys(metrics)[i]].value[ 28 | metrics[Object.keys(metrics)[i]].value.length - 1 29 | ]; 30 | console.log(val); 31 | gaugeChartArr.push( 32 | 43 | ); 44 | } 45 | return ( 46 | <> 47 |
48 | {gaugeChartArr} 49 |
50 | 51 |
52 | {lineChartArr[0]} 53 | {lineChartArr[0]} 54 |
55 | 56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /client/components/ClusterInput.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | 4 | type handleClickArg = { url: string; podName: string }; 5 | 6 | type ClusterInputProps = { 7 | handleUrlChange: (e: React.ChangeEvent) => void; 8 | handlePodNameChange: (e: React.ChangeEvent) => void; 9 | handleClick: (arg: handleClickArg) => void; 10 | url: string; 11 | podName: string; 12 | }; 13 | 14 | //passing in all props from the Welcome page 15 | const ClusterInput = ({ 16 | handlePodNameChange, 17 | handleClick, 18 | handleUrlChange, 19 | url, 20 | podName, 21 | }: ClusterInputProps) => { 22 | return ( 23 |
24 | 31 | 38 | 43 | 44 | 45 |
46 | ); 47 | }; 48 | 49 | export default ClusterInput; 50 | -------------------------------------------------------------------------------- /client/components/GuageChart.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useRef, useEffect } from 'react'; 3 | import * as d3 from 'd3'; 4 | 5 | type GuageChartProps = { 6 | metric: string; 7 | value: number; 8 | time: string; 9 | id: string; 10 | }; 11 | 12 | export default function GuageChart({ 13 | metric, 14 | value, 15 | time, 16 | id, 17 | }: GuageChartProps) { 18 | console.log('GuageChart', metric, value, time); 19 | 20 | const data = [value, 1 - value]; 21 | const svgRef = useRef(); 22 | 23 | useEffect(() => { 24 | //setting up svg container 25 | const w = 320; 26 | const h = 180; 27 | const radius = +w / 2; 28 | const svg = d3 29 | .select(svgRef.current) 30 | .attr('width', w) 31 | .attr('height', h) 32 | .style('display', 'block') 33 | .style('margin', '40px') 34 | .style('overflow', 'visible'); 35 | // .style('background-color', 'white'); 36 | 37 | //setting up pie chart 38 | const pieGenerator = d3 39 | .pie() 40 | .startAngle(-0.5 * Math.PI) 41 | .endAngle(0.5 * Math.PI) 42 | .sort(null); 43 | const instructions = pieGenerator(data); 44 | const arcGenerator: any = d3 45 | .arc() 46 | .innerRadius(radius / 2) 47 | .outerRadius(radius); 48 | 49 | const color = d3.scaleOrdinal(d3.schemeSet2); 50 | 51 | //setting up svg data 52 | svg 53 | .selectAll() 54 | .data(instructions) 55 | .join('path') 56 | .attr('d', arcGenerator) 57 | .attr('fill', (insctruction, i) => (i === 0 ? '#1E8A5A' : '#e11d48')) 58 | .attr('opacity', 0.7) 59 | .style('transform', `translate(${radius}px, ${radius}px)`); 60 | 61 | // setting up text 62 | svg 63 | .append('text') 64 | .attr('transform', `translate(${radius}, ${radius})`) 65 | .attr('text-anchor', 'middle') 66 | .style('font-size', 30) 67 | .style('fill', '#e4e4e7') 68 | .text(+data[0].toFixed(4) * 100 + '%'); 69 | }, [data]); 70 | 71 | return ( 72 |
76 |

{metric}

77 | 78 |
79 | ); 80 | } 81 | -------------------------------------------------------------------------------- /client/components/GuageChart2.tsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react'; 2 | import { 3 | RadialBarChart, 4 | RadialBar, 5 | Legend, 6 | Tooltip, 7 | ResponsiveContainer, 8 | PolarAngleAxis, 9 | PolarRadiusAxis, 10 | } from 'recharts'; 11 | 12 | type GuageChartProps = { 13 | metric: string; 14 | value: number; 15 | time: string; 16 | }; 17 | 18 | // const data = [ 19 | // { 20 | // name: 'Target (5%)', 21 | // uv: 5, 22 | // pv: 4567, 23 | // fill: '#777', 24 | // }, 25 | // { 26 | // name: 'Growth %', 27 | // uv: 8.3, 28 | // pv: 2400, 29 | // fill: '#22AA22', 30 | // }, 31 | // ]; 32 | 33 | const GuageChart2 = ({ metric, value, time }: GuageChartProps) => { 34 | return ( 35 | 36 | 42 | 48 | 49 | 54 | 62 | 8.3% 63 | 64 | 71 | Target: 5% 72 | 73 | 81 | 82 | 83 | 84 | ); 85 | }; 86 | 87 | export default GuageChart2; 88 | 89 | // import React, { PureComponent } from 'react'; 90 | // import { PieChart, Pie, Cell, Label } from 'recharts'; 91 | 92 | // const RADIAN = Math.PI / 180; 93 | // const data: any = [ 94 | // { name: 'A', value: 80, color: '#ffffff' }, 95 | // { name: 'B', value: 45, color: '#FFFF00' }, 96 | // { name: 'C', value: 25, color: '#FF0000' }, 97 | // ]; 98 | // const cx = 150; 99 | // const cy = 200; 100 | // const iR = 50; 101 | // const oR = 100; 102 | // const value = data[0].value; 103 | // const chartValue = data[0].value; 104 | 105 | // const needle = ( 106 | // value: number, 107 | // data: any[], 108 | // cx: number, 109 | // cy: number, 110 | // iR: number, 111 | // oR: number, 112 | // color: string 113 | // ) => { 114 | // let total = 0; 115 | // data.forEach((v) => { 116 | // total += v.value; 117 | // }); 118 | // const ang = 180.0 * (1 - value / total); 119 | // const length = (iR + 2 * oR) / 3; 120 | // const sin = Math.sin(-RADIAN * ang); 121 | // const cos = Math.cos(-RADIAN * ang); 122 | // const r = 5; 123 | // const x0 = cx + 5; 124 | // const y0 = cy + 5; 125 | // const xba = x0 + r * sin; 126 | // const yba = y0 - r * cos; 127 | // const xbb = x0 - r * sin; 128 | // const ybb = y0 + r * cos; 129 | // const xp = x0 + length * cos; 130 | // const yp = y0 + length * sin; 131 | 132 | // return [ 133 | // , 134 | // , 139 | // ]; 140 | // }; 141 | 142 | // export default class Example extends PureComponent { 143 | // render() { 144 | // return ( 145 | // 146 | // 158 | // {data.map((entry: { color: string }, index: any) => ( 159 | // 160 | // ))} 161 | 162 | // 172 | // {/* {needle(value, data, cx, cy, iR, oR, '#d0d000')} */} 173 | // 174 | // ); 175 | // } 176 | // } 177 | -------------------------------------------------------------------------------- /client/components/LineChart2.tsx: -------------------------------------------------------------------------------- 1 | // import { 2 | // LineChart, 3 | // Line, 4 | // CartesianGrid, 5 | // Tooltip, 6 | // XAxis, 7 | // YAxis, 8 | // Legend, 9 | // } from 'recharts'; 10 | import React from 'react'; 11 | import { 12 | Chart as ChartJS, 13 | CategoryScale, 14 | LinearScale, 15 | PointElement, 16 | LineElement, 17 | Title, 18 | Tooltip, 19 | Legend, 20 | } from 'chart.js'; 21 | import { Line } from 'react-chartjs-2'; 22 | import { MetricsData } from '../../types'; 23 | 24 | ChartJS.register( 25 | CategoryScale, 26 | LinearScale, 27 | PointElement, 28 | LineElement, 29 | Title, 30 | Tooltip, 31 | Legend 32 | ); 33 | 34 | type LineChartProps = { 35 | time: string[]; 36 | value: number[]; 37 | metric: string; 38 | id: string; 39 | }; 40 | 41 | export default function LineChart2({ 42 | metric, 43 | value, 44 | time, 45 | id, 46 | }: LineChartProps) { 47 | // const data = [ 48 | // { name: 'Page A', uv: 400 }, 49 | // { name: 'Page B', uv: 300 }, 50 | // { name: 'Page C', uv: 300 }, 51 | // { name: 'Page D', uv: 200 }, 52 | // { name: 'Page E', uv: 100 }, 53 | // ]; 54 | 55 | const data = { 56 | labels: time, 57 | datasets: [ 58 | { 59 | label: metric, 60 | data: value.map((val) => +(val * 100).toFixed(2)), 61 | borderColor: '#3AD48F', 62 | backgroundColor: '#1E8A5A', 63 | }, 64 | ], 65 | }; 66 | 67 | return ( 68 |
72 |

{metric}

73 | 74 |
75 | // 81 | // 82 | // 83 | // 84 | // 85 | // 86 | // 87 | // 88 | ); 89 | } 90 | -------------------------------------------------------------------------------- /client/components/Navbar.tsx: -------------------------------------------------------------------------------- 1 | import { RootState, useAppDispatch } from '../slices/store'; 2 | import React from 'react'; 3 | import { useSelector } from 'react-redux'; 4 | import { Link } from 'react-router-dom'; 5 | import { toggleSidebar } from '../slices/uiSlice'; 6 | import { logout } from '../slices/userSlice'; 7 | import { useNavigate } from 'react-router-dom'; 8 | type NavBarProps = { 9 | title: string; 10 | to: string; 11 | }; 12 | export default function NavBar({ title, to }: NavBarProps) { 13 | const isSidebarOpen = useSelector( 14 | (state: RootState) => state.ui.isSidebarOpen 15 | ); 16 | const navigate = useNavigate(); 17 | const logOutHandler = () => { 18 | dispatch(logout()); 19 | navigate('/signin'); 20 | }; 21 | const dispatch = useAppDispatch(); 22 | 23 | return ( 24 | 255 | ); 256 | } 257 | -------------------------------------------------------------------------------- /client/components/SnapshotButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Snapshot } from '../slices/snapshotsApi'; 3 | 4 | type SnapshotButtonProps = { 5 | currentData: Snapshot; 6 | handleClick: (arg: Snapshot) => void; 7 | }; 8 | 9 | export default function SnapshotButton({ 10 | handleClick, 11 | currentData, 12 | }: SnapshotButtonProps) { 13 | return ( 14 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /client/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './stylesheets/styles.css'; 3 | import { Provider } from 'react-redux'; 4 | import { createRoot } from 'react-dom/client'; 5 | import store from './slices/store'; 6 | import App from './App'; 7 | 8 | const rootElement = createRoot(document.getElementById('root')); 9 | rootElement.render( 10 | 11 |
12 | 13 |
14 |
15 | ); 16 | -------------------------------------------------------------------------------- /client/pages/Dashboard.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { useSelector, useDispatch } from 'react-redux'; 3 | import { RootState } from '../slices/store'; 4 | import NavBar from '../components/Navbar'; 5 | import { Snapshot, useSendSnapshotsMutation } from '../slices/snapshotsApi'; 6 | import { useGrabMetricsMutation } from '../slices/metricsApi'; 7 | import SnapshotButton from '../components/SnapshotButton'; 8 | import ChartTable from '../components/ChartTable'; 9 | 10 | export default function Dashboard() { 11 | const url = useSelector((state: RootState) => state.ui.urlInput); 12 | const podName = useSelector((state: RootState) => state.ui.nodeNameInput); 13 | const grabUserInfo = useSelector((state: RootState) => state.user.userData); 14 | const [grabMetrics, { data: currentData, error, isLoading }] = 15 | useGrabMetricsMutation({ 16 | fixedCacheKey: 'current-metric-data', 17 | }); 18 | 19 | const [createSnapshot] = useSendSnapshotsMutation({ 20 | fixedCacheKey: 'last-snapshot-data', 21 | }); 22 | 23 | useEffect(() => { 24 | const interval = setInterval(() => { 25 | grabMetrics({ url, podName }); 26 | }, 30000); 27 | return () => clearInterval(interval); 28 | }, []); 29 | 30 | function handleClick(data: Snapshot) { 31 | try { 32 | const response = createSnapshot(data); 33 | console.log('data created!', response); 34 | } catch (error) { 35 | console.log('error saving data:', error); 36 | } 37 | } 38 | 39 | return ( 40 | <> 41 | 42 | {isLoading ? ( 43 |
Data loading...
44 | ) : currentData ? ( 45 | <> 46 |
47 |

48 | Cluster URL:{' '} 49 |

50 | {url} 51 |

52 |

53 |
54 |

55 | Name of GPU 56 |

57 |

58 | NVIDIA GeForce RTX 3080 59 |

60 |
61 |
62 |

63 | Driver Version 64 |

65 |

66 | 465.19 67 |

68 |
69 | 73 |
74 | 75 | 76 | 77 | ) : null} 78 | 79 | ); 80 | } 81 | -------------------------------------------------------------------------------- /client/pages/History.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import { 4 | useDeleteSnapshotsMutation, 5 | useGetSnapshotsQuery, 6 | } from '../slices/snapshotsApi'; 7 | import Navbar from '../components/Navbar'; 8 | import { useSelector } from 'react-redux'; 9 | import { RootState } from '../slices/store'; 10 | 11 | export default function History() { 12 | const userData = useSelector((state: RootState) => state.user.userData); 13 | 14 | const { 15 | data: snapShots, 16 | error, 17 | isLoading, 18 | } = useGetSnapshotsQuery(userData.id); 19 | 20 | const [ 21 | deleteSnapshots, // This is the mutation trigger 22 | { data: deleteSnapshot, isLoading: isDeleting }, // This is the destructured mutation result 23 | ] = useDeleteSnapshotsMutation(); 24 | 25 | const handleDelete = (id: string) => { 26 | deleteSnapshots(id); 27 | if (!isDeleting) console.log('Deleted:', deleteSnapshot); 28 | }; 29 | 30 | const dataArr = []; 31 | if (snapShots) { 32 | console.log(snapShots); 33 | for (let i = 0; i < snapShots.length; i++) { 34 | dataArr.push( 35 | 36 | 37 |
38 | 43 | 46 |
47 | 48 | 52 | {snapShots[i]._id} 53 | 54 | 55 | pod 56 | {snapShots[i].podName} 57 | {snapShots[i].date} 58 | 59 | 65 | 66 | 67 | ); 68 | } 69 | } 70 | 71 | return ( 72 |
73 | 74 |
75 |
76 |
77 |
78 | 110 | {/* */} 111 | 215 |
216 | 219 |
220 |
221 | 234 |
235 | 241 |
242 |
243 | 244 | 245 | 246 | 258 | 261 | 264 | 267 | 270 | 271 | 272 | 273 | {dataArr} 274 |
247 |
248 | 253 | 256 |
257 |
259 | Name 260 | 262 | Unit 263 | 265 | Unit Name 266 | 268 | Date 269 |
275 |
276 |
277 |
278 | ); 279 | } 280 | -------------------------------------------------------------------------------- /client/pages/NotFoundPage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | 4 | export default function NotFoundPage() { 5 | return ( 6 |
7 |
8 |
9 |

10 | 404 11 |

12 |

13 | Something's missing. 14 |

15 |

16 | Sorry, we can't find that page. You'll find lots to explore on the 17 | home page.{' '} 18 |

19 | 23 | Back to Homepage 24 | 25 |
26 |
27 |
28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /client/pages/SignIn.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, ChangeEvent, FormEvent, useEffect } from 'react'; 2 | import { Link, useNavigate } from 'react-router-dom'; 3 | import { useAppDispatch } from '../slices/store'; 4 | import { useLoginMutation } from '../slices/userApi'; 5 | import { setCredential } from '../slices/userSlice'; 6 | import { VerifyData } from '../../types'; 7 | import NavBar from '../components/Navbar'; 8 | import { useSelector } from 'react-redux'; 9 | 10 | const SignInContainer = () => { 11 | const [signin] = useLoginMutation(); 12 | const dispatch = useAppDispatch(); 13 | const navigate = useNavigate(); 14 | 15 | const [verifyData, setVerifyData] = useState({ 16 | email: '', 17 | password: '', 18 | }); 19 | 20 | const [showPassword, setShowPassword] = useState(false); 21 | const [authError, setAuthError] = useState(''); 22 | 23 | const handleChange = (event: React.ChangeEvent) => { 24 | const { name, value } = event.target; 25 | 26 | setVerifyData({ 27 | ...verifyData, 28 | [name]: value, 29 | }); 30 | if (authError) setAuthError(''); 31 | }; 32 | 33 | const togglePasswordVisibility = () => { 34 | setShowPassword(!showPassword); 35 | }; 36 | 37 | //this function sends the input to userSlice which calls the middleware function and dispatches the localStorage which we store specifically the ID and first name 38 | const submitHandler = async (e: React.FormEvent) => { 39 | e.preventDefault(); 40 | signin(verifyData) 41 | .unwrap() 42 | .then((res) => { 43 | const userData = { 44 | id: res._id, 45 | firstName: res.firstName, 46 | }; 47 | dispatch(setCredential(userData)); 48 | navigate('/welcome'); 49 | }) 50 | .catch((err) => { 51 | setAuthError(`Invalid log in credentials`); 52 | }); 53 | }; 54 | 55 | return ( 56 | <> 57 | 58 |
59 |
60 |
64 |
65 |

66 | Sign in to kale 67 |

68 | {/* Form starts from here */} 69 |
74 |
75 | 81 | 95 |
96 |
97 | 103 | 117 |

121 | Use your registered password to sign in. Ensure it's entered 122 | correctly, including upper and lower case letters, numbers, 123 | and symbols 124 |

125 | 126 |
127 |
128 | {authError && ( 129 |

130 | {authError} 131 |

132 | )} 133 |
134 |
135 | 142 |
143 |
144 |
145 | 146 | 153 |
154 | Not registered yet?{' '} 155 | 159 | Create account 160 | 161 |
162 |
163 |
164 |
165 |
166 |
167 | 168 | ); 169 | }; 170 | 171 | export default SignInContainer; 172 | -------------------------------------------------------------------------------- /client/pages/Signup.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, ChangeEvent, FormEvent } from 'react'; 2 | import { useSignupMutation } from '../slices/userApi'; 3 | import { RootState } from '../slices/store'; 4 | 5 | import { Link, useNavigate } from 'react-router-dom'; 6 | import { setCredential } from '../slices/userSlice'; 7 | import { useSelector, useDispatch } from 'react-redux'; 8 | import { VerifyData } from '../../types'; 9 | import NavBar from '../components/Navbar'; 10 | const SignupContainer = () => { 11 | const [signup] = useSignupMutation(); 12 | const dispatch = useDispatch(); 13 | const navigate = useNavigate(); 14 | 15 | const [showPassword, setShowPassword] = useState(false); 16 | const [loginData, setLoginData] = useState({ 17 | firstName: '', 18 | email: '', 19 | password: '', 20 | confirmPassword: '', 21 | }); 22 | const togglePasswordVisibility = () => { 23 | setShowPassword(!showPassword); 24 | }; 25 | const [emailError, setEmailError] = useState(''); 26 | const [passwordError, setPasswordError] = useState(''); 27 | const [confirmPasswordError, setConfirmPasswordError] = useState(''); 28 | 29 | //this enables us to see the user's input with every stroke 30 | const handleChange = (event: React.ChangeEvent): void => { 31 | const { name, value } = event.target; 32 | setLoginData((prevData) => ({ 33 | ...prevData, 34 | [name]: value, 35 | })); 36 | if (name === 'email') { 37 | setEmailError(''); 38 | } 39 | if (name === 'password') { 40 | const isValid = passwordPattern.test(value) || value.length === 0; 41 | 42 | setPasswordError( 43 | passwordPattern.test(value) ? '' : 'Password must meet requirements.' 44 | ); 45 | } else if (name === 'confirmPassword') { 46 | setConfirmPasswordError( 47 | loginData.password !== value && value ? 'Passwords do not match.' : '' 48 | ); 49 | } 50 | }; 51 | const passwordPattern = 52 | /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[^a-zA-Z\d]).{8,}$/; 53 | const passwordValid = 54 | loginData.password.length === 0 || passwordPattern.test(loginData.password); 55 | 56 | const validatePassword = ( 57 | password: string, 58 | confirmPassword: string 59 | ): boolean => { 60 | setPasswordError(''); 61 | setConfirmPasswordError(''); 62 | 63 | if (!passwordPattern.test(password)) { 64 | setPasswordError( 65 | 'Password must be at least 8 characters long, including uppercase and lowercase letters, a number, and a special character.' 66 | ); 67 | return false; 68 | } else if (password !== confirmPassword) { 69 | setConfirmPasswordError('Passwords do not match.'); 70 | return false; 71 | } 72 | return true; 73 | }; 74 | 75 | //ensures both password inputs are complex enough, sends the data to the backend, and sets our localStorage using dispatch 76 | const submitHandler = async ( 77 | e: React.FormEvent 78 | ): Promise => { 79 | e.preventDefault(); 80 | 81 | if (!validatePassword(loginData.password, loginData.confirmPassword)) { 82 | console.error( 83 | 'The passwords do not match or do not meet the complexity requirements.' 84 | ); 85 | return; 86 | } 87 | 88 | try { 89 | const res = await signup(loginData).unwrap(); 90 | dispatch(setCredential({ id: res._id, firstName: res.firstName })); 91 | navigate('/welcome'); 92 | navigate('/welcome'); 93 | } catch (err) { 94 | if (err.status === 400 && err.data.includes('exist')) { 95 | setEmailError('Email address already taken'); 96 | } else { 97 | console.error(err); 98 | } 99 | } 100 | }; 101 | return ( 102 | <> 103 | 104 |
105 |
106 |
107 |
108 |

109 | Optimizing Efficiency in AI/ML: Minimizing Costs and Resource 110 | Use{' '} 111 |

112 |

113 | Here at Kale we leverage your unleashed talent, technology, and 114 | innovation to help improve flow of communication. 115 |

116 |
117 |
118 |

119 | Create an account 120 |

121 | 122 | {/* Form starts from here */} 123 |
128 |
129 | 135 | 145 |
146 |
147 | 153 | 167 | {emailError && ( 168 |

169 | {emailError} 170 |

171 | )} 172 |
173 |
174 | 180 | 190 |
191 | 192 |

0 && !passwordValid 195 | ? '!text-red-500' 196 | : '!text-white' 197 | } dark:text-white`} 198 | > 199 | Create a strong password with at least 8 characters with upper 200 | and lower case letters, numbers, and symbols. 201 |

202 | 203 |
204 | 210 | 219 |
220 |
221 |
222 | {confirmPasswordError && ( 223 |

224 | {confirmPasswordError} 225 |

226 | )} 227 |
228 |
229 | 236 |
237 |
238 | 245 |

246 | Already have an account?{' '} 247 | 251 | Login here 252 | 253 |

254 |
255 |
256 |
257 |
258 |
259 | 260 | ); 261 | }; 262 | 263 | export default SignupContainer; 264 | -------------------------------------------------------------------------------- /client/pages/SnapshotPage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import NavBar from '../components/Navbar'; 3 | import { useParams } from 'react-router-dom'; 4 | import { useSelector } from 'react-redux'; 5 | import { RootState } from '../slices/store'; 6 | import { 7 | useGetSnapshotState, 8 | useGetOneSnapshotQuery, 9 | useDeleteSnapshotsMutation, 10 | } from '../slices/snapshotsApi'; 11 | import { Snapshot } from '../slices/snapshotsApi'; 12 | import ChartTable from '../components/ChartTable'; 13 | 14 | export default function SnapshotPage() { 15 | const { snapshotId } = useParams(); 16 | const urlShow = useSelector((state: RootState) => state.ui.urlInput); 17 | 18 | const [ 19 | deleteSnapshots, // This is the mutation trigger 20 | { data: deleteSnapshot, isLoading: isDeleting }, // This is the destructured mutation result 21 | ] = useDeleteSnapshotsMutation(); 22 | 23 | const { data: currSnapshot, error: snapshotError } = 24 | useGetOneSnapshotQuery(snapshotId); 25 | 26 | const handleDelete = (id: string) => { 27 | deleteSnapshots(id); 28 | if (!isDeleting) console.log('Deleted:', deleteSnapshot); 29 | }; 30 | 31 | return ( 32 | <> 33 | 34 |
35 |

36 | Cluster URL:{' '} 37 | 38 | {urlShow} 39 | 40 |

41 | 42 |
43 |

44 | Name of GPU 45 |

46 |

47 | NVIDIA GeForce RTX 3080 48 |

49 |
50 |
51 |

52 | Driver Version 53 |

54 |

55 | 465.19 56 |

57 |
58 | 59 | 65 |
66 | {currSnapshot ? ( 67 | 68 | ) : ( 69 |

Data Loading

70 | )} 71 | 72 | ); 73 | } 74 | -------------------------------------------------------------------------------- /client/pages/SplashPage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef } from 'react'; 2 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 3 | import { faLinkedin } from '@fortawesome/free-brands-svg-icons'; 4 | import { faEnvelope } from '@fortawesome/free-solid-svg-icons'; 5 | import { Link } from 'react-router-dom'; 6 | import { useSelector } from 'react-redux'; 7 | import { RootState, useAppDispatch } from '../slices/store'; 8 | import { setGifUrl, setActiveDemo } from '../slices/uiSlice'; 9 | import { useDispatch } from 'react-redux'; 10 | 11 | interface TeamMember { 12 | name: string; 13 | role: string; 14 | imageUrl: string; 15 | linkedInUrl: string; 16 | email: string; 17 | } 18 | 19 | //team member pictures 20 | const ismaelImageURL: string = './public/Assets/Ismael-Headshot.jpg'; 21 | const soniaImageURL: string = './public/Assets/Sonia-Headshot.jpg'; 22 | const jinImageURL: string = './public/Assets/Jin-Headshot.jpg'; 23 | const jeffImageURL: string = './public/Assets/Jeff-Headshot.jpg'; 24 | 25 | //team member linkedIn 26 | const soniaLinkedInUrl: string = 'https://www.linkedin.com/in/soheunhan/'; 27 | const jinLinkedInUrl: string = 28 | 'https://www.linkedin.com/in/jinseong-nam-8b6815212/'; 29 | const jeffLinkedInUrl: string = 30 | 'https://www.linkedin.com/in/jeffrey-chao-9479142a/'; 31 | const ismaelLinkedInUrl: string = 32 | 'https://www.linkedin.com/in/ismael-boussatta-2493b2126/'; 33 | 34 | //team member email 35 | const soniaEmail: string = 'sonia.han@hey.com'; 36 | const jinEmail: string = 'jjjinnam@gmail.com'; 37 | const ismaelEmail: string = 'boussatta.ismael@gmail.com'; 38 | const jeffEmail: string = 'jeffplv@gmail.com'; 39 | 40 | const teamMembers: TeamMember[] = [ 41 | { 42 | name: 'Sonia Han', 43 | role: 'Software Engineer', 44 | imageUrl: soniaImageURL, 45 | linkedInUrl: soniaLinkedInUrl, 46 | email: soniaEmail, 47 | }, 48 | { 49 | name: 'Jeffrey Chao', 50 | role: 'Software Engineer', 51 | imageUrl: jeffImageURL, 52 | linkedInUrl: jeffLinkedInUrl, 53 | email: jeffEmail, 54 | }, 55 | { 56 | name: 'Jinseong Nam', 57 | role: 'Software Engineer', 58 | imageUrl: jinImageURL, 59 | linkedInUrl: jinLinkedInUrl, 60 | email: jinEmail, 61 | }, 62 | { 63 | name: 'Ismael Boussatta', 64 | role: 'Software Engineer', 65 | imageUrl: ismaelImageURL, 66 | linkedInUrl: ismaelLinkedInUrl, 67 | email: ismaelEmail, 68 | }, 69 | ]; 70 | 71 | // demo GIF url 72 | const autoRefresh = './public/Assets/demo/auto_refresh.gif'; 73 | const inputUrl = './public/Assets/demo/input_url.gif'; 74 | const saveSnapshot = './public/Assets/demo/save_snapshot.gif'; 75 | const signUp = './public/Assets/demo/signup.gif'; 76 | 77 | export default function SplashPage() { 78 | const demoGifUrl = useSelector((state: RootState) => state.ui.demoGifUrl); 79 | const activeDemo = useSelector((state: RootState) => state.ui.activeDemo); 80 | const whatIsK8sSectionRef = useRef(null); 81 | const handleScrollToSection = () => { 82 | whatIsK8sSectionRef.current?.scrollIntoView({ 83 | behavior: 'smooth', 84 | block: 'start', 85 | }); 86 | }; 87 | 88 | const dispatch = useDispatch(); 89 | 90 | const handleListClick = (list: string, gifUrl: string): void => { 91 | dispatch(setGifUrl(gifUrl)); 92 | 93 | dispatch(setActiveDemo(list)); 94 | }; 95 | 96 | return ( 97 | <> 98 |
99 |
100 | 101 | kale Logo 108 | 109 |
110 | 115 | Sign up 116 | 117 |
118 | 119 | Sign in 120 | 121 |
122 |
123 |
124 |
125 | 126 |
127 |
128 |
135 |
136 |
137 |
138 |
139 |

140 | Welcome to

kale

141 | , your new favorite Kubernetes autoscaling tool 142 |

143 |

144 | Deploying your Machine Learning models on Kubernetes just got 145 | easier 146 |

147 | 153 |
154 |
155 | 156 | 328 | 329 | ); 330 | } 331 | -------------------------------------------------------------------------------- /client/pages/WelcomePage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ClusterInput from '../components/ClusterInput'; 3 | import NavBar from '../components/Navbar'; 4 | import { Link } from 'react-router-dom'; 5 | import { useGrabMetricsMutation } from '../slices/metricsApi'; 6 | import { RootState } from '../slices/store'; 7 | import { useSelector, useDispatch } from 'react-redux'; 8 | import { saveUrl, saveNodeName } from '../slices/uiSlice'; 9 | 10 | type handleClickArg = { url: string; podName: string }; 11 | 12 | export default function WelcomePage() { 13 | const urlShow = useSelector((state: RootState) => state.ui.urlInput); 14 | const podName = useSelector((state: RootState) => state.ui.nodeNameInput); 15 | //allows us to get access to the localStorage data(the curr user's id and name) 16 | const userDataShow = useSelector((state: RootState) => state.user.userData); 17 | 18 | const [grabMetrics, result] = useGrabMetricsMutation({ 19 | fixedCacheKey: 'current-metric-data', 20 | }); 21 | 22 | const dispatch = useDispatch(); 23 | 24 | // handle the 'Go' button click event to fetch the metrics data with provided url 25 | function handleClick({ url, podName }: handleClickArg) { 26 | try { 27 | const response = grabMetrics({ url, podName }); 28 | } catch (error) { 29 | console.log('error creating data:', error); 30 | } 31 | } 32 | 33 | // Update url as user types 34 | function handleUrlChange(e: any) { 35 | dispatch(saveUrl(e.target.value)); 36 | } 37 | // Update node name as user types 38 | function handlePodNameChange(e: any) { 39 | dispatch(saveNodeName(e.target.value)); 40 | } 41 | 42 | return ( 43 | <> 44 | 56 |
57 |
58 |
65 |
66 |
67 |

68 | Hello,{' '} 69 |

70 | {userDataShow ? userDataShow.firstName : 'Random Hacker'} 71 |

72 |

73 |

74 | Please enter your cluster's Prometheus URL and the name of the pod 75 | you want to monitor*. 76 |

77 | 84 |

85 | *Proof of Concept: Data is currently based on CPU metrics. 86 | More comprehensive metrics are in development. 87 |

88 |
89 | 102 | 103 | ); 104 | } 105 | -------------------------------------------------------------------------------- /client/public/Assets/GPU image.jpg:Zone.Identifier: -------------------------------------------------------------------------------- 1 | [ZoneTransfer] 2 | ZoneId=3 3 | ReferrerUrl=https://unsplash.com/ 4 | HostUrl=https://images.unsplash.com/photo-1512756290469-ec264b7fbf87?ixlib=rb-4.0.3&q=85&fm=jpg&crop=entropy&cs=srgb&dl=rafael-pol-6b5uqlWabB0-unsplash.jpg 5 | -------------------------------------------------------------------------------- /client/public/Assets/GPU-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/kale/96c07cded4f93f0743580ce8e2bcd31ca6856b40/client/public/Assets/GPU-image.jpg -------------------------------------------------------------------------------- /client/public/Assets/Ismael LinkedIn Headshot.jpg:Zone.Identifier: -------------------------------------------------------------------------------- 1 | [ZoneTransfer] 2 | ZoneId=3 3 | ReferrerUrl=https://www.linkedin.com/ 4 | HostUrl=https://media.licdn.com/dms/image/C4E03AQEfrxu7_oqcxg/profile-displayphoto-shrink_800_800/0/1657887037194?e=1705536000&v=beta&t=U9hKEv5beudYhMSe0N9FrHoYnVwIS7CtCmYC9rN22hU 5 | -------------------------------------------------------------------------------- /client/public/Assets/Ismael-Headshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/kale/96c07cded4f93f0743580ce8e2bcd31ca6856b40/client/public/Assets/Ismael-Headshot.jpg -------------------------------------------------------------------------------- /client/public/Assets/Jeff Headshot.jpg:Zone.Identifier: -------------------------------------------------------------------------------- 1 | [ZoneTransfer] 2 | ZoneId=3 3 | HostUrl=https://files.slack.com/files-pri/T06MCELUAK1-F06SC8YQDHT/download/image_from_ios.jpg?origin_team=T06MCELUAK1 4 | -------------------------------------------------------------------------------- /client/public/Assets/Jeff-Headshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/kale/96c07cded4f93f0743580ce8e2bcd31ca6856b40/client/public/Assets/Jeff-Headshot.jpg -------------------------------------------------------------------------------- /client/public/Assets/Jin Headshot.jpg:Zone.Identifier: -------------------------------------------------------------------------------- 1 | [ZoneTransfer] 2 | LastWriterPackageFamilyName=Microsoft.Windows.Photos_8wekyb3d8bbwe 3 | ZoneId=3 4 | -------------------------------------------------------------------------------- /client/public/Assets/Jin-Headshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/kale/96c07cded4f93f0743580ce8e2bcd31ca6856b40/client/public/Assets/Jin-Headshot.jpg -------------------------------------------------------------------------------- /client/public/Assets/Sonia-Headshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/kale/96c07cded4f93f0743580ce8e2bcd31ca6856b40/client/public/Assets/Sonia-Headshot.jpg -------------------------------------------------------------------------------- /client/public/Assets/demo/auto_refresh.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/kale/96c07cded4f93f0743580ce8e2bcd31ca6856b40/client/public/Assets/demo/auto_refresh.gif -------------------------------------------------------------------------------- /client/public/Assets/demo/input_url.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/kale/96c07cded4f93f0743580ce8e2bcd31ca6856b40/client/public/Assets/demo/input_url.gif -------------------------------------------------------------------------------- /client/public/Assets/demo/save_snapshot.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/kale/96c07cded4f93f0743580ce8e2bcd31ca6856b40/client/public/Assets/demo/save_snapshot.gif -------------------------------------------------------------------------------- /client/public/Assets/demo/signup.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/kale/96c07cded4f93f0743580ce8e2bcd31ca6856b40/client/public/Assets/demo/signup.gif -------------------------------------------------------------------------------- /client/public/Assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/kale/96c07cded4f93f0743580ce8e2bcd31ca6856b40/client/public/Assets/favicon.png -------------------------------------------------------------------------------- /client/public/Assets/kale logo.png:Zone.Identifier: -------------------------------------------------------------------------------- 1 | [ZoneTransfer] 2 | ZoneId=3 3 | HostUrl=https://files.slack.com/files-pri/T06MCELUAK1-F06S3SLRTAQ/download/group_3.png?origin_team=T06MCELUAK1 4 | -------------------------------------------------------------------------------- /client/public/Assets/kale-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/kale/96c07cded4f93f0743580ce8e2bcd31ca6856b40/client/public/Assets/kale-logo.png -------------------------------------------------------------------------------- /client/public/Assets/kubernetes illustration.jpg:Zone.Identifier: -------------------------------------------------------------------------------- 1 | [ZoneTransfer] 2 | ZoneId=3 3 | ReferrerUrl=https://unsplash.com/ 4 | HostUrl=https://images.unsplash.com/photo-1667372459567-3853510dd5ce?ixlib=rb-4.0.3&q=85&fm=jpg&crop=entropy&cs=srgb&dl=growtika-pr5lUMgocTs-unsplash.jpg 5 | -------------------------------------------------------------------------------- /client/public/Assets/kubernetes-illustration.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/kale/96c07cded4f93f0743580ce8e2bcd31ca6856b40/client/public/Assets/kubernetes-illustration.jpg -------------------------------------------------------------------------------- /client/public/Assets/welcome-page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/kale/96c07cded4f93f0743580ce8e2bcd31ca6856b40/client/public/Assets/welcome-page.png -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Kale: Kubernetes GPU Monitoring and Autoscaling tool 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /client/slices/metricsApi.ts: -------------------------------------------------------------------------------- 1 | import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; 2 | import { ApiData } from '../../types.d'; 3 | 4 | type queryArg = { url: string; podName: string }; 5 | 6 | // API communication with server 7 | export const metricsApiSlice = createApi({ 8 | reducerPath: 'metricsApi', 9 | baseQuery: fetchBaseQuery({ baseUrl: '/' }), 10 | endpoints: (builder) => ({ 11 | grabMetrics: builder.mutation({ 12 | query: ({ url, podName }: queryArg) => ({ 13 | url: 'api', 14 | method: 'POST', 15 | body: { url, podName }, 16 | }), 17 | }), 18 | }), 19 | }); 20 | 21 | export const { 22 | useGrabMetricsMutation, //sending a post request using url 23 | } = metricsApiSlice; 24 | -------------------------------------------------------------------------------- /client/slices/snapshotsApi.ts: -------------------------------------------------------------------------------- 1 | import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; 2 | import { ApiData } from '../../types.d'; 3 | 4 | export interface Snapshot extends ApiData { 5 | user: string; 6 | } 7 | 8 | export interface SnapshotWithId extends ApiData { 9 | user: string; 10 | _id: string; 11 | } 12 | 13 | // database communication with server 14 | export const snapshotsApiSlice = createApi({ 15 | reducerPath: 'snapshotsApi', 16 | baseQuery: fetchBaseQuery({ baseUrl: '/snapshots' }), 17 | endpoints: (builder) => ({ 18 | getSnapshots: builder.query({ 19 | query: (userId) => ({ url: `/${userId}` }), 20 | }), 21 | sendSnapshots: builder.mutation({ 22 | query: (data) => ({ 23 | url: '/', 24 | method: 'POST', 25 | body: { 26 | snapshot: data, 27 | }, 28 | }), 29 | }), 30 | updateSnapshots: builder.mutation>({ 31 | query: (newSnapshotData: Partial) => { 32 | return { 33 | url: `${newSnapshotData.user}`, 34 | method: 'PATCH', 35 | body: newSnapshotData, 36 | }; 37 | }, 38 | }), 39 | getOneSnapshot: builder.query({ 40 | query: (id) => ({ url: `/one/${id}`, method: 'GET' }), 41 | }), 42 | deleteSnapshots: builder.mutation({ 43 | query: (id) => ({ 44 | url: `${id}`, 45 | method: 'DELETE', 46 | }), 47 | }), 48 | }), 49 | }); 50 | 51 | export const useGetSnapshotState = 52 | snapshotsApiSlice.endpoints.getSnapshots.useQueryState; 53 | export const useSnapshotQuerySubscription = 54 | snapshotsApiSlice.endpoints.getSnapshots.useQuerySubscription; 55 | 56 | export const { 57 | useGetSnapshotsQuery, // (for history page) 58 | useSendSnapshotsMutation, // send post request to post snapshot 59 | useUpdateSnapshotsMutation, //STRETCH: update the snapshot 60 | useGetOneSnapshotQuery, 61 | useDeleteSnapshotsMutation, //deleting the snapshot 62 | } = snapshotsApiSlice; 63 | -------------------------------------------------------------------------------- /client/slices/store.ts: -------------------------------------------------------------------------------- 1 | import { configureStore } from '@reduxjs/toolkit'; 2 | import { metricsApiSlice } from './metricsApi'; 3 | import { snapshotsApiSlice } from './snapshotsApi'; 4 | import { uiSlice } from './uiSlice'; 5 | import { useDispatch } from 'react-redux'; 6 | import { userApi } from './userApi'; 7 | import { userSlice } from './userSlice'; 8 | const store = configureStore({ 9 | reducer: { 10 | metricsApi: metricsApiSlice.reducer, 11 | snapshotsApi: snapshotsApiSlice.reducer, 12 | ui: uiSlice.reducer, 13 | userApi: userApi.reducer, 14 | user: userSlice.reducer, 15 | }, 16 | middleware: (getDefaultMiddleware) => 17 | getDefaultMiddleware() 18 | .concat(metricsApiSlice.middleware) 19 | .concat(snapshotsApiSlice.middleware) 20 | .concat(userApi.middleware), 21 | }); 22 | 23 | export default store; 24 | 25 | export type RootState = ReturnType; 26 | export type AppDispatch = typeof store.dispatch; 27 | export const useAppDispatch: () => AppDispatch = useDispatch; 28 | -------------------------------------------------------------------------------- /client/slices/uiSlice.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CreateSliceOptions, 3 | createSlice, 4 | PayloadAction, 5 | } from '@reduxjs/toolkit'; 6 | 7 | type UiState = { 8 | urlInput: string; 9 | nodeNameInput: string; 10 | isSidebarOpen: boolean; 11 | demoGifUrl: string; 12 | activeDemo: string; 13 | }; 14 | const initialState: UiState = { 15 | urlInput: '', 16 | nodeNameInput: '', 17 | isSidebarOpen: false, 18 | demoGifUrl: './public/Assets/demo/signup.gif', 19 | activeDemo: '1', 20 | }; 21 | 22 | export const uiSlice = createSlice({ 23 | name: 'ui', 24 | initialState, 25 | reducers: { 26 | toggleSidebar(state) { 27 | state.isSidebarOpen = !state.isSidebarOpen; 28 | }, 29 | saveUrl: (state, action: PayloadAction) => { 30 | state.urlInput = action.payload; 31 | }, 32 | saveNodeName: (state, action: PayloadAction) => { 33 | state.nodeNameInput = action.payload; 34 | }, 35 | setGifUrl: (state, action: PayloadAction) => { 36 | state.demoGifUrl = action.payload; 37 | }, 38 | setActiveDemo: (state, action: PayloadAction) => { 39 | state.activeDemo = action.payload; 40 | }, 41 | }, 42 | }); 43 | 44 | export const { 45 | toggleSidebar, 46 | saveUrl, 47 | saveNodeName, 48 | setGifUrl, 49 | setActiveDemo, 50 | } = uiSlice.actions; 51 | -------------------------------------------------------------------------------- /client/slices/userApi.ts: -------------------------------------------------------------------------------- 1 | import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; 2 | 3 | export const userApi = createApi({ 4 | reducerPath: 'userApi', 5 | baseQuery: fetchBaseQuery({ baseUrl: '/user' }), 6 | endpoints: (builder) => ({ 7 | signup: builder.mutation({ 8 | query: (data) => ({ 9 | url: '/signup', 10 | method: 'POST', 11 | body: data, 12 | }), 13 | }), 14 | login: builder.mutation({ 15 | query: (data) => ({ 16 | url: `/login`, 17 | method: 'POST', 18 | body: data, 19 | }), 20 | }), 21 | }), 22 | }); 23 | 24 | export const { useLoginMutation, useSignupMutation } = userApi; 25 | -------------------------------------------------------------------------------- /client/slices/userSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit'; 2 | import { UserState } from '../../types'; 3 | 4 | const initialState: UserState = { 5 | userData: localStorage.getItem('userData') 6 | ? JSON.parse(localStorage.getItem('userData')) 7 | : null, 8 | sessionData: [], 9 | }; 10 | 11 | export const userSlice = createSlice({ 12 | name: 'user', 13 | initialState, 14 | reducers: { 15 | setCredential: (state, action: PayloadAction) => { 16 | state.userData = action.payload; 17 | localStorage.setItem('userData', JSON.stringify(action.payload)); 18 | }, 19 | logout: (state) => { 20 | state.userData = null; 21 | localStorage.clear(); 22 | }, 23 | updateSessionData: (state, action: PayloadAction) => { 24 | state.sessionData.push(action.payload); 25 | }, 26 | }, 27 | }); 28 | export const { setCredential, logout, updateSessionData } = userSlice.actions; 29 | -------------------------------------------------------------------------------- /client/stylesheets/styles.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | 6 | -------------------------------------------------------------------------------- /cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'cypress'; 2 | import { devServer } from '@cypress/webpack-dev-server'; 3 | 4 | export default defineConfig({ 5 | e2e: { 6 | setupNodeEvents(on, config) { 7 | // implement node event listeners here 8 | }, 9 | }, 10 | 11 | component: { 12 | devServer(devServerConfig) { 13 | return devServer({ 14 | ...devServerConfig, 15 | framework: 'react', 16 | webpackConfig: require('./webpack.config.js'), 17 | }); 18 | }, 19 | }, 20 | }); 21 | -------------------------------------------------------------------------------- /cypress/e2e/auth.cy.ts: -------------------------------------------------------------------------------- 1 | describe('Authentication', () => { 2 | describe('Signup', () => { 3 | beforeEach(() => { 4 | cy.visit('http://localhost:8080'); 5 | }); 6 | it('signup form is visible', () => { 7 | cy.get('#sign-up-button-homepage').click(); 8 | cy.get('#signup-form').should('be.visible'); 9 | }); 10 | it('successful create account', () => { 11 | cy.get('#sign-up-button-homepage').click(); 12 | cy.fixture('users/secretUser.json').then((userInfo) => { 13 | cy.intercept('POST', '/user/signup').as('createUser'); 14 | 15 | cy.get('#first-name-signup').type(userInfo.firstName); 16 | cy.get('#email-signup').type(userInfo.email); 17 | cy.get('#password-signup').type(userInfo.password); 18 | cy.get('#confirm-password-signup').type(userInfo.password); 19 | cy.get('#create-account-btn').click(); 20 | 21 | cy.wait('@createUser').then((interception) => { 22 | expect(interception.response.body).to.have.property( 23 | 'email', 24 | userInfo.email 25 | ); 26 | expect(interception.response.body).to.have.property( 27 | 'firstName', 28 | userInfo.firstName 29 | ); 30 | expect(interception.response.statusCode).to.equal(200); 31 | }); 32 | }); 33 | }); 34 | it('create account with existing email address', () => { 35 | cy.get('#sign-up-button-homepage').click(); 36 | cy.fixture('users/secretUser.json').then((userInfo) => { 37 | cy.intercept('POST', '/user/signup').as('createUser'); 38 | 39 | cy.get('#first-name-signup').type(userInfo.firstName); 40 | cy.get('#email-signup').type(userInfo.email); 41 | cy.get('#password-signup').type(userInfo.password); 42 | cy.get('#confirm-password-signup').type(userInfo.password); 43 | cy.get('#create-account-btn').click(); 44 | 45 | cy.wait('@createUser').then((interception) => { 46 | expect(interception.response.body).to.include( 47 | 'Username already exists. Please choose another username' 48 | ); 49 | expect(interception.response.statusCode).to.equal(400); 50 | }); 51 | }); 52 | }); 53 | it('Already have an account? Sign in', () => { 54 | cy.get('#sign-up-button-homepage').click(); 55 | cy.contains('Login').click(); 56 | cy.url().should('include', '/signin'); 57 | }); 58 | }); 59 | 60 | describe('Signin', () => { 61 | beforeEach(() => { 62 | cy.visit('http://localhost:8080'); 63 | cy.get('#sign-up-button-homepage').click(); 64 | cy.get('#nav-button').click(); 65 | cy.get('#signin-nav-btn').click(); 66 | }); 67 | it('signin form is visible', () => { 68 | cy.get('#signin-form').should('be.visible'); 69 | }); 70 | it('successful signin', () => { 71 | cy.fixture('users/secretUser.json').then((userInfo) => { 72 | cy.intercept('POST', '/user/login').as('signinUser'); 73 | 74 | cy.get('#email-signin').type(userInfo.email); 75 | cy.get('#password-signin').type(userInfo.password); 76 | cy.get('#signin-btn').click(); 77 | 78 | cy.wait('@signinUser').then((interception) => { 79 | expect(interception.response.statusCode).to.equal(200); 80 | }); 81 | }); 82 | }); 83 | it('unsuccessful signin', () => { 84 | cy.fixture('users/secretUser.json').then((userInfo) => { 85 | cy.intercept('POST', '/user/login').as('signinUser'); 86 | 87 | cy.get('#email-signin').type(userInfo.email); 88 | cy.get('#password-signin').type('wrongpassword'); 89 | cy.get('#signin-btn').click(); 90 | 91 | cy.wait('@signinUser').then((interception) => { 92 | expect(interception.response.body).to.include( 93 | 'Invalid login credentials.' 94 | ); 95 | expect(interception.response.statusCode).to.equal(401); 96 | }); 97 | }); 98 | }); 99 | it('Not registered yet? Create account', () => { 100 | cy.contains('Create account').click(); 101 | cy.url().should('include', '/signup'); 102 | }); 103 | }); 104 | }); 105 | -------------------------------------------------------------------------------- /cypress/e2e/dashboardPage.cy.ts: -------------------------------------------------------------------------------- 1 | describe('The Dashboard Page', () => { 2 | const promURL = 'http://127.0.0.1:9090/'; 3 | 4 | beforeEach(() => { 5 | cy.visit('http://localhost:8080/'); 6 | cy.contains('Get started').click(); 7 | 8 | cy.get('#url-input').type(promURL); 9 | cy.get('#go-button').click(); 10 | }); 11 | 12 | it('contains /dashboard in the URL', () => { 13 | cy.url().should('include', '/dashboard'); 14 | }); 15 | it('dashboard contains: Cluster URL, a line chart and a gauge chart', () => { 16 | cy.contains('Dashboard'); 17 | cy.contains('Cluster URL: ' + promURL); 18 | cy.get('#line-chart-0').should('be.visible'); 19 | cy.get('#gauge-chart-0').should('be.visible'); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /cypress/e2e/historyPage.cy.ts: -------------------------------------------------------------------------------- 1 | describe('The History Page', () => { 2 | beforeEach(() => { 3 | cy.visit('http://localhost:8080/history'); 4 | cy.get('#nav-button').click(); 5 | cy.get('#history-nav-btn').click(); 6 | }); 7 | 8 | it('contains /history in the URL and a title "History"', () => { 9 | cy.url().should('include', '/history'); 10 | cy.contains('History'); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /cypress/e2e/navigation.cy.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | describe('The Welcome Page', () => { 4 | 5 | beforeEach(() => { 6 | cy.visit('http://localhost:8080'); 7 | }); 8 | 9 | it('has a sidebar that opens and closes', () => { 10 | cy.get('#nav-button').click(); 11 | cy.get('#drawer-navigation').should('be.visible'); 12 | 13 | cy.get('#nav-close-button').click(); 14 | cy.get('#drawer-navigation').should('not.be.visible'); 15 | }); 16 | 17 | it('dashboard sidebar button redirects to /dashboard', () => { 18 | cy.get('#nav-button').click(); 19 | cy.get('#dashboard-nav-btn').click(); 20 | cy.url().should('include', '/dashboard'); 21 | }); 22 | it('history sidebar button redirects to /history', () => { 23 | cy.get('#nav-button').click(); 24 | cy.get('#history-nav-btn').click(); 25 | cy.url().should('include', '/history'); 26 | }); 27 | it('signup sidebar button redirects to /signup', () => { 28 | cy.get('#nav-button').click(); 29 | cy.get('#signup-nav-btn').click(); 30 | cy.url().should('include', '/signup'); 31 | }); 32 | it('signin sidebar button redirects to /signin', () => { 33 | cy.get('#nav-button').click(); 34 | cy.get('#signin-nav-btn').click(); 35 | cy.url().should('include', '/signin'); 36 | }); 37 | 38 | it("cy.go() - go back or forward in the browser's history", () => { 39 | cy.get('#nav-button').click(); 40 | cy.get('#history-nav-btn').click(); 41 | cy.url().should('include', '/history'); 42 | 43 | cy.go('back'); 44 | cy.url().should('not.include', 'navigation'); 45 | 46 | cy.go('forward'); 47 | cy.url().should('include', '/history'); 48 | }); 49 | 50 | it('cy.reload() - reload the page', () => { 51 | cy.get('#nav-button').click(); 52 | cy.get('#history-nav-btn').click(); 53 | cy.url().should('include', '/history'); 54 | 55 | cy.reload(); 56 | 57 | cy.reload(true); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /cypress/e2e/splashPage.cy.ts: -------------------------------------------------------------------------------- 1 | describe('The Splash Page', () => { 2 | beforeEach(() => { 3 | cy.visit('http://localhost:8080'); 4 | }); 5 | 6 | it('has a title', () => { 7 | cy.contains('Welcome to kale'); 8 | 9 | // cy.url().should('include', '/commands/actions'); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /cypress/e2e/welcomePage.cy.ts: -------------------------------------------------------------------------------- 1 | describe('The Welcome Page', () => { 2 | const promURL = 'http://127.0.0.1:9090/'; 3 | 4 | beforeEach(() => { 5 | cy.visit('http://localhost:8080'); 6 | cy.get('#nav-button').click(); 7 | cy.get('#welcome-nav-btn').click(); 8 | }); 9 | 10 | it('has a title', () => { 11 | cy.contains('Hello'); 12 | }); 13 | it('has a "Get started" button and input field shows up when clicked', () => { 14 | cy.contains('Get started').click(); 15 | cy.get('#url-input').should('be.visible'); 16 | 17 | cy.get('#url-input').type(promURL); 18 | cy.get('#url-input').should('have.value', promURL); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /cypress/support/commands.ts: -------------------------------------------------------------------------------- 1 | /// 2 | // *********************************************** 3 | // This example commands.ts shows you how to 4 | // create various custom commands and overwrite 5 | // existing commands. 6 | // 7 | // For more comprehensive examples of custom 8 | // commands please read more here: 9 | // https://on.cypress.io/custom-commands 10 | // *********************************************** 11 | // 12 | // 13 | // -- This is a parent command -- 14 | // Cypress.Commands.add('login', (email, password) => { ... }) 15 | // 16 | // 17 | // -- This is a child command -- 18 | // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) 19 | // 20 | // 21 | // -- This is a dual command -- 22 | // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) 23 | // 24 | // 25 | // -- This will overwrite an existing command -- 26 | // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) 27 | // 28 | // declare global { 29 | // namespace Cypress { 30 | // interface Chainable { 31 | // login(email: string, password: string): Chainable 32 | // drag(subject: string, options?: Partial): Chainable 33 | // dismiss(subject: string, options?: Partial): Chainable 34 | // visit(originalFn: CommandOriginalFn, url: string, options: Partial): Chainable 35 | // } 36 | // } 37 | // } 38 | -------------------------------------------------------------------------------- /cypress/support/component-index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Components App 8 | 9 | 10 |
11 | 12 | -------------------------------------------------------------------------------- /cypress/support/component.ts: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/component.ts is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | 22 | import { mount } from 'cypress/react18' 23 | 24 | // Augment the Cypress namespace to include type definitions for 25 | // your custom command. 26 | // Alternatively, can be defined in cypress/support/component.d.ts 27 | // with a at the top of your spec. 28 | declare global { 29 | namespace Cypress { 30 | interface Chainable { 31 | mount: typeof mount 32 | } 33 | } 34 | } 35 | 36 | Cypress.Commands.add('mount', mount) 37 | 38 | // Example use: 39 | // cy.mount() -------------------------------------------------------------------------------- /cypress/support/e2e.ts: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/e2e.ts is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kale", 3 | "version": "1.0.0", 4 | "description": "Kubernetes GPU monitoring and autoscaling tool for ML", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "start": "cross-env NODE_ENV=production nodemon server/server.ts", 9 | "build": "cross-env NODE_ENV=production webpack", 10 | "dev": "concurrently \"cross-env NODE_ENV=development webpack serve --open\" \"cross-env NODE_ENV=development nodemon server/server.ts\"", 11 | "cypress:open": "cypress open" 12 | }, 13 | "author": "Jeffrey Chao, Sonia Han, Ismael Boussatta, Jinseong Nam", 14 | "license": "ISC", 15 | "dependencies": { 16 | "@fortawesome/fontawesome-svg-core": "^6.5.1", 17 | "@fortawesome/free-brands-svg-icons": "^6.5.1", 18 | "@fortawesome/free-solid-svg-icons": "^6.5.1", 19 | "@fortawesome/react-fontawesome": "^0.2.0", 20 | "@headlessui/react": "^1.7.18", 21 | "@heroicons/react": "^2.1.3", 22 | "@cypress/webpack-dev-server": "^3.7.4", 23 | "@reduxjs/toolkit": "^2.2.1", 24 | "@types/bcryptjs": "^2.4.6", 25 | "@types/d3": "^7.4.3", 26 | "@types/dotenv": "^8.2.0", 27 | "@types/recharts": "^1.8.29", 28 | "bcryptjs": "^2.4.3", 29 | "chart.js": "^4.4.2", 30 | "d3": "^7.9.0", 31 | "dotenv": "^16.4.5", 32 | "express": "^4.18.3", 33 | "mongoose": "^8.2.2", 34 | "node-fetch": "^2.6.1", 35 | "react": "^18.2.0", 36 | "react-chartjs-2": "^5.2.0", 37 | "react-dom": "^18.2.0", 38 | "react-redux": "^9.1.0", 39 | "react-router-dom": "^6.22.3", 40 | "recharts": "^2.12.3", 41 | "redux": "^5.0.1" 42 | }, 43 | "devDependencies": { 44 | "@babel/core": "^7.24.0", 45 | "@babel/preset-env": "^7.24.0", 46 | "@babel/preset-react": "^7.23.3", 47 | "@types/cypress": "^1.1.3", 48 | "@types/express": "^4.17.21", 49 | "@types/jest": "^29.5.12", 50 | "@types/node": "^20.11.28", 51 | "@types/react": "^18.2.66", 52 | "@types/react-dom": "^18.2.22", 53 | "@types/react-redux": "^7.1.33", 54 | "autoprefixer": "^10.4.18", 55 | "babel-loader": "^9.1.3", 56 | "chai": "^5.1.0", 57 | "concurrently": "^8.2.2", 58 | "cross-env": "^7.0.3", 59 | "css-loader": "^6.10.0", 60 | "cypress": "^13.7.2", 61 | "flowbite": "^2.3.0", 62 | "flowbite-react": "^0.7.3", 63 | "html-webpack-plugin": "^5.6.0", 64 | "mini-css-extract-plugin": "^2.8.1", 65 | "nodemon": "^3.1.0", 66 | "postcss": "^8.4.36", 67 | "postcss-cli": "^11.0.0", 68 | "postcss-loader": "^8.1.1", 69 | "sass": "^1.71.1", 70 | "sass-loader": "^14.1.1", 71 | "style-loader": "^3.3.4", 72 | "tailwindcss": "^3.4.1", 73 | "ts-loader": "^9.5.1", 74 | "ts-node": "^10.9.2", 75 | "typescript": "^5.4.3", 76 | "webpack": "^5.91.0", 77 | "webpack-cli": "^5.1.4", 78 | "webpack-dev-server": "^5.0.4" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /server/Models/snapshotModel.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | const { Schema } = mongoose; 3 | 4 | const snapshotSchema = new Schema({ 5 | user: { type: Schema.Types.ObjectId, ref: 'User' }, 6 | podName: String, 7 | date: { type: Date, default: Date.now() }, 8 | metrics: { 9 | gpuUsage: { 10 | metric: String, 11 | time: [String], 12 | value: [Number], 13 | }, 14 | memoryUsage: { 15 | metric: String, 16 | time: [String], 17 | value: [Number], 18 | }, 19 | }, 20 | }); 21 | 22 | const Snapshot = mongoose.model('Snapshot', snapshotSchema); 23 | 24 | export default Snapshot; 25 | -------------------------------------------------------------------------------- /server/Models/userModel.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv'; 2 | import bcrypt from 'bcryptjs'; 3 | import mongoose from 'mongoose'; 4 | 5 | const { Schema } = mongoose; 6 | dotenv.config(); 7 | 8 | declare global { 9 | namespace NodeJS { 10 | interface ProcessEnv { 11 | [key: string]: string | undefined; 12 | MONGODB_URI: string; 13 | } 14 | } 15 | } 16 | 17 | interface UserType { 18 | // Define an interface for user schema properties 19 | email: string; 20 | firstName: string; 21 | password: string; 22 | comparePassword(candidatePassword: string): Promise; 23 | } 24 | 25 | const SALT_WORK_FACTOR: number = 10; // Use number type for numeric constant 26 | 27 | const myURI = process.env.MONGODB_URI; 28 | 29 | try { 30 | mongoose.connect(myURI); 31 | console.log('connected to mongodb'); 32 | } catch (error) { 33 | console.log(error); 34 | } 35 | 36 | const userSchema = new Schema({ 37 | // Use generic type argument for UserType interface 38 | email: { type: String, required: true, unique: true }, 39 | firstName: { type: String, required: true }, 40 | password: { type: String, required: true }, 41 | }); 42 | 43 | userSchema.pre('save', async function (next) { 44 | const user = this as UserType; // Explicitly cast 'this' to UserType for property access 45 | 46 | try { 47 | // Generate a salt 48 | const salt = await bcrypt.genSalt(SALT_WORK_FACTOR); 49 | 50 | // Hash the password along with our new salt 51 | const hash = await bcrypt.hash(user.password, salt); 52 | 53 | // Override the cleartext password with the hashed one 54 | user.password = hash; 55 | return next(); 56 | } catch (err) { 57 | return next(err); 58 | } 59 | }); 60 | 61 | userSchema.methods.comparePassword = async function ( 62 | candidatePassword: string 63 | ): Promise { 64 | try { 65 | const isMatch = await bcrypt.compare(candidatePassword, this.password); 66 | return isMatch; 67 | } catch (err) { 68 | throw err; // Or handle the error differently 69 | } 70 | }; 71 | 72 | const User = mongoose.model('User', userSchema); 73 | 74 | export default User; 75 | -------------------------------------------------------------------------------- /server/controllers/apiController.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express'; 2 | import { ApiData, FetchResponseData, MetricsData } from '../../types'; 3 | 4 | const apiController = { 5 | gpuUsage: async (req: Request, res: Response, next: NextFunction) => { 6 | const podName = req.body.podName; 7 | 8 | const baseUrl = req.body.url.includes(`http://`) 9 | ? req.body.url.slice(7) 10 | : req.body.url; 11 | 12 | if (!baseUrl || !podName) { 13 | const errObj = { 14 | status: 400, 15 | message: { err: 'Your Prometheus URL or Pod Name was not provided' }, 16 | }; 17 | return next(errObj); 18 | } 19 | 20 | // buliding PromQL query 21 | const query = `container_cpu_usage_seconds_total{pod="${podName}", namespace="default"}[5m]`; 22 | const encodedQuery = encodeURIComponent(query); 23 | const apiUrl = `http://${baseUrl}/api/v1/query?query=${encodedQuery}`; 24 | 25 | try { 26 | const response = await fetch(apiUrl); 27 | const data: FetchResponseData = await response.json(); 28 | 29 | const metricsValues: MetricsData = { 30 | metric: 'GPU Usage', 31 | time: [], 32 | value: [], 33 | }; 34 | 35 | //-------- Total pod usage ------------ 36 | data.data.result[0].values.forEach(([time, value]) => { 37 | let date = new Date(time * 1000); 38 | let formattedTime = date.toLocaleTimeString('en-US', { 39 | timeZone: 'America/New_York', 40 | hour12: false, 41 | hour: '2-digit', 42 | minute: '2-digit', 43 | second: '2-digit', 44 | }); 45 | metricsValues.time.push(formattedTime); 46 | metricsValues.value.push(Number(value)); 47 | }); 48 | 49 | const formattedData: ApiData = { 50 | podName: podName, 51 | date: new Date().toLocaleDateString('en-CA', { 52 | timeZone: 'America/New_York', 53 | }), 54 | metrics: { 55 | gpuUsage: metricsValues, 56 | }, 57 | }; 58 | 59 | res.locals.gpuUsage = formattedData; 60 | 61 | return next(); 62 | } catch (error) { 63 | const errObj = { 64 | log: 'Error fetching GPU usage data: ' + error, 65 | status: 500, 66 | message: { err: 'Error fetching GPU usage data' }, 67 | }; 68 | return next(errObj); 69 | } 70 | }, 71 | }; 72 | 73 | export default apiController; 74 | -------------------------------------------------------------------------------- /server/controllers/authController.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express'; 2 | import User from '../Models/userModel'; 3 | 4 | const authController = { 5 | createUser: async (req: Request, res: Response, next: NextFunction) => { 6 | const { email, firstName, password } = req.body; 7 | 8 | try { 9 | const existingUser = await User.findOne({ email }); 10 | if (existingUser) { 11 | return next({ 12 | status: 400, 13 | log: 'Error in createUser middleware', 14 | message: 'Username already exists. Please choose another username', 15 | }); 16 | } 17 | const newUser = await User.create({ email, firstName, password }); 18 | 19 | res.locals.newUser = { firstName: newUser.firstName, _id: newUser._id }; 20 | 21 | return next(); 22 | } catch (err) { 23 | return next({ 24 | log: 'Error in createUser middleware: ' + err, 25 | message: { err: 'An error occurred while creating user' }, 26 | }); 27 | } 28 | }, 29 | 30 | login: async (req: Request, res: Response, next: NextFunction) => { 31 | let { email, password } = req.body; 32 | if (!email || !password) { 33 | return next({ 34 | status: 401, // Unauthorized 35 | log: 'Error in login middleware', 36 | message: 'Invalid username or password', 37 | }); 38 | } 39 | try { 40 | const existingUser = await User.findOne({ email }); 41 | // console.log(existingUser); 42 | if (!existingUser) { 43 | return next({ 44 | status: 401, // Unauthorized 45 | log: 'Error in login middleware', 46 | message: 'Invalid username or password', 47 | }); 48 | } 49 | 50 | if (typeof password === 'number') { 51 | password = JSON.stringify(password); 52 | } 53 | 54 | const valid = await existingUser.comparePassword(password); 55 | if (!valid) { 56 | return res 57 | .status(401) 58 | .json({ message: 'Invalid username or password' }); 59 | } 60 | const { firstName, _id } = existingUser; 61 | res.locals.user = { firstName, _id }; 62 | 63 | return next(); 64 | } catch (err) { 65 | return next({ 66 | log: 'Error in login middleware: ' + err, 67 | message: { err: 'An error occurred while user login ' }, 68 | }); 69 | } 70 | }, 71 | }; 72 | 73 | export default authController; 74 | -------------------------------------------------------------------------------- /server/controllers/dbController.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express'; 2 | import Snapshot from '../Models/snapshotModel'; 3 | 4 | const dbController = { 5 | getSnapshot: async (req: Request, res: Response, next: NextFunction) => { 6 | const { userId } = req.params; 7 | 8 | try { 9 | const snapshots = await Snapshot.find({ user: userId }); 10 | res.locals.snapshots = snapshots; 11 | return next(); 12 | } catch (err) { 13 | return next({ 14 | log: 'Error in getSnapshot middleware: ' + err, 15 | message: 'Error while fetching snapshots.', 16 | }); 17 | } 18 | }, 19 | 20 | postSnapshot: async (req: Request, res: Response, next: NextFunction) => { 21 | const { podName, metrics, user } = req.body.snapshot; 22 | 23 | try { 24 | if (!podName || !metrics || !user) { 25 | return next({ 26 | status: 400, 27 | log: 'Error in postSnapshot middleware: invalid input data', 28 | message: 29 | 'Cannot create new snapshot. Please provide all required information.', 30 | }); 31 | } 32 | const newSnapshot = await Snapshot.create({ podName, metrics, user }); 33 | res.locals.newSnapshot = newSnapshot; 34 | return next(); 35 | } catch (err) { 36 | return next({ 37 | log: 'Error in postSnapshot middleware: ' + err, 38 | message: 'Error while creating new snapshot.', 39 | }); 40 | } 41 | }, 42 | getOneSnapshot: async (req: Request, res: Response, next: NextFunction) => { 43 | const { _id } = req.params; 44 | 45 | try { 46 | if (!_id) { 47 | return next({ 48 | status: 400, 49 | log: 'Error in getOneSnapshot middleware: snapshot id not provided', 50 | message: 51 | 'Cannot get snapshot. Please provide all required information.', 52 | }); 53 | } 54 | const snapshot = await Snapshot.findOne({ _id }); 55 | if (!snapshot) { 56 | return next({ 57 | status: 400, 58 | log: 'Error in getOneSnapshot middleware: snapshot not found', 59 | message: 'The snapshot you are looking for does not exist.', 60 | }); 61 | } 62 | res.locals.snapshot = snapshot; 63 | return next(); 64 | } catch (err) { 65 | return next({ 66 | log: 'Error in getOneSnapshot middleware: ' + err, 67 | message: 'Error while fetching snapshot.', 68 | }); 69 | } 70 | }, 71 | deleteSnapshot: async (req: Request, res: Response, next: NextFunction) => { 72 | const { _id } = req.params; 73 | try { 74 | if (!_id) { 75 | return next({ 76 | status: 400, 77 | log: 'Error in deleteSnapshot middleware: snapshot id not provided', 78 | message: 79 | 'Cannot create delete snapshot. Please provide all required information.', 80 | }); 81 | } 82 | const deletedSnapshot = await Snapshot.findOneAndDelete({ _id }); 83 | if (!deletedSnapshot) { 84 | return next({ 85 | status: 400, 86 | log: 'Error in deleteSnapshot middleware: snapshot not found', 87 | message: 88 | 'The entry you want to delete does not exist in the database', 89 | }); 90 | } 91 | res.locals.deletedSnapshot = deletedSnapshot; 92 | return next(); 93 | } catch (err) { 94 | return next({ 95 | log: 'Error in deleteSnapshot middleware: ' + err, 96 | message: 'Error while deleting snapshot.', 97 | }); 98 | } 99 | }, 100 | }; 101 | 102 | export default dbController; 103 | -------------------------------------------------------------------------------- /server/router/authRouter.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import authController from '../controllers/authController'; 3 | 4 | const authRouter = express.Router(); 5 | 6 | authRouter.post('/signup', authController.createUser, (req, res) => { 7 | return res.status(200).json(res.locals.newUser); 8 | }); 9 | 10 | authRouter.post('/login', authController.login, (req, res) => { 11 | return res.status(200).json(res.locals.user); 12 | }); 13 | 14 | export default authRouter; 15 | -------------------------------------------------------------------------------- /server/router/dbRouter.ts: -------------------------------------------------------------------------------- 1 | import express, { Request, Response } from 'express'; 2 | import dbController from '../controllers/dbController'; 3 | 4 | const dbRouter = express.Router(); 5 | 6 | // get snapshots from database based on user id 7 | dbRouter.get( 8 | '/:userId', 9 | dbController.getSnapshot, 10 | (req: Request, res: Response) => { 11 | return res.status(200).json(res.locals.snapshots); 12 | } 13 | ); 14 | 15 | // get one snapshot from database 16 | dbRouter.get( 17 | '/one/:_id', 18 | dbController.getOneSnapshot, 19 | (req: Request, res: Response) => { 20 | return res.status(200).json(res.locals.snapshot); 21 | } 22 | ); 23 | 24 | // post new snapshot to database 25 | dbRouter.post('/', dbController.postSnapshot, (req: Request, res: Response) => { 26 | return res.status(200).json(res.locals.newSnapshot); 27 | }); 28 | 29 | // delete snapshot from database based on snapshot_id 30 | dbRouter.delete( 31 | '/:_id', 32 | dbController.deleteSnapshot, 33 | (req: Request, res: Response) => { 34 | return res.status(200).json(res.locals.deletedSnapshot); 35 | } 36 | ); 37 | 38 | export default dbRouter; 39 | -------------------------------------------------------------------------------- /server/server.ts: -------------------------------------------------------------------------------- 1 | import express, { Request, Response, NextFunction } from 'express'; 2 | import * as path from 'path'; 3 | import apiController from './controllers/apiController'; 4 | import dbRouter from './router/dbRouter'; 5 | import authRouter from './router/authRouter'; 6 | import type { ServerError } from '../types'; 7 | 8 | const app = express(); 9 | const PORT = 3000; 10 | 11 | app.use(express.json()); 12 | app.use(express.urlencoded({ extended: true })); 13 | 14 | // serving static files 15 | app.use('/public', express.static(path.resolve(__dirname, '../client/public'))); 16 | app.use('/dist', express.static(path.resolve(__dirname, '../dist'))); 17 | 18 | app.get('/', (req, res) => { 19 | return res 20 | .status(200) 21 | .sendFile(path.join(__dirname, '../client/public/index.html')); 22 | }); 23 | 24 | // Routes 25 | app.use('/snapshots', dbRouter); 26 | app.use('/user', authRouter); 27 | app.post( 28 | '/api', 29 | apiController.gpuUsage, 30 | (req: Request, res: Response, next: NextFunction) => { 31 | return res.status(200).json(res.locals.gpuUsage); 32 | } 33 | ); 34 | 35 | app.get('/*', (req, res) => { 36 | return res 37 | .status(200) 38 | .sendFile(path.join(__dirname, '../client/public/index.html')); 39 | }); 40 | 41 | app.get('*', (req: Request, res: Response, next: NextFunction) => 42 | res.status(404).send(`Page not found`) 43 | ); 44 | 45 | //Global Error Handler 46 | app.use((err: ServerError, req: Request, res: Response, next: NextFunction) => { 47 | const defaultErr: ServerError = { 48 | log: 'Express error handler caught unknown middleware error', 49 | status: 500, 50 | message: { err: 'An error occurred' }, 51 | }; 52 | const errorObj: ServerError = Object.assign({}, defaultErr, err); 53 | return res.status(errorObj.status).json(errorObj.message); 54 | }); 55 | 56 | app.listen(PORT, () => { 57 | console.log(`Server listening on port: ${PORT}`); 58 | }); 59 | 60 | export default app; 61 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | darkMode: 'class', 4 | content: [ 5 | './client/pages/**/*.tsx', 6 | './client/components/**/*.tsx', 7 | './client/public/index.html', 8 | 'node_modules/flowbite-react/lib/esm/', 9 | ], 10 | theme: { 11 | screens: { 12 | sm: '480px', 13 | md: '768px', 14 | lg: '976px', 15 | xl: '1440px', 16 | }, 17 | fontFamily: { 18 | sans: ['Open Sans', 'sans-serif'], 19 | serif: ['Merriweather', 'serif'], 20 | }, 21 | extend: { 22 | colors: { 23 | kalegreen: { 24 | 50: '#E6FAF1', 25 | 100: '#CDF4E2', 26 | 200: '#9FEAC8', 27 | 300: '#6CDFAB', 28 | 400: '#3AD48F', 29 | 500: '#26AE71', 30 | 600: '#1E8A5A', 31 | 700: '#176944', 32 | 800: '#10472E', 33 | 900: '#072116', 34 | 950: '#04110B', 35 | }, 36 | kaleblue: { 37 | 50: '#EDFCFD', 38 | 100: '#DBFAFB', 39 | 200: '#B2F3F6', 40 | 300: '#8DEEF1', 41 | 400: '#69E8ED', 42 | 500: '#41E2E8', 43 | 600: '#1AD0D6', 44 | 700: '#139B9F', 45 | 800: '#0D6669', 46 | 900: '#073537', 47 | 950: '#031A1B', 48 | }, 49 | }, 50 | fontFamily: { 51 | inter: ['Inter', 'sans-serif'], 52 | }, 53 | fontSize: { 54 | xs: ['0.75rem', { lineHeight: '1.5' }], 55 | sm: ['0.875rem', { lineHeight: '1.5715' }], 56 | base: ['1rem', { lineHeight: '1.5', letterSpacing: '-0.01em' }], 57 | lg: ['1.125rem', { lineHeight: '1.5', letterSpacing: '-0.01em' }], 58 | xl: ['1.25rem', { lineHeight: '1.5', letterSpacing: '-0.01em' }], 59 | '2xl': ['1.5rem', { lineHeight: '1.33', letterSpacing: '-0.01em' }], 60 | '3xl': ['1.88rem', { lineHeight: '1.33', letterSpacing: '-0.01em' }], 61 | '4xl': ['2.25rem', { lineHeight: '1.25', letterSpacing: '-0.02em' }], 62 | '5xl': ['3rem', { lineHeight: '1.25', letterSpacing: '-0.02em' }], 63 | '6xl': ['3.75rem', { lineHeight: '1.2', letterSpacing: '-0.02em' }], 64 | }, 65 | }, 66 | }, 67 | plugins: [require('flowbite/plugin')], 68 | }; 69 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["client/**/*", "cypress/**/*", "server/**/*"], 3 | "compilerOptions": { 4 | "jsx": "react", // Transpile JSX to React components 5 | "target": "ES5", // Adjust target JavaScript version as needed 6 | "module": "CommonJS", // Use CommonJS modules 7 | "esModuleInterop": true, // Enable interop between module systems 8 | "noImplicitAny": true //explicitly typing 9 | // "moduleResolution": "webpack", // Replace with your bundler's option 10 | // "allowImportingTsExtensions": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /types.d.ts: -------------------------------------------------------------------------------- 1 | export type Metric = { 2 | name: string; 3 | time: number[]; 4 | value: number[]; 5 | }; 6 | 7 | export type MetricsState = { 8 | status: 'loading' | 'failed' | 'finished'; 9 | error: { status: number; data: any } | null; 10 | data: MetricsData | null; 11 | }; 12 | 13 | export type ErrorMessage = { 14 | err: string; 15 | }; 16 | export type ServerError = { 17 | log: string; 18 | status: number; 19 | message: ErrorMessage; 20 | }; 21 | 22 | export type FetchResponseData = { 23 | status: string; 24 | data: { 25 | result: Array<{ 26 | values: Array<[number, string | number]>; 27 | }>; 28 | }; 29 | }; 30 | 31 | export type MetricsData = { 32 | metric: string; 33 | time: string[]; 34 | value: number[]; 35 | }; 36 | 37 | // res.locals.metrics 38 | export interface ApiData { 39 | podName: string; 40 | date: string; 41 | metrics: { [key: string]: MetricsData }; 42 | } 43 | 44 | export type VerifyData = { 45 | firstName?: string; 46 | email: string; 47 | password: string; 48 | confirmPassword?: string; 49 | }; 50 | 51 | export type UserState = { 52 | userData: any | null; 53 | sessionData: any[]; 54 | }; 55 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 4 | 5 | module.exports = { 6 | entry: './client/index.tsx', 7 | mode: process.env.NODE_ENV, 8 | output: { 9 | path: path.resolve(__dirname, 'dist'), 10 | filename: 'bundle.js', 11 | }, 12 | devServer: { 13 | historyApiFallback: true, 14 | static: { 15 | publicPath: '/public', 16 | directory: path.resolve(__dirname, './client/public'), 17 | }, 18 | proxy: [ 19 | { 20 | context: ['/api', '/user', '/snapshots'], 21 | target: 'http://localhost:3000', 22 | }, 23 | ], 24 | }, 25 | plugins: [ 26 | new MiniCssExtractPlugin({ 27 | filename: 'styles.css', 28 | }), 29 | new HtmlWebpackPlugin({ 30 | title: 'development', 31 | template: './client/public/index.html', 32 | }), 33 | ], 34 | module: { 35 | rules: [ 36 | { 37 | test: /\.jsx?$/, 38 | exclude: /node_modules/, 39 | use: { 40 | loader: 'babel-loader', 41 | options: { 42 | presets: [ 43 | ['@babel/preset-env', { targets: 'defaults' }], 44 | ['@babel/preset-react', { targets: 'defaults' }], 45 | ], 46 | }, 47 | }, 48 | }, 49 | { 50 | test: /\.(ts|tsx)$/, 51 | exclude: /node_modules/, 52 | use: ['ts-loader'], 53 | }, 54 | // { 55 | // test: /\.s[ac]ss$/i, 56 | // use: [ 57 | // MiniCssExtractPlugin.loader, 58 | // 'css-loader', 59 | // 'postcss-loader', 60 | // 'sass-loader', 61 | // ], 62 | // }, 63 | { 64 | test: /\.css$/, 65 | use: [ 66 | MiniCssExtractPlugin.loader, 67 | { 68 | loader: 'css-loader', 69 | options: { 70 | importLoaders: 1, 71 | }, 72 | }, 73 | { 74 | loader: 'postcss-loader', 75 | options: { 76 | postcssOptions: { 77 | plugins: [require('tailwindcss'), require('autoprefixer')], 78 | }, 79 | }, 80 | }, // Add postcss-loader for processing Tailwind CSS 81 | ], 82 | }, 83 | ], 84 | }, 85 | 86 | resolve: { 87 | extensions: ['.tsx', '.ts', '.jsx', '.js'], 88 | }, 89 | }; 90 | --------------------------------------------------------------------------------