├── .gitignore ├── src ├── client │ ├── styles.css │ ├── Pages │ │ ├── InstanceMetricsPage.jsx │ │ ├── OverviewMetricsPage.jsx │ │ ├── OverviewManagementPage.jsx │ │ └── OverviewSchedulerPage.jsx │ ├── index.js │ ├── Components │ │ ├── OverviewManagement.jsx │ │ ├── OverviewMetrics.jsx │ │ ├── SideBar.jsx │ │ ├── PageTabs.jsx │ │ ├── InstanceCard.jsx │ │ ├── MobileSidebar.jsx │ │ ├── UsageMetricsLineChart.jsx │ │ ├── SearchBar.jsx │ │ ├── SideBarContent.jsx │ │ ├── InstanceBar.jsx │ │ ├── Tables.jsx │ │ └── ScheduleTable.jsx │ ├── index.html │ ├── Containers │ │ ├── SubContainer.jsx │ │ ├── PageContainer.jsx │ │ ├── MainContainer.jsx │ │ └── InstanceContainer.jsx │ ├── App.jsx │ └── tailwind.output.css └── server │ ├── routes │ ├── cloudwatchRoute.js │ ├── ec2Route.js │ └── schedulerRoute.js │ ├── models │ └── scheduledJobModel.js │ ├── server.js │ ├── helpers │ └── ec2InstanceCommands.js │ └── controllers │ ├── ec2Controller.js │ ├── schedulerController.js │ └── cloudwatchController.js ├── postcss.config.js ├── webpack.config.js ├── package.json ├── README.md └── tailwind.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | node_modules 3 | package-lock.json 4 | build -------------------------------------------------------------------------------- /src/client/styles.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | html { 7 | @apply text-discord-100; 8 | } 9 | } -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | require('tailwindcss'), 4 | require('autoprefixer'), 5 | require('cssnano')({ 6 | preset: 'default', 7 | }), 8 | ], 9 | } 10 | -------------------------------------------------------------------------------- /src/client/Pages/InstanceMetricsPage.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | // InstanceMetricsPage renders from PageContainer 4 | const InstanceMetricsPage = () => { 5 | return
Instance Metrics Page
; 6 | }; 7 | 8 | export default InstanceMetricsPage; 9 | -------------------------------------------------------------------------------- /src/client/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | 4 | import { App } from './App'; 5 | import './styles.css'; 6 | 7 | const root = createRoot(document.getElementById('contents')); 8 | 9 | root.render(); 10 | -------------------------------------------------------------------------------- /src/client/Components/OverviewManagement.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | // OverviewManagement renders from OverviewManagementPage 4 | const OverviewManagement = () => { 5 | return
{/*
OverviewManagement
*/}
; 6 | }; 7 | 8 | export default OverviewManagement; 9 | -------------------------------------------------------------------------------- /src/client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | EZEC2 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /src/client/Components/OverviewMetrics.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | // OverviewMetrics renders from OverviewMetricsPage 4 | const OverviewMetrics = () => { 5 | return ( 6 |
7 | {/*
(Insert Overview Metrics Here)
*/} 8 |
9 | ); 10 | }; 11 | 12 | export default OverviewMetrics; 13 | -------------------------------------------------------------------------------- /src/server/routes/cloudwatchRoute.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const cloudwatchController = require('../controllers/cloudwatchController'); 3 | 4 | const router = express.Router(); 5 | 6 | router.get( 7 | '/getUsageData/:instanceId', 8 | cloudwatchController.getUsageData, 9 | (req, res) => { 10 | return res.json(res.locals); 11 | } 12 | ); 13 | 14 | module.exports = router; 15 | -------------------------------------------------------------------------------- /src/client/Components/SideBar.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import SideBarContent from './SideBarContent'; 4 | 5 | // SideBar renders from Main Container 6 | const SideBar = () => { 7 | 8 | 9 | return ( 10 | 13 | ); 14 | }; 15 | 16 | export default SideBar; 17 | -------------------------------------------------------------------------------- /src/client/Containers/SubContainer.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import PageContainer from './PageContainer.jsx'; 4 | import SearchBar from '../Components/SearchBar.jsx'; 5 | 6 | // SubContainer renders from MainContainer 7 | const SubContainer = () => { 8 | return ( 9 |
10 | 11 | 12 |
13 | ); 14 | }; 15 | 16 | export default SubContainer; 17 | -------------------------------------------------------------------------------- /src/client/Components/PageTabs.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | 4 | // PageTabs renders from PageContainer 5 | const PageTabs = () => { 6 | // remove instance metrics Link in the future. Only here for development purposes 7 | return ( 8 |
9 | Overview: Management 10 | Overview: Metrics 11 | {/* Dev Tab: Instance Metrics */} 12 |
13 | ); 14 | }; 15 | 16 | export default PageTabs; 17 | -------------------------------------------------------------------------------- /src/server/models/scheduledJobModel.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | const dotenv = require('dotenv').config(); 3 | 4 | mongoose 5 | .connect(process.env.MONGO_URI, { 6 | dbName: 'ezec2-jobs', 7 | }) 8 | .then(() => console.log('Connected to Mongo DB.')) 9 | .catch((err) => console.log(err)); 10 | 11 | const Schema = mongoose.Schema; 12 | const jobSchema = new Schema({ 13 | cronSchedule: String, 14 | jobType: String, 15 | instanceId: Array, 16 | }); 17 | 18 | const Job = mongoose.model('jobs', jobSchema); 19 | 20 | module.exports = { 21 | Job, 22 | }; 23 | -------------------------------------------------------------------------------- /src/server/routes/ec2Route.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const ec2Controller = require('../controllers/ec2Controller'); 3 | 4 | const router = express.Router(); 5 | 6 | router.post('/startInstance', ec2Controller.startInstance, (req, res) => { 7 | return res.json(res.locals); 8 | }); 9 | 10 | router.post('/stopInstance', ec2Controller.stopInstance, (req, res) => { 11 | return res.json(res.locals); 12 | }); 13 | 14 | router.get( 15 | '/getInstanceDetails', 16 | ec2Controller.getInstanceDetails, 17 | (req, res) => { 18 | return res.json(res.locals); 19 | } 20 | ); 21 | 22 | module.exports = router; 23 | -------------------------------------------------------------------------------- /src/client/Pages/OverviewMetricsPage.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import OverviewMetrics from '../Components/OverviewMetrics'; 4 | import InstanceContainer from '../Containers/InstanceContainer'; 5 | 6 | // OverviewMetricsPage renders from PageContainer 7 | const OverviewMetricsPage = () => { 8 | return ( 9 |
10 |

11 | Metrics Overview 12 |

13 | 14 | 15 |
16 | ); 17 | }; 18 | 19 | export default OverviewMetricsPage; 20 | -------------------------------------------------------------------------------- /src/client/Components/InstanceCard.jsx: -------------------------------------------------------------------------------- 1 | import { DnsNameState } from '@aws-sdk/client-ec2'; 2 | import React from 'react'; 3 | 4 | import UsageMetricsLineChart from './UsageMetricsLineChart'; 5 | 6 | // InstanceCard renders from InstanceContainer 7 | const InstanceCard = ({ instanceId, name, chartColor }) => { 8 | console.log('instance card id: ', instanceId); 9 | return ( 10 |
11 |

{name}

12 |

{instanceId}

13 | 14 |
15 | ); 16 | }; 17 | 18 | export default InstanceCard; 19 | -------------------------------------------------------------------------------- /src/server/routes/schedulerRoute.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const schedulerController = require('../controllers/schedulerController'); 3 | 4 | const router = express.Router(); 5 | 6 | router.delete( 7 | '/scheduleJob', 8 | schedulerController.deleteSchedule, 9 | (req, res) => { 10 | return res.json({ result: 'Schedule successfully deleted ' }); 11 | } 12 | ); 13 | 14 | router.post('/scheduleJob', schedulerController.scheduleJob, (req, res) => { 15 | return res.json({ result: 'Schedule successfully created ' }); 16 | }); 17 | 18 | router.get('/scheduleJob', schedulerController.getScheduledJobs, (req, res) => { 19 | return res.json(res.locals); 20 | }); 21 | 22 | module.exports = router; 23 | -------------------------------------------------------------------------------- /src/client/Pages/OverviewManagementPage.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useState, useEffect, useContext } from 'react'; 3 | 4 | import OverviewManagement from '../Components/OverviewManagement'; 5 | import Tables from '../Components/Tables'; 6 | import { InstanceContext } from '../App'; 7 | 8 | // OverviewManagementPage renders from PageContainer 9 | const OverviewManagementPage = () => { 10 | const { fetchDetails, instanceDetails, setInstanceDetails } = 11 | useContext(InstanceContext); 12 | 13 | return ( 14 | <> 15 |

16 | Control Panel 17 |

18 |
19 | 20 | {instanceDetails && } 21 |
22 | 23 | ); 24 | }; 25 | 26 | export default OverviewManagementPage; 27 | -------------------------------------------------------------------------------- /src/client/Pages/OverviewSchedulerPage.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useState, useEffect } from 'react'; 3 | 4 | import OverviewManagement from '../Components/OverviewManagement'; 5 | import ScheduleTable from '../Components/ScheduleTable'; 6 | 7 | // OverViewSchedulerPage renders from PageContainer 8 | const OverViewSchedulerPage = () => { 9 | let [instanceDetails, setInstanceDetails] = useState(null); 10 | 11 | const fetchDetails = async () => { 12 | try { 13 | const response = await fetch('/ec2/getInstanceDetails'); 14 | const data = await response.json(); 15 | setInstanceDetails(data.instanceList); 16 | } catch (e) { 17 | console.log('Error fetching instance details: ', e); 18 | } 19 | }; 20 | 21 | useEffect(() => { 22 | if (!instanceDetails) fetchDetails(); 23 | }, [instanceDetails]); 24 | return ( 25 |
26 |

27 | Instance Scheduler 28 |

29 | {instanceDetails && } 30 |
31 | ); 32 | }; 33 | 34 | export default OverViewSchedulerPage; 35 | -------------------------------------------------------------------------------- /src/client/App.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 3 | 4 | const queryClient = new QueryClient(); 5 | 6 | import { MainContainer } from './Containers/MainContainer.jsx'; 7 | 8 | export const InstanceContext = React.createContext(); 9 | 10 | // App renders from index.js 11 | export const App = () => { 12 | let [instanceDetails, setInstanceDetails] = useState(null); 13 | 14 | const fetchDetails = async () => { 15 | try { 16 | const response = await fetch('/ec2/getInstanceDetails'); 17 | const data = await response.json(); 18 | setInstanceDetails(data.instanceList); 19 | } catch (e) { 20 | console.log('Error fetching instance details: ', e); 21 | } 22 | }; 23 | 24 | useEffect(() => { 25 | if (instanceDetails === null) fetchDetails(); 26 | }, [instanceDetails]); 27 | 28 | 29 | console.log(instanceDetails) 30 | return ( 31 | 32 | 33 | 34 | 35 | 36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /src/server/server.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const bodyParser = require('body-parser'); 3 | const app = express(); 4 | const ec2Router = require('./routes/ec2Route.js'); 5 | const cloudwatchRouter = require('./routes/cloudwatchRoute.js'); 6 | const schedulerRouter = require('./routes/schedulerRoute.js'); 7 | const path = require('path'); 8 | 9 | app.use(express.json()); 10 | 11 | app.use('/', express.static(path.join(__dirname, '../../build'))); 12 | 13 | app.get('/', (req, res) => { 14 | { 15 | return res 16 | .status(200) 17 | .sendFile(path.join(__dirname, '../../build/index.html')); 18 | } 19 | }); 20 | 21 | app.use('/ec2', ec2Router); 22 | 23 | app.use('/cloudwatch', cloudwatchRouter); 24 | 25 | app.use('/scheduler', schedulerRouter); 26 | 27 | app.use('*', (req, res) => { 28 | console.log('hitting 404 message'); 29 | return res.sendStatus(404); 30 | }); 31 | 32 | app.use('/', (err, req, res, next) => { 33 | const defaultError = { 34 | log: 'Express error handler caught unknown error', 35 | status: 500, 36 | message: { err: 'An error occurred.' }, 37 | }; 38 | const errorObject = Object.assign({}, defaultError, err); 39 | return res.status(errorObject.status).json(errorObject.message); 40 | }); 41 | 42 | app.listen(3000, () => console.log('Listening on port 3000')); 43 | -------------------------------------------------------------------------------- /src/client/Containers/PageContainer.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Routes, Route } from 'react-router-dom'; 3 | // set up react routing 4 | 5 | import PageTabs from '../Components/PageTabs'; 6 | import OverviewMetricsPage from '../Pages/OverviewMetricsPage'; 7 | import OverviewManagementPage from '../Pages/OverviewManagementPage'; 8 | import InstanceMetricsPage from '../Pages/InstanceMetricsPage'; 9 | import OverViewSchedulerPage from '../Pages/OverviewSchedulerPage'; 10 | 11 | // tabs 12 | // different pages 13 | 14 | // PageContainer renders from SubContainer 15 | const PageContainer = () => { 16 | return ( 17 | <> 18 | {/* */} 19 | 20 | }> 21 | } 24 | > 25 | } 28 | > 29 | } 32 | > 33 | } 36 | > 37 | 38 | 39 | ); 40 | }; 41 | 42 | export default PageContainer; 43 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | 4 | module.exports = { 5 | entry: './src/client/index.js', 6 | mode: 'development', 7 | output: { 8 | path: path.resolve(__dirname, 'build'), 9 | filename: 'bundle.js', 10 | }, 11 | module: { 12 | rules: [ 13 | { 14 | test: /\.jsx?/, 15 | exclude: /node_modules/, 16 | use: [ 17 | { 18 | loader: 'babel-loader', 19 | options: { 20 | presets: ['@babel/preset-env', '@babel/preset-react'], 21 | }, 22 | }, 23 | ], 24 | }, 25 | { 26 | test: /\.tsx?/, 27 | // exclude: /node_modules/, 28 | use: ['ts-loader'], 29 | }, 30 | { 31 | test: /\.css$/, 32 | use: ['style-loader', 'css-loader', 'postcss-loader'], 33 | }, 34 | ], 35 | }, 36 | devServer: { 37 | static: { 38 | directory: path.join(__dirname, './client'), 39 | }, 40 | proxy: [ 41 | { 42 | context: ['/'], 43 | target: 'http://localhost:3000', 44 | changeOrigin: true, 45 | logLevel: 'info', 46 | }, 47 | ], 48 | port: 8080, 49 | }, 50 | plugins: [ 51 | new HtmlWebpackPlugin({ 52 | template: './src/client/index.html', 53 | }), 54 | ], 55 | resolve: { 56 | extensions: ['.jsx', '.js', '.ts', '.tsx'], 57 | }, 58 | }; 59 | -------------------------------------------------------------------------------- /src/client/Components/MobileSidebar.jsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | 3 | import SideBarContent from './SideBarContent'; 4 | import { Transition, Backdrop } from '@windmill/react-ui'; 5 | import { SidebarContext } from '../Containers/MainContainer'; 6 | 7 | // rendered in main container 8 | function MobileSidebar() { 9 | const { isSidebarOpen, closeSidebar } = useContext(SidebarContext); 10 | 11 | return ( 12 | 13 | <> 14 | 22 | 23 | 24 | 25 | 33 | 36 | 37 | 38 | 39 | ); 40 | } 41 | 42 | export default MobileSidebar; 43 | -------------------------------------------------------------------------------- /src/client/Containers/MainContainer.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { BrowserRouter } from 'react-router-dom'; 3 | 4 | import UsageMetricsLineChart from '../Components/UsageMetricsLineChart.jsx'; 5 | import SubContainer from './SubContainer.jsx'; 6 | import SideBar from '../Components/SideBar.jsx'; 7 | import MobileSidebar from '../Components/MobileSidebar.jsx'; 8 | 9 | export const SidebarContext = React.createContext(); 10 | export const SearchBarContext = React.createContext(); 11 | 12 | // MainContainer renders from App 13 | export const MainContainer = () => { 14 | const [isSidebarOpen, setIsSidebarOpen] = useState(false); 15 | const [search, setSearch] = useState(''); 16 | 17 | function toggleSidebar() { 18 | setIsSidebarOpen(!isSidebarOpen); 19 | } 20 | 21 | function closeSidebar() { 22 | setIsSidebarOpen(false); 23 | } 24 | 25 | return ( 26 | 27 |
31 | 32 | 39 | 40 | 41 | 42 | 43 | 44 |
45 |
46 | ); 47 | }; 48 | -------------------------------------------------------------------------------- /src/server/helpers/ec2InstanceCommands.js: -------------------------------------------------------------------------------- 1 | const aws = require('@aws-sdk/client-ec2'); 2 | const client = new aws.EC2Client({ 3 | region: 'us-east-1', 4 | }); 5 | 6 | const ec2Commands = {}; 7 | 8 | ec2Commands.stopInstance = async (instanceIds) => { 9 | const command = new aws.StopInstancesCommand({ 10 | InstanceIds: instanceIds, 11 | Hibernate: false, 12 | DryRun: false, 13 | Force: false, 14 | }); 15 | 16 | const stopResponse = await client.send(command); 17 | console.log(stopResponse); 18 | return stopResponse; 19 | }; 20 | 21 | ec2Commands.startInstance = async (instanceIds) => { 22 | try { 23 | const startCommand = new aws.StartInstancesCommand({ 24 | InstanceIds: instanceIds, 25 | DryRun: false, 26 | }); 27 | 28 | const startResponse = await client.send(startCommand); 29 | console.log(startResponse); 30 | return startResponse; 31 | } catch (e) { 32 | console.log(e); 33 | return; 34 | } 35 | }; 36 | 37 | ec2Commands.getInstanceDetails = async () => { 38 | const instanceDescriptionCommand = new aws.DescribeInstancesCommand({ 39 | DryRun: false, 40 | }); 41 | const instanceDescriptionResponse = await client.send( 42 | instanceDescriptionCommand 43 | ); 44 | 45 | const instanceList = []; 46 | 47 | for (let i = 0; i < instanceDescriptionResponse.Reservations.length; i++) { 48 | for ( 49 | let j = 0; 50 | j < instanceDescriptionResponse.Reservations[i].Instances.length; 51 | j++ 52 | ) { 53 | const currentInstance = 54 | instanceDescriptionResponse.Reservations[i].Instances[j]; 55 | instanceList.push({ 56 | instanceId: currentInstance.InstanceId, 57 | state: currentInstance.State, 58 | tags: currentInstance.Tags, 59 | securityGroups: currentInstance.SecurityGroups, 60 | }); 61 | } 62 | } 63 | 64 | return { 65 | status: instanceDescriptionResponse.$metadata.httpStatusCode, 66 | instanceList: instanceList, 67 | }; 68 | }; 69 | 70 | module.exports = ec2Commands; 71 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ezec2", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "node src/server/server.js", 8 | "dev": "webpack server --open", 9 | "devc": "concurrently \"cross-env NODE_ENV=development webpack serve --open\" \"cross-env NODE_ENV=development nodemon src/server/server.js\"", 10 | "build": "webpack", 11 | "tailwind:dev": "tailwindcss build src/client/styles.css -o src/client/tailwind.output.css" 12 | }, 13 | "author": "", 14 | "license": "ISC", 15 | "dependencies": { 16 | "@aws-sdk/client-cloudwatch": "^3.670.0", 17 | "@aws-sdk/client-ec2": "^3.669.0", 18 | "@babel/preset-env": "^7.25.8", 19 | "@babel/preset-react": "^7.25.7", 20 | "@emotion/react": "^11.13.3", 21 | "@emotion/styled": "^11.13.0", 22 | "@mui/material": "^6.1.3", 23 | "@mui/x-charts": "^7.20.0", 24 | "@mui/x-date-pickers": "^7.22.0", 25 | "@tailwindcss/forms": "^0.5.9", 26 | "@tanstack/react-query": "^5.59.13", 27 | "@windmill/react-ui": "^0.6.0", 28 | "aws-sdk": "^2.1691.0", 29 | "babel-loader": "^9.2.1", 30 | "body-parser": "^1.20.3", 31 | "concurrently": "^9.0.1", 32 | "css-loader": "^7.1.2", 33 | "cssnano": "^7.0.6", 34 | "dayjs": "^1.11.13", 35 | "dotenv": "^16.4.5", 36 | "express": "^4.21.1", 37 | "html-webpack-plugin": "^5.6.0", 38 | "mongoose": "^8.7.2", 39 | "mui": "^0.0.1", 40 | "node-cron": "^3.0.3", 41 | "react": "^18.3.1", 42 | "react-dom": "^18.3.1", 43 | "react-router-dom": "^6.26.2", 44 | "style-loader": "^4.0.0" 45 | }, 46 | "devDependencies": { 47 | "@babel/preset-env": "^7.25.8", 48 | "@babel/preset-react": "^7.25.7", 49 | "autoprefixer": "^10.4.20", 50 | "babel-loader": "^9.2.1", 51 | "concurrently": "^9.0.1", 52 | "cross-env": "^7.0.3", 53 | "nodemon": "^3.1.7", 54 | "postcss": "^8.4.47", 55 | "postcss-loader": "^8.1.1", 56 | "tailwindcss": "^3.4.13", 57 | "webpack": "^5.95.0", 58 | "webpack-cli": "^5.1.4", 59 | "webpack-dev-server": "^5.1.0" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/server/controllers/ec2Controller.js: -------------------------------------------------------------------------------- 1 | const ec2Commands = require('../helpers/ec2InstanceCommands'); 2 | 3 | const ec2Controller = {}; 4 | ec2Controller.getInstanceDetails = async (req, res, next) => { 5 | console.log('Getting all instance details.'); 6 | try { 7 | const instanceDetails = await ec2Commands.getInstanceDetails(); 8 | res.status(instanceDetails.status); 9 | res.locals.instanceList = instanceDetails.instanceList; 10 | 11 | return next(); 12 | } catch (e) { 13 | console.log(e); 14 | return next({ 15 | status: 401, 16 | message: { err: e.toString() }, 17 | }); 18 | } 19 | }; 20 | 21 | ec2Controller.stopInstance = async (req, res, next) => { 22 | console.log('Stopping instance.'); 23 | try { 24 | if (!req.body.instanceIds.length) { 25 | return next({ 26 | message: { err: 'At least 1 Instance ID is required to stop instance' }, 27 | status: 400, 28 | }); 29 | } 30 | 31 | const stopResponse = await ec2Commands.stopInstance(req.body.instanceIds); 32 | console.log(stopResponse); 33 | 34 | res.status(stopResponse.$metadata.httpStatusCode); 35 | res.locals.stopResponse = stopResponse; 36 | 37 | return next(); 38 | } catch (e) { 39 | console.log(e); 40 | return next({ 41 | status: 401, 42 | message: { err: e.toString() }, 43 | }); 44 | } 45 | }; 46 | 47 | ec2Controller.startInstance = async (req, res, next) => { 48 | console.log('Starting instance.'); 49 | try { 50 | if (!req.body.instanceIds.length) { 51 | return next({ 52 | message: { err: 'At least 1 Instance ID required to start instance' }, 53 | status: 400, 54 | }); 55 | } 56 | 57 | const startResponse = await ec2Commands.startInstance(req.body.instanceIds); 58 | 59 | res.status(startResponse.$metadata.httpStatusCode); 60 | res.locals.startResponse = startResponse; 61 | 62 | return next(); 63 | } catch (e) { 64 | return next({ 65 | status: 401, 66 | message: { err: e.toString() }, 67 | }); 68 | } 69 | }; 70 | 71 | module.exports = ec2Controller; 72 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # EZEC2 2 | 3 | ## Overview 4 | 5 | We built EZEC2 for teams that want visibility into their EC2 resources with the ability to easily automate shutdowns. EZEC can be run locally or hosted on an EC2 instance for persistent enforcement of shutdown/startup schedules. 6 | 7 | ## Getting Started 8 | 9 | Using EZEC2 requires IAM Access Role that can access EC2 resources, retrieve Cloudwatch Data, and start/stop EC2 instances. 10 | Scheduling functionality will persist via connection to a MongoDB Database. 11 | 12 | ## Running Locally 13 | 14 | - **Disclaimer:** If running EZEC2 locally, scheduled instance starts and stops will only happen if your server is running. 15 | - Fork the repo, clone it to your local machine 16 | - Set the .env variable for your MONGO_URI 17 | - Configure your AWS SSO for the AWS CLI. Your SSO role must have the IAM Access Role with access described above. 18 | - The configred AWS SSO Role should be under your _default_ settings in the AWS SSO Config file. 19 | - The file must be accessible from the working directory of where you are running EZEC 20 | 21 | ## Running on an EC2 instance 22 | 23 | - Fork the repo 24 | - Deploy EZEC2 to an EC2 instance with an IAM Access Role with the permissions described above. You can use the deployment method of your choice (e.g. Elastic Beanstalk) 25 | - Set the .env variable for your MONGO_URI 26 | - Set the .env variable for your MANAGER_INSTANCE_ID - this will ensure you cannot stop the EC2 instance that's hosting EZEC2 27 | - Ensure the security policy for the host instance limits access to your host instance. EZEC2 will allow someone to start/stop all of your EC2 resources. 28 | 29 | ## Continued Development 30 | 31 | ### Future Features and Goals 32 | 33 | - Converting the codebase to Typescript 34 | - Adding testing 35 | - Containerizing the application 36 | - Tracking EC2 instance costs and stopping EC2 instances if they go over budget automatically 37 | - Increasing the amount of different instance metrics the user can request to view 38 | - The ability to filter displayed metrics by metric type and time period 39 | - Add the ability to export data 40 | - Automaitcally stopping instances when certain user set conditions are met 41 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | const { Template } = require('webpack'); 2 | 3 | /** @type {import('tailwindcss').Config} */ 4 | module.exports = { 5 | content: ['./src/client/**/*.{html,js,jsx,ts,tsx}'], 6 | theme: { 7 | extend: { 8 | colors: { 9 | discord: { 10 | 100: '#dcddde', // the text color 11 | 900: '#36393f', // the midnight background 12 | }, 13 | templateGray: { 14 | 50: '#f9fafb', // ours is #f9fafb ooooo another samsies 15 | 100: '#f4f5f7', // ours is #f3f4f6 16 | 200: '#e5e7eb', // ours is #e5e7eb ooooh nice samsies 17 | 300: '#d5d6d7', // ours is #d1d5db 18 | 400: '#9e9e9e', // ours is #9ca3af 19 | 500: '#707275', // ours is #6b7280 20 | 600: '#4c4f52', // ours is #4b5563 21 | 700: '#24262d', // ours is #374151 22 | 800: '#1a1c23', // ours is #1f2937 23 | 900: '#121317', // ours is #111827 24 | }, 25 | templatePurple: { 26 | 300: '#cabffd', // ours is #d8b4fe 27 | 400: '#ac94fa', // ours is #c084fc 28 | 500: '#9061f9', // ours is #a855f7 29 | 600: '#7e3af2', // ours is #9333ea 30 | }, 31 | templateRed: { 32 | 100: '#fde8e8', // ours is #fee2e2 33 | 500: '#f05252', // ours is #ef4444 34 | 600: '#e02424', // ours is #dc2626 35 | 700: '#c81e1e', // ours is #b91c1c 36 | }, 37 | templateGreen: { 38 | 100: '#def7ec', // ours is #ecfccb 39 | 500: '#0e9f6e', // ours is #84cc16 40 | 700: '#046c4e', // ours is #4d7c0f 41 | }, 42 | templateYellow: { 43 | 100: '#fdf6b2', // ours is #fef9c3 44 | 300: '#faca15', // ours is #fde047 45 | 800: '#723b13', // ours is #854d0e 46 | }, 47 | templateSlate: { // this one im not sure about since slate doesnt show up in their css im using their cool-gray 48 | 700: '#364152', // ours is #334155 49 | }, 50 | templateOrange: { 51 | 100: '#feecdc', // ours is #ffedd5 52 | 700: '#b43403', // ours is #c2410c 53 | }, 54 | templateBlue: { 55 | 100: '#e1effe', // ours is #dbeafe 56 | 700: '#1a56db', // ours is #1d4ed8 57 | }, 58 | }, 59 | }, 60 | }, 61 | plugins: [require('@tailwindcss/forms')], 62 | }; 63 | -------------------------------------------------------------------------------- /src/client/Containers/InstanceContainer.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useEffect, useState, useContext } from 'react'; 3 | 4 | import InstanceCard from '../Components/InstanceCard.jsx'; 5 | import { InstanceContext } from '../App.jsx'; 6 | import { SearchBarContext } from './MainContainer.jsx'; 7 | 8 | // IntanceContainer renders from OverviewManagementPage 9 | const InstanceContainer = () => { 10 | const { fetchDetails, instanceDetails, setInstanceDetails } = 11 | useContext(InstanceContext); 12 | const { search } = useContext(SearchBarContext); 13 | 14 | let [instanceCards, setInstanceCards] = useState(null); 15 | console.log(instanceDetails); 16 | 17 | const createCards = () => { 18 | const cards = []; 19 | const colors = [ 20 | '#4e79a7', 21 | '#f28e2c', 22 | '#e15759', 23 | '#76b7b2', 24 | '#59a14f', 25 | '#edc949', 26 | '#af7aa1', 27 | '#ff9da7', 28 | '#9c755f', 29 | '#bab0ab', 30 | ]; 31 | 32 | // searching 33 | if (search.length === 0) { 34 | console.log('no search'); 35 | for (let i = 0; i < instanceDetails.length; i++) { 36 | const nameTag = instanceDetails[i].tags.find( 37 | (tag) => tag.Key === 'Name' 38 | ); 39 | const name = nameTag ? nameTag.Value : 'Unnamed Instance'; 40 | const colorIndex = i >= colors.length ? i % colors.length : i; 41 | cards.push( 42 | 48 | ); 49 | } 50 | } else { 51 | console.log('searching'); 52 | for (let i = 0; i < instanceDetails.length; i++) { 53 | const nameTag = instanceDetails[i].tags.find( 54 | (tag) => tag.Key === 'Name' 55 | ); 56 | const name = nameTag ? nameTag.Value : null; 57 | const colorIndex = i >= colors.length ? i % colors.length : i; 58 | if ( 59 | instanceDetails[i].instanceId 60 | .toUpperCase() 61 | .includes(search.toUpperCase()) || 62 | name.toUpperCase().includes(search.toUpperCase()) 63 | ) { 64 | cards.push( 65 | 71 | ); 72 | } 73 | } 74 | } 75 | setInstanceCards(cards); 76 | }; 77 | 78 | useEffect(() => { 79 | if (!instanceDetails) fetchDetails(); 80 | if (!!instanceDetails) createCards(); 81 | }, [instanceDetails, search]); 82 | 83 | return ( 84 |
85 |
{instanceCards}
86 |
87 | ); 88 | }; 89 | 90 | export default InstanceContainer; 91 | -------------------------------------------------------------------------------- /src/client/Components/UsageMetricsLineChart.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | LineChart, 4 | lineElementClasses, 5 | markElementClasses, 6 | } from '@mui/x-charts/LineChart'; 7 | import { useQuery } from '@tanstack/react-query'; 8 | 9 | const colors = [ 10 | '#4e79a7', 11 | '#f28e2c', 12 | '#e15759', 13 | '#76b7b2', 14 | '#59a14f', 15 | '#edc949', 16 | '#af7aa1', 17 | '#ff9da7', 18 | '#9c755f', 19 | '#bab0ab', 20 | ]; 21 | 22 | //configuration object for the different metrics 23 | const metricConfigs = { 24 | cpuUsage: { 25 | label: 'CPU Utilization', 26 | valueFormatter: (v) => v + '%', 27 | max: 100, 28 | }, 29 | cpuCreditBalance: { 30 | label: 'CPU Credit Balance', 31 | valueFormatter: (v) => v.toFixed(2), 32 | }, 33 | cpuCreditUsage: { 34 | label: 'CPU Credit Usage', 35 | valueFormatter: (v) => v.toFixed(2), 36 | }, 37 | memoryUsage: { 38 | label: 'Memory Usage', 39 | valueFormatter: (v) => v + '%', 40 | max: 100, 41 | }, 42 | networkIn: { 43 | label: 'Network In', 44 | valueFormatter: (v) => (v / 1024 / 1024).toFixed(2) + ' MB', 45 | }, 46 | networkOut: { 47 | label: 'Network Out', 48 | valueFormatter: (v) => (v / 1024 / 1024).toFixed(2) + ' MB', 49 | }, 50 | diskReadOps: { 51 | label: 'Disk Read Operations', 52 | valueFormatter: (v) => v.toFixed(0), 53 | }, 54 | diskWriteOps: { 55 | label: 'Disk Write Operations', 56 | valueFormatter: (v) => v.toFixed(0), 57 | }, 58 | }; 59 | 60 | //takes EC2 instance 61 | const UsageMetricsLineChart = ({ instanceId }) => { 62 | let metricsData; 63 | let timeData; 64 | console.log('UsageMetricsLineChart', instanceId); 65 | //React query is used to handle api call fetched from cloudwatchendpoint 66 | const { isPending, error, data } = useQuery({ 67 | queryKey: ['metricData' + instanceId], 68 | queryFn: async () => { 69 | return await fetch('/cloudwatch/getUsageData/' + instanceId).then((res) => 70 | res.json() 71 | ); 72 | }, 73 | }); 74 | 75 | if (isPending) return 'Loading...'; 76 | 77 | if (error) return 'An error has occurred: ' + error.message; 78 | 79 | const charts = []; 80 | 81 | // Process each metric result 82 | data.MetricDataResults.forEach((metricResult, index) => { 83 | const config = metricConfigs[metricResult.Id]; 84 | if (!config) return; 85 | 86 | timeData = metricResult.Timestamps.map((timestamp) => { 87 | return new Date(timestamp).toTimeString().split(' (')[0].split(' ')[0]; 88 | }); 89 | 90 | metricsData = metricResult.Values; 91 | 92 | charts.push( 93 |
94 |

{config.label}

95 | 123 |
124 | ); 125 | }); 126 | 127 | return
{charts}
; 128 | }; 129 | 130 | export default UsageMetricsLineChart; 131 | -------------------------------------------------------------------------------- /src/server/controllers/schedulerController.js: -------------------------------------------------------------------------------- 1 | const cron = require('node-cron'); 2 | const ec2Commands = require('../helpers/ec2InstanceCommands'); 3 | const models = require('../models/scheduledJobModel'); 4 | 5 | const runOnce = (fn) => { 6 | let hasRun = false; 7 | return (...args) => { 8 | if (hasRun) { 9 | console.log('This function already ran once'); 10 | } else { 11 | fn(...args); 12 | hasRun = true; 13 | } 14 | }; 15 | }; 16 | 17 | const cronCache = {}; 18 | 19 | const scheduleSavedJobsOnce = runOnce((jobs) => { 20 | for (let i = 0; i < jobs.length; i++) { 21 | const cronExpression = jobs[i].cronSchedule; 22 | switch (jobs[i].jobType) { 23 | case 'startInstance': 24 | console.log('Setting cron function for start instance'); 25 | cronFunction = async () => { 26 | ec2Commands.startInstance(jobs[i].instanceId); 27 | }; 28 | 29 | break; 30 | case 'stopInstance': 31 | console.log('Setting cron function for stop instance'); 32 | cronFunction = () => { 33 | ec2Commands.stopInstance(jobs[i].instanceId); 34 | }; 35 | break; 36 | default: 37 | console.log('Unrecognized case'); 38 | return; 39 | } 40 | const job = cron.schedule(cronExpression, cronFunction); 41 | cronCache[jobs[i]._id] = job; 42 | } 43 | }); 44 | 45 | const schedulerController = {}; 46 | 47 | function parseCronExpression(req) { 48 | let expression = ''; 49 | if (req.body.minute || req.body.minute === 0) { 50 | // only expect a single value 51 | expression += req.body.minute.toString() + ' '; 52 | } else { 53 | expression += '* '; 54 | } 55 | if (req.body.hour || req.body.hour === 0) { 56 | // only expect a single value 57 | expression += req.body.hour.toString() + ' '; 58 | } else { 59 | expression += '* '; 60 | } 61 | 62 | // Not currently supporting customization by day of month 63 | expression += '* '; 64 | 65 | // Not currently supporting customization by month 66 | expression += '* '; 67 | 68 | if (req.body.dayOfWeek.length) { 69 | req.body.dayOfWeek.join(','); 70 | expression += req.body.dayOfWeek.toString(); 71 | } else { 72 | expression += '*'; 73 | } 74 | console.log(expression); 75 | return expression; 76 | } 77 | 78 | schedulerController.getScheduledJobs = async (req, res, next) => { 79 | try { 80 | const jobList = await models.Job.find({}); 81 | res.locals = jobList; 82 | scheduleSavedJobsOnce(jobList); 83 | return next(); 84 | } catch (e) { 85 | return next({ 86 | message: { err: e.toString() }, 87 | }); 88 | } 89 | }; 90 | 91 | schedulerController.scheduleJob = async (req, res, next) => { 92 | const cronExpression = parseCronExpression(req); 93 | let cronFunction; 94 | 95 | switch (req.body.jobAction) { 96 | case 'startInstance': 97 | cronFunction = async () => { 98 | ec2Commands.startInstance(req.body.instanceIds); 99 | }; 100 | 101 | break; 102 | case 'stopInstance': 103 | cronFunction = () => { 104 | ec2Commands.stopInstance(req.body.instanceIds); 105 | }; 106 | break; 107 | default: 108 | return next({ 109 | message: { 110 | err: 'Unknown instance command. Expected jobAction to be either startInstance or stopInstance', 111 | }, 112 | }); 113 | } 114 | 115 | try { 116 | // clear existing schedule for existing job 117 | const result = await models.Job.findOneAndDelete({ 118 | $and: [{ instanceId: req.body.instanceIds, jobType: req.body.jobAction }], 119 | }); 120 | 121 | if (result) { 122 | delete cronCache[result._id]; 123 | } 124 | 125 | const job = cron.schedule(cronExpression, cronFunction, { 126 | timezone: req.body.timezone, 127 | }); 128 | const jobResult = await models.Job.create({ 129 | cronSchedule: cronExpression, 130 | jobType: req.body.jobAction, 131 | instanceId: req.body.instanceIds, 132 | }); 133 | cronCache[jobResult._id] = job; 134 | return next(); 135 | } catch (e) { 136 | console.log(e); 137 | return next({ 138 | message: { err: e.toString() }, 139 | }); 140 | } 141 | }; 142 | 143 | schedulerController.deleteSchedule = async (req, res, next) => { 144 | try { 145 | const result = await models.Job.findOneAndDelete({ 146 | $and: [{ instanceId: req.body.instanceIds, jobType: req.body.jobAction }], 147 | }); 148 | 149 | if (result) { 150 | delete cronCache[result._id]; 151 | } 152 | 153 | return next(); 154 | } catch (e) { 155 | console.log(e); 156 | return next({ 157 | message: { err: e.toString() }, 158 | }); 159 | } 160 | }; 161 | 162 | module.exports = schedulerController; 163 | -------------------------------------------------------------------------------- /src/server/controllers/cloudwatchController.js: -------------------------------------------------------------------------------- 1 | const aws = require('@aws-sdk/client-cloudwatch'); 2 | const { fromSSO } = require('@aws-sdk/credential-provider-sso'); 3 | 4 | const cloudwatchController = {}; 5 | 6 | cloudwatchController.getUsageData = async (req, res, next) => { 7 | console.log( 8 | 'Querying Cloudwatch for usage data on instance ' + req.params.instanceId 9 | ); 10 | const now = new Date(Date.now()); 11 | const yesterday = new Date(Date.now() - 1000 * 60 * 60 * 24); 12 | const instanceId = req.params.instanceId; 13 | 14 | try { 15 | const client = new aws.CloudWatchClient({ 16 | region: 'us-east-1', 17 | }); 18 | //find schema 19 | const input = { 20 | // GetMetricDataInput 21 | MetricDataQueries: [ 22 | // CPU Utilization 23 | { 24 | Id: 'cpuUsage', 25 | MetricStat: { 26 | Metric: { 27 | Namespace: 'AWS/EC2', 28 | MetricName: 'CPUUtilization', 29 | Dimensions: [ 30 | { 31 | Name: 'InstanceId', 32 | Value: instanceId, 33 | }, 34 | ], 35 | }, 36 | Period: 900, 37 | Stat: 'Average', 38 | Unit: 'Percent', 39 | }, 40 | ReturnData: true, 41 | }, 42 | // CPU Credit Balance 43 | { 44 | Id: 'cpuCreditBalance', 45 | MetricStat: { 46 | Metric: { 47 | Namespace: 'AWS/EC2', 48 | MetricName: 'CPUCreditBalance', 49 | Dimensions: [ 50 | { 51 | Name: 'InstanceId', 52 | Value: instanceId, 53 | }, 54 | ], 55 | }, 56 | Period: 900, 57 | Stat: 'Average', 58 | }, 59 | ReturnData: true, 60 | }, 61 | // CPU Credit Usage 62 | { 63 | Id: 'cpuCreditUsage', 64 | MetricStat: { 65 | Metric: { 66 | Namespace: 'AWS/EC2', 67 | MetricName: 'CPUCreditUsage', 68 | Dimensions: [ 69 | { 70 | Name: 'InstanceId', 71 | Value: instanceId, 72 | }, 73 | ], 74 | }, 75 | Period: 900, 76 | Stat: 'Average', 77 | }, 78 | ReturnData: true, 79 | }, 80 | // Memory Usage 81 | { 82 | Id: 'memoryUsage', 83 | MetricStat: { 84 | Metric: { 85 | Namespace: 'CWAgent', 86 | MetricName: 'mem_used_percent', 87 | Dimensions: [ 88 | { 89 | Name: 'InstanceId', 90 | Value: instanceId, 91 | }, 92 | ], 93 | }, 94 | Period: 900, 95 | Stat: 'Average', 96 | Unit: 'Percent', 97 | }, 98 | ReturnData: true, 99 | }, 100 | // Network In 101 | { 102 | Id: 'networkIn', 103 | MetricStat: { 104 | Metric: { 105 | Namespace: 'AWS/EC2', 106 | MetricName: 'NetworkIn', 107 | Dimensions: [ 108 | { 109 | Name: 'InstanceId', 110 | Value: instanceId, 111 | }, 112 | ], 113 | }, 114 | Period: 900, 115 | Stat: 'Average', 116 | Unit: 'Bytes', 117 | }, 118 | ReturnData: true, 119 | }, 120 | // Network Out 121 | { 122 | Id: 'networkOut', 123 | MetricStat: { 124 | Metric: { 125 | Namespace: 'AWS/EC2', 126 | MetricName: 'NetworkOut', 127 | Dimensions: [ 128 | { 129 | Name: 'InstanceId', 130 | Value: instanceId, 131 | }, 132 | ], 133 | }, 134 | Period: 900, 135 | Stat: 'Average', 136 | Unit: 'Bytes', 137 | }, 138 | ReturnData: true, 139 | }, 140 | 141 | // Disk Read Operations 142 | { 143 | Id: 'diskReadOps', 144 | MetricStat: { 145 | Metric: { 146 | Namespace: 'AWS/EC2', 147 | MetricName: 'DiskReadOps', 148 | Dimensions: [ 149 | { 150 | Name: 'InstanceId', 151 | Value: instanceId, 152 | }, 153 | ], 154 | }, 155 | Period: 900, 156 | Stat: 'Sum', 157 | }, 158 | ReturnData: true, 159 | }, 160 | // Disk Write Operations 161 | { 162 | Id: 'diskWriteOps', 163 | MetricStat: { 164 | Metric: { 165 | Namespace: 'AWS/EC2', 166 | MetricName: 'DiskWriteOps', 167 | Dimensions: [ 168 | { 169 | Name: 'InstanceId', 170 | Value: instanceId, 171 | }, 172 | ], 173 | }, 174 | Period: 900, 175 | Stat: 'Sum', 176 | }, 177 | ReturnData: true, 178 | }, 179 | ], 180 | StartTime: yesterday, // required 181 | EndTime: now, // required 182 | ScanBy: 'TimestampAscending', 183 | // MaxDatapoints: Number('int'), 184 | }; 185 | const command = new aws.GetMetricDataCommand(input); 186 | 187 | const response = await client.send(command); 188 | // console.log(response); 189 | res.locals = response; 190 | return next(); 191 | } catch (e) { 192 | console.log(e); 193 | return next({ 194 | status: 401, 195 | message: { err: e.toString() }, 196 | }); 197 | } 198 | }; 199 | 200 | module.exports = cloudwatchController; 201 | -------------------------------------------------------------------------------- /src/client/Components/SearchBar.jsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | 3 | import { SidebarContext } from '../Containers/MainContainer'; 4 | import { SearchBarContext } from '../Containers/MainContainer'; 5 | import { InstanceContext } from '../App'; 6 | 7 | // SearchBar renders from MainContainer 8 | const SearchBar = () => { 9 | const { toggleSidebar } = useContext(SidebarContext); 10 | const { setSearch } = useContext(SearchBarContext); 11 | 12 | return ( 13 | 138 | ); 139 | }; 140 | 141 | export default SearchBar; 142 | -------------------------------------------------------------------------------- /src/client/Components/SideBarContent.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useNavigate, useLocation } from 'react-router-dom'; 3 | 4 | const SideBarContent = () => { 5 | const navigate = useNavigate(); 6 | const location = useLocation(); 7 | 8 | return ( 9 |
10 | 11 | EZEC2 12 | {/* ~Danny placeholder~ */} 13 | 14 | 140 |
141 | ); 142 | }; 143 | 144 | export default SideBarContent; 145 | -------------------------------------------------------------------------------- /src/client/Components/InstanceBar.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useNavigate, useLocation } from 'react-router-dom'; 3 | 4 | // InstanceBar renders from SubContainer 5 | const InstanceBar = () => { 6 | const navigate = useNavigate(); 7 | const location = useLocation(); 8 | 9 | return ( 10 | 144 | ); 145 | }; 146 | 147 | export default InstanceBar; 148 | -------------------------------------------------------------------------------- /src/client/Components/Tables.jsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import { 3 | Table, 4 | TableHeader, 5 | TableCell, 6 | TableBody, 7 | TableRow, 8 | TableFooter, 9 | TableContainer, 10 | } from '@windmill/react-ui'; 11 | 12 | import { SearchBarContext } from '../Containers/MainContainer'; 13 | 14 | const badgeStyles = { 15 | running: 16 | 'inline-flex px-2 text-xs font-medium leading-5 rounded-full text-templateGreen-700 bg-templateGreen-100 dark:bg-templateGreen-700 dark:text-templateGreen-100', 17 | pending: 18 | 'inline-flex px-2 text-xs font-medium leading-5 rounded-full text-templateYellow-800 bg-templateYellow-100 dark:text-templateSlate-700 dark:bg-templateYellow-300', 19 | stopped: 20 | 'inline-flex px-2 text-xs font-medium leading-5 rounded-full text-templateRed-700 bg-templateRed-100 dark:text-templateRed-100 dark:bg-templateRed-700', 21 | stopping: 22 | 'inline-flex px-2 text-xs font-medium leading-5 rounded-full text-templateOrange-700 bg-templateOrange-100 dark:text-templateOrange-100 dark:bg-templateOrange-700', 23 | 'shutting-down': 24 | 'inline-flex px-2 text-xs font-medium leading-5 rounded-full text-templateBlue-700 bg-templateBlue-100 dark:text-templateBlue-100 dark:bg-templateBlue-700', 25 | terminated: 26 | 'inline-flex px-2 text-xs font-medium leading-5 rounded-full text-templateGray-700 bg-templateGray-100 dark:text-templateGray-100 dark:bg-templateGray-700', 27 | }; 28 | 29 | const buttonStyles = { 30 | running: 31 | 'align-bottom inline-flex items-center justify-center cursor-pointer leading-5 transition-colors duration-150 font-medium focus:outline-none px-4 py-2 rounded-lg text-sm text-templateGray-600 border-templateRed-700 border dark:text-templateGray-400 focus:outline-none active:bg-transparent hover:border-templateRed-500 focus:border-templateRed-500 active:text-templateRed-500 focus:shadow-outline-red', 32 | stopped: 33 | 'align-bottom inline-flex items-center justify-center cursor-pointer leading-5 transition-colors duration-150 font-medium focus:outline-none px-4 py-2 rounded-lg text-sm text-templateGray-600 border-templateGreen-700 border dark:text-templateGray-400 focus:outline-none active:bg-transparent hover:border-templateGreen-500 focus:border-templateGreen-500 active:text-templateGray-500 focus:shadow-outline-gray', 34 | pending: 35 | 'align-bottom inline-flex items-center justify-center cursor-pointer leading-5 transition-colors duration-150 font-medium focus:outline-none px-4 py-2 rounded-lg text-sm text-templateGray-600 border-templateGray-300 border dark:text-templateGray-400 focus:outline-none opacity-50 cursor-not-allowed bg-templateGray-300', 36 | stopping: 37 | 'align-bottom inline-flex items-center justify-center cursor-pointer leading-5 transition-colors duration-150 font-medium focus:outline-none px-4 py-2 rounded-lg text-sm text-templateGray-600 border-templateGray-300 border dark:text-templateGray-400 focus:outline-none opacity-50 cursor-not-allowed bg-templateGray-300', 38 | }; 39 | 40 | function Tables({ instanceList }) { 41 | const { search } = useContext(SearchBarContext); 42 | 43 | function handleStop(instanceIds) { 44 | if (!Array.isArray(instanceIds)) { 45 | instanceIds = [instanceIds]; 46 | } 47 | const data = { 48 | instanceIds: instanceIds, 49 | }; 50 | 51 | fetch('/ec2/stopInstance', { 52 | method: 'POST', 53 | headers: { 54 | 'Content-Type': 'application/json', 55 | }, 56 | body: JSON.stringify(data), 57 | }) 58 | .then((res) => res.json()) 59 | .then((data) => console.log(data)); 60 | } 61 | 62 | function handleStart(instanceIds) { 63 | if (!Array.isArray(instanceIds)) { 64 | instanceIds = [instanceIds]; 65 | } 66 | const data = { 67 | instanceIds: instanceIds, 68 | }; 69 | 70 | fetch('/ec2/startInstance', { 71 | method: 'POST', 72 | headers: { 73 | 'Content-Type': 'application/json', 74 | }, 75 | body: JSON.stringify(data), 76 | }) 77 | .then((res) => res.json()) 78 | .then((data) => console.log(data)); 79 | } 80 | 81 | return ( 82 | <> 83 |
84 |
88 | 89 | 90 | 91 | 92 | Instance 93 | ID 94 | Security Groups 95 | Status 96 | Action 97 | 98 | 99 | 100 | {instanceList.map((instance, i) => { 101 | const nameTag = instance.tags.find( 102 | (tag) => tag.Key === 'Name' 103 | ); 104 | 105 | if ( 106 | nameTag?.Value?.toUpperCase().includes( 107 | search.toUpperCase() 108 | ) || 109 | instance.instanceId 110 | .toUpperCase() 111 | .includes(search.toUpperCase()) || 112 | instance.securityGroups.some((group) => 113 | group.GroupName.toUpperCase().includes( 114 | search.toUpperCase() 115 | ) 116 | ) 117 | ) { 118 | return ( 119 | 120 | 121 |
122 |
123 |

{nameTag.Value}

124 |
125 |
126 |
127 | 128 | {instance.instanceId} 129 | 130 | 131 | {instance.securityGroups.map((group, i) => { 132 | if (i === instance.securityGroups.length - 1) { 133 | return {group.GroupName}; 134 | } else { 135 | return {group.GroupName}, ; 136 | } 137 | })} 138 | 139 | 140 | 141 | {instance.state.Name} 142 | 143 | 144 | 145 | {instance.state.Name !== 'shutting-down' && 146 | instance.state.Name !== 'terminated' && ( 147 | 163 | )} 164 | 165 |
166 | ); 167 | } 168 | })} 169 |
170 |
171 | 172 |
173 |
174 |
175 | 176 | ); 177 | } 178 | 179 | export default Tables; 180 | -------------------------------------------------------------------------------- /src/client/Components/ScheduleTable.jsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useEffect, useState } from 'react'; 2 | import { 3 | Table, 4 | TableHeader, 5 | TableCell, 6 | TableBody, 7 | TableRow, 8 | TableContainer, 9 | } from '@windmill/react-ui'; 10 | 11 | import dayjs from 'dayjs'; 12 | import { DemoContainer } from '@mui/x-date-pickers/internals/demo'; 13 | import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; 14 | import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; 15 | import { TimePicker } from '@mui/x-date-pickers/TimePicker'; 16 | import { useQuery } from '@tanstack/react-query'; 17 | 18 | import { SearchBarContext } from '../Containers/MainContainer'; 19 | 20 | function ScheduleTable({ instanceList }) { 21 | const { search } = useContext(SearchBarContext); 22 | 23 | let initialSchedule = {}; 24 | 25 | instanceList.map((instance) => { 26 | initialSchedule[instance.instanceId] = { 27 | startHour: null, 28 | startMinute: null, 29 | startDate: null, 30 | stopHour: null, 31 | stopMinute: null, 32 | stopDate: null, 33 | dayOfWeek: [], 34 | }; 35 | }); 36 | 37 | const [schedule, setSchedule] = useState(initialSchedule); 38 | // const [savedSchedule, setSavedSchedule] = useState('') -- for rerendering upon schedule clear 39 | 40 | const { isPending, error, data, refetch } = useQuery({ 41 | queryKey: ['scheduleData'], 42 | queryFn: async () => { 43 | return await fetch('/scheduler/scheduleJob/') 44 | .then((res) => res.json()) 45 | .then((data) => { 46 | const newSchedule = Object.assign({}, { ...schedule }); 47 | if (data.length) { 48 | data.map((scheduleItem) => { 49 | const cronSchedule = scheduleItem.cronSchedule.split(' '); 50 | const minute = cronSchedule[0]; 51 | const hour = cronSchedule[1]; 52 | const dayOfWeek = cronSchedule[4].split(','); 53 | const instanceId = scheduleItem.instanceId[0]; 54 | const jobType = scheduleItem.jobType; 55 | 56 | if (jobType === 'startInstance') { 57 | newSchedule[instanceId].startHour = hour; 58 | newSchedule[instanceId].startMinute = minute; 59 | newSchedule[instanceId].startDate = new Date(); 60 | newSchedule[instanceId].startDate.setHours(hour); 61 | newSchedule[instanceId].startDate.setMinutes(minute); 62 | } 63 | 64 | if (jobType === 'stopInstance') { 65 | newSchedule[instanceId].stopHour = hour; 66 | newSchedule[instanceId].stopMinute = minute; 67 | newSchedule[instanceId].stopDate = new Date(); 68 | newSchedule[instanceId].stopDate.setHours(hour); 69 | newSchedule[instanceId].stopDate.setMinutes(minute); 70 | } 71 | newSchedule[instanceId].dayOfWeek = dayOfWeek; 72 | setSchedule(newSchedule); 73 | }); 74 | } 75 | return data; 76 | }); 77 | }, 78 | }); 79 | 80 | async function saveSchedule(e) { 81 | const instanceToBeSaved = e.target.id.split('|')[0]; 82 | if (schedule.hasOwnProperty(instanceToBeSaved)) { 83 | const headers = new Headers(); 84 | headers.append('Content-Type', 'application/json'); 85 | console.log(schedule[instanceToBeSaved]); 86 | 87 | if ( 88 | schedule[instanceToBeSaved].startHour !== null || 89 | schedule[instanceToBeSaved].startMinute !== null 90 | ) { 91 | const startBody = JSON.stringify({ 92 | minute: schedule[instanceToBeSaved].startMinute, 93 | hour: schedule[instanceToBeSaved].startHour, 94 | timezone: 'America/New_York', 95 | dayOfWeek: schedule[instanceToBeSaved].dayOfWeek, 96 | jobAction: 'startInstance', 97 | instanceIds: [instanceToBeSaved], 98 | }); 99 | 100 | const startRequestOptions = { 101 | method: 'POST', 102 | headers: headers, 103 | body: startBody, 104 | redirect: 'follow', 105 | }; 106 | await fetch('/scheduler/scheduleJob', startRequestOptions); 107 | } 108 | 109 | if ( 110 | schedule[instanceToBeSaved].stopHour !== null || 111 | schedule[instanceToBeSaved].stopMinute !== null 112 | ) { 113 | const headers = new Headers(); 114 | headers.append('Content-Type', 'application/json'); 115 | 116 | const stopBody = JSON.stringify({ 117 | minute: schedule[instanceToBeSaved].stopMinute, 118 | hour: schedule[instanceToBeSaved].stopHour, 119 | timezone: 'America/New_York', 120 | dayOfWeek: schedule[instanceToBeSaved].dayOfWeek, 121 | jobAction: 'stopInstance', 122 | instanceIds: [instanceToBeSaved], 123 | }); 124 | 125 | const requestOptions = { 126 | method: 'POST', 127 | headers: headers, 128 | body: stopBody, 129 | redirect: 'follow', 130 | }; 131 | 132 | await fetch('/scheduler/scheduleJob', requestOptions); 133 | refetch(); 134 | alert('Schedule saved'); 135 | } 136 | } else { 137 | alert('No schedule set for this instance'); 138 | } 139 | } 140 | 141 | async function deleteSchedule(e) { 142 | const instanceToClearSchedule = e.target.id.split('|')[0]; 143 | if (schedule.hasOwnProperty(instanceToClearSchedule)) { 144 | const headers = new Headers(); 145 | headers.append('Content-Type', 'application/json'); 146 | 147 | const deleteStartBody = JSON.stringify({ 148 | jobAction: 'startInstance', 149 | instanceIds: [instanceToClearSchedule], 150 | }); 151 | 152 | const deleteStartOptions = { 153 | method: 'DELETE', 154 | headers: headers, 155 | body: deleteStartBody, 156 | redirect: 'follow', 157 | }; 158 | 159 | await fetch('/scheduler/scheduleJob', deleteStartOptions); 160 | 161 | const deleteStopBody = JSON.stringify({ 162 | jobAction: 'stopInstance', 163 | instanceIds: [instanceToClearSchedule], 164 | }); 165 | 166 | const deleteStopOptions = { 167 | method: 'DELETE', 168 | headers: headers, 169 | body: deleteStopBody, 170 | redirect: 'follow', 171 | }; 172 | 173 | await fetch('/scheduler/scheduleJob', deleteStopOptions); 174 | 175 | const newSchedule = Object.assign({}, { ...schedule }); 176 | newSchedule[instanceToClearSchedule].startHour = null; 177 | newSchedule[instanceToClearSchedule].startMinute = null; 178 | newSchedule[instanceToClearSchedule].startDate = null; 179 | newSchedule[instanceToClearSchedule].stopHour = null; 180 | newSchedule[instanceToClearSchedule].stopMinute = null; 181 | newSchedule[instanceToClearSchedule].stopDate = null; 182 | newSchedule[instanceToClearSchedule].dayOfWeek = []; 183 | setSchedule(newSchedule); 184 | refetch(); 185 | } else { 186 | alert('No schedule set for this instance'); 187 | } 188 | } 189 | 190 | function handleCheckboxClick(e) { 191 | const instanceToBeSaved = e.target.id.split('|')[1]; 192 | const dayToBeAdded = e.target.id.split('|')[0]; 193 | const checked = e.target.checked; 194 | const newSchedule = Object.assign({}, { ...schedule }); 195 | 196 | if (!newSchedule.hasOwnProperty(instanceToBeSaved)) { 197 | newSchedule[instanceToBeSaved] = { 198 | startHour: null, 199 | startMinute: null, 200 | stopHour: null, 201 | stopMinute: null, 202 | dayOfWeek: [], 203 | }; 204 | } 205 | 206 | if (checked) { 207 | newSchedule[instanceToBeSaved].dayOfWeek.push(dayToBeAdded); 208 | } else { 209 | newSchedule[instanceToBeSaved].dayOfWeek = newSchedule[ 210 | instanceToBeSaved 211 | ].dayOfWeek.filter((day) => day !== dayToBeAdded); 212 | } 213 | 214 | setSchedule(newSchedule); 215 | } 216 | 217 | if (isPending) return 'Loading...'; 218 | 219 | if (error) return 'An error has occurred: ' + error.message; 220 | 221 | return ( 222 |
223 | 224 | 225 | 226 | 227 | Instance Name 228 | Instance ID 229 | Start Time 230 | Stop Time 231 | Days 232 | 233 | 234 | 235 | 236 | {instanceList.map((instance, i) => { 237 | const nameTag = instance.tags.find((tag) => tag.Key === 'Name'); 238 | if ( 239 | nameTag?.Value?.toUpperCase().includes(search.toUpperCase()) || 240 | instance.instanceId.toUpperCase().includes(search.toUpperCase()) 241 | ) { 242 | return ( 243 | 244 | 245 |
246 |
247 |

{nameTag.Value}

248 |
249 |
250 |
251 | 252 | {instance.instanceId} 253 | 254 | 255 | 256 | 257 |
258 | { 269 | const newSchedule = Object.assign( 270 | {}, 271 | { ...schedule } 272 | ); 273 | newSchedule[instance.instanceId].startHour = 274 | value.$H; 275 | newSchedule[instance.instanceId].startMinute = 276 | value.$m; 277 | 278 | setSchedule(newSchedule); 279 | }} 280 | id={instance.instanceId + '-start-time'} 281 | /> 282 |
283 |
284 |
285 |
286 | 287 | 288 | 289 |
290 | { 300 | const newSchedule = Object.assign( 301 | {}, 302 | { ...schedule } 303 | ); 304 | newSchedule[instance.instanceId].stopHour = 305 | value.$H; 306 | newSchedule[instance.instanceId].stopMinute = 307 | value.$m; 308 | 309 | setSchedule(newSchedule); 310 | }} 311 | id={instance.instanceId + '-stop-time'} 312 | /> 313 |
314 |
315 |
316 |
317 | 318 |
319 | 333 | 337 |
338 | 352 | 356 |
357 | 371 | 375 |
376 | 390 | 394 |
395 | 409 | 413 |
414 | 428 | 432 |
433 | 447 | 451 |
452 |
453 |
454 | 455 | 464 |
465 | 474 |
475 |
476 | ); 477 | } 478 | })} 479 |
480 |
481 |
482 |
483 | ); 484 | } 485 | 486 | export default ScheduleTable; 487 | -------------------------------------------------------------------------------- /src/client/tailwind.output.css: -------------------------------------------------------------------------------- 1 | *, ::before, ::after { 2 | --tw-border-spacing-x: 0; 3 | --tw-border-spacing-y: 0; 4 | --tw-translate-x: 0; 5 | --tw-translate-y: 0; 6 | --tw-rotate: 0; 7 | --tw-skew-x: 0; 8 | --tw-skew-y: 0; 9 | --tw-scale-x: 1; 10 | --tw-scale-y: 1; 11 | --tw-pan-x: ; 12 | --tw-pan-y: ; 13 | --tw-pinch-zoom: ; 14 | --tw-scroll-snap-strictness: proximity; 15 | --tw-gradient-from-position: ; 16 | --tw-gradient-via-position: ; 17 | --tw-gradient-to-position: ; 18 | --tw-ordinal: ; 19 | --tw-slashed-zero: ; 20 | --tw-numeric-figure: ; 21 | --tw-numeric-spacing: ; 22 | --tw-numeric-fraction: ; 23 | --tw-ring-inset: ; 24 | --tw-ring-offset-width: 0px; 25 | --tw-ring-offset-color: #fff; 26 | --tw-ring-color: rgb(59 130 246 / 0.5); 27 | --tw-ring-offset-shadow: 0 0 #0000; 28 | --tw-ring-shadow: 0 0 #0000; 29 | --tw-shadow: 0 0 #0000; 30 | --tw-shadow-colored: 0 0 #0000; 31 | --tw-blur: ; 32 | --tw-brightness: ; 33 | --tw-contrast: ; 34 | --tw-grayscale: ; 35 | --tw-hue-rotate: ; 36 | --tw-invert: ; 37 | --tw-saturate: ; 38 | --tw-sepia: ; 39 | --tw-drop-shadow: ; 40 | --tw-backdrop-blur: ; 41 | --tw-backdrop-brightness: ; 42 | --tw-backdrop-contrast: ; 43 | --tw-backdrop-grayscale: ; 44 | --tw-backdrop-hue-rotate: ; 45 | --tw-backdrop-invert: ; 46 | --tw-backdrop-opacity: ; 47 | --tw-backdrop-saturate: ; 48 | --tw-backdrop-sepia: ; 49 | --tw-contain-size: ; 50 | --tw-contain-layout: ; 51 | --tw-contain-paint: ; 52 | --tw-contain-style: ; 53 | } 54 | 55 | ::backdrop { 56 | --tw-border-spacing-x: 0; 57 | --tw-border-spacing-y: 0; 58 | --tw-translate-x: 0; 59 | --tw-translate-y: 0; 60 | --tw-rotate: 0; 61 | --tw-skew-x: 0; 62 | --tw-skew-y: 0; 63 | --tw-scale-x: 1; 64 | --tw-scale-y: 1; 65 | --tw-pan-x: ; 66 | --tw-pan-y: ; 67 | --tw-pinch-zoom: ; 68 | --tw-scroll-snap-strictness: proximity; 69 | --tw-gradient-from-position: ; 70 | --tw-gradient-via-position: ; 71 | --tw-gradient-to-position: ; 72 | --tw-ordinal: ; 73 | --tw-slashed-zero: ; 74 | --tw-numeric-figure: ; 75 | --tw-numeric-spacing: ; 76 | --tw-numeric-fraction: ; 77 | --tw-ring-inset: ; 78 | --tw-ring-offset-width: 0px; 79 | --tw-ring-offset-color: #fff; 80 | --tw-ring-color: rgb(59 130 246 / 0.5); 81 | --tw-ring-offset-shadow: 0 0 #0000; 82 | --tw-ring-shadow: 0 0 #0000; 83 | --tw-shadow: 0 0 #0000; 84 | --tw-shadow-colored: 0 0 #0000; 85 | --tw-blur: ; 86 | --tw-brightness: ; 87 | --tw-contrast: ; 88 | --tw-grayscale: ; 89 | --tw-hue-rotate: ; 90 | --tw-invert: ; 91 | --tw-saturate: ; 92 | --tw-sepia: ; 93 | --tw-drop-shadow: ; 94 | --tw-backdrop-blur: ; 95 | --tw-backdrop-brightness: ; 96 | --tw-backdrop-contrast: ; 97 | --tw-backdrop-grayscale: ; 98 | --tw-backdrop-hue-rotate: ; 99 | --tw-backdrop-invert: ; 100 | --tw-backdrop-opacity: ; 101 | --tw-backdrop-saturate: ; 102 | --tw-backdrop-sepia: ; 103 | --tw-contain-size: ; 104 | --tw-contain-layout: ; 105 | --tw-contain-paint: ; 106 | --tw-contain-style: ; 107 | } 108 | 109 | /* 110 | ! tailwindcss v3.4.13 | MIT License | https://tailwindcss.com 111 | */ 112 | 113 | /* 114 | 1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) 115 | 2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116) 116 | */ 117 | 118 | *, 119 | ::before, 120 | ::after { 121 | box-sizing: border-box; 122 | /* 1 */ 123 | border-width: 0; 124 | /* 2 */ 125 | border-style: solid; 126 | /* 2 */ 127 | border-color: #e5e7eb; 128 | /* 2 */ 129 | } 130 | 131 | ::before, 132 | ::after { 133 | --tw-content: ''; 134 | } 135 | 136 | /* 137 | 1. Use a consistent sensible line-height in all browsers. 138 | 2. Prevent adjustments of font size after orientation changes in iOS. 139 | 3. Use a more readable tab size. 140 | 4. Use the user's configured `sans` font-family by default. 141 | 5. Use the user's configured `sans` font-feature-settings by default. 142 | 6. Use the user's configured `sans` font-variation-settings by default. 143 | 7. Disable tap highlights on iOS 144 | */ 145 | 146 | html, 147 | :host { 148 | line-height: 1.5; 149 | /* 1 */ 150 | -webkit-text-size-adjust: 100%; 151 | /* 2 */ 152 | -moz-tab-size: 4; 153 | /* 3 */ 154 | -o-tab-size: 4; 155 | tab-size: 4; 156 | /* 3 */ 157 | font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 158 | /* 4 */ 159 | font-feature-settings: normal; 160 | /* 5 */ 161 | font-variation-settings: normal; 162 | /* 6 */ 163 | -webkit-tap-highlight-color: transparent; 164 | /* 7 */ 165 | } 166 | 167 | /* 168 | 1. Remove the margin in all browsers. 169 | 2. Inherit line-height from `html` so users can set them as a class directly on the `html` element. 170 | */ 171 | 172 | body { 173 | margin: 0; 174 | /* 1 */ 175 | line-height: inherit; 176 | /* 2 */ 177 | } 178 | 179 | /* 180 | 1. Add the correct height in Firefox. 181 | 2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) 182 | 3. Ensure horizontal rules are visible by default. 183 | */ 184 | 185 | hr { 186 | height: 0; 187 | /* 1 */ 188 | color: inherit; 189 | /* 2 */ 190 | border-top-width: 1px; 191 | /* 3 */ 192 | } 193 | 194 | /* 195 | Add the correct text decoration in Chrome, Edge, and Safari. 196 | */ 197 | 198 | abbr:where([title]) { 199 | -webkit-text-decoration: underline dotted; 200 | text-decoration: underline dotted; 201 | } 202 | 203 | /* 204 | Remove the default font size and weight for headings. 205 | */ 206 | 207 | h1, 208 | h2, 209 | h3, 210 | h4, 211 | h5, 212 | h6 { 213 | font-size: inherit; 214 | font-weight: inherit; 215 | } 216 | 217 | /* 218 | Reset links to optimize for opt-in styling instead of opt-out. 219 | */ 220 | 221 | a { 222 | color: inherit; 223 | text-decoration: inherit; 224 | } 225 | 226 | /* 227 | Add the correct font weight in Edge and Safari. 228 | */ 229 | 230 | b, 231 | strong { 232 | font-weight: bolder; 233 | } 234 | 235 | /* 236 | 1. Use the user's configured `mono` font-family by default. 237 | 2. Use the user's configured `mono` font-feature-settings by default. 238 | 3. Use the user's configured `mono` font-variation-settings by default. 239 | 4. Correct the odd `em` font sizing in all browsers. 240 | */ 241 | 242 | code, 243 | kbd, 244 | samp, 245 | pre { 246 | font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; 247 | /* 1 */ 248 | font-feature-settings: normal; 249 | /* 2 */ 250 | font-variation-settings: normal; 251 | /* 3 */ 252 | font-size: 1em; 253 | /* 4 */ 254 | } 255 | 256 | /* 257 | Add the correct font size in all browsers. 258 | */ 259 | 260 | small { 261 | font-size: 80%; 262 | } 263 | 264 | /* 265 | Prevent `sub` and `sup` elements from affecting the line height in all browsers. 266 | */ 267 | 268 | sub, 269 | sup { 270 | font-size: 75%; 271 | line-height: 0; 272 | position: relative; 273 | vertical-align: baseline; 274 | } 275 | 276 | sub { 277 | bottom: -0.25em; 278 | } 279 | 280 | sup { 281 | top: -0.5em; 282 | } 283 | 284 | /* 285 | 1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) 286 | 2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) 287 | 3. Remove gaps between table borders by default. 288 | */ 289 | 290 | table { 291 | text-indent: 0; 292 | /* 1 */ 293 | border-color: inherit; 294 | /* 2 */ 295 | border-collapse: collapse; 296 | /* 3 */ 297 | } 298 | 299 | /* 300 | 1. Change the font styles in all browsers. 301 | 2. Remove the margin in Firefox and Safari. 302 | 3. Remove default padding in all browsers. 303 | */ 304 | 305 | button, 306 | input, 307 | optgroup, 308 | select, 309 | textarea { 310 | font-family: inherit; 311 | /* 1 */ 312 | font-feature-settings: inherit; 313 | /* 1 */ 314 | font-variation-settings: inherit; 315 | /* 1 */ 316 | font-size: 100%; 317 | /* 1 */ 318 | font-weight: inherit; 319 | /* 1 */ 320 | line-height: inherit; 321 | /* 1 */ 322 | letter-spacing: inherit; 323 | /* 1 */ 324 | color: inherit; 325 | /* 1 */ 326 | margin: 0; 327 | /* 2 */ 328 | padding: 0; 329 | /* 3 */ 330 | } 331 | 332 | /* 333 | Remove the inheritance of text transform in Edge and Firefox. 334 | */ 335 | 336 | button, 337 | select { 338 | text-transform: none; 339 | } 340 | 341 | /* 342 | 1. Correct the inability to style clickable types in iOS and Safari. 343 | 2. Remove default button styles. 344 | */ 345 | 346 | button, 347 | input:where([type='button']), 348 | input:where([type='reset']), 349 | input:where([type='submit']) { 350 | -webkit-appearance: button; 351 | /* 1 */ 352 | background-color: transparent; 353 | /* 2 */ 354 | background-image: none; 355 | /* 2 */ 356 | } 357 | 358 | /* 359 | Use the modern Firefox focus style for all focusable elements. 360 | */ 361 | 362 | :-moz-focusring { 363 | outline: auto; 364 | } 365 | 366 | /* 367 | Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) 368 | */ 369 | 370 | :-moz-ui-invalid { 371 | box-shadow: none; 372 | } 373 | 374 | /* 375 | Add the correct vertical alignment in Chrome and Firefox. 376 | */ 377 | 378 | progress { 379 | vertical-align: baseline; 380 | } 381 | 382 | /* 383 | Correct the cursor style of increment and decrement buttons in Safari. 384 | */ 385 | 386 | ::-webkit-inner-spin-button, 387 | ::-webkit-outer-spin-button { 388 | height: auto; 389 | } 390 | 391 | /* 392 | 1. Correct the odd appearance in Chrome and Safari. 393 | 2. Correct the outline style in Safari. 394 | */ 395 | 396 | [type='search'] { 397 | -webkit-appearance: textfield; 398 | /* 1 */ 399 | outline-offset: -2px; 400 | /* 2 */ 401 | } 402 | 403 | /* 404 | Remove the inner padding in Chrome and Safari on macOS. 405 | */ 406 | 407 | ::-webkit-search-decoration { 408 | -webkit-appearance: none; 409 | } 410 | 411 | /* 412 | 1. Correct the inability to style clickable types in iOS and Safari. 413 | 2. Change font properties to `inherit` in Safari. 414 | */ 415 | 416 | ::-webkit-file-upload-button { 417 | -webkit-appearance: button; 418 | /* 1 */ 419 | font: inherit; 420 | /* 2 */ 421 | } 422 | 423 | /* 424 | Add the correct display in Chrome and Safari. 425 | */ 426 | 427 | summary { 428 | display: list-item; 429 | } 430 | 431 | /* 432 | Removes the default spacing and border for appropriate elements. 433 | */ 434 | 435 | blockquote, 436 | dl, 437 | dd, 438 | h1, 439 | h2, 440 | h3, 441 | h4, 442 | h5, 443 | h6, 444 | hr, 445 | figure, 446 | p, 447 | pre { 448 | margin: 0; 449 | } 450 | 451 | fieldset { 452 | margin: 0; 453 | padding: 0; 454 | } 455 | 456 | legend { 457 | padding: 0; 458 | } 459 | 460 | ol, 461 | ul, 462 | menu { 463 | list-style: none; 464 | margin: 0; 465 | padding: 0; 466 | } 467 | 468 | /* 469 | Reset default styling for dialogs. 470 | */ 471 | 472 | dialog { 473 | padding: 0; 474 | } 475 | 476 | /* 477 | Prevent resizing textareas horizontally by default. 478 | */ 479 | 480 | textarea { 481 | resize: vertical; 482 | } 483 | 484 | /* 485 | 1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) 486 | 2. Set the default placeholder color to the user's configured gray 400 color. 487 | */ 488 | 489 | input::-moz-placeholder, textarea::-moz-placeholder { 490 | opacity: 1; 491 | /* 1 */ 492 | color: #9ca3af; 493 | /* 2 */ 494 | } 495 | 496 | input::placeholder, 497 | textarea::placeholder { 498 | opacity: 1; 499 | /* 1 */ 500 | color: #9ca3af; 501 | /* 2 */ 502 | } 503 | 504 | /* 505 | Set the default cursor for buttons. 506 | */ 507 | 508 | button, 509 | [role="button"] { 510 | cursor: pointer; 511 | } 512 | 513 | /* 514 | Make sure disabled buttons don't get the pointer cursor. 515 | */ 516 | 517 | :disabled { 518 | cursor: default; 519 | } 520 | 521 | /* 522 | 1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14) 523 | 2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) 524 | This can trigger a poorly considered lint error in some tools but is included by design. 525 | */ 526 | 527 | img, 528 | svg, 529 | video, 530 | canvas, 531 | audio, 532 | iframe, 533 | embed, 534 | object { 535 | display: block; 536 | /* 1 */ 537 | vertical-align: middle; 538 | /* 2 */ 539 | } 540 | 541 | /* 542 | Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) 543 | */ 544 | 545 | img, 546 | video { 547 | max-width: 100%; 548 | height: auto; 549 | } 550 | 551 | /* Make elements with the HTML hidden attribute stay hidden by default */ 552 | 553 | [hidden] { 554 | display: none; 555 | } 556 | 557 | [type='text'],input:where(:not([type])),[type='email'],[type='url'],[type='password'],[type='number'],[type='date'],[type='datetime-local'],[type='month'],[type='search'],[type='tel'],[type='time'],[type='week'],[multiple],textarea,select { 558 | -webkit-appearance: none; 559 | -moz-appearance: none; 560 | appearance: none; 561 | background-color: #fff; 562 | border-color: #6b7280; 563 | border-width: 1px; 564 | border-radius: 0px; 565 | padding-top: 0.5rem; 566 | padding-right: 0.75rem; 567 | padding-bottom: 0.5rem; 568 | padding-left: 0.75rem; 569 | font-size: 1rem; 570 | line-height: 1.5rem; 571 | --tw-shadow: 0 0 #0000; 572 | } 573 | 574 | [type='text']:focus, input:where(:not([type])):focus, [type='email']:focus, [type='url']:focus, [type='password']:focus, [type='number']:focus, [type='date']:focus, [type='datetime-local']:focus, [type='month']:focus, [type='search']:focus, [type='tel']:focus, [type='time']:focus, [type='week']:focus, [multiple]:focus, textarea:focus, select:focus { 575 | outline: 2px solid transparent; 576 | outline-offset: 2px; 577 | --tw-ring-inset: var(--tw-empty,/*!*/ /*!*/); 578 | --tw-ring-offset-width: 0px; 579 | --tw-ring-offset-color: #fff; 580 | --tw-ring-color: #2563eb; 581 | --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); 582 | --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color); 583 | box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); 584 | border-color: #2563eb; 585 | } 586 | 587 | input::-moz-placeholder, textarea::-moz-placeholder { 588 | color: #6b7280; 589 | opacity: 1; 590 | } 591 | 592 | input::placeholder,textarea::placeholder { 593 | color: #6b7280; 594 | opacity: 1; 595 | } 596 | 597 | ::-webkit-datetime-edit-fields-wrapper { 598 | padding: 0; 599 | } 600 | 601 | ::-webkit-date-and-time-value { 602 | min-height: 1.5em; 603 | text-align: inherit; 604 | } 605 | 606 | ::-webkit-datetime-edit { 607 | display: inline-flex; 608 | } 609 | 610 | ::-webkit-datetime-edit,::-webkit-datetime-edit-year-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-meridiem-field { 611 | padding-top: 0; 612 | padding-bottom: 0; 613 | } 614 | 615 | select { 616 | background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e"); 617 | background-position: right 0.5rem center; 618 | background-repeat: no-repeat; 619 | background-size: 1.5em 1.5em; 620 | padding-right: 2.5rem; 621 | -webkit-print-color-adjust: exact; 622 | print-color-adjust: exact; 623 | } 624 | 625 | [multiple],[size]:where(select:not([size="1"])) { 626 | background-image: initial; 627 | background-position: initial; 628 | background-repeat: unset; 629 | background-size: initial; 630 | padding-right: 0.75rem; 631 | -webkit-print-color-adjust: unset; 632 | print-color-adjust: unset; 633 | } 634 | 635 | [type='checkbox'],[type='radio'] { 636 | -webkit-appearance: none; 637 | -moz-appearance: none; 638 | appearance: none; 639 | padding: 0; 640 | -webkit-print-color-adjust: exact; 641 | print-color-adjust: exact; 642 | display: inline-block; 643 | vertical-align: middle; 644 | background-origin: border-box; 645 | -webkit-user-select: none; 646 | -moz-user-select: none; 647 | user-select: none; 648 | flex-shrink: 0; 649 | height: 1rem; 650 | width: 1rem; 651 | color: #2563eb; 652 | background-color: #fff; 653 | border-color: #6b7280; 654 | border-width: 1px; 655 | --tw-shadow: 0 0 #0000; 656 | } 657 | 658 | [type='checkbox'] { 659 | border-radius: 0px; 660 | } 661 | 662 | [type='radio'] { 663 | border-radius: 100%; 664 | } 665 | 666 | [type='checkbox']:focus,[type='radio']:focus { 667 | outline: 2px solid transparent; 668 | outline-offset: 2px; 669 | --tw-ring-inset: var(--tw-empty,/*!*/ /*!*/); 670 | --tw-ring-offset-width: 2px; 671 | --tw-ring-offset-color: #fff; 672 | --tw-ring-color: #2563eb; 673 | --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); 674 | --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color); 675 | box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); 676 | } 677 | 678 | [type='checkbox']:checked,[type='radio']:checked { 679 | border-color: transparent; 680 | background-color: currentColor; 681 | background-size: 100% 100%; 682 | background-position: center; 683 | background-repeat: no-repeat; 684 | } 685 | 686 | [type='checkbox']:checked { 687 | background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e"); 688 | } 689 | 690 | @media (forced-colors: active) { 691 | [type='checkbox']:checked { 692 | -webkit-appearance: auto; 693 | -moz-appearance: auto; 694 | appearance: auto; 695 | } 696 | } 697 | 698 | [type='radio']:checked { 699 | background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle cx='8' cy='8' r='3'/%3e%3c/svg%3e"); 700 | } 701 | 702 | @media (forced-colors: active) { 703 | [type='radio']:checked { 704 | -webkit-appearance: auto; 705 | -moz-appearance: auto; 706 | appearance: auto; 707 | } 708 | } 709 | 710 | [type='checkbox']:checked:hover,[type='checkbox']:checked:focus,[type='radio']:checked:hover,[type='radio']:checked:focus { 711 | border-color: transparent; 712 | background-color: currentColor; 713 | } 714 | 715 | [type='checkbox']:indeterminate { 716 | background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3e%3cpath stroke='white' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3e%3c/svg%3e"); 717 | border-color: transparent; 718 | background-color: currentColor; 719 | background-size: 100% 100%; 720 | background-position: center; 721 | background-repeat: no-repeat; 722 | } 723 | 724 | @media (forced-colors: active) { 725 | [type='checkbox']:indeterminate { 726 | -webkit-appearance: auto; 727 | -moz-appearance: auto; 728 | appearance: auto; 729 | } 730 | } 731 | 732 | [type='checkbox']:indeterminate:hover,[type='checkbox']:indeterminate:focus { 733 | border-color: transparent; 734 | background-color: currentColor; 735 | } 736 | 737 | [type='file'] { 738 | background: unset; 739 | border-color: inherit; 740 | border-width: 0; 741 | border-radius: 0; 742 | padding: 0; 743 | font-size: unset; 744 | line-height: inherit; 745 | } 746 | 747 | [type='file']:focus { 748 | outline: 1px solid ButtonText; 749 | outline: 1px auto -webkit-focus-ring-color; 750 | } 751 | 752 | html { 753 | --tw-text-opacity: 1; 754 | color: rgb(220 221 222 / var(--tw-text-opacity)); 755 | } 756 | 757 | .container { 758 | width: 100%; 759 | } 760 | 761 | @media (min-width: 640px) { 762 | .container { 763 | max-width: 640px; 764 | } 765 | } 766 | 767 | @media (min-width: 768px) { 768 | .container { 769 | max-width: 768px; 770 | } 771 | } 772 | 773 | @media (min-width: 1024px) { 774 | .container { 775 | max-width: 1024px; 776 | } 777 | } 778 | 779 | @media (min-width: 1280px) { 780 | .container { 781 | max-width: 1280px; 782 | } 783 | } 784 | 785 | @media (min-width: 1536px) { 786 | .container { 787 | max-width: 1536px; 788 | } 789 | } 790 | 791 | .form-input,.form-textarea,.form-select,.form-multiselect { 792 | -webkit-appearance: none; 793 | -moz-appearance: none; 794 | appearance: none; 795 | background-color: #fff; 796 | border-color: #6b7280; 797 | border-width: 1px; 798 | border-radius: 0px; 799 | padding-top: 0.5rem; 800 | padding-right: 0.75rem; 801 | padding-bottom: 0.5rem; 802 | padding-left: 0.75rem; 803 | font-size: 1rem; 804 | line-height: 1.5rem; 805 | --tw-shadow: 0 0 #0000; 806 | } 807 | 808 | .form-input:focus, .form-textarea:focus, .form-select:focus, .form-multiselect:focus { 809 | outline: 2px solid transparent; 810 | outline-offset: 2px; 811 | --tw-ring-inset: var(--tw-empty,/*!*/ /*!*/); 812 | --tw-ring-offset-width: 0px; 813 | --tw-ring-offset-color: #fff; 814 | --tw-ring-color: #2563eb; 815 | --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); 816 | --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color); 817 | box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); 818 | border-color: #2563eb; 819 | } 820 | 821 | .form-input::-moz-placeholder, .form-textarea::-moz-placeholder { 822 | color: #6b7280; 823 | opacity: 1; 824 | } 825 | 826 | .form-input::placeholder,.form-textarea::placeholder { 827 | color: #6b7280; 828 | opacity: 1; 829 | } 830 | 831 | .form-input::-webkit-datetime-edit-fields-wrapper { 832 | padding: 0; 833 | } 834 | 835 | .form-input::-webkit-date-and-time-value { 836 | min-height: 1.5em; 837 | text-align: inherit; 838 | } 839 | 840 | .form-input::-webkit-datetime-edit { 841 | display: inline-flex; 842 | } 843 | 844 | .form-input::-webkit-datetime-edit,.form-input::-webkit-datetime-edit-year-field,.form-input::-webkit-datetime-edit-month-field,.form-input::-webkit-datetime-edit-day-field,.form-input::-webkit-datetime-edit-hour-field,.form-input::-webkit-datetime-edit-minute-field,.form-input::-webkit-datetime-edit-second-field,.form-input::-webkit-datetime-edit-millisecond-field,.form-input::-webkit-datetime-edit-meridiem-field { 845 | padding-top: 0; 846 | padding-bottom: 0; 847 | } 848 | 849 | .absolute { 850 | position: absolute; 851 | } 852 | 853 | .relative { 854 | position: relative; 855 | } 856 | 857 | .inset-0 { 858 | inset: 0px; 859 | } 860 | 861 | .inset-y-0 { 862 | top: 0px; 863 | bottom: 0px; 864 | } 865 | 866 | .left-0 { 867 | left: 0px; 868 | } 869 | 870 | .right-0 { 871 | right: 0px; 872 | } 873 | 874 | .top-0 { 875 | top: 0px; 876 | } 877 | 878 | .z-30 { 879 | z-index: 30; 880 | } 881 | 882 | .z-40 { 883 | z-index: 40; 884 | } 885 | 886 | .mx-auto { 887 | margin-left: auto; 888 | margin-right: auto; 889 | } 890 | 891 | .-ml-1 { 892 | margin-left: -0.25rem; 893 | } 894 | 895 | .mb-8 { 896 | margin-bottom: 2rem; 897 | } 898 | 899 | .ml-4 { 900 | margin-left: 1rem; 901 | } 902 | 903 | .ml-6 { 904 | margin-left: 1.5rem; 905 | } 906 | 907 | .mr-5 { 908 | margin-right: 1.25rem; 909 | } 910 | 911 | .mr-6 { 912 | margin-right: 1.5rem; 913 | } 914 | 915 | .mt-6 { 916 | margin-top: 1.5rem; 917 | } 918 | 919 | .block { 920 | display: block; 921 | } 922 | 923 | .inline-block { 924 | display: inline-block; 925 | } 926 | 927 | .flex { 928 | display: flex; 929 | } 930 | 931 | .inline-flex { 932 | display: inline-flex; 933 | } 934 | 935 | .table { 936 | display: table; 937 | } 938 | 939 | .grid { 940 | display: grid; 941 | } 942 | 943 | .contents { 944 | display: contents; 945 | } 946 | 947 | .hidden { 948 | display: none; 949 | } 950 | 951 | .h-3 { 952 | height: 0.75rem; 953 | } 954 | 955 | .h-4 { 956 | height: 1rem; 957 | } 958 | 959 | .h-5 { 960 | height: 1.25rem; 961 | } 962 | 963 | .h-6 { 964 | height: 1.5rem; 965 | } 966 | 967 | .h-8 { 968 | height: 2rem; 969 | } 970 | 971 | .h-full { 972 | height: 100%; 973 | } 974 | 975 | .h-screen { 976 | height: 100vh; 977 | } 978 | 979 | .w-1 { 980 | width: 0.25rem; 981 | } 982 | 983 | .w-3 { 984 | width: 0.75rem; 985 | } 986 | 987 | .w-4 { 988 | width: 1rem; 989 | } 990 | 991 | .w-5 { 992 | width: 1.25rem; 993 | } 994 | 995 | .w-6 { 996 | width: 1.5rem; 997 | } 998 | 999 | .w-64 { 1000 | width: 16rem; 1001 | } 1002 | 1003 | .w-8 { 1004 | width: 2rem; 1005 | } 1006 | 1007 | .w-full { 1008 | width: 100%; 1009 | } 1010 | 1011 | .min-w-0 { 1012 | min-width: 0px; 1013 | } 1014 | 1015 | .max-w-xl { 1016 | max-width: 36rem; 1017 | } 1018 | 1019 | .flex-1 { 1020 | flex: 1 1 0%; 1021 | } 1022 | 1023 | .flex-shrink-0 { 1024 | flex-shrink: 0; 1025 | } 1026 | 1027 | .-translate-y-1 { 1028 | --tw-translate-y: -0.25rem; 1029 | transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); 1030 | } 1031 | 1032 | .translate-x-1 { 1033 | --tw-translate-x: 0.25rem; 1034 | transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); 1035 | } 1036 | 1037 | .transform { 1038 | transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); 1039 | } 1040 | 1041 | .cursor-not-allowed { 1042 | cursor: not-allowed; 1043 | } 1044 | 1045 | .cursor-pointer { 1046 | cursor: pointer; 1047 | } 1048 | 1049 | .flex-col { 1050 | flex-direction: column; 1051 | } 1052 | 1053 | .items-center { 1054 | align-items: center; 1055 | } 1056 | 1057 | .justify-start { 1058 | justify-content: flex-start; 1059 | } 1060 | 1061 | .justify-center { 1062 | justify-content: center; 1063 | } 1064 | 1065 | .justify-between { 1066 | justify-content: space-between; 1067 | } 1068 | 1069 | .gap-6 { 1070 | gap: 1.5rem; 1071 | } 1072 | 1073 | .space-x-6 > :not([hidden]) ~ :not([hidden]) { 1074 | --tw-space-x-reverse: 0; 1075 | margin-right: calc(1.5rem * var(--tw-space-x-reverse)); 1076 | margin-left: calc(1.5rem * calc(1 - var(--tw-space-x-reverse))); 1077 | } 1078 | 1079 | .overflow-y-auto { 1080 | overflow-y: auto; 1081 | } 1082 | 1083 | .rounded-full { 1084 | border-radius: 9999px; 1085 | } 1086 | 1087 | .rounded-lg { 1088 | border-radius: 0.5rem; 1089 | } 1090 | 1091 | .rounded-md { 1092 | border-radius: 0.375rem; 1093 | } 1094 | 1095 | .rounded-br-lg { 1096 | border-bottom-right-radius: 0.5rem; 1097 | } 1098 | 1099 | .rounded-tr-lg { 1100 | border-top-right-radius: 0.5rem; 1101 | } 1102 | 1103 | .border { 1104 | border-width: 1px; 1105 | } 1106 | 1107 | .border-2 { 1108 | border-width: 2px; 1109 | } 1110 | 1111 | .border-gray-300 { 1112 | --tw-border-opacity: 1; 1113 | border-color: rgb(209 213 219 / var(--tw-border-opacity)); 1114 | } 1115 | 1116 | .border-green-700 { 1117 | --tw-border-opacity: 1; 1118 | border-color: rgb(21 128 61 / var(--tw-border-opacity)); 1119 | } 1120 | 1121 | .border-red-700 { 1122 | --tw-border-opacity: 1; 1123 | border-color: rgb(185 28 28 / var(--tw-border-opacity)); 1124 | } 1125 | 1126 | .border-white { 1127 | --tw-border-opacity: 1; 1128 | border-color: rgb(255 255 255 / var(--tw-border-opacity)); 1129 | } 1130 | 1131 | .bg-blue-100 { 1132 | --tw-bg-opacity: 1; 1133 | background-color: rgb(219 234 254 / var(--tw-bg-opacity)); 1134 | } 1135 | 1136 | .bg-gray-100 { 1137 | --tw-bg-opacity: 1; 1138 | background-color: rgb(243 244 246 / var(--tw-bg-opacity)); 1139 | } 1140 | 1141 | .bg-gray-300 { 1142 | --tw-bg-opacity: 1; 1143 | background-color: rgb(209 213 219 / var(--tw-bg-opacity)); 1144 | } 1145 | 1146 | .bg-gray-50 { 1147 | --tw-bg-opacity: 1; 1148 | background-color: rgb(249 250 251 / var(--tw-bg-opacity)); 1149 | } 1150 | 1151 | .bg-green-100 { 1152 | --tw-bg-opacity: 1; 1153 | background-color: rgb(220 252 231 / var(--tw-bg-opacity)); 1154 | } 1155 | 1156 | .bg-orange-100 { 1157 | --tw-bg-opacity: 1; 1158 | background-color: rgb(255 237 213 / var(--tw-bg-opacity)); 1159 | } 1160 | 1161 | .bg-purple-600 { 1162 | --tw-bg-opacity: 1; 1163 | background-color: rgb(147 51 234 / var(--tw-bg-opacity)); 1164 | } 1165 | 1166 | .bg-red-100 { 1167 | --tw-bg-opacity: 1; 1168 | background-color: rgb(254 226 226 / var(--tw-bg-opacity)); 1169 | } 1170 | 1171 | .bg-templateRed-600 { 1172 | --tw-bg-opacity: 1; 1173 | background-color: rgb(224 36 36 / var(--tw-bg-opacity)); 1174 | } 1175 | 1176 | .bg-white { 1177 | --tw-bg-opacity: 1; 1178 | background-color: rgb(255 255 255 / var(--tw-bg-opacity)); 1179 | } 1180 | 1181 | .bg-yellow-100 { 1182 | --tw-bg-opacity: 1; 1183 | background-color: rgb(254 249 195 / var(--tw-bg-opacity)); 1184 | } 1185 | 1186 | .bg-opacity-70 { 1187 | --tw-bg-opacity: 0.7; 1188 | } 1189 | 1190 | .object-cover { 1191 | -o-object-fit: cover; 1192 | object-fit: cover; 1193 | } 1194 | 1195 | .p-1 { 1196 | padding: 0.25rem; 1197 | } 1198 | 1199 | .p-4 { 1200 | padding: 1rem; 1201 | } 1202 | 1203 | .px-2 { 1204 | padding-left: 0.5rem; 1205 | padding-right: 0.5rem; 1206 | } 1207 | 1208 | .px-4 { 1209 | padding-left: 1rem; 1210 | padding-right: 1rem; 1211 | } 1212 | 1213 | .px-6 { 1214 | padding-left: 1.5rem; 1215 | padding-right: 1.5rem; 1216 | } 1217 | 1218 | .py-2 { 1219 | padding-top: 0.5rem; 1220 | padding-bottom: 0.5rem; 1221 | } 1222 | 1223 | .py-3 { 1224 | padding-top: 0.75rem; 1225 | padding-bottom: 0.75rem; 1226 | } 1227 | 1228 | .py-4 { 1229 | padding-top: 1rem; 1230 | padding-bottom: 1rem; 1231 | } 1232 | 1233 | .pl-2 { 1234 | padding-left: 0.5rem; 1235 | } 1236 | 1237 | .pl-8 { 1238 | padding-left: 2rem; 1239 | } 1240 | 1241 | .align-middle { 1242 | vertical-align: middle; 1243 | } 1244 | 1245 | .align-bottom { 1246 | vertical-align: bottom; 1247 | } 1248 | 1249 | .text-lg { 1250 | font-size: 1.125rem; 1251 | line-height: 1.75rem; 1252 | } 1253 | 1254 | .text-sm { 1255 | font-size: 0.875rem; 1256 | line-height: 1.25rem; 1257 | } 1258 | 1259 | .text-xs { 1260 | font-size: 0.75rem; 1261 | line-height: 1rem; 1262 | } 1263 | 1264 | .font-bold { 1265 | font-weight: 700; 1266 | } 1267 | 1268 | .font-medium { 1269 | font-weight: 500; 1270 | } 1271 | 1272 | .font-semibold { 1273 | font-weight: 600; 1274 | } 1275 | 1276 | .leading-5 { 1277 | line-height: 1.25rem; 1278 | } 1279 | 1280 | .text-blue-700 { 1281 | --tw-text-opacity: 1; 1282 | color: rgb(29 78 216 / var(--tw-text-opacity)); 1283 | } 1284 | 1285 | .text-gray-500 { 1286 | --tw-text-opacity: 1; 1287 | color: rgb(107 114 128 / var(--tw-text-opacity)); 1288 | } 1289 | 1290 | .text-gray-600 { 1291 | --tw-text-opacity: 1; 1292 | color: rgb(75 85 99 / var(--tw-text-opacity)); 1293 | } 1294 | 1295 | .text-gray-700 { 1296 | --tw-text-opacity: 1; 1297 | color: rgb(55 65 81 / var(--tw-text-opacity)); 1298 | } 1299 | 1300 | .text-gray-800 { 1301 | --tw-text-opacity: 1; 1302 | color: rgb(31 41 55 / var(--tw-text-opacity)); 1303 | } 1304 | 1305 | .text-green-700 { 1306 | --tw-text-opacity: 1; 1307 | color: rgb(21 128 61 / var(--tw-text-opacity)); 1308 | } 1309 | 1310 | .text-orange-700 { 1311 | --tw-text-opacity: 1; 1312 | color: rgb(194 65 12 / var(--tw-text-opacity)); 1313 | } 1314 | 1315 | .text-red-700 { 1316 | --tw-text-opacity: 1; 1317 | color: rgb(185 28 28 / var(--tw-text-opacity)); 1318 | } 1319 | 1320 | .text-templateGray-700 { 1321 | --tw-text-opacity: 1; 1322 | color: rgb(36 38 45 / var(--tw-text-opacity)); 1323 | } 1324 | 1325 | .text-templatePurple-600 { 1326 | --tw-text-opacity: 1; 1327 | color: rgb(126 58 242 / var(--tw-text-opacity)); 1328 | } 1329 | 1330 | .text-yellow-800 { 1331 | --tw-text-opacity: 1; 1332 | color: rgb(133 77 14 / var(--tw-text-opacity)); 1333 | } 1334 | 1335 | .opacity-50 { 1336 | opacity: 0.5; 1337 | } 1338 | 1339 | .shadow-inner { 1340 | --tw-shadow: inset 0 2px 4px 0 rgb(0 0 0 / 0.05); 1341 | --tw-shadow-colored: inset 0 2px 4px 0 var(--tw-shadow-color); 1342 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 1343 | } 1344 | 1345 | .transition-colors { 1346 | transition-property: color, background-color, border-color, text-decoration-color, fill, stroke; 1347 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 1348 | transition-duration: 150ms; 1349 | } 1350 | 1351 | .duration-150 { 1352 | transition-duration: 150ms; 1353 | } 1354 | 1355 | .focus-within\:text-templatePurple-500:focus-within { 1356 | --tw-text-opacity: 1; 1357 | color: rgb(144 97 249 / var(--tw-text-opacity)); 1358 | } 1359 | 1360 | .hover\:cursor-pointer:hover { 1361 | cursor: pointer; 1362 | } 1363 | 1364 | .hover\:border-gray-500:hover { 1365 | --tw-border-opacity: 1; 1366 | border-color: rgb(107 114 128 / var(--tw-border-opacity)); 1367 | } 1368 | 1369 | .hover\:border-green-500:hover { 1370 | --tw-border-opacity: 1; 1371 | border-color: rgb(34 197 94 / var(--tw-border-opacity)); 1372 | } 1373 | 1374 | .hover\:border-red-500:hover { 1375 | --tw-border-opacity: 1; 1376 | border-color: rgb(239 68 68 / var(--tw-border-opacity)); 1377 | } 1378 | 1379 | .hover\:text-gray-800:hover { 1380 | --tw-text-opacity: 1; 1381 | color: rgb(31 41 55 / var(--tw-text-opacity)); 1382 | } 1383 | 1384 | .focus\:border-gray-500:focus { 1385 | --tw-border-opacity: 1; 1386 | border-color: rgb(107 114 128 / var(--tw-border-opacity)); 1387 | } 1388 | 1389 | .focus\:border-green-500:focus { 1390 | --tw-border-opacity: 1; 1391 | border-color: rgb(34 197 94 / var(--tw-border-opacity)); 1392 | } 1393 | 1394 | .focus\:border-red-500:focus { 1395 | --tw-border-opacity: 1; 1396 | border-color: rgb(239 68 68 / var(--tw-border-opacity)); 1397 | } 1398 | 1399 | .focus\:border-templatePurple-400:focus { 1400 | --tw-border-opacity: 1; 1401 | border-color: rgb(172 148 250 / var(--tw-border-opacity)); 1402 | } 1403 | 1404 | .focus\:outline-none:focus { 1405 | outline: 2px solid transparent; 1406 | outline-offset: 2px; 1407 | } 1408 | 1409 | .active\:bg-transparent:active { 1410 | background-color: transparent; 1411 | } 1412 | 1413 | .active\:text-gray-500:active { 1414 | --tw-text-opacity: 1; 1415 | color: rgb(107 114 128 / var(--tw-text-opacity)); 1416 | } 1417 | 1418 | .active\:text-red-500:active { 1419 | --tw-text-opacity: 1; 1420 | color: rgb(239 68 68 / var(--tw-text-opacity)); 1421 | } 1422 | 1423 | @media (min-width: 768px) { 1424 | .md\:grid-cols-2 { 1425 | grid-template-columns: repeat(2, minmax(0, 1fr)); 1426 | } 1427 | } 1428 | 1429 | @media (min-width: 1024px) { 1430 | .lg\:mr-32 { 1431 | margin-right: 8rem; 1432 | } 1433 | 1434 | .lg\:block { 1435 | display: block; 1436 | } 1437 | 1438 | .lg\:hidden { 1439 | display: none; 1440 | } 1441 | } 1442 | 1443 | @media (prefers-color-scheme: dark) { 1444 | .dark\:border-templateGray-600 { 1445 | --tw-border-opacity: 1; 1446 | border-color: rgb(76 79 82 / var(--tw-border-opacity)); 1447 | } 1448 | 1449 | .dark\:border-templateGray-800 { 1450 | --tw-border-opacity: 1; 1451 | border-color: rgb(26 28 35 / var(--tw-border-opacity)); 1452 | } 1453 | 1454 | .dark\:bg-blue-700 { 1455 | --tw-bg-opacity: 1; 1456 | background-color: rgb(29 78 216 / var(--tw-bg-opacity)); 1457 | } 1458 | 1459 | .dark\:bg-gray-700 { 1460 | --tw-bg-opacity: 1; 1461 | background-color: rgb(55 65 81 / var(--tw-bg-opacity)); 1462 | } 1463 | 1464 | .dark\:bg-gray-800 { 1465 | --tw-bg-opacity: 1; 1466 | background-color: rgb(31 41 55 / var(--tw-bg-opacity)); 1467 | } 1468 | 1469 | .dark\:bg-gray-900 { 1470 | --tw-bg-opacity: 1; 1471 | background-color: rgb(17 24 39 / var(--tw-bg-opacity)); 1472 | } 1473 | 1474 | .dark\:bg-green-700 { 1475 | --tw-bg-opacity: 1; 1476 | background-color: rgb(21 128 61 / var(--tw-bg-opacity)); 1477 | } 1478 | 1479 | .dark\:bg-orange-700 { 1480 | --tw-bg-opacity: 1; 1481 | background-color: rgb(194 65 12 / var(--tw-bg-opacity)); 1482 | } 1483 | 1484 | .dark\:bg-red-700 { 1485 | --tw-bg-opacity: 1; 1486 | background-color: rgb(185 28 28 / var(--tw-bg-opacity)); 1487 | } 1488 | 1489 | .dark\:bg-templateGray-700 { 1490 | --tw-bg-opacity: 1; 1491 | background-color: rgb(36 38 45 / var(--tw-bg-opacity)); 1492 | } 1493 | 1494 | .dark\:bg-templateGray-800 { 1495 | --tw-bg-opacity: 1; 1496 | background-color: rgb(26 28 35 / var(--tw-bg-opacity)); 1497 | } 1498 | 1499 | .dark\:bg-yellow-300 { 1500 | --tw-bg-opacity: 1; 1501 | background-color: rgb(253 224 71 / var(--tw-bg-opacity)); 1502 | } 1503 | 1504 | .dark\:text-blue-100 { 1505 | --tw-text-opacity: 1; 1506 | color: rgb(219 234 254 / var(--tw-text-opacity)); 1507 | } 1508 | 1509 | .dark\:text-gray-100 { 1510 | --tw-text-opacity: 1; 1511 | color: rgb(243 244 246 / var(--tw-text-opacity)); 1512 | } 1513 | 1514 | .dark\:text-gray-200 { 1515 | --tw-text-opacity: 1; 1516 | color: rgb(229 231 235 / var(--tw-text-opacity)); 1517 | } 1518 | 1519 | .dark\:text-gray-400 { 1520 | --tw-text-opacity: 1; 1521 | color: rgb(156 163 175 / var(--tw-text-opacity)); 1522 | } 1523 | 1524 | .dark\:text-green-100 { 1525 | --tw-text-opacity: 1; 1526 | color: rgb(220 252 231 / var(--tw-text-opacity)); 1527 | } 1528 | 1529 | .dark\:text-orange-100 { 1530 | --tw-text-opacity: 1; 1531 | color: rgb(255 237 213 / var(--tw-text-opacity)); 1532 | } 1533 | 1534 | .dark\:text-red-100 { 1535 | --tw-text-opacity: 1; 1536 | color: rgb(254 226 226 / var(--tw-text-opacity)); 1537 | } 1538 | 1539 | .dark\:text-slate-700 { 1540 | --tw-text-opacity: 1; 1541 | color: rgb(51 65 85 / var(--tw-text-opacity)); 1542 | } 1543 | 1544 | .dark\:text-templateGray-300 { 1545 | --tw-text-opacity: 1; 1546 | color: rgb(213 214 215 / var(--tw-text-opacity)); 1547 | } 1548 | 1549 | .dark\:text-templatePurple-300 { 1550 | --tw-text-opacity: 1; 1551 | color: rgb(202 191 253 / var(--tw-text-opacity)); 1552 | } 1553 | 1554 | .dark\:hover\:text-gray-200:hover { 1555 | --tw-text-opacity: 1; 1556 | color: rgb(229 231 235 / var(--tw-text-opacity)); 1557 | } 1558 | 1559 | .dark\:focus\:border-templateGray-600:focus { 1560 | --tw-border-opacity: 1; 1561 | border-color: rgb(76 79 82 / var(--tw-border-opacity)); 1562 | } 1563 | } --------------------------------------------------------------------------------