Merge pull request #1287 from bluewave-labs/develop

Develop -> Master 2.0 release
This commit is contained in:
Alexander Holliday
2024-12-04 18:06:21 -08:00
committed by GitHub
177 changed files with 13341 additions and 4408 deletions

View File

@ -1,23 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link
rel="icon"
href="./bluewave_favicon.ico"
/>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
/>
<title>BlueWave Uptime</title>
</head>
<body>
<div id="root"></div>
<script
type="module"
src="/src/main.jsx"
></script>
</body>
</html>
<head>
<meta charset="UTF-8" />
<link rel="icon" href="./checkmate_favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Checkmate</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

1208
Client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -11,18 +11,17 @@
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.5",
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
"@fontsource/roboto": "^5.0.13",
"@mui/icons-material": "^5.15.17",
"@mui/lab": "^5.0.0-alpha.170",
"@mui/material": "^5.15.16",
"@mui/icons-material": "6.1.10",
"@mui/lab": "6.0.0-beta.18",
"@mui/material": "6.1.10",
"@mui/x-charts": "^7.5.1",
"@mui/x-data-grid": "7.22.0",
"@mui/x-date-pickers": "7.22.0",
"@reduxjs/toolkit": "2.3.0",
"@mui/x-data-grid": "7.23.0",
"@mui/x-date-pickers": "7.23.0",
"@reduxjs/toolkit": "2.4.0",
"axios": "^1.7.4",
"chart.js": "^4.4.3",
"dayjs": "1.11.13",
"joi": "17.13.3",
"jwt-decode": "^4.0.0",
@ -32,7 +31,7 @@
"react-router": "^6.23.0",
"react-router-dom": "^6.23.1",
"react-toastify": "^10.0.5",
"recharts": "2.13.1",
"recharts": "2.14.1",
"redux-persist": "6.0.0",
"vite-plugin-svgr": "^4.2.0"
},

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -0,0 +1,4 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="32" height="32" fill="#1570EF"/>
<path d="M21.8849 14.3622H19.2287C19.1529 13.9266 19.0133 13.5407 18.8097 13.2045C18.6061 12.8636 18.3527 12.5748 18.0497 12.3381C17.7467 12.1013 17.401 11.9238 17.0128 11.8054C16.6293 11.6823 16.215 11.6207 15.7699 11.6207C14.9792 11.6207 14.2784 11.8196 13.6676 12.2173C13.0568 12.6103 12.5786 13.188 12.233 13.9503C11.8873 14.7079 11.7145 15.6335 11.7145 16.7273C11.7145 17.84 11.8873 18.7775 12.233 19.5398C12.5833 20.2973 13.0616 20.8703 13.6676 21.2585C14.2784 21.642 14.9768 21.8338 15.7628 21.8338C16.1984 21.8338 16.6056 21.777 16.9844 21.6634C17.3679 21.545 17.7112 21.3722 18.0142 21.1449C18.322 20.9176 18.58 20.6383 18.7884 20.3068C19.0014 19.9754 19.1482 19.5966 19.2287 19.1705L21.8849 19.1847C21.7855 19.8759 21.5701 20.5246 21.2386 21.1307C20.9119 21.7367 20.4834 22.2718 19.9531 22.7358C19.4228 23.1951 18.8026 23.5549 18.0923 23.8153C17.3821 24.071 16.5938 24.1989 15.7273 24.1989C14.4489 24.1989 13.3078 23.9029 12.304 23.3111C11.3002 22.7192 10.5095 21.8646 9.93182 20.7472C9.35417 19.6297 9.06534 18.2898 9.06534 16.7273C9.06534 15.16 9.35653 13.8201 9.93892 12.7074C10.5213 11.59 11.3144 10.7353 12.3182 10.1435C13.322 9.55161 14.4583 9.25568 15.7273 9.25568C16.5369 9.25568 17.2898 9.36932 17.9858 9.59659C18.6818 9.82386 19.3021 10.1577 19.8466 10.598C20.3911 11.0336 20.8385 11.5687 21.1889 12.2031C21.544 12.8329 21.776 13.5526 21.8849 14.3622Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -1,7 +0,0 @@
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

View File

@ -1,14 +1,16 @@
import { Routes, Route } from "react-router-dom";
import { useEffect } from "react";
import { Routes, Route, Navigate } from "react-router-dom";
import { useSelector } from "react-redux";
import { useDispatch } from "react-redux";
import "react-toastify/dist/ReactToastify.css";
import { ToastContainer } from "react-toastify";
// import "./App.css";
import NotFound from "./Pages/NotFound";
import Login from "./Pages/Auth/Login";
import Register from "./Pages/Auth/Register/Register";
import HomeLayout from "./Layouts/HomeLayout";
import Account from "./Pages/Account";
import Monitors from "./Pages/Monitors/Home";
import CreateMonitor from "./Pages/Monitors/CreateMonitor";
import CreateInfrastructureMonitor from "./Pages/Infrastructure/CreateMonitor";
import Incidents from "./Pages/Incidents";
import Status from "./Pages/Status";
import Integrations from "./Pages/Integrations";
@ -21,24 +23,24 @@ import ProtectedRoute from "./Components/ProtectedRoute";
import Details from "./Pages/Monitors/Details";
import AdvancedSettings from "./Pages/AdvancedSettings";
import Maintenance from "./Pages/Maintenance";
import withAdminCheck from "./HOC/withAdminCheck";
import withAdminProp from "./HOC/withAdminProp";
import Configure from "./Pages/Monitors/Configure";
import PageSpeed from "./Pages/PageSpeed";
import CreatePageSpeed from "./Pages/PageSpeed/CreatePageSpeed";
import CreateNewMaintenanceWindow from "./Pages/Maintenance/CreateMaintenance";
import PageSpeedDetails from "./Pages/PageSpeed/Details";
import PageSpeedConfigure from "./Pages/PageSpeed/Configure";
import HomeLayout from "./Components/Layouts/HomeLayout";
import withAdminCheck from "./Components/HOC/withAdminCheck";
import withAdminProp from "./Components/HOC/withAdminProp";
import { ThemeProvider } from "@emotion/react";
import lightTheme from "./Utils/Theme/lightTheme";
import darkTheme from "./Utils/Theme/darkTheme";
import { useSelector } from "react-redux";
import { CssBaseline } from "@mui/material";
import { useEffect } from "react";
import { useDispatch } from "react-redux";
import { getAppSettings, updateAppSettings } from "./Features/Settings/settingsSlice";
import { CssBaseline, GlobalStyles } from "@mui/material";
import { getAppSettings } from "./Features/Settings/settingsSlice";
import { logger } from "./Utils/Logger"; // Import the logger
import { networkService } from "./main";
import { Infrastructure } from "./Pages/Infrastructure";
import InfrastructureDetails from "./Pages/Infrastructure/Details";
function App() {
const AdminCheckedRegister = withAdminCheck(Register);
const MonitorsWithAdminProp = withAdminProp(Monitors);
@ -48,6 +50,7 @@ function App() {
const MaintenanceWithAdminProp = withAdminProp(Maintenance);
const SettingsWithAdminProp = withAdminProp(Settings);
const AdvancedSettingsWithAdminProp = withAdminProp(AdvancedSettings);
const InfrastructureDetailsWithAdminProp = withAdminProp(InfrastructureDetails);
const mode = useSelector((state) => state.ui.mode);
const { authToken } = useSelector((state) => state.auth);
const dispatch = useDispatch();
@ -67,8 +70,19 @@ function App() {
}, []);
return (
/* Extract Themeprovider, baseline and global styles to Styles */
<ThemeProvider theme={mode === "light" ? lightTheme : darkTheme}>
<CssBaseline />
<GlobalStyles
styles={({ palette }) => {
return {
body: {
backgroundImage: `radial-gradient(circle, ${palette.gradient.color1}, ${palette.gradient.color2}, ${palette.gradient.color3}, ${palette.gradient.color4}, ${palette.gradient.color5})`,
},
};
}}
/>
{/* Extract Routes to Routes */}
<Routes>
<Route
exact
@ -78,7 +92,7 @@ function App() {
<Route
exact
path="/"
element={<ProtectedRoute Component={MonitorsWithAdminProp} />}
element={<Navigate to="/monitors" />}
/>
<Route
path="/monitors"
@ -96,6 +110,35 @@ function App() {
path="/monitors/configure/:monitorId/"
element={<ProtectedRoute Component={Configure} />}
/>
<Route
path="pagespeed"
element={<ProtectedRoute Component={PageSpeedWithAdminProp} />}
/>
<Route
path="pagespeed/create"
element={<ProtectedRoute Component={CreatePageSpeed} />}
/>
<Route
path="pagespeed/:monitorId"
element={<ProtectedRoute Component={PageSpeedDetailsWithAdminProp} />}
/>
<Route
path="pagespeed/configure/:monitorId"
element={<ProtectedRoute Component={PageSpeedConfigure} />}
/>
<Route
path="infrastructure"
element={<ProtectedRoute Component={Infrastructure} />}
/>
<Route
path="infrastructure/create"
element={<ProtectedRoute Component={CreateInfrastructureMonitor} />}
/>
<Route
path="infrastructure/:monitorId"
element={<ProtectedRoute Component={InfrastructureDetailsWithAdminProp} />}
/>
<Route
path="incidents/:monitorId?"
element={<ProtectedRoute Component={Incidents} />}
@ -152,22 +195,6 @@ function App() {
/>
}
/>
<Route
path="pagespeed"
element={<ProtectedRoute Component={PageSpeedWithAdminProp} />}
/>
<Route
path="pagespeed/create"
element={<ProtectedRoute Component={CreatePageSpeed} />}
/>
<Route
path="pagespeed/:monitorId"
element={<ProtectedRoute Component={PageSpeedDetailsWithAdminProp} />}
/>
<Route
path="pagespeed/configure/:monitorId"
element={<ProtectedRoute Component={PageSpeedConfigure} />}
/>
</Route>
<Route
@ -181,16 +208,18 @@ function App() {
path="/register"
element={<AdminCheckedRegister />}
/>
<Route
exact
path="/register/:token"
element={<Register />}
/>
{/* <Route path="/toast" element={<ToastComponent />} /> */}
<Route
path="*"
element={<NotFound />}
/>
<Route
path="/forgot-password"
element={<ForgotPassword />}

View File

@ -31,6 +31,9 @@ const icons = {
const Alert = ({ variant, title, body, isToast, hasIcon = true, onClick }) => {
const theme = useTheme();
/* TODO
This needs fixing. text bg and border not necessarilly exist. Probably text becomes contrastText. border becomes contrastText. bg becomes dark.
Check possible variants, see implementation in light and dark theme, and adjust */
const { text, bg, border } = theme.palette[variant];
const icon = icons[variant];

View File

@ -27,7 +27,7 @@
font-size: var(--env-var-font-size-medium);
}
.MuiTable-root .MuiTableHead-root .MuiTableCell-root {
padding: var(--env-var-spacing-1) var(--env-var-spacing-2);
padding: var(--env-var-spacing-1);
font-weight: 500;
}
.MuiTable-root .MuiTableHead-root span {
@ -43,7 +43,7 @@
height: 20px;
}
.MuiTable-root .MuiTableBody-root .MuiTableCell-root {
padding: 6px var(--env-var-spacing-2);
padding: var(--env-var-spacing-1);
}
.MuiTable-root .MuiTableBody-root .MuiTableRow-root {
height: 50px;

View File

@ -0,0 +1,214 @@
/**
* CustomAreaChart component for rendering an area chart with optional gradient and custom ticks.
*
* @param {Object} props - The properties object.
* @param {Array} props.data - The data array for the chart.
* @param {Array} props.dataKeys - An array of data keys to be plotted as separate areas.
* @param {string} props.xKey - The key for the x-axis data.
* @param {string} [props.yKey] - The key for the y-axis data (optional).
* @param {Object} [props.xTick] - Custom tick component for the x-axis.
* @param {Object} [props.yTick] - Custom tick component for the y-axis.
* @param {string} [props.strokeColor] - The base stroke color for the areas.
* If not provided, uses a predefined color palette.
* @param {string} [props.fillColor] - The base fill color for the areas.
* @param {boolean} [props.gradient=false] - Whether to apply a gradient fill to the areas.
* @param {string} [props.gradientDirection="vertical"] - The direction of the gradient.
* @param {string} [props.gradientStartColor] - The start color of the gradient.
* Defaults to the area's stroke color if not provided.
* @param {string} [props.gradientEndColor] - The end color of the gradient.
* @param {Object} [props.customTooltip] - Custom tooltip component for the chart.
* @param {string|number} [props.height="100%"] - Height of the chart container.
*
* @returns {JSX.Element} The rendered area chart component.
*
* @example
* // Single series chart
* <CustomAreaChart
* data={temperatureData}
* dataKeys={["temperature"]}
* xKey="date"
* yKey="temperature"
* gradient={true}
* gradientStartColor="#ff6b6b"
* gradientEndColor="#4ecdc4"
* />
*
* @example
* // Multi-series chart with custom tooltip
* <CustomAreaChart
* data={performanceData}
* dataKeys={["cpu.usage", "memory.usage"]}
* xKey="timestamp"
* xTick={<CustomTimeTick />}
* yTick={<PercentageTick />}
* gradient={true}
* customTooltip={({ active, payload, label }) => (
* <CustomTooltip
* label={label}
* payload={payload}
* active={active}
* />
* )}
* />
*/
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
import { createGradient } from "../Utils/gradientUtils";
import PropTypes from "prop-types";
import { useTheme } from "@mui/material";
import { useId } from "react";
import { Fragment } from "react";
const CustomAreaChart = ({
data,
dataKeys,
xKey,
xDomain,
yKey,
yDomain,
xTick,
yTick,
strokeColor,
fillColor,
gradient = false,
gradientDirection = "vertical",
gradientStartColor,
gradientEndColor,
customTooltip,
height = "100%",
}) => {
const theme = useTheme();
const uniqueId = useId();
const AREA_COLORS = [
// Blues
"#3182bd", // Deep blue
"#6baed6", // Medium blue
"#9ecae1", // Light blue
// Greens
"#74c476", // Soft green
"#a1d99b", // Light green
"#c7e9c0", // Pale green
// Oranges
"#fdae6b", // Warm orange
"#fdd0a2", // Light orange
"#feedde", // Pale orange
// Purples
"#9467bd", // Lavender
"#a55194", // Deep magenta
"#c994c7", // Soft magenta
// Reds
"#ff9896", // Soft red
"#de2d26", // Deep red
"#fc9272", // Medium red
// Cyans/Teals
"#17becf", // Cyan
"#7fcdbb", // Teal
"#a1dab4", // Light teal
// Yellows
"#fec44f", // Mustard
"#fee391", // Light yellow
"#ffffd4", // Pale yellow
// Additional colors
"#e377c2", // Soft pink
"#bcbd22", // Olive
"#2ca02c", // Vibrant green
];
return (
<ResponsiveContainer
width="100%"
height={height}
// FE team HELP! Why does this overflow if set to 100%?
>
<AreaChart data={data}>
<XAxis
dataKey={xKey}
{...(xDomain && { domain: xDomain })}
{...(xTick && { tick: xTick })}
/>
<YAxis
dataKey={yKey}
{...(yDomain && { domain: yDomain })}
{...(yTick && { tick: yTick })}
/>
<CartesianGrid
stroke={theme.palette.border.light}
strokeWidth={1}
strokeOpacity={1}
fill="transparent"
vertical={false}
/>
{dataKeys.map((dataKey, index) => {
const gradientId = `gradient-${uniqueId}-${index}`;
return (
<Fragment key={dataKey}>
{gradient === true &&
createGradient({
id: gradientId,
startColor: gradientStartColor || AREA_COLORS[index],
endColor: gradientEndColor,
direction: gradientDirection,
})}
<Area
yKey={dataKey}
key={dataKey}
type="monotone"
dataKey={dataKey}
stroke={strokeColor || AREA_COLORS[index]}
fill={gradient === true ? `url(#${gradientId})` : fillColor}
/>
</Fragment>
);
})}
{customTooltip ? (
<Tooltip
cursor={{ stroke: theme.palette.border.light }}
content={customTooltip}
wrapperStyle={{ pointerEvents: "none" }}
/>
) : (
<Tooltip />
)}
</AreaChart>
</ResponsiveContainer>
);
};
CustomAreaChart.propTypes = {
data: PropTypes.array.isRequired,
dataKeys: PropTypes.array.isRequired,
xTick: PropTypes.object, // Recharts takes an instance of component, so we can't pass the component itself
yTick: PropTypes.object, // Recharts takes an instance of component, so we can't pass the component itself
xKey: PropTypes.string.isRequired,
xDomain: PropTypes.array,
yKey: PropTypes.string,
yDomain: PropTypes.array,
fillColor: PropTypes.string,
strokeColor: PropTypes.string,
gradient: PropTypes.bool,
gradientDirection: PropTypes.string,
gradientStartColor: PropTypes.string,
gradientEndColor: PropTypes.string,
customTooltip: PropTypes.object,
height: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
};
export default CustomAreaChart;

View File

@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
import "./index.css";
import { useSelector } from "react-redux";
/* TODO add prop validation and jsdocs */
const BarChart = ({ checks = [] }) => {
const theme = useTheme();
const [animate, setAnimate] = useState(false);
@ -43,7 +44,7 @@ const BarChart = ({ checks = [] }) => {
position="relative"
width={theme.spacing(4.5)}
height="100%"
backgroundColor={theme.palette.background.fill}
backgroundColor={theme.palette.border.light}
sx={{
borderRadius: theme.spacing(1.5),
}}
@ -65,7 +66,9 @@ const BarChart = ({ checks = [] }) => {
width={theme.spacing(4)}
height={theme.spacing(4)}
backgroundColor={
check.status ? theme.palette.success.main : theme.palette.error.text
check.status
? theme.palette.success.main
: theme.palette.error.contrastText
}
sx={{ borderRadius: "50%" }}
/>
@ -111,7 +114,7 @@ const BarChart = ({ checks = [] }) => {
],
sx: {
"& .MuiTooltip-tooltip": {
backgroundColor: theme.palette.background.main,
backgroundColor: theme.palette.secondary.main,
border: 1,
borderColor: theme.palette.border.dark,
borderRadius: theme.shape.borderRadius,
@ -138,7 +141,9 @@ const BarChart = ({ checks = [] }) => {
width="9px"
height="100%"
backgroundColor={
check.status ? theme.palette.success.bg : theme.palette.error.bg
check.status
? theme.palette.success.contrastText
: theme.palette.error.dark
}
sx={{
borderRadius: theme.spacing(1.5),
@ -153,7 +158,7 @@ const BarChart = ({ checks = [] }) => {
width="100%"
height={`${animate ? check.responseTime : 0}%`}
backgroundColor={
check.status ? theme.palette.success.main : theme.palette.error.text
check.status ? theme.palette.success.main : theme.palette.error.main
}
sx={{
borderRadius: theme.spacing(1.5),

View File

@ -0,0 +1,14 @@
.radial-chart {
position: relative;
display: inline-block;
}
.radial-chart-base {
opacity: 0.3;
}
.radial-chart-progress {
transform: rotate(-90deg);
transform-origin: center;
transition: stroke-dashoffset 1.5s ease-in-out;
}

View File

@ -0,0 +1,118 @@
import { useTheme } from "@emotion/react";
import { useEffect, useState, useMemo } from "react";
import { Box, Typography } from "@mui/material";
import PropTypes from "prop-types";
import "./index.css";
const MINIMUM_VALUE = 0;
const MAXIMUM_VALUE = 100;
/**
* A Performant SVG based circular gauge
*
* @component
* @param {Object} props - Component properties
* @param {number} [props.progress=0] - Progress percentage (0-100)
* @param {number} [props.radius=60] - Radius of the gauge circle
* @param {number} [props.strokeWidth=15] - Width of the gauge stroke
* @param {number} [props.threshold=50] - Threshold for color change
*
* @example
* <CustomGauge
* progress={75}
* radius={50}
* strokeWidth={10}
* threshold={50}
* />
*
* @returns {React.ReactElement} Rendered CustomGauge component
*/
const CustomGauge = ({ progress = 0, radius = 70, strokeWidth = 15, threshold = 50 }) => {
const theme = useTheme();
// Calculate the length of the stroke for the circle
const { circumference, totalSize, strokeLength } = useMemo(
() => ({
circumference: 2 * Math.PI * radius,
totalSize: radius * 2 + strokeWidth * 2,
strokeLength: (progress / 100) * (2 * Math.PI * radius),
}),
[radius, strokeWidth, progress]
);
// Handle initial animation
const [offset, setOffset] = useState(circumference);
useEffect(() => {
setOffset(circumference);
const timer = setTimeout(() => {
setOffset(circumference - strokeLength);
}, 100);
return () => clearTimeout(timer);
}, [progress, circumference, strokeLength]);
const progressWithinRange = Math.max(MINIMUM_VALUE, Math.min(progress, MAXIMUM_VALUE));
const fillColor =
progressWithinRange > threshold
? theme.palette.percentage.uptimePoor
: theme.palette.primary.main;
return (
<Box
className="radial-chart"
width={radius}
height={radius}
>
<svg
viewBox={`0 0 ${totalSize} ${totalSize}`}
width={radius}
height={radius}
>
<circle
className="radial-chart-base"
stroke={theme.palette.background.fill}
strokeWidth={strokeWidth}
fill="none"
cx={totalSize / 2} // Center the circle
cy={totalSize / 2} // Center the circle
r={radius}
/>
<circle
className="radial-chart-progress"
stroke={fillColor}
strokeWidth={strokeWidth}
strokeDasharray={`${circumference} ${circumference}`}
strokeDashoffset={offset}
strokeLinecap="round"
fill="none"
cx={totalSize / 2}
cy={totalSize / 2}
r={radius}
/>
</svg>
<Typography
className="radial-chart-text"
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
...theme.typography.body2,
fill: theme.typography.body2.color,
}}
>
{`${progressWithinRange.toFixed(1)}%`}
</Typography>
</Box>
);
};
export default CustomGauge;
CustomGauge.propTypes = {
progress: PropTypes.number,
radius: PropTypes.number,
strokeWidth: PropTypes.number,
threshold: PropTypes.number,
};

View File

@ -0,0 +1,111 @@
import { RadialBarChart, RadialBar, ResponsiveContainer } from "recharts";
import PropTypes from "prop-types";
import { useTheme } from "@emotion/react";
/* TODO delete component */
const MINIMUM_VALUE = 0;
const MAXIMUM_VALUE = 100;
const CHART_MAXIMUM_DATA = {
value: MAXIMUM_VALUE,
fill: "transparent",
};
const PROGRESS_THRESHOLD = 50;
const DEFAULT_WIDTH = 60;
const RADIUS_SIZE = "90%";
const START_ANGLE = 90;
const CHART_RANGE = 360;
const RADIAL_BAR_CHART_PROPS = {
innerRadius: RADIUS_SIZE,
outerRadius: RADIUS_SIZE,
barSize: 6,
startAngle: START_ANGLE,
endAngle: START_ANGLE - CHART_RANGE,
margin: { top: 0, right: 0, bottom: 0, left: 0 },
};
const RADIAL_BAR_PROPS = {
dataKey: "value",
cornerRadius: 8,
};
Gauge.propTypes = {
progressValue: PropTypes.number.isRequired,
width: PropTypes.number,
};
/**
* A circular gauge component that displays a progress value and text.
* The gauge fills based on the progress value and changes color at a 50% threshold.
*
* @component
* @param {Object} props - Component props
* @param {number} props.progressValue - The value to display in the gauge (0-100)
* @param {number} props.width - Width of the gauge container in pixels
* @returns {JSX.Element} A circular gauge chart with progress value and text
*
* @example
* <Gauge
* progressValue={75}
* width={200}
* />
*/
function Gauge({ progressValue, width = DEFAULT_WIDTH }) {
const theme = useTheme();
const myProgressValue = Math.max(MINIMUM_VALUE, Math.min(progressValue, MAXIMUM_VALUE));
const chartColor =
progressValue > PROGRESS_THRESHOLD
? theme.palette.primary.main
: theme.palette.percentage.uptimePoor;
const chartData = [
CHART_MAXIMUM_DATA,
{
value: myProgressValue,
fill: chartColor,
},
];
return (
<ResponsiveContainer
aspect={1}
width={width}
style={{ marginInline: "auto" }}
>
<RadialBarChart
{...RADIAL_BAR_CHART_PROPS}
data={chartData}
>
<RadialBar
{...RADIAL_BAR_PROPS}
fill={
progressValue > PROGRESS_THRESHOLD
? theme.palette.primary.main
: theme.palette.percentage.uptimePoor
}
background={{ fill: theme.palette.background.fill }}
label={{
position: "center",
content: () => (
<text
x="50%"
y="50%"
textAnchor="middle"
dominantBaseline="middle"
style={{
...theme.typography.body2,
fill: theme.typography.body2.color,
}}
>
{`${myProgressValue}%`}
</text>
),
}}
/>
</RadialBarChart>
</ResponsiveContainer>
);
}
export { Gauge };

View File

@ -0,0 +1,277 @@
import PropTypes from "prop-types";
import { useSelector } from "react-redux";
import { useTheme } from "@mui/material";
import { Text } from "recharts";
import { formatDateWithTz } from "../../../Utils/timeUtils";
import { Box, Stack, Typography } from "@mui/material";
/**
* Custom tick component for rendering time with timezone.
*
* @param {Object} props - The properties object.
* @param {number} props.x - The x-coordinate for the tick.
* @param {number} props.y - The y-coordinate for the tick.
* @param {Object} props.payload - The payload object containing tick data.
* @param {number} props.index - The index of the tick.
* @returns {JSX.Element} The rendered tick component.
*/
export const TzTick = ({ x, y, payload, index }) => {
const theme = useTheme();
const uiTimezone = useSelector((state) => state.ui.timezone);
return (
<Text
x={x}
y={y + 10}
textAnchor="middle"
fill={theme.palette.text.tertiary}
fontSize={11}
fontWeight={400}
>
{formatDateWithTz(payload?.value, "h:mm a", uiTimezone)}
</Text>
);
};
TzTick.propTypes = {
x: PropTypes.number,
y: PropTypes.number,
payload: PropTypes.object,
index: PropTypes.number,
};
/**
* Custom tick component for rendering percentage values.
*
* @param {Object} props - The properties object.
* @param {number} props.x - The x-coordinate for the tick.
* @param {number} props.y - The y-coordinate for the tick.
* @param {Object} props.payload - The payload object containing tick data.
* @param {number} props.index - The index of the tick.
* @returns {JSX.Element|null} The rendered tick component or null for the first tick.
*/
export const PercentTick = ({ x, y, payload, index }) => {
const theme = useTheme();
if (index === 0) return null;
return (
<Text
x={x - 20}
y={y}
textAnchor="middle"
fill={theme.palette.text.tertiary}
fontSize={11}
fontWeight={400}
>
{`${(payload?.value * 100).toFixed()}%`}
</Text>
);
};
PercentTick.propTypes = {
x: PropTypes.number,
y: PropTypes.number,
payload: PropTypes.object,
index: PropTypes.number,
};
/**
* Converts a decimal value to a formatted percentage string.
*
* @param {number} value - The decimal value to convert (e.g., 0.75)
* @returns {string} Formatted percentage string (e.g., "75.00%") or original input if not a number
*
* @example
* getFormattedPercentage(0.7543) // Returns "75.43%"
* getFormattedPercentage(1) // Returns "100.00%"
* getFormattedPercentage("test") // Returns "test"
*/
const getFormattedPercentage = (value) => {
if (typeof value !== "number") return value;
return `${(value * 100).toFixed(2)}.%`;
};
/**
* Custom tooltip component for displaying infrastructure data.
*
* @param {Object} props - The properties object.
* @param {boolean} props.active - Indicates if the tooltip is active.
* @param {Array} props.payload - The payload array containing tooltip data.
* @param {string} props.label - The label for the tooltip.
* @param {string} props.yKey - The key for the y-axis data.
* @param {string} props.yLabel - The label for the y-axis data.
* @param {string} props.dotColor - The color of the dot in the tooltip.
* @returns {JSX.Element|null} The rendered tooltip component or null if inactive.
*/
export const InfrastructureTooltip = ({
active,
payload,
label,
yKey,
yIdx = -1,
yLabel,
dotColor,
}) => {
const uiTimezone = useSelector((state) => state.ui.timezone);
const theme = useTheme();
if (active && payload && payload.length) {
const [hardwareType, metric] = yKey.split(".");
return (
<Box
className="area-tooltip"
sx={{
backgroundColor: theme.palette.background.main,
border: 1,
borderColor: theme.palette.border.dark,
borderRadius: theme.shape.borderRadius,
py: theme.spacing(2),
px: theme.spacing(4),
}}
>
<Typography
sx={{
color: theme.palette.text.tertiary,
fontSize: 12,
fontWeight: 500,
}}
>
{formatDateWithTz(label, "ddd, MMMM D, YYYY, h:mm A", uiTimezone)}
</Typography>
<Box mt={theme.spacing(1)}>
<Box
display="inline-block"
width={theme.spacing(4)}
height={theme.spacing(4)}
backgroundColor={dotColor}
sx={{ borderRadius: "50%" }}
/>
<Stack
display="inline-flex"
direction="row"
justifyContent="space-between"
ml={theme.spacing(3)}
sx={{
"& span": {
color: theme.palette.text.tertiary,
fontSize: 11,
fontWeight: 500,
},
}}
>
<Typography
component="span"
sx={{ opacity: 0.8 }}
>
{yIdx >= 0
? `${yLabel} ${getFormattedPercentage(payload[0].payload[hardwareType][yIdx][metric])}`
: `${yLabel} ${getFormattedPercentage(payload[0].payload[hardwareType][metric])}`}
</Typography>
</Stack>
</Box>
{/* Display original value */}
</Box>
);
}
return null;
};
InfrastructureTooltip.propTypes = {
active: PropTypes.bool,
payload: PropTypes.array,
label: PropTypes.oneOfType([
PropTypes.instanceOf(Date),
PropTypes.string,
PropTypes.number,
]),
yKey: PropTypes.string,
yIdx: PropTypes.number,
yLabel: PropTypes.string,
dotColor: PropTypes.string,
};
export const TemperatureTooltip = ({ active, payload, label, keys, dotColor }) => {
const uiTimezone = useSelector((state) => state.ui.timezone);
const theme = useTheme();
const formatCoreKey = (key) => {
return key.replace(/^core(\d+)$/, "Core $1");
};
if (active && payload && payload.length) {
return (
<Box
className="area-tooltip"
sx={{
backgroundColor: theme.palette.background.main,
border: 1,
borderColor: theme.palette.border.dark,
borderRadius: theme.shape.borderRadius,
py: theme.spacing(2),
px: theme.spacing(4),
}}
>
<Typography
sx={{
color: theme.palette.text.tertiary,
fontSize: 12,
fontWeight: 500,
}}
>
{formatDateWithTz(label, "ddd, MMMM D, YYYY, h:mm A", uiTimezone)}
</Typography>
<Stack direction="column">
{keys.map((key) => {
return (
<Stack
key={key}
display="inline-flex"
direction="row"
justifyContent="space-between"
ml={theme.spacing(3)}
sx={{
"& span": {
color: theme.palette.text.tertiary,
fontSize: 11,
fontWeight: 500,
},
}}
>
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(2)}
>
<Box
display="inline-block"
width={theme.spacing(4)}
height={theme.spacing(4)}
backgroundColor={dotColor}
sx={{ borderRadius: "50%" }}
/>
<Typography
component="span"
sx={{ opacity: 0.8 }}
>
{`${formatCoreKey(key)}: ${payload[0].payload[key]} °C`}
</Typography>
</Stack>
<Typography component="span"></Typography>
</Stack>
);
})}
</Stack>
</Box>
);
}
return null;
};
TemperatureTooltip.propTypes = {
active: PropTypes.bool,
keys: PropTypes.array,
payload: PropTypes.array,
label: PropTypes.oneOfType([
PropTypes.instanceOf(Date),
PropTypes.string,
PropTypes.number,
]),
};

View File

@ -0,0 +1,47 @@
/**
* Creates an SVG gradient definition for use in charts
* @param {Object} params - The gradient parameters
* @param {string} [params.id="colorUv"] - Unique identifier for the gradient
* @param {string} params.startColor - Starting color of the gradient (hex, rgb, or color name)
* @param {string} params.endColor - Ending color of the gradient (hex, rgb, or color name)
* @param {number} [params.startOpacity=0.8] - Starting opacity (0-1)
* @param {number} [params.endOpacity=0] - Ending opacity (0-1)
* @param {('vertical'|'horizontal')} [params.direction="vertical"] - Direction of the gradient
* @returns {JSX.Element} SVG gradient definition element
* @example
* createCustomGradient({
* startColor: "#1976D2",
* endColor: "#42A5F5",
* direction: "horizontal"
* })
*/
export const createGradient = ({
id,
startColor,
endColor,
startOpacity = 0.8,
endOpacity = 0,
direction = "vertical", // or "horizontal"
}) => (
<defs>
<linearGradient
id={id}
x1={direction === "vertical" ? "0" : "0"}
y1={direction === "vertical" ? "0" : "0"}
x2={direction === "vertical" ? "0" : "1"}
y2={direction === "vertical" ? "1" : "0"}
>
<stop
offset="0%"
stopColor={startColor}
stopOpacity={startOpacity}
/>
<stop
offset="100%"
stopColor={endColor}
stopOpacity={endOpacity}
/>
</linearGradient>
</defs>
);

View File

@ -20,14 +20,13 @@ import { useTheme } from "@emotion/react";
*
* @returns {React.Element} The `Check` component with a check icon and a label, defined by the `text` prop.
*/
const Check = ({ text, variant = "info", outlined = false }) => {
const Check = ({ text, noHighlightText, variant = "info", outlined = false }) => {
const theme = useTheme();
const colors = {
success: theme.palette.success.main,
error: theme.palette.error.text,
error: theme.palette.error.main,
info: theme.palette.info.border,
};
return (
<Stack
direction="row"
@ -54,6 +53,7 @@ const Check = ({ text, variant = "info", outlined = false }) => {
opacity: 0.8,
}}
>
{noHighlightText && <Typography component="span">{noHighlightText}</Typography>}{" "}
{text}
</Typography>
</Stack>
@ -62,6 +62,7 @@ const Check = ({ text, variant = "info", outlined = false }) => {
Check.propTypes = {
text: PropTypes.oneOfType([PropTypes.string, PropTypes.element]).isRequired,
noHighlightText: PropTypes.string,
variant: PropTypes.oneOf(["info", "error", "success"]),
outlined: PropTypes.bool,
};

View File

@ -12,7 +12,7 @@ const GenericDialog = ({ title, description, open, onClose, theme, children }) =
aria-describedby={ariaDescribedBy}
open={open}
onClose={onClose}
onClick={(e)=>e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<Stack
gap={theme.spacing(2)}
@ -62,7 +62,8 @@ GenericDialog.propTypes = {
open: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
theme: PropTypes.object.isRequired,
children: PropTypes.element.isRequired,
children: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.node), PropTypes.node])
.isRequired,
};
export { GenericDialog };

View File

@ -51,7 +51,6 @@ Dialog.propTypes = {
title: PropTypes.string.isRequired,
description: PropTypes.string,
open: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
theme: PropTypes.object.isRequired,
onCancel: PropTypes.func.isRequired,
confirmationButtonLabel: PropTypes.string.isRequired,

View File

@ -16,11 +16,11 @@ import "./index.css";
* @param {string} props.title - The title to be displayed in the fallback UI.
* @param {Array<string>} props.checks - An array of strings representing the checks to display.
* @param {string} [props.link="/"] - The link to navigate to.
*
* @param {boolean} [props.vowelStart=false] - Whether the title starts with a vowel.
* @returns {JSX.Element} The rendered fallback UI.
*/
const Fallback = ({ title, checks, link = "/", isAdmin }) => {
const Fallback = ({ title, checks, link = "/", isAdmin, vowelStart = false }) => {
const theme = useTheme();
const navigate = useNavigate();
const mode = useSelector((state) => state.ui.mode);
@ -48,7 +48,7 @@ const Fallback = ({ title, checks, link = "/", isAdmin }) => {
</Box>
<Stack
gap={theme.spacing(4)}
maxWidth={"275px"}
maxWidth={"300px"}
zIndex={1}
>
<Typography
@ -56,7 +56,7 @@ const Fallback = ({ title, checks, link = "/", isAdmin }) => {
marginY={theme.spacing(4)}
color={theme.palette.text.tertiary}
>
A {title} is used to:
{vowelStart ? "An" : "A"} {title} is used to:
</Typography>
{checks.map((check, index) => (
<Check
@ -86,6 +86,7 @@ Fallback.propTypes = {
checks: PropTypes.arrayOf(PropTypes.string).isRequired,
link: PropTypes.string,
isAdmin: PropTypes.bool,
vowelStart: PropTypes.bool,
};
export default Fallback;

View File

@ -1,8 +1,8 @@
import React, { useEffect } from "react";
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { logger } from "../Utils/Logger";
import { networkService } from "../main";
import { logger } from "../../Utils/Logger";
import { networkService } from "../../main";
const withAdminCheck = (WrappedComponent) => {
const WithAdminCheck = (props) => {

View File

@ -0,0 +1,29 @@
import { Typography } from "@mui/material";
import PropTypes from "prop-types";
/**
* A heading component that renders text with a specific heading level.
*
* @param {Object} props - The properties passed to the Heading component.
* @param {('h1'|'h2'|'h3')} props.component - The heading level for the component.
* @param {string} props.children - The content to display inside the heading.
* @returns {JSX.Element} The Typography component with specified heading properties.
*/
function Heading({ component, children }) {
return (
<Typography
component={component}
variant="h2"
fontWeight={600}
>
{children}
</Typography>
);
}
Heading.propTypes = {
component: PropTypes.oneOf(["h1", "h2", "h3"]).isRequired,
children: PropTypes.string.isRequired,
};
export { Heading };

View File

@ -0,0 +1,71 @@
import PropTypes from "prop-types";
import { useTheme } from "@mui/material";
import { BaseLabel } from "../Label";
/**
* @component
* @param {Object} props
* @param {number} props.status - The http status for the label
* @param {Styles} props.customStyles - CSS Styles passed from parent component
* @returns {JSX.Element}
* @example
* // Render a http status label
* <HttpStatusLabel status={404} />
*/
const DEFAULT_CODE = 9999; // Default code for unknown status
const handleStatusCode = (status) => {
if (status >= 100 && status < 600) {
return status;
}
return DEFAULT_CODE;
};
const getRoundedStatusCode = (status) => {
return Math.floor(status / 100) * 100;
};
const HttpStatusLabel = ({ status, customStyles }) => {
const theme = useTheme();
const colors = {
400: {
dotColor: theme.palette.warning.main,
bgColor: theme.palette.warning.dark,
borderColor: theme.palette.warning.light,
},
500: {
dotColor: theme.palette.error.main,
bgColor: theme.palette.error.dark,
borderColor: theme.palette.error.light,
},
default: {
dotColor: theme.palette.unresolved.main,
bgColor: theme.palette.unresolved.bg,
borderColor: theme.palette.unresolved.light,
},
};
const statusCode = handleStatusCode(status);
const { borderColor, bgColor, dotColor } =
colors[getRoundedStatusCode(statusCode)] || colors.default;
return (
<BaseLabel
label={String(statusCode)}
styles={{
color: dotColor,
backgroundColor: bgColor,
borderColor: borderColor,
...customStyles,
}}
/>
);
};
HttpStatusLabel.propTypes = {
status: PropTypes.number,
customStyles: PropTypes.object,
};
export { HttpStatusLabel };

View File

@ -37,9 +37,9 @@ const Checkbox = ({
onChange,
isDisabled,
}) => {
/* TODO move sizes to theme */
const sizes = { small: "14px", medium: "16px", large: "18px" };
const theme = useTheme();
return (
<FormControlLabel
className="checkbox-wrapper"
@ -54,9 +54,10 @@ const Checkbox = ({
"aria-label": "controlled checkbox",
id: id,
}}
sx={{
sx={{
"&:hover": { backgroundColor: "transparent" },
"& svg": { width: sizes[size], height: sizes[size] },
alignSelf: "flex-start",
}}
/>
}
@ -65,7 +66,6 @@ const Checkbox = ({
sx={{
borderRadius: theme.shape.borderRadius,
p: theme.spacing(2.5),
m: theme.spacing(-2.5),
"& .MuiButtonBase-root": {
width: theme.spacing(10),
p: 0,
@ -78,6 +78,10 @@ const Checkbox = ({
fontSize: 13,
color: theme.palette.text.tertiary,
},
".MuiFormControlLabel-label.Mui-disabled": {
color: theme.palette.text.tertiary,
opacity: 0.25,
},
}}
/>
);
@ -85,7 +89,7 @@ const Checkbox = ({
Checkbox.propTypes = {
id: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
label: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired,
size: PropTypes.oneOf(["small", "medium", "large"]),
isChecked: PropTypes.bool.isRequired,
value: PropTypes.string,

View File

@ -1,40 +0,0 @@
.field {
min-width: 250px;
}
.field h3.MuiTypography-root,
.field h5.MuiTypography-root,
.field input,
.field textarea,
.field .input-error {
font-size: var(--env-var-font-size-medium);
}
.field h5.MuiTypography-root {
position: relative;
opacity: 0.8;
padding-right: var(--env-var-spacing-1-minus);
}
.field .MuiInputBase-root:has(input) {
height: 34px;
}
.field .MuiInputBase-root:has(.MuiInputAdornment-root) {
padding-right: var(--env-var-spacing-1-minus);
}
.field input {
height: 100%;
padding: 0 var(--env-var-spacing-1-minus);
}
.field .MuiInputBase-root:has(textarea) {
padding: var(--env-var-spacing-1-minus);
}
.register-page .field .MuiOutlinedInput-root fieldset,
.register-page .field input,
.login-page .field .MuiOutlinedInput-root fieldset,
.login-page .field input,
.forgot-password-page .field .MuiOutlinedInput-root fieldset,
.forgot-password-page .field input,
.set-new-password-page .field .MuiOutlinedInput-root fieldset,
.set-new-password-page .field input {
border-radius: var(--env-var-radius-2);
}

View File

@ -1,228 +0,0 @@
import PropTypes from "prop-types";
import { forwardRef, useState } from "react";
import { useTheme } from "@emotion/react";
import { IconButton, InputAdornment, Stack, TextField, Typography } from "@mui/material";
import VisibilityOff from "@mui/icons-material/VisibilityOff";
import Visibility from "@mui/icons-material/Visibility";
import "./index.css";
/**
* @param {Object} props
* @param {string} [props.type] - Type of input field (e.g., 'text', 'password').
* @param {string} props.id - ID of the input field.
* @param {string} props.name - Name of the input field.
* @param {string} [props.label] - Label for the input field.
* @param {boolean} [props.https] - Indicates if it should display http or https.
* @param {boolean} [props.isRequired] - Indicates if the field is required, will display a red asterisk.
* @param {boolean} [props.isOptional] - Indicates if the field is optional, will display optional text.
* @param {string} [props.optionalLabel] - Optional label for the input field.
* @param {string} [props.autoComplete] - Autocomplete value for the input field.
* @param {string} [props.placeholder] - Placeholder text for the input field.
* @param {string} props.value - Value of the input field.
* @param {function} props.onChange - Function called on input change.
* @param {string} [props.error] - Error message to display for the input field.
* @param {boolean} [props.disabled] - Indicates if the input field is disabled.
* @param {boolean} [props.hidden] - Indicates if the input field is hidden.
* @param {React.Ref} [ref] - Ref forwarded to the underlying `TextField` component. Allows for direct interactions such as focusing.
*/
const Field = forwardRef(
(
{
type = "text",
id,
name,
label,
https,
isRequired,
isOptional,
optionalLabel,
autoComplete,
placeholder,
value,
onChange,
onBlur,
onInput,
error,
disabled,
hidden,
},
ref
) => {
const theme = useTheme();
const [isVisible, setVisible] = useState(false);
return (
<Stack
gap={theme.spacing(2)}
className={`field field-${type}`}
sx={{
"& fieldset": {
borderColor: theme.palette.border.dark,
borderRadius: theme.shape.borderRadius,
},
"&:not(:has(.Mui-disabled)):not(:has(.input-error)) .MuiOutlinedInput-root:hover:not(:has(input:focus)):not(:has(textarea:focus)) fieldset":
{
borderColor: theme.palette.border.dark,
},
"&:has(.input-error) .MuiOutlinedInput-root fieldset": {
borderColor: theme.palette.error.text,
},
display: hidden ? "none" : "",
}}
>
{label && (
<Typography
component="h3"
color={theme.palette.text.secondary}
fontWeight={500}
>
{label}
{isRequired ? (
<Typography
component="span"
ml={theme.spacing(1)}
color={theme.palette.error.text}
>
*
</Typography>
) : (
""
)}
{isOptional ? (
<Typography
component="span"
fontSize="inherit"
fontWeight={400}
ml={theme.spacing(2)}
sx={{ opacity: 0.6 }}
>
{optionalLabel || "(optional)"}
</Typography>
) : (
""
)}
</Typography>
)}
<TextField
type={type === "password" ? (isVisible ? "text" : type) : type}
name={name}
id={id}
autoComplete={autoComplete}
placeholder={placeholder}
multiline={type === "description"}
rows={type === "description" ? 4 : 1}
value={value}
onInput={onInput}
onChange={onChange}
onBlur={onBlur}
disabled={disabled}
inputRef={ref}
inputProps={{
sx: {
color: theme.palette.text.secondary,
"&:-webkit-autofill": {
WebkitBoxShadow: `0 0 0 100px ${theme.palette.other.autofill} inset`,
WebkitTextFillColor: theme.palette.text.secondary,
borderRadius: "0 5px 5px 0",
},
},
}}
sx={
type === "url"
? {
"& .MuiInputBase-root": { padding: 0 },
"& .MuiStack-root": {
borderTopLeftRadius: theme.shape.borderRadius,
borderBottomLeftRadius: theme.shape.borderRadius,
},
}
: {}
}
InputProps={{
startAdornment: type === "url" && (
<Stack
direction="row"
alignItems="center"
height="100%"
sx={{
borderRight: `solid 1px ${theme.palette.border.dark}`,
backgroundColor: theme.palette.background.accent,
pl: theme.spacing(6),
}}
>
<Typography
component="h5"
color={theme.palette.text.secondary}
sx={{ lineHeight: 1 }}
>
{https ? "https" : "http"}://
</Typography>
</Stack>
),
endAdornment: type === "password" && (
<InputAdornment position="end">
<IconButton
aria-label="toggle password visibility"
onClick={() => setVisible((show) => !show)}
tabIndex={-1}
sx={{
color: theme.palette.border.dark,
padding: theme.spacing(1),
"&:focus": {
outline: "none",
},
"& .MuiTouchRipple-root": {
pointerEvents: "none",
display: "none",
},
}}
>
{!isVisible ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
/>
{error && (
<Typography
component="span"
className="input-error"
color={theme.palette.error.text}
mt={theme.spacing(2)}
sx={{
opacity: 0.8,
}}
>
{error}
</Typography>
)}
</Stack>
);
}
);
Field.displayName = "Field";
Field.propTypes = {
type: PropTypes.oneOf(["text", "password", "url", "email", "description", "number"]),
id: PropTypes.string.isRequired,
name: PropTypes.string,
label: PropTypes.string,
https: PropTypes.bool,
isRequired: PropTypes.bool,
isOptional: PropTypes.bool,
optionalLabel: PropTypes.string,
autoComplete: PropTypes.string,
placeholder: PropTypes.string,
value: PropTypes.string.isRequired,
onChange: PropTypes.func,
onBlur: PropTypes.func,
onInput: PropTypes.func,
error: PropTypes.string,
disabled: PropTypes.bool,
hidden: PropTypes.bool,
};
export default Field;

View File

@ -41,7 +41,7 @@ const Radio = (props) => {
color: "transparent",
width: 16,
height: 16,
boxShadow: "inset 0 0 0 1px #656a74",
boxShadow: `inset 0 0 0 1px ${theme.palette.secondary.main}`,
mt: theme.spacing(0.5),
}}
/>

View File

@ -99,7 +99,7 @@ const Search = ({
<Typography
component="span"
className="input-error"
color={theme.palette.error.text}
color={theme.palette.error.contrastText}
mt={theme.spacing(2)}
sx={{
opacity: 0.8,
@ -161,7 +161,7 @@ const Search = ({
},
}}
sx={{
height: 34,
/* height: 34,*/
"&.MuiAutocomplete-root .MuiAutocomplete-input": { p: 0 },
...sx,
}}

View File

@ -0,0 +1,65 @@
import { Stack, Typography, InputAdornment, IconButton } from "@mui/material";
import { useTheme } from "@mui/material/styles";
import { useState } from "react";
import PropTypes from "prop-types";
import VisibilityOff from "@mui/icons-material/VisibilityOff";
import Visibility from "@mui/icons-material/Visibility";
export const HttpAdornment = ({ https }) => {
const theme = useTheme();
return (
<Stack
direction="row"
alignItems="center"
height="100%"
sx={{
borderRight: `solid 1px ${theme.palette.border.dark}`,
backgroundColor: theme.palette.background.accent,
pl: theme.spacing(6),
}}
>
<Typography
component="h5"
paddingRight={"var(--env-var-spacing-1-minus)"}
color={theme.palette.text.secondary}
sx={{ lineHeight: 1, opacity: 0.8 }}
>
{https ? "https" : "http"}://
</Typography>
</Stack>
);
};
HttpAdornment.propTypes = {
https: PropTypes.bool.isRequired,
};
export const PasswordEndAdornment = ({ fieldType, setFieldType }) => {
const theme = useTheme();
return (
<InputAdornment position="end">
<IconButton
aria-label="toggle password visibility"
onClick={() => setFieldType(fieldType === "password" ? "text" : "password")}
sx={{
color: theme.palette.border.dark,
padding: theme.spacing(1),
"&:focus-visible": {
outline: `2px solid ${theme.palette.primary.main}`,
outlineOffset: `2px`,
},
"& .MuiTouchRipple-root": {
pointerEvents: "none",
display: "none",
},
}}
>
{fieldType === "password" ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
);
};
PasswordEndAdornment.propTypes = {
fieldType: PropTypes.string,
setFieldType: PropTypes.func,
};

View File

@ -0,0 +1,166 @@
import { Stack, TextField, Typography } from "@mui/material";
import { useTheme } from "@emotion/react";
import { forwardRef, useState, cloneElement } from "react";
import PropTypes from "prop-types";
const getSx = (theme, type, maxWidth) => {
const sx = {
maxWidth: maxWidth,
"& .MuiFormHelperText-root": {
position: "absolute",
bottom: `-${theme.spacing(24)}`,
minHeight: theme.spacing(24),
},
};
if (type === "url") {
return {
...sx,
"& .MuiInputBase-root": { padding: 0 },
"& .MuiStack-root": {
borderTopLeftRadius: theme.shape.borderRadius,
borderBottomLeftRadius: theme.shape.borderRadius,
},
};
}
return sx;
};
const Required = () => {
const theme = useTheme();
return (
<Typography
component="span"
ml={theme.spacing(1)}
color={theme.palette.error.main}
>
*
</Typography>
);
};
const Optional = ({ optionalLabel }) => {
const theme = useTheme();
return (
<Typography
component="span"
fontSize="inherit"
fontWeight={400}
ml={theme.spacing(2)}
sx={{ opacity: 0.6 }}
>
{optionalLabel || "(optional)"}
</Typography>
);
};
Optional.propTypes = {
optionalLabel: PropTypes.string,
};
const TextInput = forwardRef(
(
{
id,
name,
type,
value,
placeholder,
isRequired,
isOptional,
optionalLabel,
onChange,
onBlur,
error = false,
helperText = null,
startAdornment = null,
endAdornment = null,
label = null,
maxWidth = "100%",
flex,
marginTop,
marginRight,
marginBottom,
marginLeft,
disabled = false,
hidden = false,
},
ref
) => {
const [fieldType, setFieldType] = useState(type);
const theme = useTheme();
return (
<Stack
flex={flex}
display={hidden ? "none" : ""}
marginTop={marginTop}
marginRight={marginRight}
marginBottom={marginBottom}
marginLeft={marginLeft}
>
<Typography
component="h3"
fontSize={"var(--env-var-font-size-medium)"}
color={theme.palette.text.secondary}
fontWeight={500}
>
{label}
{isRequired && <Required />}
{isOptional && <Optional optionalLabel={optionalLabel} />}
</Typography>
<TextField
id={id}
name={name}
type={fieldType}
value={value}
placeholder={placeholder}
onChange={onChange}
onBlur={onBlur}
error={error}
helperText={helperText}
inputRef={ref}
sx={getSx(theme, type, maxWidth)}
slotProps={{
input: {
startAdornment: startAdornment,
endAdornment: endAdornment
? cloneElement(endAdornment, { fieldType, setFieldType })
: null,
},
}}
disabled={disabled}
/>
</Stack>
);
}
);
TextInput.displayName = "TextInput";
TextInput.propTypes = {
type: PropTypes.string,
id: PropTypes.string.isRequired,
name: PropTypes.string,
value: PropTypes.string,
placeholder: PropTypes.string,
isRequired: PropTypes.bool,
isOptional: PropTypes.bool,
optionalLabel: PropTypes.string,
onChange: PropTypes.func,
onBlur: PropTypes.func,
error: PropTypes.bool,
helperText: PropTypes.string,
startAdornment: PropTypes.node,
endAdornment: PropTypes.node,
label: PropTypes.string,
maxWidth: PropTypes.string,
flex: PropTypes.number,
marginTop: PropTypes.string,
marginRight: PropTypes.string,
marginBottom: PropTypes.string,
marginLeft: PropTypes.string,
disabled: PropTypes.bool,
hidden: PropTypes.bool,
};
export default TextInput;

View File

@ -1,5 +1,4 @@
.label {
border: 1px solid #000;
display: inline-flex;
justify-content: center;
align-items: center;

View File

@ -31,7 +31,7 @@ const BaseLabel = ({ label, styles, children }) => {
className="label"
sx={{
borderRadius: borderRadius,
borderColor: theme.palette.text.tertiary,
border: `1px solid ${theme.palette.text.tertiary}`,
color: theme.palette.text.tertiary,
padding: padding,
...styles,
@ -127,22 +127,22 @@ const StatusLabel = ({ status, text, customStyles }) => {
const colors = {
up: {
dotColor: theme.palette.success.main,
bgColor: theme.palette.success.bg,
borderColor: theme.palette.success.light,
bgColor: theme.palette.success.contrastText /* dark */,
borderColor: theme.palette.success.main /* light */,
},
down: {
dotColor: theme.palette.error.text,
bgColor: theme.palette.error.bg,
dotColor: theme.palette.error.main,
bgColor: theme.palette.error.dark,
borderColor: theme.palette.error.light,
},
paused: {
dotColor: theme.palette.warning.main,
bgColor: theme.palette.warning.bg,
bgColor: theme.palette.warning.dark,
borderColor: theme.palette.warning.light,
},
pending: {
dotColor: theme.palette.warning.main,
bgColor: theme.palette.warning.bg,
bgColor: theme.palette.warning.dark,
borderColor: theme.palette.warning.light,
},
"cannot resolve": {
@ -182,4 +182,4 @@ StatusLabel.propTypes = {
customStyles: PropTypes.object,
};
export { ColoredLabel, StatusLabel };
export { BaseLabel, ColoredLabel, StatusLabel };

View File

@ -6,6 +6,13 @@
padding: var(--env-var-spacing-2);
}
/* TODO go for this approach for responsiveness. The aside needs to be taken care of */
/* @media (max-width: 1000px) {
.home-layout {
flex-direction: column !important;
}
} */
.home-layout aside {
position: sticky;
top: var(--env-var-spacing-2);

View File

@ -1,4 +1,4 @@
import Sidebar from "../../Components/Sidebar";
import Sidebar from "../../Sidebar";
import { Outlet } from "react-router";
import { Stack } from "@mui/material";

View File

@ -34,11 +34,11 @@ const ProgressUpload = ({ icon, label, size, progress = 0, onClick, error }) =>
backgroundColor: theme.palette.background.fill,
"&:has(.input-error)": {
borderColor: theme.palette.error.main,
backgroundColor: theme.palette.error.bg,
backgroundColor: theme.palette.error.dark,
py: theme.spacing(4),
px: theme.spacing(8),
"& > .MuiStack-root > svg": {
fill: theme.palette.error.text,
fill: theme.palette.error.contrastText,
width: "20px",
height: "20px",
},
@ -85,7 +85,7 @@ const ProgressUpload = ({ icon, label, size, progress = 0, onClick, error }) =>
<Typography
component="p"
className="input-error"
color={theme.palette.error.text}
color={theme.palette.error.contrastText}
>
{error}
</Typography>

View File

@ -19,7 +19,7 @@ import { useLocation, useNavigate } from "react-router";
import { useTheme } from "@emotion/react";
import { useDispatch, useSelector } from "react-redux";
import { clearAuthState } from "../../Features/Auth/authSlice";
import { setMode, toggleSidebar } from "../../Features/UI/uiSlice";
import { toggleSidebar } from "../../Features/UI/uiSlice";
import { clearUptimeMonitorState } from "../../Features/UptimeMonitors/uptimeMonitorsSlice";
import Avatar from "../Avatar";
import LockSvg from "../../assets/icons/lock.svg?react";
@ -29,7 +29,6 @@ import LogoutSvg from "../../assets/icons/logout.svg?react";
import Support from "../../assets/icons/support.svg?react";
import Dashboard from "../../assets/icons/dashboard.svg?react";
import Account from "../../assets/icons/user-edit.svg?react";
import StatusPages from "../../assets/icons/status-pages.svg?react";
import Maintenance from "../../assets/icons/maintenance.svg?react";
import Monitors from "../../assets/icons/monitors.svg?react";
import Incidents from "../../assets/icons/incidents.svg?react";
@ -48,14 +47,9 @@ import Folder from "../../assets/icons/folder.svg?react";
import "./index.css";
const menu = [
{
name: "Dashboard",
icon: <Dashboard />,
nested: [
{ name: "Monitors", path: "monitors", icon: <Monitors /> },
{ name: "Pagespeed", path: "pagespeed", icon: <PageSpeed /> },
],
},
{ name: "Monitors", path: "monitors", icon: <Monitors /> },
{ name: "Pagespeed", path: "pagespeed", icon: <PageSpeed /> },
{ name: "Infrastructure", path: "infrastructure", icon: <Integrations /> },
{ name: "Incidents", path: "incidents", icon: <Incidents /> },
// { name: "Status pages", path: "status", icon: <StatusPages /> },
{ name: "Maintenance", path: "maintenance", icon: <Maintenance /> },
@ -83,13 +77,14 @@ const menu = [
const URL_MAP = {
support: "https://github.com/bluewave-labs/bluewave-uptime/issues",
docs: "https://bluewavelabs.gitbook.io/uptime-manager",
docs: "https://bluewavelabs.gitbook.io/checkmate",
changelog: "https://github.com/bluewave-labs/bluewave-uptime/releases",
};
const PATH_MAP = {
monitors: "Dashboard",
pagespeed: "Dashboard",
infrastructure: "Dashboard",
account: "Account",
settings: "Other",
};
@ -113,7 +108,6 @@ function Sidebar() {
const [popup, setPopup] = useState();
const { user } = useSelector((state) => state.auth);
// Remove demo password if demo
const accountMenuItem = menu.find((item) => item.name === "Account");
if (user.role?.includes("demo") && accountMenuItem) {
accountMenuItem.nested = accountMenuItem.nested.filter((item) => {
@ -148,7 +142,9 @@ function Sidebar() {
if (matchedKey) {
setOpen((prev) => ({ ...prev, [PATH_MAP[matchedKey]]: true }));
}
}, []);
}, [location]);
/* TODO refactor this, there are a some ternaries and comments in the return */
return (
<Stack
@ -181,7 +177,13 @@ function Sidebar() {
direction="row"
alignItems="center"
gap={theme.spacing(4)}
onClick={() => window.open("https://github.com/bluewave-labs/bluewave-uptime", "_blank", "noreferrer")}
onClick={() =>
window.open(
"https://github.com/bluewave-labs/bluewave-uptime",
"_blank",
"noreferrer"
)
}
sx={{ cursor: "pointer" }}
>
<Stack
@ -199,14 +201,14 @@ function Sidebar() {
userSelect: "none",
}}
>
BU
C
</Stack>
<Typography
component="span"
mt={theme.spacing(2)}
sx={{ opacity: 0.8, fontWeight: 500 }}
>
BlueWave Uptime
Checkmate
</Typography>
</Stack>
<IconButton
@ -297,6 +299,7 @@ function Sidebar() {
</ListItemButton>
</Tooltip>
) : collapsed ? (
/* TODO Do we ever get here? */
<React.Fragment key={item.name}>
<Tooltip
placement="right"
@ -616,22 +619,6 @@ function Sidebar() {
</MenuItem>
)}
{collapsed && <Divider />}
{/* <MenuItem
onClick={() => {
dispatch(setMode("light"));
closePopup();
}}
>
Light
</MenuItem>
<MenuItem
onClick={() => {
dispatch(setMode("dark"));
closePopup();
}}
>
Dark
</MenuItem> */}
<Divider />
<MenuItem
onClick={logout}

View File

@ -3,12 +3,20 @@ import { useState } from "react";
import { useTheme } from "@emotion/react";
import { Box, Stack, Typography } from "@mui/material";
import LoadingButton from "@mui/lab/LoadingButton";
import Field from "../../Inputs/Field";
import { PasswordEndAdornment } from "../../Inputs/TextInput/Adornments";
import TextInput from "../../Inputs/TextInput";
import { credentials } from "../../../Validation/validation";
import Alert from "../../Alert";
import { update } from "../../../Features/Auth/authSlice";
import { useDispatch, useSelector } from "react-redux";
import { createToast } from "../../../Utils/toastUtils";
import { getTouchedFieldErrors } from "../../../Validation/error";
const defaultPasswordsState = {
password: "",
newPassword: "",
confirm: "",
};
/**
* PasswordPanel component manages the form for editing password.
@ -29,36 +37,40 @@ const PasswordPanel = () => {
"edit-confirm-password": "confirm",
};
const [localData, setLocalData] = useState({
password: "",
newPassword: "",
confirm: "",
const [localData, setLocalData] = useState(defaultPasswordsState);
const [errors, setErrors] = useState(defaultPasswordsState);
const [touchedFields, setTouchedFields] = useState({
password: false,
newPassword: false,
confirm: false,
});
const [errors, setErrors] = useState({});
const handleChange = (event) => {
const { value, id } = event.target;
const name = idToName[id];
setLocalData((prev) => ({
...prev,
const updatedData = {
...localData,
[name]: value,
}));
};
const updatedTouchedFields = {
...touchedFields,
[name]: true,
};
const validation = credentials.validate(
{ [name]: value },
{ abortEarly: false, context: { password: localData.newPassword } }
{ ...updatedData },
{ abortEarly: false, context: { password: updatedData.newPassword } }
);
setErrors((prev) => {
const updatedErrors = { ...prev };
const updatedErrors = getTouchedFieldErrors(validation, updatedTouchedFields);
if (validation.error) {
updatedErrors[name] = validation.error.details[0].message;
} else {
delete updatedErrors[name];
}
return updatedErrors;
});
if (!touchedFields[name]) {
setTouchedFields(updatedTouchedFields);
}
setLocalData(updatedData);
setErrors(updatedErrors);
};
const handleSubmit = async (event) => {
@ -110,67 +122,105 @@ const PasswordPanel = () => {
onSubmit={handleSubmit}
noValidate
spellCheck="false"
gap={theme.spacing(20)}
gap={theme.spacing(26)}
maxWidth={"80ch"}
marginInline={"auto"}
>
<Stack direction="row">
<Box flex={0.9}>
<Typography component="h1">Current password</Typography>
</Box>
<Field
type="text"
id="hidden-username"
name="username"
autoComplete="username"
hidden={true}
value=""
/>
<Field
<TextInput
type="text"
id="hidden-username"
name="username"
autoComplete="username"
hidden={true}
value=""
/>
<Stack
direction="row"
justifyContent={"flex-start"}
alignItems={"center"}
gap={theme.spacing(8)}
flexWrap={"wrap"}
>
<Typography
component="h1"
width="20ch"
>
Current password
</Typography>
<TextInput
type="password"
id="edit-current-password"
placeholder="Enter your current password"
autoComplete="current-password"
value={localData.password}
onChange={handleChange}
error={errors[idToName["edit-current-password"]]}
error={errors[idToName["edit-current-password"]] ? true : false}
helperText={errors[idToName["edit-current-password"]]}
endAdornment={<PasswordEndAdornment />}
flex={1}
/>
</Stack>
<Stack direction="row">
<Box flex={0.9}>
<Typography component="h1">New password</Typography>
</Box>
<Field
<Stack
direction="row"
alignItems={"center"}
gap={theme.spacing(8)}
flexWrap={"wrap"}
>
<Typography
component="h1"
width="20ch"
>
New password
</Typography>
<TextInput
type="password"
id="edit-new-password"
placeholder="Enter your new password"
autoComplete="new-password"
value={localData.newPassword}
onChange={handleChange}
error={errors[idToName["edit-new-password"]]}
error={errors[idToName["edit-new-password"]] ? true : false}
helperText={errors[idToName["edit-new-password"]]}
endAdornment={<PasswordEndAdornment />}
flex={1}
/>
</Stack>
<Stack direction="row">
<Box flex={0.9}>
<Typography component="h1">Confirm new password</Typography>
</Box>
<Field
<Stack
direction="row"
alignItems={"center"}
gap={theme.spacing(8)}
flexWrap={"wrap"}
>
<Typography
component="h1"
width="20ch"
>
Confirm new password
</Typography>
<TextInput
type="password"
id="edit-confirm-password"
placeholder="Reenter your new password"
autoComplete="new-password"
value={localData.confirm}
onChange={handleChange}
error={errors[idToName["edit-confirm-password"]]}
error={errors[idToName["edit-confirm-password"]] ? true : false}
helperText={errors[idToName["edit-confirm-password"]]}
endAdornment={<PasswordEndAdornment />}
flex={1}
/>
</Stack>
<Stack direction="row">
<Box flex={0.9}></Box>
<Box sx={{ flex: 1 }}>
{Object.keys(errors).length > 0 && (
<Box sx={{ maxWidth: "70ch" }}>
<Alert
variant="warning"
body="New password must contain at least 8 characters and must have at least one uppercase letter, one number and one symbol."
body="New password must contain at least 8 characters and must have at least one uppercase letter, one lowercase letter, one number and one special character."
/>
</Box>
</Stack>
)}
<Stack
direction="row"
justifyContent="flex-end"
@ -178,10 +228,13 @@ const PasswordPanel = () => {
<LoadingButton
variant="contained"
color="primary"
onClick={handleSubmit}
type="submit"
loading={isLoading}
loadingIndicator="Saving..."
disabled={Object.keys(errors).length !== 0 && true}
disabled={
Object.keys(errors).length > 0 ||
Object.values(localData).filter((value) => value === "").length > 0
}
sx={{
px: theme.spacing(12),
mt: theme.spacing(20),

View File

@ -3,7 +3,7 @@ import { useRef, useState } from "react";
import TabPanel from "@mui/lab/TabPanel";
import { Box, Button, Divider, Stack, Typography } from "@mui/material";
import Avatar from "../../Avatar";
import Field from "../../Inputs/Field";
import TextInput from "../../Inputs/TextInput";
import ImageField from "../../Inputs/Image";
import { credentials, imageValidation } from "../../../Validation/validation";
import { useDispatch, useSelector } from "react-redux";
@ -229,26 +229,30 @@ const ProfilePanel = () => {
<Box flex={0.9}>
<Typography component="h1">First name</Typography>
</Box>
<Field
<TextInput
id="edit-first-name"
value={localData.firstName}
placeholder="Enter your first name"
autoComplete="given-name"
onChange={handleChange}
error={errors[idToName["edit-first-name"]]}
error={errors[idToName["edit-first-name"]] ? true : false}
helperText={errors[idToName["edit-first-name"]]}
flex={1}
/>
</Stack>
<Stack direction="row">
<Box flex={0.9}>
<Typography component="h1">Last name</Typography>
</Box>
<Field
<TextInput
id="edit-last-name"
placeholder="Enter your last name"
autoComplete="family-name"
value={localData.lastName}
onChange={handleChange}
error={errors[idToName["edit-last-name"]]}
error={errors[idToName["edit-last-name"]] ? true : false}
helperText={errors[idToName["edit-last-name"]]}
flex={1}
/>
</Stack>
<Stack direction="row">
@ -261,14 +265,14 @@ const ProfilePanel = () => {
This is your current email address it cannot be changed.
</Typography>
</Stack>
<Field
<TextInput
id="edit-email"
value={user.email}
placeholder="Enter your email"
autoComplete="email"
onChange={() => logger.warn("disabled")}
// error={errors[idToName["edit-email"]]}
disabled={true}
flex={1}
/>
</Stack>
<Stack direction="row">
@ -370,8 +374,6 @@ const ProfilePanel = () => {
</Button>
</Box>
)}
{/* TODO - Update ModalPopup Component with @mui for reusability */}
<Dialog
open={isModalOpen("delete")}
theme={theme}

View File

@ -2,7 +2,7 @@ import { useTheme } from "@emotion/react";
import TabPanel from "@mui/lab/TabPanel";
import { Button, ButtonGroup, Stack, Typography } from "@mui/material";
import { useEffect, useState } from "react";
import Field from "../../Inputs/Field";
import TextInput from "../../Inputs/TextInput";
import { credentials } from "../../../Validation/validation";
import { networkService } from "../../../main";
import { createToast } from "../../../Utils/toastUtils";
@ -301,7 +301,7 @@ const TeamPanel = () => {
filled={(filter === "admin").toString()}
onClick={() => setFilter("admin")}
>
Administrator
Super admin
</Button>
<Button
variant="group"
@ -338,13 +338,15 @@ const TeamPanel = () => {
onClose={closeInviteModal}
theme={theme}
>
<Field
<TextInput
marginBottom={theme.spacing(12)}
type="email"
id="input-team-member"
placeholder="Email"
value={toInvite.email}
onChange={handleChange}
error={errors.email}
error={errors.email ? true : false}
helperText={errors.email}
/>
<Select
id="team-member-role"

View File

@ -0,0 +1,411 @@
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
import { jwtDecode } from "jwt-decode";
import { networkService } from "../../main";
const initialState = {
isLoading: false,
monitorsSummary: [],
success: null,
msg: null,
};
export const createInfrastructureMonitor = createAsyncThunk(
"infrastructureMonitors/createMonitor",
async (data, thunkApi) => {
try {
const { authToken, monitor } = data;
const res = await networkService.createMonitor({
authToken: authToken,
monitor: monitor,
});
return res.data;
} catch (error) {
if (error.response && error.response.data) {
return thunkApi.rejectWithValue(error.response.data);
}
const payload = {
status: false,
msg: error.message ? error.message : "Unknown error",
};
return thunkApi.rejectWithValue(payload);
}
}
);
export const checkInfrastructureEndpointResolution = createAsyncThunk(
"infrastructureMonitors/CheckEndpoint",
async (data, thunkApi) => {
try {
const { authToken, monitorURL } = data;
const res = await networkService.checkEndpointResolution({
authToken: authToken,
monitorURL: monitorURL,
});
return res.data;
} catch (error) {
if (error.response && error.response.data) {
return thunkApi.rejectWithValue(error.response.data);
}
const payload = {
status: false,
msg: error.message ? error.message : "Unknown error",
};
return thunkApi.rejectWithValue(payload);
}
}
);
export const getInfrastructureMonitorById = createAsyncThunk(
"infrastructureMonitors/getMonitorById",
async (data, thunkApi) => {
try {
const { authToken, monitorId } = data;
const res = await networkService.getMonitorById({
authToken: authToken,
monitorId: monitorId,
});
return res.data;
} catch (error) {
if (error.response && error.response.data) {
return thunkApi.rejectWithValue(error.response.data);
}
const payload = {
status: false,
msg: error.message ? error.message : "Unknown error",
};
return thunkApi.rejectWithValue(payload);
}
}
);
export const getInfrastructureMonitorsByTeamId = createAsyncThunk(
"infrastructureMonitors/getMonitorsByTeamId",
async (token, thunkApi) => {
const user = jwtDecode(token);
try {
const res = await networkService.getMonitorsAndSummaryByTeamId({
authToken: token,
teamId: user.teamId,
types: ["hardware"],
limit: 1,
rowsPerPage: 0,
});
return res.data;
} catch (error) {
if (error.response && error.response.data) {
return thunkApi.rejectWithValue(error.response.data);
}
const payload = {
status: false,
msg: error.message ? error.message : "Unknown error",
};
return thunkApi.rejectWithValue(payload);
}
}
);
export const updateInfrastructureMonitor = createAsyncThunk(
"infrastructureMonitors/updateMonitor",
async (data, thunkApi) => {
try {
const { authToken, monitor } = data;
const updatedFields = {
name: monitor.name,
description: monitor.description,
interval: monitor.interval,
notifications: monitor.notifications,
threshold: monitor.threshold,
};
const res = await networkService.updateMonitor({
authToken: authToken,
monitorId: monitor._id,
updatedFields: updatedFields,
});
return res.data;
} catch (error) {
if (error.response && error.response.data) {
return thunkApi.rejectWithValue(error.response.data);
}
const payload = {
status: false,
msg: error.message ? error.message : "Unknown error",
};
return thunkApi.rejectWithValue(payload);
}
}
);
export const deleteInfrastructureMonitor = createAsyncThunk(
"infrastructureMonitors/deleteMonitor",
async (data, thunkApi) => {
try {
const { authToken, monitor } = data;
const res = await networkService.deleteMonitorById({
authToken: authToken,
monitorId: monitor._id,
});
return res.data;
} catch (error) {
if (error.response && error.response.data) {
return thunkApi.rejectWithValue(error.response.data);
}
const payload = {
status: false,
msg: error.message ? error.message : "Unknown error",
};
return thunkApi.rejectWithValue(payload);
}
}
);
export const pauseInfrastructureMonitor = createAsyncThunk(
"infrastructureMonitors/pauseMonitor",
async (data, thunkApi) => {
try {
const { authToken, monitorId } = data;
const res = await networkService.pauseMonitorById({
authToken: authToken,
monitorId: monitorId,
});
return res.data;
} catch (error) {
if (error.response && error.response.data) {
return thunkApi.rejectWithValue(error.response.data);
}
const payload = {
status: false,
msg: error.message ? error.message : "Unknown error",
};
return thunkApi.rejectWithValue(payload);
}
}
);
export const deleteInfrastructureMonitorChecksByTeamId = createAsyncThunk(
"infrastructureMonitors/deleteChecksByTeamId",
async (data, thunkApi) => {
try {
const { authToken, teamId } = data;
const res = await networkService.deleteChecksByTeamId({
authToken: authToken,
teamId: teamId,
});
return res.data;
} catch (error) {
if (error.response && error.response.data) {
return thunkApi.rejectWithValue(error.response.data);
}
const payload = {
status: false,
msg: error.message ? error.message : "Unknown error",
};
return thunkApi.rejectWithValue(payload);
}
}
);
export const deleteAllInfrastructureMonitors = createAsyncThunk(
"infrastructureMonitors/deleteAllMonitors",
async (data, thunkApi) => {
try {
const { authToken } = data;
const res = await networkService.deleteAllMonitors({
authToken: authToken,
});
return res.data;
} catch (error) {
if (error.response && error.response.data) {
return thunkApi.rejectWithValue(error.response.data);
}
const payload = {
status: false,
msg: error.message ? error.message : "Unknown error",
};
return thunkApi.rejectWithValue(payload);
}
}
);
const infrastructureMonitorsSlice = createSlice({
name: "infrastructureMonitors",
initialState,
reducers: {
clearInfrastructureMonitorState: (state) => {
state.isLoading = false;
state.monitorsSummary = [];
state.success = null;
state.msg = null;
},
},
extraReducers: (builder) => {
builder
// *****************************************************
// Monitors by teamId
// *****************************************************
.addCase(getInfrastructureMonitorsByTeamId.pending, (state) => {
state.isLoading = true;
})
.addCase(getInfrastructureMonitorsByTeamId.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.msg;
state.monitorsSummary = action.payload.data;
})
.addCase(getInfrastructureMonitorsByTeamId.rejected, (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Getting infrastructure monitors failed";
})
// *****************************************************
// Create Monitor
// *****************************************************
.addCase(createInfrastructureMonitor.pending, (state) => {
state.isLoading = true;
})
.addCase(createInfrastructureMonitor.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
})
.addCase(createInfrastructureMonitor.rejected, (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Failed to create infrastructure monitor";
})
// *****************************************************
// Resolve Endpoint
// *****************************************************
.addCase(checkInfrastructureEndpointResolution.pending, (state) => {
state.isLoading = true;
})
.addCase(checkInfrastructureEndpointResolution.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
})
.addCase(checkInfrastructureEndpointResolution.rejected, (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Failed to check endpoint resolution";
})
// *****************************************************
// Get Monitor By Id
// *****************************************************
.addCase(getInfrastructureMonitorById.pending, (state) => {
state.isLoading = true;
})
.addCase(getInfrastructureMonitorById.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
})
.addCase(getInfrastructureMonitorById.rejected, (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Failed to get infrastructure monitor";
})
// *****************************************************
// update Monitor
// *****************************************************
.addCase(updateInfrastructureMonitor.pending, (state) => {
state.isLoading = true;
})
.addCase(updateInfrastructureMonitor.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
})
.addCase(updateInfrastructureMonitor.rejected, (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Failed to update infrastructure monitor";
})
// *****************************************************
// Delete Monitor
// *****************************************************
.addCase(deleteInfrastructureMonitor.pending, (state) => {
state.isLoading = true;
})
.addCase(deleteInfrastructureMonitor.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
})
.addCase(deleteInfrastructureMonitor.rejected, (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Failed to delete infrastructure monitor";
})
// *****************************************************
// Delete Monitor checks by Team ID
// *****************************************************
.addCase(deleteInfrastructureMonitorChecksByTeamId.pending, (state) => {
state.isLoading = true;
})
.addCase(deleteInfrastructureMonitorChecksByTeamId.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
})
.addCase(deleteInfrastructureMonitorChecksByTeamId.rejected, (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Failed to delete monitor checks";
})
// *****************************************************
// Pause Monitor
// *****************************************************
.addCase(pauseInfrastructureMonitor.pending, (state) => {
state.isLoading = true;
})
.addCase(pauseInfrastructureMonitor.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
})
.addCase(pauseInfrastructureMonitor.rejected, (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Failed to pause infrastructure monitor";
})
// *****************************************************
// Delete all Monitors
// *****************************************************
.addCase(deleteAllInfrastructureMonitors.pending, (state) => {
state.isLoading = true;
})
.addCase(deleteAllInfrastructureMonitors.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
})
.addCase(deleteAllInfrastructureMonitors.rejected, (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload ? action.payload.msg : "Failed to delete all monitors";
});
},
});
export const { setInfrastructureMonitors, clearInfrastructureMonitorState } =
infrastructureMonitorsSlice.actions;
export default infrastructureMonitorsSlice.reducer;

View File

@ -40,7 +40,7 @@ export const checkEndpointResolution = createAsyncThunk(
const res = await networkService.checkEndpointResolution({
authToken: authToken,
monitorURL: monitorURL,
})
});
return res.data;
} catch (error) {
if (error.response && error.response.data) {
@ -53,7 +53,7 @@ export const checkEndpointResolution = createAsyncThunk(
return thunkApi.rejectWithValue(payload);
}
}
)
);
export const getUptimeMonitorById = createAsyncThunk(
"monitors/getMonitorById",
@ -86,7 +86,7 @@ export const getUptimeMonitorsByTeamId = createAsyncThunk(
const res = await networkService.getMonitorsAndSummaryByTeamId({
authToken: token,
teamId: user.teamId,
types: ["http", "ping"],
types: ["http", "ping", "docker"],
});
return res.data;
} catch (error) {
@ -221,6 +221,7 @@ export const addDemoMonitors = createAsyncThunk(
}
}
);
export const deleteAllMonitors = createAsyncThunk(
"monitors/deleteAllMonitors",
async (data, thunkApi) => {

View File

@ -0,0 +1,11 @@
import { useSelector } from "react-redux";
const useIsAdmin = () => {
const { user } = useSelector((state) => state.auth);
const isAdmin =
(user?.role?.includes("admin") ?? false) ||
(user?.role?.includes("superadmin") ?? false);
return isAdmin;
};
export { useIsAdmin };

View File

@ -1,3 +1,4 @@
import { useState } from "react";
import PropTypes from "prop-types";
import { useNavigate } from "react-router";
import { useSelector } from "react-redux";
@ -14,10 +15,10 @@ import "./index.css";
* @param {string} [props.open] - Specifies the initially open tab: 'profile', 'password', or 'team'.
* @returns {JSX.Element}
*/
const Account = ({ open = "profile" }) => {
const theme = useTheme();
const navigate = useNavigate();
const [focusedTab, setFocusedTab] = useState(null); // Track focused tab
const tab = open;
const handleTabChange = (event, newTab) => {
navigate(`/account/${newTab}`);
@ -36,6 +37,21 @@ const Account = ({ open = "profile" }) => {
tabList = ["Profile"];
}
const handleKeyDown = (event) => {
const currentIndex = tabList.findIndex((label) => label.toLowerCase() === tab);
if (event.key === "Tab") {
const nextIndex = (currentIndex + 1) % tabList.length;
setFocusedTab(tabList[nextIndex].toLowerCase());
} else if (event.key === "Enter") {
event.preventDefault();
navigate(`/account/${focusedTab}`);
}
};
const handleFocus = (tabName) => {
setFocusedTab(tabName);
};
return (
<Box
className="account"
@ -63,6 +79,9 @@ const Account = ({ open = "profile" }) => {
label={label}
key={index}
value={label.toLowerCase()}
onKeyDown={handleKeyDown}
onFocus={() => handleFocus(label.toLowerCase())}
tabIndex={index}
sx={{
fontSize: 13,
color: theme.palette.text.tertiary,
@ -74,7 +93,11 @@ const Account = ({ open = "profile" }) => {
fontWeight: 400,
marginRight: theme.spacing(8),
"&:focus": {
outline: "none",
borderBottom: `2px solid ${theme.palette.border.light}`,
},
"&:hover": {
borderBottom: `2px solid ${theme.palette.border.light}`,
},
}}
/>

View File

@ -1,6 +1,6 @@
import { useTheme } from "@emotion/react";
import { Box, Stack, Typography } from "@mui/material";
import Field from "../../Components/Inputs/Field";
import TextInput from "../../Components/Inputs/TextInput";
import Link from "../../Components/Link";
import "./index.css";
import { useDispatch, useSelector } from "react-redux";
@ -160,13 +160,14 @@ const AdvancedSettings = ({ isAdmin }) => {
</Typography>
</Box>
<Stack gap={theme.spacing(20)}>
<Field
<TextInput
id="apiBaseUrl"
label="API URL Host"
value={localSettings.apiBaseUrl}
onChange={handleChange}
onBlur={handleBlur}
error={errors.apiBaseUrl}
error={errors.apiBaseUrl ? true : false}
helperText={errors.apiBaseUrl}
/>
<Select
id="logLevel"
@ -189,7 +190,7 @@ const AdvancedSettings = ({ isAdmin }) => {
</Typography>
</Box>
<Stack gap={theme.spacing(20)}>
<Field
<TextInput
type="text"
id="systemEmailHost"
label="System email host"
@ -197,9 +198,10 @@ const AdvancedSettings = ({ isAdmin }) => {
value={localSettings.systemEmailHost}
onChange={handleChange}
onBlur={handleBlur}
error={errors.systemEmailHost}
error={errors.systemEmailHost ? true : false}
helperText={errors.systemEmailHost}
/>
<Field
<TextInput
type="number"
id="systemEmailPort"
label="System email port"
@ -207,18 +209,21 @@ const AdvancedSettings = ({ isAdmin }) => {
value={localSettings.systemEmailPort?.toString()}
onChange={handleChange}
onBlur={handleBlur}
error={errors.systemEmailPort}
error={errors.systemEmailPort ? true : false}
helperText={errors.systemEmailPort}
/>
<Field
<TextInput
type="email"
id="systemEmailAddress"
label="System email address"
name="systemEmailAddress"
value={localSettings.systemEmailAddress}
onChange={handleChange}
error={errors.systemEmailAddress}
onBlur={handleBlur}
error={errors.systemEmailAddress ? true : false}
helperText={errors.systemEmailAddress}
/>
<Field
<TextInput
type="text"
id="systemEmailPassword"
label="System email password"
@ -226,7 +231,8 @@ const AdvancedSettings = ({ isAdmin }) => {
value={localSettings.systemEmailPassword}
onChange={handleChange}
onBlur={handleBlur}
error={errors.systemEmailPassword}
error={errors.systemEmailPassword ? true : false}
helperText={errors.systemEmailPassword}
/>
</Stack>
</ConfigBox>
@ -242,7 +248,7 @@ const AdvancedSettings = ({ isAdmin }) => {
direction="row"
gap={theme.spacing(10)}
>
<Field
<TextInput
type="number"
id="jwtTTLNum"
label="JWT time to live"
@ -250,7 +256,8 @@ const AdvancedSettings = ({ isAdmin }) => {
value={localSettings.jwtTTLNum.toString()}
onChange={handleChange}
onBlur={handleBlur}
error={errors.jwtTTLNum}
error={errors.jwtTTLNum ? true : false}
helperText={errors.jwtTTLNum}
/>
<Select
id="jwtTTLUnits"
@ -265,7 +272,7 @@ const AdvancedSettings = ({ isAdmin }) => {
error={errors.jwtTTLUnits}
/>
</Stack>
<Field
<TextInput
type="text"
id="dbType"
label="Database type"
@ -273,9 +280,10 @@ const AdvancedSettings = ({ isAdmin }) => {
value={localSettings.dbType}
onChange={handleChange}
onBlur={handleBlur}
error={errors.dbType}
error={errors.dbType ? true : false}
helperText={errors.dbType}
/>
<Field
<TextInput
type="text"
id="redisHost"
label="Redis host"
@ -283,9 +291,10 @@ const AdvancedSettings = ({ isAdmin }) => {
value={localSettings.redisHost}
onChange={handleChange}
onBlur={handleBlur}
error={errors.redisHost}
error={errors.redisHost ? true : false}
helperText={errors.redisHost}
/>
<Field
<TextInput
type="number"
id="redisPort"
label="Redis port"
@ -293,9 +302,10 @@ const AdvancedSettings = ({ isAdmin }) => {
value={localSettings.redisPort?.toString()}
onChange={handleChange}
onBlur={handleBlur}
error={errors.redisPort}
error={errors.redisPort ? true : false}
helperText={errors.redisPort}
/>
<Field
<TextInput
type="text"
id="pagespeedApiKey"
label="PageSpeed API key"
@ -303,7 +313,8 @@ const AdvancedSettings = ({ isAdmin }) => {
value={localSettings.pagespeedApiKey}
onChange={handleChange}
onBlur={handleBlur}
error={errors.pagespeedApiKey}
error={errors.pagespeedApiKey ? true : false}
helperText={errors.pagespeedApiKey}
/>
</Stack>
</ConfigBox>

View File

@ -7,7 +7,7 @@ import { useEffect, useState } from "react";
import { credentials } from "../../Validation/validation";
import { useNavigate } from "react-router-dom";
import { IconBox } from "./styled";
import Field from "../../Components/Inputs/Field";
import TextInput from "../../Components/Inputs/TextInput";
import Logo from "../../assets/icons/bwu-icon.svg?react";
import Key from "../../assets/icons/key.svg?react";
import Background from "../../assets/Images/background-grid.svg?react";
@ -160,7 +160,7 @@ const ForgotPassword = () => {
spellCheck={false}
onSubmit={handleSubmit}
>
<Field
<TextInput
type="email"
id="forgot-password-email-input"
label="Email"
@ -168,7 +168,8 @@ const ForgotPassword = () => {
placeholder="Enter your email"
value={form.email}
onChange={handleChange}
error={errors.email}
error={errors.email ? true : false}
helperText={errors.email}
/>
<LoadingButton
variant="contained"

View File

@ -4,10 +4,12 @@ import { Box, Button, Stack, Typography } from "@mui/material";
import { useTheme } from "@emotion/react";
import { credentials } from "../../Validation/validation";
import { login } from "../../Features/Auth/authSlice";
import LoadingButton from "@mui/lab/LoadingButton";
import { useDispatch, useSelector } from "react-redux";
import { createToast } from "../../Utils/toastUtils";
import { networkService } from "../../main";
import Field from "../../Components/Inputs/Field";
import TextInput from "../../Components/Inputs/TextInput";
import { PasswordEndAdornment } from "../../Components/Inputs/TextInput/Adornments";
import Background from "../../assets/Images/background-grid.svg?react";
import Logo from "../../assets/icons/bwu-icon.svg?react";
import Mail from "../../assets/icons/mail.svg?react";
@ -51,6 +53,10 @@ const LandingPage = ({ onContinue }) => {
stroke: theme.palette.other.icon,
},
},
"&:focus-visible": {
outline: `2px solid ${theme.palette.primary.main}`,
outlineOffset: `2px`,
},
}}
>
<Mail />
@ -130,7 +136,7 @@ const StepOne = ({ form, errors, onSubmit, onChange, onBack }) => {
return (
<>
<Stack
gap={{ xs: theme.spacing(8), sm: theme.spacing(12) }}
gap={{ xs: theme.spacing(12), sm: theme.spacing(16) }}
textAlign="center"
>
<Box>
@ -139,57 +145,67 @@ const StepOne = ({ form, errors, onSubmit, onChange, onBack }) => {
</Box>
<Box
textAlign="left"
mb={theme.spacing(5)}
component="form"
noValidate
spellCheck={false}
onSubmit={onSubmit}
display="grid"
gap={{ xs: theme.spacing(12), sm: theme.spacing(16) }}
>
<form
noValidate
spellCheck={false}
onSubmit={onSubmit}
<TextInput
type="email"
id="login-email-input"
label="Email"
isRequired={true}
placeholder="jordan.ellis@domain.com"
autoComplete="email"
value={form.email}
onInput={(e) => (e.target.value = e.target.value.toLowerCase())}
onChange={onChange}
error={errors.email ? true : false}
helperText={errors.email}
ref={inputRef}
/>
<Stack
direction="row"
justifyContent="space-between"
>
<Field
type="email"
id="login-email-input"
label="Email"
isRequired={true}
placeholder="jordan.ellis@domain.com"
autoComplete="email"
value={form.email}
onInput={(e) => (e.target.value = e.target.value.toLowerCase())}
onChange={onChange}
error={errors.email}
ref={inputRef}
/>
</form>
<Button
variant="outlined"
color="info"
onClick={onBack}
sx={{
px: theme.spacing(5),
"& svg.MuiSvgIcon-root": {
mr: theme.spacing(3),
},
"&:focus-visible": {
outline: `2px solid ${theme.palette.primary.main}`,
outlineOffset: `2px`,
},
}}
>
<ArrowBackRoundedIcon />
Back
</Button>
<Button
variant="contained"
color="primary"
type="submit"
disabled={errors.email && true}
sx={{
width: "30%",
"&.Mui-focusVisible": {
outline: `2px solid ${theme.palette.primary.main}`,
outlineOffset: `2px`,
boxShadow: `none`,
},
}}
>
Continue
</Button>
</Stack>
</Box>
<Stack
direction="row"
justifyContent="space-between"
>
<Button
variant="outlined"
color="info"
onClick={onBack}
sx={{
px: theme.spacing(5),
"& svg.MuiSvgIcon-root": {
mr: theme.spacing(3),
},
}}
props={{ tabIndex: -1 }}
>
<ArrowBackRoundedIcon />
Back
</Button>
<Button
variant="contained"
color="primary"
onClick={onSubmit}
disabled={errors.email && true}
sx={{ width: "30%" }}
>
Continue
</Button>
</Stack>
</Stack>
</>
);
@ -218,6 +234,7 @@ const StepTwo = ({ form, errors, onSubmit, onChange, onBack }) => {
const theme = useTheme();
const navigate = useNavigate();
const inputRef = useRef(null);
const authState = useSelector((state) => state.auth);
useEffect(() => {
if (inputRef.current) {
@ -235,7 +252,7 @@ const StepTwo = ({ form, errors, onSubmit, onChange, onBack }) => {
return (
<>
<Stack
gap={{ xs: theme.spacing(8), sm: theme.spacing(12) }}
gap={{ xs: theme.spacing(12), sm: theme.spacing(16) }}
position="relative"
textAlign="center"
>
@ -244,64 +261,79 @@ const StepTwo = ({ form, errors, onSubmit, onChange, onBack }) => {
<Typography>Enter your password</Typography>
</Box>
<Box
component="form"
noValidate
spellCheck={false}
onSubmit={onSubmit}
textAlign="left"
mb={theme.spacing(5)}
sx={{
display: "grid",
gap: { xs: theme.spacing(12), sm: theme.spacing(16) },
}}
>
<form
noValidate
spellCheck={false}
onSubmit={onSubmit}
<TextInput
type="password"
id="login-password-input"
label="Password"
isRequired={true}
placeholder="••••••••••"
autoComplete="current-password"
value={form.password}
onChange={onChange}
error={errors.password ? true : false}
helperText={errors.password}
ref={inputRef}
endAdornment={<PasswordEndAdornment />}
/>
<Stack
direction="row"
justifyContent="space-between"
>
<Field
type="password"
id="login-password-input"
label="Password"
isRequired={true}
placeholder="••••••••••"
autoComplete="current-password"
value={form.password}
onChange={onChange}
error={errors.password}
ref={inputRef}
/>
</form>
<Button
variant="outlined"
color="info"
onClick={onBack}
sx={{
px: theme.spacing(5),
"& svg.MuiSvgIcon-root": {
mr: theme.spacing(3),
},
"&:focus-visible": {
outline: `2px solid ${theme.palette.primary.main}`,
outlineOffset: `2px`,
},
}}
>
<ArrowBackRoundedIcon />
Back
</Button>
<LoadingButton
variant="contained"
color="primary"
type="submit"
loading={authState.isLoading}
disabled={errors.password && true}
sx={{
width: "30%",
"&.Mui-focusVisible": {
outline: `2px solid ${theme.palette.primary.main}`,
outlineOffset: `2px`,
boxShadow: `none`,
},
}}
>
Continue
</LoadingButton>
</Stack>
</Box>
<Stack
direction="row"
justifyContent="space-between"
>
<Button
variant="outlined"
color="info"
onClick={onBack}
sx={{
px: theme.spacing(5),
"& svg.MuiSvgIcon-root": {
mr: theme.spacing(3),
},
}}
props={{ tabIndex: -1 }}
>
<ArrowBackRoundedIcon />
Back
</Button>
<Button
variant="contained"
color="primary"
onClick={onSubmit}
disabled={errors.password && true}
sx={{ width: "30%" }}
>
Continue
</Button>
</Stack>
<Box
textAlign="center"
sx={{
position: "absolute",
top: "104%",
bottom: 0,
left: "50%",
transform: "translateX(-50%)",
transform: `translate(-50%, 150%)`,
}}
>
<Typography
@ -523,23 +555,6 @@ const Login = () => {
)
)}
</Stack>
<Box
textAlign="center"
p={theme.spacing(12)}
>
<Typography display="inline-block">Don&apos;t have an account? </Typography>
<Typography
component="span"
color={theme.palette.primary.main}
ml={theme.spacing(2)}
onClick={() => {
navigate("/register");
}}
sx={{ userSelect: "none" }}
>
Sign Up
</Typography>
</Box>
</Stack>
);
};

View File

@ -1,20 +1,19 @@
import { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useDispatch } from "react-redux";
import PropTypes from "prop-types";
import { useState, useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { useTheme } from "@emotion/react";
import { Box, Button, Stack, Typography } from "@mui/material";
import { useDispatch } from "react-redux";
import { StepOne } from "./StepOne";
import { StepTwo } from "./StepTwo";
import { StepThree } from "./StepThree";
import { networkService } from "../../../main";
import { credentials } from "../../../Validation/validation";
import { createToast } from "../../../Utils/toastUtils";
import { register } from "../../../Features/Auth/authSlice";
import { useParams } from "react-router-dom";
import Background from "../../../assets/Images/background-grid.svg?react";
import Logo from "../../../assets/icons/bwu-icon.svg?react";
import Mail from "../../../assets/icons/mail.svg?react";
import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded";
import Check from "../../../Components/Check/Check";
import Field from "../../../Components/Inputs/Field";
import { networkService } from "../../../main";
import "../index.css";
/**
@ -54,6 +53,10 @@ const LandingPage = ({ isSuperAdmin, onSignup }) => {
stroke: theme.palette.other.icon,
},
},
"&:focus-visible": {
outline: `2px solid ${theme.palette.primary.main}`,
outlineOffset: `2px`,
},
}}
>
<Mail />
@ -110,389 +113,6 @@ LandingPage.propTypes = {
onSignup: PropTypes.func,
};
/**
* Renders the first step of the sign up process.
*
* @param {Object} props
* @param {Object} props.form - Form state object.
* @param {Object} props.errors - Object containing form validation errors.
* @param {Function} props.onSubmit - Callback function to handle form submission.
* @param {Function} props.onChange - Callback function to handle form input changes.
* @param {Function} props.onBack - Callback function to handle "Back" button click.
* @returns {JSX.Element}
*/
const StepOne = ({ form, errors, onSubmit, onChange, onBack }) => {
const theme = useTheme();
const inputRef = useRef(null);
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, []);
return (
<>
<Stack
gap={{ xs: theme.spacing(8), sm: theme.spacing(12) }}
textAlign="center"
>
<Box>
<Typography component="h1">Sign Up</Typography>
<Typography>Enter your personal details</Typography>
</Box>
<Box textAlign="left">
<Box
component="form"
noValidate
spellCheck={false}
onSubmit={onSubmit}
mb={theme.spacing(10)}
>
<Field
id="register-firstname-input"
label="Name"
isRequired={true}
placeholder="Jordan"
autoComplete="given-name"
value={form.firstName}
onChange={onChange}
error={errors.firstName}
ref={inputRef}
/>
</Box>
<Box
component="form"
noValidate
spellCheck={false}
onSubmit={onSubmit}
mb={theme.spacing(5)}
>
<Field
id="register-lastname-input"
label="Surname"
isRequired={true}
placeholder="Ellis"
autoComplete="family-name"
value={form.lastName}
onChange={onChange}
error={errors.lastName}
/>
</Box>
</Box>
<Stack
direction="row"
justifyContent="space-between"
>
<Button
variant="outlined"
color="info"
onClick={onBack}
sx={{
px: theme.spacing(5),
"& svg.MuiSvgIcon-root": {
mr: theme.spacing(3),
},
}}
props={{ tabIndex: -1 }}
>
<ArrowBackRoundedIcon />
Back
</Button>
<Button
variant="contained"
color="primary"
onClick={onSubmit}
disabled={(errors.firstName || errors.lastName) && true}
sx={{ width: "30%" }}
>
Continue
</Button>
</Stack>
</Stack>
</>
);
};
StepOne.propTypes = {
form: PropTypes.object,
errors: PropTypes.object,
onSubmit: PropTypes.func,
onChange: PropTypes.func,
onBack: PropTypes.func,
};
/**
* Renders the second step of the sign up process.
*
* @param {Object} props
* @param {Object} props.form - Form state object.
* @param {Object} props.errors - Object containing form validation errors.
* @param {Function} props.onSubmit - Callback function to handle form submission.
* @param {Function} props.onChange - Callback function to handle form input changes.
* @param {Function} props.onBack - Callback function to handle "Back" button click.
* @returns {JSX.Element}
*/
const StepTwo = ({ form, errors, onSubmit, onChange, onBack }) => {
const theme = useTheme();
const inputRef = useRef(null);
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, []);
return (
<>
<Stack
gap={{ xs: theme.spacing(8), sm: theme.spacing(12) }}
textAlign="center"
>
<Box>
<Typography component="h1">Sign Up</Typography>
<Typography>Enter your email address</Typography>
</Box>
<Box textAlign="left">
<Box
component="form"
noValidate
spellCheck={false}
onSubmit={onSubmit}
mb={theme.spacing(5)}
>
<Field
type="email"
id="register-email-input"
label="Email"
isRequired={true}
placeholder="jordan.ellis@domain.com"
autoComplete="email"
value={form.email}
onInput={(e) => (e.target.value = e.target.value.toLowerCase())}
onChange={onChange}
error={errors.email}
ref={inputRef}
/>
</Box>
</Box>
<Stack
direction="row"
justifyContent="space-between"
>
<Button
variant="outlined"
color="info"
onClick={onBack}
sx={{
px: theme.spacing(5),
"& svg.MuiSvgIcon-root": {
mr: theme.spacing(3),
},
}}
props={{ tabIndex: -1 }}
>
<ArrowBackRoundedIcon />
Back
</Button>
<Button
variant="contained"
color="primary"
onClick={onSubmit}
disabled={errors.email && true}
sx={{ width: "30%" }}
>
Continue
</Button>
</Stack>
</Stack>
</>
);
};
StepTwo.propTypes = {
form: PropTypes.object,
errors: PropTypes.object,
onSubmit: PropTypes.func,
onChange: PropTypes.func,
onBack: PropTypes.func,
};
/**
* Renders the third step of the sign up process.
*
* @param {Object} props
* @param {Object} props.form - Form state object.
* @param {Object} props.errors - Object containing form validation errors.
* @param {Function} props.onSubmit - Callback function to handle form submission.
* @param {Function} props.onChange - Callback function to handle form input changes.
* @param {Function} props.onBack - Callback function to handle "Back" button click.
* @returns {JSX.Element}
*/
const StepThree = ({ form, errors, onSubmit, onChange, onBack }) => {
const theme = useTheme();
const inputRef = useRef(null);
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, []);
return (
<>
<Stack
gap={{ xs: theme.spacing(8), sm: theme.spacing(12) }}
textAlign="center"
>
<Box>
<Typography component="h1">Sign Up</Typography>
<Typography>Create your password</Typography>
</Box>
<Box
textAlign="left"
sx={{
"& .input-error": {
display: "none",
},
}}
>
<Box
component="form"
noValidate
spellCheck={false}
onSubmit={onSubmit}
>
<Field
type="password"
id="register-password-input"
label="Password"
isRequired={true}
placeholder="Create a password"
autoComplete="current-password"
value={form.password}
onChange={onChange}
error={errors.password}
ref={inputRef}
/>
</Box>
<Box
component="form"
noValidate
spellCheck={false}
onSubmit={onSubmit}
>
<Field
type="password"
id="register-confirm-input"
label="Confirm password"
isRequired={true}
placeholder="Confirm your password"
autoComplete="current-password"
value={form.confirm}
onChange={onChange}
error={errors.confirm}
/>
</Box>
<Stack
gap={theme.spacing(4)}
mb={{ xs: theme.spacing(6), sm: theme.spacing(8) }}
>
<Check
text={
<>
<Typography component="span">Must be at least</Typography> 8 characters
long
</>
}
variant={
errors?.password === "Password is required"
? "error"
: form.password === ""
? "info"
: form.password.length < 8
? "error"
: "success"
}
/>
<Check
text={
<>
<Typography component="span">Must contain</Typography> one special
character and a number
</>
}
variant={
errors?.password === "Password is required"
? "error"
: form.password === ""
? "info"
: !/^(?=.*[!@#$%^&*(),.?":{}|])(?=.*\d).+$/.test(form.password)
? "error"
: "success"
}
/>
<Check
text={
<>
<Typography component="span">Must contain at least</Typography> one
upper and lower character
</>
}
variant={
errors?.password === "Password is required"
? "error"
: form.password === ""
? "info"
: !/^(?=.*[A-Z])(?=.*[a-z]).+$/.test(form.password)
? "error"
: "success"
}
/>
</Stack>
</Box>
<Stack
direction="row"
justifyContent="space-between"
>
<Button
variant="outlined"
color="info"
onClick={onBack}
sx={{
px: theme.spacing(5),
"& svg.MuiSvgIcon-root": {
mr: theme.spacing(3),
},
}}
props={{ tabIndex: -1 }}
>
<ArrowBackRoundedIcon />
Back
</Button>
<Button
variant="contained"
color="primary"
onClick={onSubmit}
disabled={errors.email && true}
sx={{ width: "30%" }}
>
Continue
</Button>
</Stack>
</Stack>
</>
);
};
StepThree.propTypes = {
form: PropTypes.object,
errors: PropTypes.object,
onSubmit: PropTypes.func,
onChange: PropTypes.func,
onBack: PropTypes.func,
};
const Register = ({ isSuperAdmin }) => {
const dispatch = useDispatch();
const navigate = useNavigate();
@ -596,14 +216,16 @@ const Register = ({ isSuperAdmin }) => {
// Attempts account registration
const handleStepThree = async (e) => {
e.preventDefault();
const { password, confirm } = e.target.elements;
let registerForm = {
...form,
password: password.value,
confirm: confirm.value,
role: isSuperAdmin ? ["superadmin"] : form.role,
inviteToken: token ? token : "", // Add the token to the request for verification
};
let error = validateForm(registerForm, {
context: { password: form.password },
context: { password: registerForm.password },
});
if (error) {
handleError(error);
@ -615,18 +237,16 @@ const Register = ({ isSuperAdmin }) => {
if (action.payload.success) {
const authToken = action.payload.data;
localStorage.setItem("token", authToken);
navigate("/");
navigate("/monitors");
createToast({
body: "Welcome! Your account was created successfully.",
});
} else {
if (action.payload) {
// dispatch errors
createToast({
body: action.payload.msg,
});
} else {
// unknown errors
createToast({
body: "Unknown error.",
});
@ -732,10 +352,10 @@ const Register = ({ isSuperAdmin }) => {
/>
) : step === 3 ? (
<StepThree
form={form}
errors={errors}
/* form={form}
errors={errors} */
onSubmit={handleStepThree}
onChange={handleChange}
/* onChange={handleChange} */
onBack={() => setStep(2)}
/>
) : (

View File

@ -0,0 +1,135 @@
import { useEffect, useRef } from "react";
import PropTypes from "prop-types";
import { useTheme } from "@emotion/react";
import { Box, Button, Stack, Typography } from "@mui/material";
import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded";
import TextInput from "../../../../Components/Inputs/TextInput";
StepOne.propTypes = {
form: PropTypes.object,
errors: PropTypes.object,
onSubmit: PropTypes.func,
onChange: PropTypes.func,
onBack: PropTypes.func,
};
/**
* Renders the first step of the sign up process.
*
* @param {Object} props
* @param {Object} props.form - Form state object.
* @param {Object} props.errors - Object containing form validation errors.
* @param {Function} props.onSubmit - Callback function to handle form submission.
* @param {Function} props.onChange - Callback function to handle form input changes.
* @param {Function} props.onBack - Callback function to handle "Back" button click.
* @returns {JSX.Element}
*/
function StepOne({ form, errors, onSubmit, onChange, onBack }) {
const theme = useTheme();
const inputRef = useRef(null);
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, []);
return (
<>
{/* TODO this stack should be a component */}
<Stack
gap={{ xs: theme.spacing(12), sm: theme.spacing(16) }}
textAlign="center"
>
<Box>
<Typography component="h1">Sign Up</Typography>
<Typography>Enter your personal details</Typography>
</Box>
<Box
textAlign="left"
component="form"
noValidate
spellCheck={false}
onSubmit={onSubmit}
display="grid"
gap={{ xs: theme.spacing(12), sm: theme.spacing(16) }}
>
<Box
display="grid"
gap={{ xs: theme.spacing(8), sm: theme.spacing(12) }}
>
<TextInput
id="register-firstname-input"
label="Name"
isRequired={true}
placeholder="Jordan"
autoComplete="given-name"
value={form.firstName}
onChange={onChange}
error={errors.firstName ? true : false}
helperText={errors.firstName}
ref={inputRef}
/>
<TextInput
id="register-lastname-input"
label="Surname"
isRequired={true}
placeholder="Ellis"
autoComplete="family-name"
value={form.lastName}
onChange={onChange}
error={errors.lastName ? true : false}
helperText={errors.lastName}
ref={inputRef}
/>
</Box>
<Stack
direction="row"
justifyContent="space-between"
>
{/* TODO buttons should be a component should be a component */}
<Button
variant="outlined"
color="info"
onClick={onBack}
sx={{
px: theme.spacing(5),
"& svg.MuiSvgIcon-root": {
mr: theme.spacing(3),
},
"&:focus-visible": {
outline: `2px solid ${theme.palette.primary.main}`,
outlineOffset: `2px`,
},
}}
>
<ArrowBackRoundedIcon />
Back
</Button>
<Button
variant="contained"
color="primary"
type="submit"
disabled={(errors.firstName || errors.lastName) && true}
sx={{
width: "30%",
"&.Mui-focusVisible": {
outline: `2px solid ${theme.palette.primary.main}`,
outlineOffset: `2px`,
boxShadow: `none`,
},
}}
>
Continue
</Button>
</Stack>
</Box>
</Stack>
</>
);
}
export { StepOne };

View File

@ -0,0 +1,173 @@
import { useEffect, useRef } from "react";
import PropTypes from "prop-types";
import { useTheme } from "@emotion/react";
import { Box, Button, Stack, Typography } from "@mui/material";
import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded";
import TextInput from "../../../../Components/Inputs/TextInput";
import Check from "../../../../Components/Check/Check";
import { useValidatePassword } from "../../hooks/useValidatePassword";
StepThree.propTypes = {
onSubmit: PropTypes.func,
onBack: PropTypes.func,
};
/**
* Renders the third step of the sign up process.
*
* @param {Object} props
* @param {Function} props.onSubmit - Callback function to handle form submission.
* @param {Function} props.onBack - Callback function to handle "Back" button click.
* @returns {JSX.Element}
*/
function StepThree({ onSubmit, onBack }) {
const theme = useTheme();
const inputRef = useRef(null);
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, []);
const { handleChange, feedbacks, form, errors } = useValidatePassword();
console.log(errors);
return (
<>
<Stack
gap={{ xs: theme.spacing(8), sm: theme.spacing(12) }}
textAlign="center"
>
<Box>
<Typography component="h1">Sign Up</Typography>
<Typography>Create your password</Typography>
</Box>
<Box
component="form"
noValidate
spellCheck={false}
onSubmit={onSubmit}
textAlign="left"
display="grid"
gap={{ xs: theme.spacing(12), sm: theme.spacing(16) }}
sx={{
"& .input-error": {
display: "none",
},
}}
>
<Box
display="grid"
gap={{ xs: theme.spacing(8), sm: theme.spacing(12) }}
>
<TextInput
type="password"
id="register-password-input"
name="password"
label="Password"
isRequired={true}
placeholder="Create a password"
autoComplete="current-password"
value={form.password}
onChange={handleChange}
error={errors.password && errors.password[0] ? true : false}
ref={inputRef}
/>
<TextInput
type="password"
id="register-confirm-input"
name="confirm"
label="Confirm password"
isRequired={true}
placeholder="Confirm your password"
autoComplete="current-password"
value={form.confirm}
onChange={handleChange}
error={errors.confirm && errors.confirm[0] ? true : false}
/>
</Box>
<Stack
gap={theme.spacing(4)}
mb={{ xs: theme.spacing(6), sm: theme.spacing(8) }}
>
<Check
noHighlightText={"Must be at least"}
text={"8 characters long"}
variant={feedbacks.length}
/>
<Check
noHighlightText={"Must contain at least"}
text={"one special character"}
variant={feedbacks.special}
/>
<Check
noHighlightText={"Must contain at least"}
text={"one number"}
variant={feedbacks.number}
/>
<Check
noHighlightText={"Must contain at least"}
text={"one upper character"}
variant={feedbacks.uppercase}
/>
<Check
noHighlightText={"Must contain at least"}
text={"one lower character"}
variant={feedbacks.lowercase}
/>
<Check
noHighlightText={"Confirm password and password"}
text={"must match"}
variant={feedbacks.confirm}
/>
</Stack>
<Stack
direction="row"
justifyContent="space-between"
>
<Button
variant="outlined"
color="info"
onClick={onBack}
sx={{
px: theme.spacing(5),
"& svg.MuiSvgIcon-root": {
mr: theme.spacing(3),
},
":focus-visible": {
outline: `2px solid ${theme.palette.primary.dark}`,
outlineOffset: "4px",
},
}}
>
<ArrowBackRoundedIcon />
Back
</Button>
<Button
type="submit"
variant="contained"
color="primary"
disabled={
form.password.length === 0 ||
form.confirm.length === 0 ||
Object.keys(errors).length !== 0
}
sx={{
width: "30%",
"&.Mui-focusVisible": {
outline: `2px solid ${theme.palette.primary.main}`,
outlineOffset: `2px`,
boxShadow: `none`,
},
}}
>
Continue
</Button>
</Stack>
</Box>
</Stack>
</>
);
}
export { StepThree };

View File

@ -0,0 +1,117 @@
import { useEffect, useRef } from "react";
import PropTypes from "prop-types";
import { useTheme } from "@emotion/react";
import { Box, Button, Stack, Typography } from "@mui/material";
import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded";
import TextInput from "../../../../Components/Inputs/TextInput";
StepTwo.propTypes = {
form: PropTypes.object,
errors: PropTypes.object,
onSubmit: PropTypes.func,
onChange: PropTypes.func,
onBack: PropTypes.func,
};
/**
* Renders the second step of the sign up process.
*
* @param {Object} props
* @param {Object} props.form - Form state object.
* @param {Object} props.errors - Object containing form validation errors.
* @param {Function} props.onSubmit - Callback function to handle form submission.
* @param {Function} props.onChange - Callback function to handle form input changes.
* @param {Function} props.onBack - Callback function to handle "Back" button click.
* @returns {JSX.Element}
*/
function StepTwo({ form, errors, onSubmit, onChange, onBack }) {
const theme = useTheme();
const inputRef = useRef(null);
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, []);
return (
<>
<Stack
gap={{ xs: theme.spacing(8), sm: theme.spacing(12) }}
textAlign="center"
>
<Box>
<Typography component="h1">Sign Up</Typography>
<Typography>Enter your email address</Typography>
</Box>
<Box
component="form"
textAlign="left"
noValidate
spellCheck={false}
onSubmit={onSubmit}
mb={theme.spacing(5)}
display="grid"
gap={{ xs: theme.spacing(12), sm: theme.spacing(16) }}
>
<TextInput
type="email"
id="register-email-input"
label="Email"
isRequired={true}
placeholder="jordan.ellis@domain.com"
autoComplete="email"
value={form.email}
onInput={(e) => (e.target.value = e.target.value.toLowerCase())}
onChange={onChange}
error={errors.email ? true : false}
helperText={errors.email}
ref={inputRef}
/>
<Stack
direction="row"
justifyContent="space-between"
>
<Button
variant="outlined"
color="info"
onClick={onBack}
sx={{
px: theme.spacing(5),
"& svg.MuiSvgIcon-root": {
mr: theme.spacing(3),
},
"&:focus-visible": {
outline: `2px solid ${theme.palette.primary.main}`,
outlineOffset: `2px`,
},
}}
>
<ArrowBackRoundedIcon />
Back
</Button>
<Button
variant="contained"
color="primary"
onClick={onSubmit}
disabled={errors.email && true}
sx={{
width: "30%",
"&.Mui-focusVisible": {
outline: `2px solid ${theme.palette.primary.main}`,
outlineOffset: `2px`,
boxShadow: `none`,
},
}}
>
Continue
</Button>
</Stack>
</Box>
</Stack>
</>
);
}
export { StepTwo };

View File

@ -1,56 +1,45 @@
import { useId } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useNavigate } from "react-router-dom";
import { useParams } from "react-router-dom";
import { useTheme } from "@emotion/react";
import { Box, Stack, Typography } from "@mui/material";
import LoadingButton from "@mui/lab/LoadingButton";
import { setNewPassword } from "../../Features/Auth/authSlice";
import { createToast } from "../../Utils/toastUtils";
import { Box, Stack, Typography } from "@mui/material";
import { useTheme } from "@emotion/react";
import { useParams } from "react-router-dom";
import { useState } from "react";
import { credentials } from "../../Validation/validation";
import { useNavigate } from "react-router-dom";
import Check from "../../Components/Check/Check";
import Field from "../../Components/Inputs/Field";
import LockIcon from "../../assets/icons/lock.svg?react";
import Background from "../../assets/Images/background-grid.svg?react";
import Logo from "../../assets/icons/bwu-icon.svg?react";
import LoadingButton from "@mui/lab/LoadingButton";
import "./index.css";
import TextInput from "../../Components/Inputs/TextInput";
import { PasswordEndAdornment } from "../../Components/Inputs/TextInput/Adornments";
import { IconBox } from "./styled";
import LockIcon from "../../assets/icons/lock.svg?react";
import Logo from "../../assets/icons/bwu-icon.svg?react";
import Background from "../../assets/Images/background-grid.svg?react";
import "./index.css";
import { useValidatePassword } from "./hooks/useValidatePassword";
const SetNewPassword = () => {
const navigate = useNavigate();
const dispatch = useDispatch();
const theme = useTheme();
const [errors, setErrors] = useState({});
const [form, setForm] = useState({
password: "",
confirm: "",
});
const passwordId = useId();
const confirmPasswordId = useId();
const idMap = {
"register-password-input": "password",
"confirm-password-input": "confirm",
};
const { form, errors, handleChange, feedbacks } = useValidatePassword();
const { isLoading } = useSelector((state) => state.auth);
const { token } = useParams();
const handleSubmit = async (e) => {
e.preventDefault();
const passwordForm = { ...form };
const { error } = credentials.validate(passwordForm, {
const { error } = credentials.validate(form, {
abortEarly: false,
context: { password: form.password },
});
if (error) {
// validation errors
const newErrors = {};
error.details.forEach((err) => {
newErrors[err.path[0]] = err.message;
});
setErrors(newErrors);
createToast({
body:
error.details && error.details.length > 0
@ -58,50 +47,23 @@ const SetNewPassword = () => {
: "Error validating data.",
});
} else {
delete passwordForm.confirm;
const action = await dispatch(setNewPassword({ token: token, form: passwordForm }));
const action = await dispatch(setNewPassword({ token, form }));
if (action.payload.success) {
navigate("/new-password-confirmed");
createToast({
body: "Your password was reset successfully.",
});
} else {
if (action.payload) {
// dispatch errors
createToast({
body: action.payload.msg,
});
} else {
// unknown errors
createToast({
body: "Unknown error.",
});
}
const errorMessage = action.payload
? action.payload.msg
: "Unable to reset password. Please try again later or contact support.";
createToast({
body: errorMessage,
});
}
}
};
const handleChange = (event) => {
const { value, id } = event.target;
const name = idMap[id];
setForm((prev) => ({
...prev,
[name]: value,
}));
const { error } = credentials.validate(
{ [name]: value },
{ abortEarly: false, context: { password: form.password } }
);
setErrors((prev) => {
const prevErrors = { ...prev };
if (error) prevErrors[name] = error.details[0].message;
else delete prevErrors[name];
return prevErrors;
});
};
return (
<Stack
className="set-new-password-page auth"
@ -187,15 +149,18 @@ const SetNewPassword = () => {
spellCheck={false}
onSubmit={handleSubmit}
>
<Field
<TextInput
id={passwordId}
type="password"
id="register-password-input"
name="password"
label="Password"
isRequired={true}
placeholder="••••••••"
value={form.password}
onChange={handleChange}
error={errors.password}
error={errors.password ? true : false}
helperText={errors.password}
endAdornment={<PasswordEndAdornment />}
/>
</Box>
<Box
@ -204,15 +169,18 @@ const SetNewPassword = () => {
spellCheck={false}
onSubmit={handleSubmit}
>
<Field
<TextInput
id={confirmPasswordId}
type="password"
id="confirm-password-input"
name="confirm"
label="Confirm password"
isRequired={true}
placeholder="••••••••"
value={form.confirm}
onChange={handleChange}
error={errors.confirm}
error={errors.confirm ? true : false}
helperText={errors.confirm}
endAdornment={<PasswordEndAdornment />}
/>
</Box>
<Stack
@ -220,55 +188,34 @@ const SetNewPassword = () => {
mb={theme.spacing(12)}
>
<Check
text={
<>
<Typography component="span">Must be at least</Typography> 8
characters long
</>
}
variant={
errors?.password === "Password is required"
? "error"
: form.password === ""
? "info"
: form.password.length < 8
? "error"
: "success"
}
noHighlightText={"Must be at least"}
text={"8 characters long"}
variant={feedbacks.length}
/>
<Check
text={
<>
<Typography component="span">Must contain</Typography> one special
character and a number
</>
}
variant={
errors?.password === "Password is required"
? "error"
: form.password === ""
? "info"
: !/^(?=.*[!@#$%^&*(),.?":{}|])(?=.*\d).+$/.test(form.password)
? "error"
: "success"
}
noHighlightText={"Must contain at least"}
text={"one special character"}
variant={feedbacks.special}
/>
<Check
text={
<>
<Typography component="span">Must contain at least</Typography> one
upper and lower character
</>
}
variant={
errors?.password === "Password is required"
? "error"
: form.password === ""
? "info"
: !/^(?=.*[A-Z])(?=.*[a-z]).+$/.test(form.password)
? "error"
: "success"
}
noHighlightText={"Must contain at least"}
text={"one number"}
variant={feedbacks.number}
/>
<Check
noHighlightText={"Must contain at least"}
text={"one upper character"}
variant={feedbacks.uppercase}
/>
<Check
noHighlightText={"Must contain at least"}
text={"one lower character"}
variant={feedbacks.lowercase}
/>
<Check
noHighlightText={"Confirm password and password"}
text={"must match"}
variant={feedbacks.confirm}
/>
</Stack>
</Box>
@ -277,7 +224,11 @@ const SetNewPassword = () => {
color="primary"
loading={isLoading}
onClick={handleSubmit}
disabled={Object.keys(errors).length !== 0}
disabled={
form.password.length === 0 ||
form.confirm.length === 0 ||
Object.keys(errors).length !== 0
}
sx={{ width: "100%", maxWidth: 400 }}
>
Reset password

View File

@ -0,0 +1,70 @@
import { useMemo, useState } from "react";
import { credentials } from "../../../Validation/validation";
const getFeedbackStatus = (form, errors, field, criteria) => {
const fieldErrors = errors[field];
const isFieldEmpty = form[field].length === 0;
const hasError = fieldErrors?.includes(criteria) || fieldErrors?.includes("empty");
const isCorrect = !isFieldEmpty && !hasError;
if (isCorrect) {
return "success";
} else if (hasError) {
return "error";
} else {
return "info";
}
};
function useValidatePassword() {
const [errors, setErrors] = useState({});
const [form, setForm] = useState({
password: "",
confirm: "",
});
const handleChange = (event) => {
const { value, name } = event.target;
setForm((prev) => ({ ...prev, [name]: value }));
const validateValue = { [name]: value };
const validateOptions = { abortEarly: false, context: { password: form.password } };
if (name === "password" && form.confirm.length > 0) {
validateValue.confirm = form.confirm;
validateOptions.context = { password: value };
} else if (name === "confirm") {
validateValue.password = form.password;
}
const { error } = credentials.validate(validateValue, validateOptions);
const errors = error?.details.map((error) => ({
path: error.path[0],
type: error.type,
}));
const errorsByPath =
errors &&
errors.reduce((acc, { path, type }) => {
if (!acc[path]) {
acc[path] = [];
}
acc[path].push(type);
return acc;
}, {});
setErrors(() => (errorsByPath ? { ...errorsByPath } : {}));
};
const feedbacks = useMemo(
() => ({
length: getFeedbackStatus(form, errors, "password", "string.min"),
special: getFeedbackStatus(form, errors, "password", "special"),
number: getFeedbackStatus(form, errors, "password", "number"),
uppercase: getFeedbackStatus(form, errors, "password", "uppercase"),
lowercase: getFeedbackStatus(form, errors, "password", "lowercase"),
confirm: getFeedbackStatus(form, errors, "confirm", "different"),
}),
[form, errors]
);
return { handleChange, feedbacks, form, errors };
}
export { useValidatePassword };

View File

@ -24,6 +24,7 @@ import { useTheme } from "@emotion/react";
import { formatDateWithTz } from "../../../Utils/timeUtils";
import PlaceholderLight from "../../../assets/Images/data_placeholder.svg?react";
import PlaceholderDark from "../../../assets/Images/data_placeholder_dark.svg?react";
import { HttpStatusLabel } from "../../../Components/HttpStatusLabel";
import { Empty } from "./Empty/Empty";
import { IncidentSkeleton } from "./Skeleton/Skeleton";
@ -39,7 +40,7 @@ const IncidentTable = ({ monitors, selectedMonitor, filter }) => {
page: 0,
rowsPerPage: 14,
});
const [isLoading, setIsLoading] = useState(true);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
setPaginationController((prevPaginationController) => ({
@ -184,7 +185,7 @@ const IncidentTable = ({ monitors, selectedMonitor, filter }) => {
"YYYY-MM-DD HH:mm:ss A",
uiTimezone
);
return (
<TableRow key={check._id}>
<TableCell>{monitors[check.monitorId]?.name}</TableCell>
@ -196,7 +197,9 @@ const IncidentTable = ({ monitors, selectedMonitor, filter }) => {
/>
</TableCell>
<TableCell>{formattedDate}</TableCell>
<TableCell>{check.statusCode ? check.statusCode : "N/A"}</TableCell>
<TableCell>
<HttpStatusLabel status={check.statusCode} />
</TableCell>
<TableCell>{check.message}</TableCell>
</TableRow>
);

View File

@ -0,0 +1,92 @@
import { Box, Stack, Typography } from "@mui/material";
import TextInput from "../../../../Components/Inputs/TextInput";
import Checkbox from "../../../../Components/Inputs/Checkbox";
import { useTheme } from "@emotion/react";
import PropTypes from "prop-types";
/**
* `CustomThreshold` is a functional React component that displays a
* group of CheckBox with a label and its correspondant threshold input field.
*
* @param {{ checkboxId: any; checkboxLabel: any; onCheckboxChange: any; fieldId: any; onFieldChange: any; onFieldBlur: any; alertUnit: any; infrastructureMonitor: any; errors: any; }} param0
* @param {string} param0.checkboxId - The text is the id of the checkbox.
* @param {string} param0.checkboxLabel - The text to be displayed as the label next to the check icon.
* @param {func} param0.onCheckboxChange - The function to invoke when checkbox is checked or unchecked.
* @param {string} param0.fieldId - The text is the id of the input field.
* @param {func} param0.onFieldChange - The function to invoke when input field is changed.
* @param {func} param0.onFieldBlur - The function to invoke when input field is losing focus.
* @param {string} param0.alertUnit the threshold unit such as usage percentage '%' etc
* @param {object} param0.infrastructureMonitor the form object of the create infrastrcuture monitor page
* @param {object} param0.errors the object that holds all the errors of the form page
* @returns A compound React component that renders the custom threshold alert section
*
*/
export const CustomThreshold = ({
checkboxId,
checkboxLabel,
onCheckboxChange,
fieldId,
onFieldChange,
onFieldBlur,
alertUnit,
infrastructureMonitor,
errors,
}) => {
const theme = useTheme();
return (
<Stack
direction={"row"}
sx={{
width: "50%",
justifyContent: "space-between",
flexWrap: "wrap",
}}
>
<Box>
<Checkbox
id={checkboxId}
label={checkboxLabel}
isChecked={infrastructureMonitor[checkboxId]}
onChange={onCheckboxChange}
/>
</Box>
<Stack
direction={"row"}
sx={{
justifyContent: "flex-end",
}}
>
<TextInput
maxWidth="var(--env-var-width-4)"
type="number"
id={fieldId}
value={infrastructureMonitor[fieldId]}
onBlur={onFieldBlur}
onChange={onFieldChange}
error={errors[fieldId] ? true : false}
disabled={!infrastructureMonitor[checkboxId]}
/>
<Typography
component="p"
m={theme.spacing(3)}
>
{alertUnit}
</Typography>
</Stack>
</Stack>
);
};
CustomThreshold.propTypes = {
checkboxId: PropTypes.string.isRequired,
checkboxLabel: PropTypes.string.isRequired,
onCheckboxChange: PropTypes.func.isRequired,
fieldId: PropTypes.string.isRequired,
onFieldChange: PropTypes.func.isRequired,
onFieldBlur: PropTypes.func.isRequired,
alertUnit: PropTypes.string.isRequired,
infrastructureMonitor: PropTypes.object.isRequired,
errors: PropTypes.object.isRequired,
};

View File

@ -0,0 +1,402 @@
import { useState } from "react";
import { Box, Stack, Typography } from "@mui/material";
import LoadingButton from "@mui/lab/LoadingButton";
import { useSelector, useDispatch } from "react-redux";
import { infrastructureMonitorValidation } from "../../../Validation/validation";
import { parseDomainName } from "../../../Utils/monitorUtils";
import {
createInfrastructureMonitor,
checkInfrastructureEndpointResolution,
} from "../../../Features/InfrastructureMonitors/infrastructureMonitorsSlice";
import { useNavigate } from "react-router-dom";
import { useTheme } from "@emotion/react";
import { createToast } from "../../../Utils/toastUtils";
import Link from "../../../Components/Link";
import { ConfigBox } from "../../Monitors/styled";
import TextInput from "../../../Components/Inputs/TextInput";
import Select from "../../../Components/Inputs/Select";
import Checkbox from "../../../Components/Inputs/Checkbox";
import Breadcrumbs from "../../../Components/Breadcrumbs";
import { buildErrors, hasValidationErrors } from "../../../Validation/error";
import { capitalizeFirstLetter } from "../../../Utils/stringUtils";
import { CustomThreshold } from "../CreateMonitor/CustomThreshold";
const CreateInfrastructureMonitor = () => {
const [infrastructureMonitor, setInfrastructureMonitor] = useState({
url: "",
name: "",
notifications: [],
interval: 0.25,
cpu: false,
usage_cpu: "",
memory: false,
usage_memory: "",
disk: false,
usage_disk: "",
temperature: false,
usage_temperature: "",
secret: "",
});
const MS_PER_MINUTE = 60000;
const THRESHOLD_FIELD_PREFIX = "usage_";
const HARDWARE_MONITOR_TYPES = ["cpu", "memory", "disk", "temperature"];
const { user, authToken } = useSelector((state) => state.auth);
const monitorState = useSelector((state) => state.infrastructureMonitor);
const dispatch = useDispatch();
const navigate = useNavigate();
const theme = useTheme();
const idMap = {
"notify-email-default": "notification-email",
};
const [errors, setErrors] = useState({});
const alertErrKeyLen = Object.keys(errors).filter((k) =>
k.startsWith(THRESHOLD_FIELD_PREFIX)
).length;
const handleCustomAlertCheckChange = (event) => {
const { value, id } = event.target;
setInfrastructureMonitor((prev) => {
const newState = {
[id]: prev[id] == undefined && value == "on" ? true : !prev[id],
};
return {
...prev,
...newState,
[THRESHOLD_FIELD_PREFIX + id]: newState[id]
? prev[THRESHOLD_FIELD_PREFIX + id]
: "",
};
});
// Remove the error if unchecked
setErrors((prev) => {
return buildErrors(prev, [THRESHOLD_FIELD_PREFIX + id]);
});
};
const handleBlur = (event, appendID) => {
event.preventDefault();
const { value, id } = event.target;
let name = idMap[id] ?? id;
if (name === "url" && infrastructureMonitor.name === "") {
setInfrastructureMonitor((prev) => ({
...prev,
name: parseDomainName(value),
}));
}
if (id?.startsWith("notify-email-")) return;
const { error } = infrastructureMonitorValidation.validate(
{ [id ?? appendID]: value },
{
abortEarly: false,
}
);
setErrors((prev) => {
return buildErrors(prev, id ?? appendID, error);
});
};
const handleChange = (event, appendedId) => {
event.preventDefault();
const { value, id } = event.target;
let name = appendedId ?? idMap[id] ?? id;
if (name.includes("notification-")) {
name = name.replace("notification-", "");
let hasNotif = infrastructureMonitor.notifications.some(
(notification) => notification.type === name
);
setInfrastructureMonitor((prev) => {
const notifs = [...prev.notifications];
if (hasNotif) {
return {
...prev,
notifications: notifs.filter((notif) => notif.type !== name),
};
} else {
return {
...prev,
notifications: [
...notifs,
name === "email"
? { type: name, address: value }
: // TODO - phone number
{ type: name, phone: value },
],
};
}
});
} else {
setInfrastructureMonitor((prev) => ({
...prev,
[name]: value,
}));
}
};
const generatePayload = (form) => {
let thresholds = {};
Object.keys(form)
.filter((k) => k.startsWith(THRESHOLD_FIELD_PREFIX))
.map((k) => {
if (form[k]) thresholds[k] = form[k] / 100;
delete form[k];
delete form[k.substring(THRESHOLD_FIELD_PREFIX.length)];
});
form = {
...form,
description: form.name,
teamId: user.teamId,
userId: user._id,
type: "hardware",
notifications: infrastructureMonitor.notifications,
thresholds,
};
return form;
};
const handleCreateInfrastructureMonitor = async (event) => {
event.preventDefault();
let form = {
...infrastructureMonitor,
name:
infrastructureMonitor.name === ""
? infrastructureMonitor.url
: infrastructureMonitor.name,
interval: infrastructureMonitor.interval * MS_PER_MINUTE,
};
delete form.notifications;
if (hasValidationErrors(form, infrastructureMonitorValidation, setErrors)) {
return;
} else {
const checkEndpointAction = await dispatch(
checkInfrastructureEndpointResolution({ authToken, monitorURL: form.url })
);
if (checkEndpointAction.meta.requestStatus === "rejected") {
createToast({
body: "The endpoint you entered doesn't resolve. Check the URL again.",
});
setErrors({ url: "The entered URL is not reachable." });
return;
}
const action = await dispatch(
createInfrastructureMonitor({ authToken, monitor: generatePayload(form) })
);
if (action.meta.requestStatus === "fulfilled") {
createToast({ body: "Infrastructure monitor created successfully!" });
navigate("/infrastructure");
} else {
createToast({ body: "Failed to create monitor." });
}
}
};
//select values
const frequencies = [
{ _id: 0.25, name: "15 seconds" },
{ _id: 0.5, name: "30 seconds" },
{ _id: 1, name: "1 minute" },
{ _id: 2, name: "2 minutes" },
{ _id: 5, name: "5 minutes" },
{ _id: 10, name: "10 minutes" },
];
return (
<Box className="create-infrastructure-monitor">
<Breadcrumbs
list={[
{ name: "Infrastructure monitors", path: "infrastructure" },
{ name: "create", path: `infrastructure/create` },
]}
/>
<Stack
component="form"
className="create-infrastructure-monitor-form"
onSubmit={handleCreateInfrastructureMonitor}
noValidate
spellCheck="false"
gap={theme.spacing(12)}
mt={theme.spacing(6)}
>
<Typography
component="h1"
variant="h1"
>
<Typography
component="span"
fontSize="inherit"
>
Create your{" "}
</Typography>
<Typography
component="span"
variant="h2"
fontSize="inherit"
fontWeight="inherit"
>
infrastructure monitor
</Typography>
</Typography>
<ConfigBox>
<Box>
<Stack gap={theme.spacing(6)}>
<Typography component="h2">General settings</Typography>
<Typography component="p">
Here you can select the URL of the host, together with the friendly name
and authorization secret to connect to the server agent.
</Typography>
<Typography component="p">
The server you are monitoring must be running the{" "}
<Link
level="primary"
url="https://github.com/bluewave-labs/checkmate-agent"
label="Checkmate Monitoring Agent"
/>
</Typography>
</Stack>
</Box>
<Stack gap={theme.spacing(15)}>
<TextInput
type="text"
id="url"
label="Server URL"
placeholder="https://"
value={infrastructureMonitor.url}
onBlur={handleBlur}
onChange={handleChange}
error={errors["url"] ? true : false}
helperText={errors["url"]}
/>
<TextInput
type="text"
id="name"
label="Display name"
placeholder="Google"
isOptional={true}
value={infrastructureMonitor.name}
onBlur={handleBlur}
onChange={handleChange}
error={errors["name"]}
/>
<TextInput
type="text"
id="secret"
label="Authorization secret"
value={infrastructureMonitor.secret}
onBlur={handleBlur}
onChange={handleChange}
error={errors["secret"] ? true : false}
helperText={errors["secret"]}
/>
</Stack>
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h2">Incident notifications</Typography>
<Typography component="p">
When there is an incident, notify users.
</Typography>
</Box>
<Stack gap={theme.spacing(6)}>
<Typography component="p">When there is a new incident,</Typography>
<Checkbox
id="notify-email-default"
label={`Notify via email (to ${user.email})`}
isChecked={infrastructureMonitor.notifications.some(
(notification) => notification.type === "email"
)}
value={user?.email}
onChange={(e) => handleChange(e)}
onBlur={handleBlur}
/>
</Stack>
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h2">Customize alerts</Typography>
<Typography component="p">
Send a notification to user(s) when thresholds exceed a specified
percentage.
</Typography>
</Box>
<Stack gap={theme.spacing(6)}>
{HARDWARE_MONITOR_TYPES.map((type, idx) => (
<CustomThreshold
key={idx}
checkboxId={type}
checkboxLabel={
type !== "cpu" ? capitalizeFirstLetter(type) : type.toUpperCase()
}
onCheckboxChange={handleCustomAlertCheckChange}
fieldId={THRESHOLD_FIELD_PREFIX + type}
fieldValue={infrastructureMonitor[THRESHOLD_FIELD_PREFIX + type] ?? ""}
onFieldChange={handleChange}
onFieldBlur={handleBlur}
// TODO: need BE, maybe in another PR
alertUnit={type == "temperature" ? "°C" : "%"}
infrastructureMonitor={infrastructureMonitor}
errors={errors}
/>
))}
{alertErrKeyLen > 0 && (
<Typography
component="span"
className="input-error"
color={theme.palette.error.main}
mt={theme.spacing(2)}
sx={{
opacity: 0.8,
}}
>
{
errors[
THRESHOLD_FIELD_PREFIX +
HARDWARE_MONITOR_TYPES.filter(
(type) => errors[THRESHOLD_FIELD_PREFIX + type]
)[0]
]
}
</Typography>
)}
</Stack>
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h2">Advanced settings</Typography>
</Box>
<Stack gap={theme.spacing(12)}>
<Select
id="interval"
label="Check frequency"
value={infrastructureMonitor.interval || 15}
onChange={(e) => handleChange(e, "interval")}
onBlur={(e) => handleBlur(e, "interval")}
items={frequencies}
/>
</Stack>
</ConfigBox>
<Stack
direction="row"
justifyContent="flex-end"
>
<LoadingButton
variant="contained"
color="primary"
onClick={handleCreateInfrastructureMonitor}
loading={monitorState?.isLoading}
>
Create infrastructure monitor
</LoadingButton>
</Stack>
</Stack>
</Box>
);
};
export default CreateInfrastructureMonitor;

View File

@ -0,0 +1,37 @@
import { useTheme } from "@emotion/react";
import PlaceholderLight from "../../../assets/Images/data_placeholder.svg?react";
import PlaceholderDark from "../../../assets/Images/data_placeholder_dark.svg?react";
import { Box, Typography, Stack } from "@mui/material";
import PropTypes from "prop-types";
import { useSelector } from "react-redux";
const Empty = ({ styles }) => {
const theme = useTheme();
const mode = useSelector((state) => state.ui.mode);
return (
<Box sx={{ ...styles, marginTop: theme.spacing(24) }}>
<Stack
direction="column"
gap={theme.spacing(8)}
alignItems="center"
>
{mode === "light" ? <PlaceholderLight /> : <PlaceholderDark />}
<Typography variant="h2">Your infrastructure dashboard will show here</Typography>
<Typography
textAlign="center"
color={theme.palette.text.secondary}
>
Hang tight! When we receive data, we'll show it here. Please check back in a few
minutes.
</Typography>
</Stack>
</Box>
);
};
Empty.propTypes = {
styles: PropTypes.object,
mode: PropTypes.string,
};
export default Empty;

View File

@ -0,0 +1,589 @@
import { useParams } from "react-router-dom";
import { useEffect, useState } from "react";
import Breadcrumbs from "../../../Components/Breadcrumbs";
import { Stack, Box, Typography } from "@mui/material";
import { useTheme } from "@emotion/react";
import CustomGauge from "../../../Components/Charts/CustomGauge";
import AreaChart from "../../../Components/Charts/AreaChart";
import { useSelector } from "react-redux";
import { networkService } from "../../../main";
import PulseDot from "../../../Components/Animated/PulseDot";
import useUtils from "../../Monitors/utils";
import { useNavigate } from "react-router-dom";
import Empty from "./empty";
import { logger } from "../../../Utils/Logger";
import { formatDurationRounded, formatDurationSplit } from "../../../Utils/timeUtils";
import {
TzTick,
PercentTick,
InfrastructureTooltip,
TemperatureTooltip,
} from "../../../Components/Charts/Utils/chartUtils";
import PropTypes from "prop-types";
const BASE_BOX_PADDING_VERTICAL = 4;
const BASE_BOX_PADDING_HORIZONTAL = 8;
const TYPOGRAPHY_PADDING = 8;
/**
* Converts bytes to gigabytes
* @param {number} bytes - Number of bytes to convert
* @returns {number} Converted value in gigabytes
*/
const formatBytes = (bytes) => {
if (bytes === undefined || bytes === null) return "0 GB";
if (typeof bytes !== "number") return "0 GB";
if (bytes === 0) return "0 GB";
const GB = bytes / (1024 * 1024 * 1024);
const MB = bytes / (1024 * 1024);
if (GB >= 1) {
return `${Number(GB.toFixed(0))} GB`;
} else {
return `${Number(MB.toFixed(0))} MB`;
}
};
/**
* Converts a decimal value to a percentage
*
* @function decimalToPercentage
* @param {number} value - Decimal value to convert
* @returns {number} Percentage representation
*
* @example
* decimalToPercentage(0.75) // Returns 75
* decimalToPercentage(null) // Returns 0
*/
const decimalToPercentage = (value) => {
if (value === null || value === undefined) return 0;
return value * 100;
};
/**
* Renders a base box with consistent styling
* @param {Object} props - Component properties
* @param {React.ReactNode} props.children - Child components to render inside the box
* @param {Object} props.sx - Additional styling for the box
* @returns {React.ReactElement} Styled box component
*/
const BaseBox = ({ children, sx = {} }) => {
const theme = useTheme();
return (
<Box
sx={{
height: "100%",
padding: `${theme.spacing(BASE_BOX_PADDING_VERTICAL)} ${theme.spacing(BASE_BOX_PADDING_HORIZONTAL)}`,
minWidth: 200,
width: 225,
backgroundColor: theme.palette.background.main,
border: 1,
borderStyle: "solid",
borderColor: theme.palette.border.light,
...sx,
}}
>
{children}
</Box>
);
};
BaseBox.propTypes = {
children: PropTypes.node.isRequired,
sx: PropTypes.object,
};
/**
* Renders a statistic box with a heading and subheading
* @param {Object} props - Component properties
* @param {string} props.heading - Primary heading text
* @param {string} props.subHeading - Secondary heading text
* @returns {React.ReactElement} Stat box component
*/
const StatBox = ({ heading, subHeading }) => {
return (
<BaseBox>
<Typography component="h2">{heading}</Typography>
<Typography>{subHeading}</Typography>
</BaseBox>
);
};
StatBox.propTypes = {
heading: PropTypes.string.isRequired,
subHeading: PropTypes.string.isRequired,
};
/**
* Renders a gauge box with usage visualization
* @param {Object} props - Component properties
* @param {number} props.value - Percentage value for gauge
* @param {string} props.heading - Box heading
* @param {string} props.metricOne - First metric label
* @param {string} props.valueOne - First metric value
* @param {string} props.metricTwo - Second metric label
* @param {string} props.valueTwo - Second metric value
* @returns {React.ReactElement} Gauge box component
*/
const GaugeBox = ({ value, heading, metricOne, valueOne, metricTwo, valueTwo }) => {
const theme = useTheme();
return (
<BaseBox>
<Stack
direction="column"
gap={theme.spacing(2)}
alignItems="center"
>
<CustomGauge
progress={value}
radius={100}
color={theme.palette.primary.main}
/>
<Typography component="h2">{heading}</Typography>
<Box
sx={{
width: "100%",
borderTop: `1px solid ${theme.palette.border.light}`,
}}
>
<Stack
justifyContent={"space-between"}
direction="row"
gap={theme.spacing(2)}
>
<Typography>{metricOne}</Typography>
<Typography>{valueOne}</Typography>
</Stack>
<Stack
justifyContent={"space-between"}
direction="row"
gap={theme.spacing(2)}
>
<Typography>{metricTwo}</Typography>
<Typography>{valueTwo}</Typography>
</Stack>
</Box>
</Stack>
</BaseBox>
);
};
GaugeBox.propTypes = {
value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
heading: PropTypes.string.isRequired,
metricOne: PropTypes.string.isRequired,
valueOne: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
metricTwo: PropTypes.string.isRequired,
valueTwo: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
};
/**
* Renders the infrastructure details page
* @returns {React.ReactElement} Infrastructure details page component
*/
const InfrastructureDetails = () => {
const navigate = useNavigate();
const theme = useTheme();
const { monitorId } = useParams();
const navList = [
{ name: "infrastructure monitors", path: "/infrastructure" },
{ name: "details", path: `/infrastructure/${monitorId}` },
];
const [monitor, setMonitor] = useState(null);
const { authToken } = useSelector((state) => state.auth);
const [dateRange, setDateRange] = useState("all");
const { statusColor, determineState } = useUtils();
// These calculations are needed because ResponsiveContainer
// doesn't take padding of parent/siblings into account
// when calculating height.
const chartContainerHeight = 300;
const totalChartContainerPadding =
parseInt(theme.spacing(BASE_BOX_PADDING_VERTICAL), 10) * 2;
const totalTypographyPadding = parseInt(theme.spacing(TYPOGRAPHY_PADDING), 10) * 2;
const areaChartHeight =
(chartContainerHeight - totalChartContainerPadding - totalTypographyPadding) * 0.95;
// end height calculations
const buildStatBoxes = (checks) => {
let latestCheck = checks[0] ?? null;
if (latestCheck === null) return [];
// Extract values from latest check
const physicalCores = latestCheck?.cpu?.physical_core ?? 0;
const logicalCores = latestCheck?.cpu?.logical_core ?? 0;
const cpuFrequency = latestCheck?.cpu?.frequency ?? 0;
const cpuTemperature =
latestCheck?.cpu?.temperature?.length > 0
? latestCheck.cpu.temperature.reduce((acc, curr) => acc + curr, 0) /
latestCheck.cpu.temperature.length
: 0;
const memoryTotalBytes = latestCheck?.memory?.total_bytes ?? 0;
const diskTotalBytes = latestCheck?.disk[0]?.total_bytes ?? 0;
const os = latestCheck?.host?.os ?? null;
const platform = latestCheck?.host?.platform ?? null;
const osPlatform = os === null && platform === null ? null : `${os} ${platform}`;
return [
{
id: 0,
heading: "CPU (Physical)",
subHeading: `${physicalCores} cores`,
},
{
id: 1,
heading: "CPU (Logical)",
subHeading: `${logicalCores} cores`,
},
{
id: 2,
heading: "CPU Frequency",
subHeading: `${(cpuFrequency / 1000).toFixed(2)} Ghz`,
},
{
id: 3,
heading: "Average CPU Temperature",
subHeading: `${cpuTemperature.toFixed(2)} C`,
},
{
id: 4,
heading: "Memory",
subHeading: formatBytes(memoryTotalBytes),
},
{
id: 5,
heading: "Disk",
subHeading: formatBytes(diskTotalBytes),
},
{ id: 6, heading: "Uptime", subHeading: "100%" },
{
id: 7,
heading: "Status",
subHeading: monitor?.status === true ? "Active" : "Inactive",
},
{
id: 8,
heading: "OS",
subHeading: osPlatform,
},
];
};
const buildGaugeBoxConfigs = (checks) => {
let latestCheck = checks[0] ?? null;
if (latestCheck === null) return [];
// Extract values from latest check
const memoryUsagePercent = latestCheck?.memory?.usage_percent ?? 0;
const memoryUsedBytes = latestCheck?.memory?.used_bytes ?? 0;
const memoryTotalBytes = latestCheck?.memory?.total_bytes ?? 0;
const cpuUsagePercent = latestCheck?.cpu?.usage_percent ?? 0;
const cpuPhysicalCores = latestCheck?.cpu?.physical_core ?? 0;
const cpuFrequency = latestCheck?.cpu?.frequency ?? 0;
return [
{
type: "memory",
value: decimalToPercentage(memoryUsagePercent),
heading: "Memory Usage",
metricOne: "Used",
valueOne: formatBytes(memoryUsedBytes),
metricTwo: "Total",
valueTwo: formatBytes(memoryTotalBytes),
},
{
type: "cpu",
value: decimalToPercentage(cpuUsagePercent),
heading: "CPU Usage",
metricOne: "Cores",
valueOne: cpuPhysicalCores ?? 0,
metricTwo: "Frequency",
valueTwo: `${(cpuFrequency / 1000).toFixed(2)} Ghz`,
},
...(latestCheck?.disk ?? []).map((disk, idx) => ({
type: "disk",
diskIndex: idx,
value: decimalToPercentage(disk.usage_percent),
heading: `Disk${idx} usage`,
metricOne: "Used",
valueOne: formatBytes(disk.total_bytes - disk.free_bytes),
metricTwo: "Total",
valueTwo: formatBytes(disk.total_bytes),
})),
];
};
const buildTemps = (checks) => {
let numCores = 1;
if (checks === null) return { temps: [], tempKeys: [] };
for (const check of checks) {
if (check?.cpu?.temperature?.length > numCores) {
numCores = check.cpu.temperature.length;
break;
}
}
const temps = checks.map((check) => {
// If there's no data, set the temperature to 0
if (
check?.cpu?.temperature?.length === 0 ||
check?.cpu?.temperature === undefined ||
check?.cpu?.temperature === null
) {
check.cpu.temperature = Array(numCores).fill(0);
}
const res = check?.cpu?.temperature?.reduce(
(acc, cur, idx) => {
acc[`core${idx + 1}`] = cur;
return acc;
},
{
createdAt: check.createdAt,
}
);
return res;
});
if (temps.length === 0 || !temps[0]) {
return { temps: [], tempKeys: [] };
}
return {
tempKeys: Object.keys(temps[0] || {}).filter((key) => key !== "createdAt"),
temps,
};
};
const buildAreaChartConfigs = (checks) => {
let latestCheck = checks[0] ?? null;
if (latestCheck === null) return [];
const reversedChecks = checks.toReversed();
const tempData = buildTemps(reversedChecks);
return [
{
type: "memory",
data: reversedChecks,
dataKeys: ["memory.usage_percent"],
heading: "Memory usage",
strokeColor: theme.palette.primary.main,
gradientStartColor: theme.palette.primary.main,
yLabel: "Memory Usage",
yDomain: [0, 1],
yTick: <PercentTick />,
xTick: <TzTick />,
toolTip: (
<InfrastructureTooltip
dotColor={theme.palette.primary.main}
yKey={"memory.usage_percent"}
yLabel={"Memory Usage"}
/>
),
},
{
type: "cpu",
data: reversedChecks,
dataKeys: ["cpu.usage_percent"],
heading: "CPU usage",
strokeColor: theme.palette.success.main,
gradientStartColor: theme.palette.success.main,
yLabel: "CPU Usage",
yDomain: [0, 1],
yTick: <PercentTick />,
xTick: <TzTick />,
toolTip: (
<InfrastructureTooltip
dotColor={theme.palette.success.main}
yKey={"cpu.usage_percent"}
yLabel={"CPU Usage"}
/>
),
},
{
type: "temperature",
data: tempData.temps,
dataKeys: tempData.tempKeys,
strokeColor: theme.palette.error.main,
gradientStartColor: theme.palette.error.main,
heading: "CPU Temperature",
yLabel: "Temperature",
xTick: <TzTick />,
yDomain: [
0,
Math.max(
Math.max(
...tempData.temps.flatMap((t) => tempData.tempKeys.map((k) => t[k]))
) * 1.1,
200
),
],
toolTip: (
<TemperatureTooltip
keys={tempData.tempKeys}
dotColor={theme.palette.error.main}
/>
),
},
...(latestCheck?.disk?.map((disk, idx) => ({
type: "disk",
data: reversedChecks,
diskIndex: idx,
dataKeys: [`disk[${idx}].usage_percent`],
heading: `Disk${idx} usage`,
strokeColor: theme.palette.warning.main,
gradientStartColor: theme.palette.warning.main,
yLabel: "Disk Usage",
yDomain: [0, 1],
yTick: <PercentTick />,
xTick: <TzTick />,
toolTip: (
<InfrastructureTooltip
dotColor={theme.palette.warning.main}
yKey={`disk.usage_percent`}
yLabel={"Disc usage"}
yIdx={idx}
/>
),
})) || []),
];
};
// Fetch data
useEffect(() => {
const fetchData = async () => {
try {
const response = await networkService.getStatsByMonitorId({
authToken: authToken,
monitorId: monitorId,
sortOrder: null,
limit: null,
dateRange: dateRange,
numToDisplay: 50,
normalize: false,
});
setMonitor(response.data.data);
} catch (error) {
navigate("/not-found", { replace: true });
logger.error(error);
}
};
fetchData();
}, [authToken, monitorId, dateRange, navigate]);
const statBoxConfigs = buildStatBoxes(monitor?.checks ?? []);
const gaugeBoxConfigs = buildGaugeBoxConfigs(monitor?.checks ?? []);
const areaChartConfigs = buildAreaChartConfigs(monitor?.checks ?? []);
return (
<Box>
<Breadcrumbs list={navList} />
{monitor?.checks?.length > 0 ? (
<Stack
direction="column"
gap={theme.spacing(10)}
mt={theme.spacing(10)}
>
<Stack
direction="row"
gap={theme.spacing(8)}
>
<Box>
<PulseDot color={statusColor[determineState(monitor)]} />
</Box>
<Typography
alignSelf="end"
component="h1"
variant="h1"
>
{monitor.name}
</Typography>
<Typography alignSelf="end">{monitor.url || "..."}</Typography>
<Box sx={{ flexGrow: 1 }} />
<Typography alignSelf="end">
Checking every {formatDurationRounded(monitor?.interval)}
</Typography>
<Typography alignSelf="end">
Last checked {formatDurationSplit(monitor?.lastChecked).time}{" "}
{formatDurationSplit(monitor?.lastChecked).format} ago
</Typography>
</Stack>
<Stack
direction="row"
flexWrap="wrap"
gap={theme.spacing(8)}
>
{statBoxConfigs.map((statBox) => (
<StatBox
key={statBox.id}
{...statBox}
/>
))}
</Stack>
<Stack
direction="row"
gap={theme.spacing(8)}
>
{gaugeBoxConfigs.map((config) => (
<GaugeBox
key={`${config.type}-${config.diskIndex ?? ""}`}
value={config.value}
heading={config.heading}
metricOne={config.metricOne}
valueOne={config.valueOne}
metricTwo={config.metricTwo}
valueTwo={config.valueTwo}
/>
))}
</Stack>
<Stack
direction={"row"}
height={chartContainerHeight} // FE team HELP!
gap={theme.spacing(8)} // FE team HELP!
flexWrap="wrap" // //FE team HELP! Better way to do this?
sx={{
"& > *": {
flexBasis: `calc(50% - ${theme.spacing(8)})`,
maxWidth: `calc(50% - ${theme.spacing(8)})`,
},
}}
>
{areaChartConfigs.map((config) => {
return (
<BaseBox key={`${config.type}-${config.diskIndex ?? ""}`}>
<Typography
component="h2"
padding={theme.spacing(8)}
>
{config.heading}
</Typography>
<AreaChart
height={areaChartHeight}
data={config.data}
dataKeys={config.dataKeys}
xKey="createdAt"
yDomain={config.yDomain}
customTooltip={config.toolTip}
xTick={config.xTick}
yTick={config.yTick}
strokeColor={config.strokeColor}
gradient={true}
gradientStartColor={config.gradientStartColor}
gradientEndColor="#ffffff"
/>
</BaseBox>
);
})}
</Stack>
</Stack>
) : (
<Empty
styles={{
border: 1,
borderColor: theme.palette.border.light,
borderRadius: theme.shape.borderRadius,
backgroundColor: theme.palette.background.main,
p: theme.spacing(30),
}}
/>
)}
</Box>
);
};
export default InfrastructureDetails;

View File

@ -0,0 +1,223 @@
/* TODO I basically copied and pasted this component from the actionsMenu. Check how we can make it reusable */
import { useRef, useState } from "react";
import { useSelector } from "react-redux";
import { useTheme } from "@emotion/react";
import { useNavigate } from "react-router-dom";
import { createToast } from "../../../../Utils/toastUtils";
import { IconButton, Menu, MenuItem } from "@mui/material";
import Settings from "../../../../assets/icons/settings-bold.svg?react";
import PropTypes from "prop-types";
import Dialog from "../../../../Components/Dialog";
import { networkService } from "../../../../Utils/NetworkService.js";
/**
* InfrastructureMenu Component
* Provides a dropdown menu for managing infrastructure monitors.
*
* @param {Object} props - The component props.
* @param {Object} props.monitor - The monitor object containing details about the infrastructure monitor.
* @param {string} props.monitor.id - Unique ID of the monitor.
* @param {string} [props.monitor.url] - URL associated with the monitor.
* @param {string} props.monitor.type - Type of monitor (e.g., uptime, infrastructure).
* @param {boolean} props.monitor.isActive - Indicates if the monitor is currently active.
* @param {boolean} props.isAdmin - Whether the user has admin privileges.
* @param {Function} props.updateCallback - Callback to trigger when the monitor data is updated.
* @returns {JSX.Element} The rendered component.
*/
const InfrastructureMenu = ({ monitor, isAdmin, updateCallback }) => {
const anchor = useRef(null);
const [isOpen, setIsOpen] = useState(false);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const theme = useTheme();
const authState = useSelector((state) => state.auth);
const authToken = authState.authToken;
const { isLoading } = useSelector((state) => state.uptimeMonitors);
const openMenu = (e) => {
e.stopPropagation();
setIsOpen(true);
};
const closeMenu = (e) => {
e.stopPropagation();
setIsOpen(false);
};
const openRemove = (e) => {
closeMenu(e);
setIsDialogOpen(true);
};
const cancelRemove = () => {
setIsDialogOpen(false);
};
const navigate = useNavigate();
function openDetails(id) {
navigate(`/infrastructure/${id}`);
}
const handleRemove = async () => {
try {
await networkService.deleteMonitorById({
authToken,
monitorId: monitor.id,
});
createToast({ body: "Monitor deleted successfully." });
} catch (error) {
createToast({ body: "Failed to delete monitor." });
} finally {
setIsDialogOpen(false);
updateCallback();
}
};
return (
<>
<IconButton
aria-label="monitor actions"
onClick={openMenu}
sx={{
"&:focus": {
outline: "none",
},
"& svg path": {
stroke: theme.palette.other.icon,
},
}}
ref={anchor}
>
<Settings />
</IconButton>
<Menu
className="actions-menu"
anchorEl={anchor.current}
open={isOpen}
onClose={closeMenu}
disableScrollLock
slotProps={{
paper: {
sx: {
"& ul": { p: theme.spacing(2.5) },
"& li": { m: 0 },
"& li:last-of-type": {
color: theme.palette.error.main,
},
},
},
}}
>
{/*
Open site action. Not necessary for infrastructure?
{actions.url !== null ? (
<MenuItem
onClick={(e) => {
closeMenu(e);
e.stopPropagation();
window.open(actions.url, "_blank", "noreferrer");
}}
>
Open site
</MenuItem>
) : (
""
)}
*/}
<MenuItem onClick={() => openDetails(monitor.id)}>Details</MenuItem>
{/*
Incidents. Necessary?
<MenuItem
onClick={(e) => {
e.stopPropagation();
navigate(`/incidents/${actions.id}`);
}}
>
Incidents
</MenuItem> */}
{/*
Configure. Necessary?
{isAdmin && (
<MenuItem
onClick={(e) => {
e.stopPropagation();
navigate(`/monitors/configure/${actions.id}`);
}}
>
Configure
</MenuItem>
)} */}
{/*
Clone. Necessary?
{isAdmin && (
<MenuItem
onClick={(e) => {
e.stopPropagation();
navigate(`/monitors/create/${actions.id}`);
}}
>
Clone
</MenuItem>
)} */}
{/*
Pause. Necessary?
const handlePause = async () => {
try {
const action = await dispatch(
pauseUptimeMonitor({ authToken, monitorId: monitor._id })
);
if (pauseUptimeMonitor.fulfilled.match(action)) {
updateCallback();
const state = action?.payload?.data.isActive === false ? "paused" : "resumed";
createToast({ body: `Monitor ${state} successfully.` });
} else {
throw new Error(action?.error?.message ?? "Failed to pause monitor.");
}
} catch (error) {
logger.error("Error pausing monitor:", monitor._id, error);
createToast({ body: "Failed to pause monitor." });
}
};
{isAdmin && (
<MenuItem
onClick={(e) => {
e.stopPropagation();
handlePause(e);
}}
>
{monitor?.isActive === true ? "Pause" : "Resume"}
</MenuItem>
)} */}
{isAdmin && <MenuItem onClick={openRemove}>Remove</MenuItem>}
</Menu>
<Dialog
open={isDialogOpen}
theme={theme}
title="Do you really want to delete this monitor?"
description="Once deleted, this monitor cannot be retrieved."
onCancel={cancelRemove}
confirmationButtonLabel="Delete"
onConfirm={handleRemove}
isLoading={isLoading}
modelTitle="modal-delete-monitor"
modelDescription="delete-monitor-confirmation"
/>
</>
);
};
InfrastructureMenu.propTypes = {
monitor: PropTypes.shape({
id: PropTypes.string,
url: PropTypes.string,
type: PropTypes.string,
isActive: PropTypes.bool,
}).isRequired,
isAdmin: PropTypes.bool,
updateCallback: PropTypes.func,
};
export { InfrastructureMenu };

View File

@ -0,0 +1,80 @@
import PropTypes from "prop-types";
import { Box, Button } from "@mui/material";
import LeftArrowDouble from "../../../../../assets/icons/left-arrow-double.svg?react";
import RightArrowDouble from "../../../../../assets/icons/right-arrow-double.svg?react";
import LeftArrow from "../../../../../assets/icons/left-arrow.svg?react";
import RightArrow from "../../../../../assets/icons/right-arrow.svg?react";
TablePaginationActions.propTypes = {
count: PropTypes.number.isRequired,
page: PropTypes.number.isRequired,
rowsPerPage: PropTypes.number.isRequired,
onPageChange: PropTypes.func.isRequired,
};
/**
* Component for pagination actions (first, previous, next, last).
*
* @component
* @param {Object} props
* @param {number} props.count - Total number of items.
* @param {number} props.page - Current page number.
* @param {number} props.rowsPerPage - Number of rows per page.
* @param {function} props.onPageChange - Callback function to handle page change.
*
* @returns {JSX.Element} Pagination actions component.
*/
function TablePaginationActions({ count, page, rowsPerPage, onPageChange }) {
const handleFirstPageButtonClick = (event) => {
onPageChange(event, 0);
};
const handleBackButtonClick = (event) => {
onPageChange(event, page - 1);
};
const handleNextButtonClick = (event) => {
onPageChange(event, page + 1);
};
const handleLastPageButtonClick = (event) => {
onPageChange(event, Math.max(0, Math.ceil(count / rowsPerPage) - 1));
};
return (
<Box sx={{ flexShrink: 0, ml: "24px" }}>
<Button
variant="group"
onClick={handleFirstPageButtonClick}
disabled={page === 0}
aria-label="first page"
>
<LeftArrowDouble />
</Button>
<Button
variant="group"
onClick={handleBackButtonClick}
disabled={page === 0}
aria-label="previous page"
>
<LeftArrow />
</Button>
<Button
variant="group"
onClick={handleNextButtonClick}
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
aria-label="next page"
>
<RightArrow />
</Button>
<Button
variant="group"
onClick={handleLastPageButtonClick}
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
aria-label="last page"
>
<RightArrowDouble />
</Button>
</Box>
);
}
export { TablePaginationActions };

View File

@ -0,0 +1,117 @@
import PropTypes from "prop-types";
import { useTheme } from "@emotion/react";
import { Stack, TablePagination, Typography } from "@mui/material";
import { TablePaginationActions } from "./Actions";
import SelectorVertical from "../../../../assets/icons/selector-vertical.svg?react";
Pagination.propTypes = {
monitorCount: PropTypes.number.isRequired, // Total number of items for pagination.
page: PropTypes.number.isRequired, // Current page index.
rowsPerPage: PropTypes.number.isRequired, // Number of rows displayed per page.
handleChangePage: PropTypes.func.isRequired, // Function to handle page changes.
handleChangeRowsPerPage: PropTypes.func.isRequired, // Function to handle changes in rows per page.
};
const ROWS_PER_PAGE_OPTIONS = [5, 10, 15, 25];
/**
* Pagination component for table navigation with customized styling and behavior.
*
* @param {object} props - Component properties.
* @param {number} props.monitorCount - Total number of monitors to paginate.
* @param {number} props.page - Current page index (0-based).
* @param {number} props.rowsPerPage - Number of rows to display per page.
* @param {function} props.handleChangePage - Callback for handling page changes.
* @param {function} props.handleChangeRowsPerPage - Callback for handling changes to rows per page.
* @returns {JSX.Element} The Pagination component.
*/
function Pagination({
monitorCount,
page,
rowsPerPage,
handleChangePage,
handleChangeRowsPerPage,
}) {
const theme = useTheme();
const start = page * rowsPerPage + 1;
const end = Math.min(page * rowsPerPage + rowsPerPage, monitorCount);
const range = `${start} - ${end}`;
return (
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
px={theme.spacing(4)}
marginTop={8}
>
<Typography
px={theme.spacing(2)}
variant="body2"
sx={{ opacity: 0.7 }}
>
Showing {range} of {monitorCount} monitor(s)
</Typography>
<TablePagination
component="div"
count={monitorCount}
page={page}
onPageChange={handleChangePage}
rowsPerPage={rowsPerPage}
rowsPerPageOptions={ROWS_PER_PAGE_OPTIONS}
onRowsPerPageChange={handleChangeRowsPerPage}
ActionsComponent={TablePaginationActions}
labelRowsPerPage="Rows per page"
labelDisplayedRows={({ page, count }) =>
`Page ${page + 1} of ${Math.max(0, Math.ceil(count / rowsPerPage))}`
}
slotProps={{
select: {
MenuProps: {
keepMounted: true,
disableScrollLock: true,
PaperProps: {
className: "pagination-dropdown",
sx: {
mt: 0,
mb: theme.spacing(2),
},
},
transformOrigin: { vertical: "bottom", horizontal: "left" },
anchorOrigin: { vertical: "top", horizontal: "left" },
sx: {
mt: theme.spacing(-2),
},
},
inputProps: { id: "pagination-dropdown" },
IconComponent: SelectorVertical,
sx: {
ml: theme.spacing(4),
mr: theme.spacing(12),
minWidth: theme.spacing(20),
textAlign: "left",
"&.Mui-focused > div": {
backgroundColor: theme.palette.background.main,
},
},
},
}}
sx={{
color: theme.palette.text.secondary,
"& svg path": {
stroke: theme.palette.text.tertiary,
strokeWidth: 1.3,
},
"& .MuiSelect-select": {
border: 1,
borderColor: theme.palette.border.light,
borderRadius: theme.shape.borderRadius,
},
}}
/>
</Stack>
);
}
export { Pagination };

View File

@ -0,0 +1,368 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { /* useDispatch, */ useSelector } from "react-redux";
import { useTheme } from "@emotion/react";
import useUtils from "../Monitors/utils";
import { jwtDecode } from "jwt-decode";
import SkeletonLayout from "./skeleton";
import Fallback from "../../Components/Fallback";
// import GearIcon from "../../Assets/icons/settings-bold.svg?react";
import CPUChipIcon from "../../assets/icons/cpu-chip.svg?react";
import {
Box,
Button,
IconButton,
Paper,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
} from "@mui/material";
import Breadcrumbs from "../../Components/Breadcrumbs";
import { StatusLabel } from "../../Components/Label";
import { Heading } from "../../Components/Heading";
import { Pagination } from "./components/TablePagination";
// import { getInfrastructureMonitorsByTeamId } from "../../Features/InfrastructureMonitors/infrastructureMonitorsSlice";
import { networkService } from "../../Utils/NetworkService.js";
import CustomGauge from "../../Components/Charts/CustomGauge/index.jsx";
import Host from "../Monitors/Home/host.jsx";
import { useIsAdmin } from "../../Hooks/useIsAdmin.js";
import { InfrastructureMenu } from "./components/Menu";
const columns = [
{ label: "Host" },
{ label: "Status" },
{ label: "Frequency" },
{ label: "CPU" },
{ label: "Mem" },
{ label: "Disk" },
{ label: "Actions" },
];
const BREADCRUMBS = [{ name: `infrastructure`, path: "/infrastructure" }];
/* TODO
Create reusable table component.
It should receive as a parameter the following object:
tableData = [
columns = [
{
id: example,
label: Example Extendable,
align: "center" | "left" (default)
}
],
rows: [
{
**Number of keys will be equal to number of columns**
key1: string,
key2: number,
key3: Component
}
]
]
Apply to Monitor Table, and Account/Team.
Analyze existing BasicTable
*/
/**
* This is the Infrastructure monitoring page. This is a work in progress
*
* @param - Define params.
* @returns {JSX.Element} The infrastructure monitoring page.
*/
function Infrastructure() {
/* Adding this custom hook so we can avoid using the HOC approach that can lower performance (we are calling the admin logic N times on initializing the project. using a custom hook will cal it ass needed ) */
const isAdmin = useIsAdmin();
const theme = useTheme();
const [isLoading, setIsLoading] = useState(true);
const navigate = useNavigate();
const navigateToCreate = () => navigate("/infrastructure/create");
const [page, setPage] = useState(0);
/* TODO refactor this, so it is not aware of the MUI implementation. First argument only exists because of MUI. This should require onlu the new page. Adapting for MUI should happen inside of table pagination component */
const handleChangePage = (_, newPage) => {
setPage(newPage);
};
const [rowsPerPage, setRowsPerPage] = useState(5);
const handleChangeRowsPerPage = (event) => {
setRowsPerPage(parseInt(event.target.value));
setPage(0);
};
const [monitorState, setMonitorState] = useState({ monitors: [], total: 0 });
const { authToken } = useSelector((state) => state.auth);
const user = jwtDecode(authToken);
const fetchMonitors = async () => {
try {
setIsLoading(true);
const response = await networkService.getMonitorsByTeamId({
authToken,
teamId: user.teamId,
limit: 1,
types: ["hardware"],
status: null,
checkOrder: "desc",
normalize: true,
page: page,
rowsPerPage: rowsPerPage,
});
setMonitorState({
monitors: response?.data?.data?.monitors ?? [],
total: response?.data?.data?.monitorCount ?? 0,
});
} catch (error) {
console.error(error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchMonitors();
}, [page, rowsPerPage]);
const { determineState } = useUtils();
const { monitors, total: totalMonitors } = monitorState;
// do it here
function openDetails(id) {
navigate(`/infrastructure/${id}`);
}
function handleActionMenuDelete() {
fetchMonitors();
}
const monitorsAsRows = monitors.map((monitor) => {
const processor =
((monitor.checks[0]?.cpu?.usage_frequency ?? 0) / 1000).toFixed(2) + " GHz";
const cpu = (monitor?.checks[0]?.cpu.usage_percent ?? 0) * 100;
const mem = (monitor?.checks[0]?.memory.usage_percent ?? 0) * 100;
const disk = (monitor?.checks[0]?.disk[0]?.usage_percent ?? 0) * 100;
const status = determineState(monitor);
const uptimePercentage = ((monitor?.uptimePercentage ?? 0) * 100)
.toFixed(2)
.toString();
const percentageColor =
monitor.uptimePercentage < 0.25
? theme.palette.percentage.uptimePoor
: monitor.uptimePercentage < 0.5
? theme.palette.percentage.uptimeFair
: monitor.uptimePercentage < 0.75
? theme.palette.percentage.uptimeGood
: theme.palette.percentage.uptimeExcellent;
return {
id: monitor._id,
name: monitor.name,
url: monitor.url,
processor,
cpu,
mem,
disk,
status,
uptimePercentage,
percentageColor,
};
});
let isActuallyLoading = isLoading && monitorState.monitors?.length === 0;
return (
<Box
className="infrastructure-monitor"
sx={{
':has(> [class*="fallback__"])': {
position: "relative",
border: 1,
borderColor: theme.palette.border.light,
borderRadius: theme.shape.borderRadius,
borderStyle: "dashed",
backgroundColor: theme.palette.background.main,
overflow: "hidden",
},
}}
>
{isActuallyLoading ? (
<SkeletonLayout />
) : monitorState.monitors?.length !== 0 ? (
<Stack gap={theme.spacing(8)}>
<Breadcrumbs list={BREADCRUMBS} />
<Stack
direction="row"
sx={{
justifyContent: "end",
alignItems: "center",
gap: "1rem",
flexWrap: "wrap",
marginBottom: "2rem",
}}
>
{/*
This will be removed from here, but keeping the commented code to remind me to add a max width to the greeting component
<Box style={{ maxWidth: "65ch" }}>
<Greeting type="uptime" />
</Box> */}
<Button
variant="contained"
color="primary"
onClick={navigateToCreate}
sx={{ fontWeight: 500 }}
>
Create infrastructure monitor
</Button>
</Stack>
<Stack
sx={{
gap: "1rem",
}}
>
<Stack
direction="row"
sx={{
alignItems: "center",
gap: ".25rem",
flexWrap: "wrap",
}}
>
<Heading component="h2">Infrastructure monitors</Heading>
{/* TODO Correct the class current-monitors-counter, there are some unnecessary things there */}
<Box
component="span"
className="current-monitors-counter"
color={theme.palette.text.primary}
border={1}
borderColor={theme.palette.border.light}
backgroundColor={theme.palette.background.accent}
>
{totalMonitors}
</Box>
</Stack>
<TableContainer component={Paper}>
<Table stickyHeader>
<TableHead sx={{ backgroundColor: theme.palette.background.accent }}>
<TableRow>
{columns.map((column, index) => (
<TableCell
key={index}
align={index === 0 ? "left" : "center"}
sx={{
backgroundColor: theme.palette.background.accent,
}}
>
{column.label}
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{monitorsAsRows.map((row) => {
return (
<TableRow
key={row.id}
onClick={() => openDetails(row.id)}
sx={{
cursor: "pointer",
"&:hover": {
backgroundColor: theme.palette.background.accent,
},
}}
>
{/* TODO iterate over column and get column id, applying row[column.id] */}
<TableCell>
<Host
title={row.name}
url={row.url}
percentage={row.uptimePercentage}
percentageColor={row.percentageColor}
/>
</TableCell>
<TableCell align="center">
<StatusLabel
status={row.status}
text={row.status}
/* Use capitalize inside of Status Label */
/* Update component so we don't need to pass text and status separately*/
customStyles={{ textTransform: "capitalize" }}
/>
</TableCell>
<TableCell align="center">
<Stack
direction={"row"}
justifyContent={"center"}
alignItems={"center"}
gap=".25rem"
>
<CPUChipIcon
width={20}
height={20}
/>
{row.processor}
</Stack>
</TableCell>
<TableCell align="center">
<CustomGauge progress={row.cpu} />
</TableCell>
<TableCell align="center">
<CustomGauge progress={row.mem} />
</TableCell>
<TableCell align="center">
<CustomGauge progress={row.disk} />
</TableCell>
<TableCell align="center">
{/* Get ActionsMenu from Monitor Table and create a component */}
<IconButton
sx={{
"& svg path": {
stroke: theme.palette.other.icon,
},
}}
>
<InfrastructureMenu
monitor={row}
isAdmin={isAdmin}
updateCallback={handleActionMenuDelete}
/>
{/* <GearIcon
width={20}
height={20}
/> */}
</IconButton>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
<Pagination
monitorCount={totalMonitors}
page={page}
rowsPerPage={rowsPerPage}
handleChangePage={handleChangePage}
handleChangeRowsPerPage={handleChangeRowsPerPage}
/>
</Stack>
</Stack>
) : (
<Fallback
vowelStart={true}
title="infrastructure monitor"
checks={[
"Track the performance of your servers",
"Identify bottlenecks and optimize usage",
"Ensure reliability with real-time monitoring",
]}
link="/infrastructure/create"
isAdmin={isAdmin}
/>
)}
</Box>
);
}
export { Infrastructure };

View File

@ -0,0 +1,73 @@
import { Box, Skeleton, Stack } from "@mui/material";
import { useTheme } from "@emotion/react";
/**
* Renders a skeleton layout.
*
* @returns {JSX.Element}
*/
const SkeletonLayout = () => {
const theme = useTheme();
return (
<Stack gap={theme.spacing(2)}>
<Stack
direction="row"
justifyContent="space-between"
mb={theme.spacing(12)}
>
<Box width="80%">
<Skeleton
variant="rounded"
width="25%"
height={24}
/>
<Skeleton
variant="rounded"
width="50%"
height={19.5}
sx={{ mt: theme.spacing(2) }}
/>
</Box>
<Skeleton
variant="rounded"
width="20%"
height={34}
sx={{ alignSelf: "flex-end" }}
/>
</Stack>
<Stack
direction="row"
flexWrap="wrap"
gap={theme.spacing(12)}
>
<Skeleton
variant="rounded"
width="100%"
height={120}
sx={{ flex: "35%" }}
/>
<Skeleton
variant="rounded"
width="100%"
height={120}
sx={{ flex: "35%" }}
/>
<Skeleton
variant="rounded"
width="100%"
height={120}
sx={{ flex: "35%" }}
/>
<Skeleton
variant="rounded"
width="100%"
height={120}
sx={{ flex: "35%" }}
/>
</Stack>
</Stack>
);
};
export default SkeletonLayout;

View File

@ -1,4 +1,4 @@
import { Box, Button, duration, Stack, Typography } from "@mui/material";
import { Box, Button, Stack, Typography } from "@mui/material";
import { useSelector } from "react-redux";
import { useTheme } from "@emotion/react";
import { useEffect, useState } from "react";
@ -13,7 +13,7 @@ import LoadingButton from "@mui/lab/LoadingButton";
import dayjs from "dayjs";
import Select from "../../../Components/Inputs/Select";
import Field from "../../../Components/Inputs/Field";
import TextInput from "../../../Components/Inputs/TextInput";
import Breadcrumbs from "../../../Components/Breadcrumbs";
import CalendarIcon from "../../../assets/icons/calendar.svg?react";
import "./index.css";
@ -215,8 +215,7 @@ const CreateMaintenance = () => {
};
const handleSubmit = async () => {
if (hasValidationErrors(form, maintenanceWindowValidation, setErrors))
return;
if (hasValidationErrors(form, maintenanceWindowValidation, setErrors)) return;
// Build timestamp for maintenance window from startDate and startTime
const start = dayjs(form.startDate)
.set("hour", form.startTime.hour())
@ -467,14 +466,15 @@ const CreateMaintenance = () => {
direction="row"
spacing={theme.spacing(8)}
>
<Field
<TextInput
type="number"
id="duration"
value={form.duration}
onChange={(event) => {
handleFormChange("duration", event.target.value);
}}
error={errors["duration"]}
error={errors["duration"] ? true : false}
helperText={errors["duration"]}
/>
<Select
id="durationUnit"
@ -511,14 +511,15 @@ const CreateMaintenance = () => {
</Typography>
</Box>
<Box>
<Field
<TextInput
id="name"
placeholder="Maintenance at __ : __ for ___ minutes"
value={form.name}
onChange={(event) => {
handleFormChange("name", event.target.value);
}}
error={errors["name"]}
error={errors["name"] ? true : false}
helperText={errors["name"]}
/>
</Box>
</Stack>

View File

@ -114,7 +114,7 @@ const ActionsMenu = ({ /* isAdmin, */ maintenanceWindow, updateCallback }) => {
"& ul": { p: theme.spacing(2.5) },
"& li": { m: 0 },
"& li:last-of-type": {
color: theme.palette.error.text,
color: theme.palette.error.main,
},
},
},

View File

@ -14,7 +14,8 @@ import {
getUptimeMonitorsByTeamId,
deleteUptimeMonitor,
} from "../../../Features/UptimeMonitors/uptimeMonitorsSlice";
import Field from "../../../Components/Inputs/Field";
import TextInput from "../../../Components/Inputs/TextInput";
import { HttpAdornment } from "../../../Components/Inputs/TextInput/Adornments";
import PauseIcon from "../../../assets/icons/pause-icon.svg?react";
import ResumeIcon from "../../../assets/icons/resume-icon.svg?react";
import Select from "../../../Components/Inputs/Select";
@ -343,17 +344,17 @@ const Configure = () => {
</Typography>
</Box>
<Stack gap={theme.spacing(20)}>
<Field
<TextInput
type={monitor?.type === "http" ? "url" : "text"}
https={protocol === "https"}
startAdornment={<HttpAdornment https={protocol === "https"} />}
id="monitor-url"
label="URL to monitor"
placeholder="google.com"
value={parsedUrl?.host || monitor?.url || ""}
disabled={true}
error={errors["url"]}
/>
<Field
<TextInput
type="text"
id="monitor-name"
label="Display name"
@ -361,7 +362,8 @@ const Configure = () => {
placeholder="Google"
value={monitor?.name || ""}
onChange={handleChange}
error={errors["name"]}
error={errors["name"] ? true : false}
helperText={errors["name"]}
/>
</Stack>
</ConfigBox>
@ -405,7 +407,7 @@ const Configure = () => {
(notification) => notification.type === "emails"
) ? (
<Box mx={theme.spacing(16)}>
<Field
<TextInput
id="notify-email-list"
type="text"
placeholder="name@gmail.com"

View File

@ -1,40 +1,72 @@
import { useState, useEffect } from "react";
import { Box, Button, ButtonGroup, Stack, Typography } from "@mui/material";
import LoadingButton from '@mui/lab/LoadingButton';
import { useSelector, useDispatch } from "react-redux";
import { monitorValidation } from "../../../Validation/validation";
import { createUptimeMonitor } from "../../../Features/UptimeMonitors/uptimeMonitorsSlice";
import { checkEndpointResolution } from "../../../Features/UptimeMonitors/uptimeMonitorsSlice"
import { useNavigate, useParams } from "react-router-dom";
// React, Redux, Router
import { useTheme } from "@emotion/react";
import { createToast } from "../../../Utils/toastUtils";
import { logger } from "../../../Utils/Logger";
import { ConfigBox } from "../styled";
import Radio from "../../../Components/Inputs/Radio";
import Field from "../../../Components/Inputs/Field";
import Select from "../../../Components/Inputs/Select";
import Checkbox from "../../../Components/Inputs/Checkbox";
import Breadcrumbs from "../../../Components/Breadcrumbs";
import { useNavigate, useParams } from "react-router-dom";
import { useEffect } from "react";
import { useState } from "react";
import { useSelector, useDispatch } from "react-redux";
// Utility and Network
import { checkEndpointResolution } from "../../../Features/UptimeMonitors/uptimeMonitorsSlice";
import { monitorValidation } from "../../../Validation/validation";
import { getUptimeMonitorById } from "../../../Features/UptimeMonitors/uptimeMonitorsSlice";
import "./index.css";
import { createUptimeMonitor } from "../../../Features/UptimeMonitors/uptimeMonitorsSlice";
// MUI
import { Box, Stack, Typography, Button, ButtonGroup } from "@mui/material";
import LoadingButton from "@mui/lab/LoadingButton";
//Components
import Breadcrumbs from "../../../Components/Breadcrumbs";
import { ConfigBox } from "../styled";
import TextInput from "../../../Components/Inputs/TextInput";
import { HttpAdornment } from "../../../Components/Inputs/TextInput/Adornments";
import { createToast } from "../../../Utils/toastUtils";
import Radio from "../../../Components/Inputs/Radio";
import Checkbox from "../../../Components/Inputs/Checkbox";
import Select from "../../../Components/Inputs/Select";
const CreateMonitor = () => {
const MS_PER_MINUTE = 60000;
const { user, authToken } = useSelector((state) => state.auth);
const { monitors, isLoading } = useSelector((state) => state.uptimeMonitors);
const dispatch = useDispatch();
const navigate = useNavigate();
const theme = useTheme();
const MS_PER_MINUTE = 60000;
const SELECT_VALUES = [
{ _id: 1, name: "1 minute" },
{ _id: 2, name: "2 minutes" },
{ _id: 3, name: "3 minutes" },
{ _id: 4, name: "4 minutes" },
{ _id: 5, name: "5 minutes" },
];
const idMap = {
"monitor-url": "url",
"monitor-name": "name",
"monitor-checks-http": "type",
"monitor-checks-ping": "type",
"notify-email-default": "notification-email",
const monitorTypeMaps = {
http: {
label: "URL to monitor",
placeholder: "google.com",
namePlaceholder: "Google",
},
ping: {
label: "IP address to monitor",
placeholder: "1.1.1.1",
namePlaceholder: "Google",
},
docker: {
label: "Container ID",
placeholder: "abc123",
namePlaceholder: "My Container",
},
};
const { user, authToken } = useSelector((state) => state.auth);
const { monitors, isLoading } = useSelector((state) => state.uptimeMonitors);
const dispatch = useDispatch();
const navigate = useNavigate();
const theme = useTheme();
const { monitorId } = useParams();
const crumbs = [
{ name: "monitors", path: "/monitors" },
{ name: "create", path: `/monitors/create` },
];
// State
const [errors, setErrors] = useState({});
const [https, setHttps] = useState(true);
const [monitor, setMonitor] = useState({
url: "",
name: "",
@ -42,8 +74,105 @@ const CreateMonitor = () => {
notifications: [],
interval: 1,
});
const [https, setHttps] = useState(true);
const [errors, setErrors] = useState({});
const handleCreateMonitor = async (event) => {
event.preventDefault();
let form = {
url:
//prepending protocol for url
monitor.type === "http"
? `http${https ? "s" : ""}://` + monitor.url
: monitor.url,
name: monitor.name === "" ? monitor.url : monitor.name,
type: monitor.type,
interval: monitor.interval * MS_PER_MINUTE,
};
const { error } = monitorValidation.validate(form, {
abortEarly: false,
});
if (error) {
const newErrors = {};
error.details.forEach((err) => {
newErrors[err.path[0]] = err.message;
});
setErrors(newErrors);
createToast({ body: "Please check the form for errors." });
return;
}
if (monitor.type === "http") {
const checkEndpointAction = await dispatch(
checkEndpointResolution({ authToken, monitorURL: form.url })
);
if (checkEndpointAction.meta.requestStatus === "rejected") {
createToast({
body: "The endpoint you entered doesn't resolve. Check the URL again.",
});
setErrors({ url: "The entered URL is not reachable." });
return;
}
}
form = {
...form,
description: form.name,
teamId: user.teamId,
userId: user._id,
notifications: monitor.notifications,
};
const action = await dispatch(createUptimeMonitor({ authToken, monitor: form }));
if (action.meta.requestStatus === "fulfilled") {
createToast({ body: "Monitor created successfully!" });
navigate("/monitors");
} else {
createToast({ body: "Failed to create monitor." });
}
};
const handleChange = (event, formName) => {
const { value } = event.target;
setMonitor({
...monitor,
[formName]: value,
});
const { error } = monitorValidation.validate(
{ [formName]: value },
{ abortEarly: false }
);
setErrors((prev) => ({
...prev,
...(error ? { [formName]: error.details[0].message } : { [formName]: undefined }),
}));
};
const handleNotifications = (event, type) => {
const { value } = event.target;
let notifications = [...monitor.notifications];
const notificationExists = notifications.some((notification) => {
if (notification.type === type && notification.address === value) {
return true;
}
return false;
});
if (notificationExists) {
notifications = notifications.filter((notification) => {
if (notification.type === type && notification.address === value) {
return false;
}
return true;
});
} else {
notifications.push({ type, address: value });
}
setMonitor((prev) => ({
...prev,
notifications,
}));
};
useEffect(() => {
const fetchMonitor = async () => {
@ -71,136 +200,16 @@ const CreateMonitor = () => {
}
};
fetchMonitor();
}, [monitorId, authToken, monitors]);
const handleChange = (event, name) => {
const { value, id } = event.target;
if (!name) name = idMap[id];
if (name.includes("notification-")) {
name = name.replace("notification-", "");
let hasNotif = monitor.notifications.some(
(notification) => notification.type === name
);
setMonitor((prev) => {
const notifs = [...prev.notifications];
if (hasNotif) {
return {
...prev,
notifications: notifs.filter((notif) => notif.type !== name),
};
} else {
return {
...prev,
notifications: [
...notifs,
name === "email"
? { type: name, address: value }
: // TODO - phone number
{ type: name, phone: value },
],
};
}
});
} else {
setMonitor((prev) => ({
...prev,
[name]: value,
}));
const { error } = monitorValidation.validate(
{ [name]: value },
{ abortEarly: false }
);
console.log(error);
setErrors((prev) => {
const updatedErrors = { ...prev };
if (error) updatedErrors[name] = error.details[0].message;
else delete updatedErrors[name];
return updatedErrors;
});
}
};
const handleCreateMonitor = async (event) => {
event.preventDefault();
//obj to submit
let form = {
url:
//preprending protocol for url
monitor.type === "http"
? `http${https ? "s" : ""}://` + monitor.url
: monitor.url,
name: monitor.name === "" ? monitor.url : monitor.name,
type: monitor.type,
interval: monitor.interval * MS_PER_MINUTE,
};
const { error } = monitorValidation.validate(form, {
abortEarly: false,
});
if (error) {
const newErrors = {};
error.details.forEach((err) => {
newErrors[err.path[0]] = err.message;
});
setErrors(newErrors);
createToast({ body: "Error validation data." });
} else {
if (monitor.type === "http") {
const checkEndpointAction = await dispatch(
checkEndpointResolution({ authToken, monitorURL: form.url })
)
if (checkEndpointAction.meta.requestStatus === "rejected") {
createToast({ body: "The endpoint you entered doesn't resolve. Check the URL again." });
setErrors({ url: "The entered URL is not reachable." });
return;
}
}
form = {
...form,
description: form.name,
teamId: user.teamId,
userId: user._id,
notifications: monitor.notifications,
};
const action = await dispatch(createUptimeMonitor({ authToken, monitor: form }));
if (action.meta.requestStatus === "fulfilled") {
createToast({ body: "Monitor created successfully!" });
navigate("/monitors");
} else {
createToast({ body: "Failed to create monitor." });
}
}
};
//select values
const frequencies = [
{ _id: 1, name: "1 minute" },
{ _id: 2, name: "2 minutes" },
{ _id: 3, name: "3 minutes" },
{ _id: 4, name: "4 minutes" },
{ _id: 5, name: "5 minutes" },
];
}, [monitorId, authToken, monitors, dispatch, navigate]);
return (
<Box className="create-monitor">
<Breadcrumbs
list={[
{ name: "monitors", path: "/monitors" },
{ name: "create", path: `/monitors/create` },
]}
/>
<Breadcrumbs list={crumbs} />
<Stack
component="form"
className="create-monitor-form"
onSubmit={handleCreateMonitor}
noValidate
spellCheck="false"
gap={theme.spacing(12)}
mt={theme.spacing(6)}
onSubmit={handleCreateMonitor}
>
<Typography
component="h1"
@ -221,6 +230,7 @@ const CreateMonitor = () => {
monitor
</Typography>
</Typography>
<ConfigBox>
<Box>
<Typography component="h2">General settings</Typography>
@ -229,25 +239,29 @@ const CreateMonitor = () => {
</Typography>
</Box>
<Stack gap={theme.spacing(15)}>
<Field
<TextInput
type={monitor.type === "http" ? "url" : "text"}
id="monitor-url"
label="URL to monitor"
startAdornment={
monitor.type === "http" ? <HttpAdornment https={https} /> : null
}
label={monitorTypeMaps[monitor.type].label || "URL to monitor"}
https={https}
placeholder="google.com"
placeholder={monitorTypeMaps[monitor.type].placeholder || ""}
value={monitor.url}
onChange={handleChange}
error={errors["url"]}
onChange={(event) => handleChange(event, "url")}
error={errors["url"] ? true : false}
helperText={errors["url"]}
/>
<Field
<TextInput
type="text"
id="monitor-name"
label="Display name"
isOptional={true}
placeholder="Google"
value={monitor.name}
onChange={handleChange}
error={errors["name"]}
onChange={(event) => handleChange(event, "name")}
error={errors["name"] ? true : false}
helperText={errors["name"]}
/>
</Stack>
</ConfigBox>
@ -267,7 +281,7 @@ const CreateMonitor = () => {
size="small"
value="http"
checked={monitor.type === "http"}
onChange={(event) => handleChange(event)}
onChange={(event) => handleChange(event, "type")}
/>
{monitor.type === "http" ? (
<ButtonGroup sx={{ ml: theme.spacing(16) }}>
@ -297,14 +311,23 @@ const CreateMonitor = () => {
size="small"
value="ping"
checked={monitor.type === "ping"}
onChange={(event) => handleChange(event)}
onChange={(event) => handleChange(event, "type")}
/>
<Radio
id="monitor-checks-docker"
title="Docker container monitoring"
desc="Check whether your container is running or not."
size="small"
value="docker"
checked={monitor.type === "docker"}
onChange={(event) => handleChange(event, "type")}
/>
{errors["type"] ? (
<Box className="error-container">
<Typography
component="p"
className="input-error"
color={theme.palette.error.text}
color={theme.palette.error.contrastText}
>
{errors["type"]}
</Typography>
@ -323,14 +346,7 @@ const CreateMonitor = () => {
</Box>
<Stack gap={theme.spacing(6)}>
<Typography component="p">When there is a new incident,</Typography>
<Checkbox
id="notify-sms"
label="Notify via SMS (coming soon)"
isChecked={false}
value=""
onChange={() => logger.warn("disabled")}
isDisabled={true}
/>
<Checkbox
id="notify-email-default"
label={`Notify via email (to ${user.email})`}
@ -338,34 +354,8 @@ const CreateMonitor = () => {
(notification) => notification.type === "email"
)}
value={user?.email}
onChange={(event) => handleChange(event)}
onChange={(event) => handleNotifications(event, "email")}
/>
<Checkbox
id="notify-email"
label="Also notify via email to multiple addresses (coming soon)"
isChecked={false}
value=""
onChange={() => logger.warn("disabled")}
isDisabled={true}
/>
{monitor.notifications.some(
(notification) => notification.type === "emails"
) ? (
<Box mx={theme.spacing(16)}>
<Field
id="notify-email-list"
type="text"
placeholder="name@gmail.com"
value=""
onChange={() => logger.warn("disabled")}
/>
<Typography mt={theme.spacing(4)}>
You can separate multiple emails with a comma
</Typography>
</Box>
) : (
""
)}
</Stack>
</ConfigBox>
<ConfigBox>
@ -378,7 +368,7 @@ const CreateMonitor = () => {
label="Check frequency"
value={monitor.interval || 1}
onChange={(event) => handleChange(event, "interval")}
items={frequencies}
items={SELECT_VALUES}
/>
</Stack>
</ConfigBox>
@ -386,19 +376,19 @@ const CreateMonitor = () => {
direction="row"
justifyContent="flex-end"
>
<LoadingButton
variant="contained"
color="primary"
onClick={handleCreateMonitor}
disabled={Object.keys(errors).length !== 0 && true}
loading={isLoading}
>
Create monitor
</LoadingButton>
<LoadingButton
variant="contained"
color="primary"
onClick={handleCreateMonitor}
disabled={!Object.values(errors).every((value) => value === undefined)}
loading={isLoading}
>
Create monitor
</LoadingButton>
</Stack>
</Stack>
</Box>
);
};
export default CreateMonitor;
export default CreateMonitor;

View File

@ -61,7 +61,7 @@ const UpBarChart = memo(({ data, type, onBarHover }) => {
? { main: theme.palette.success.main, light: theme.palette.success.light }
: uptime > 50
? { main: theme.palette.warning.main, light: theme.palette.warning.light }
: { main: theme.palette.error.text, light: theme.palette.error.light };
: { main: theme.palette.error.contrastText, light: theme.palette.error.light };
};
// TODO - REMOVE THIS LATER
@ -201,10 +201,10 @@ const DownBarChart = memo(({ data, type, onBarHover }) => {
key={`cell-${entry.time}`}
fill={
hoveredBarIndex === index
? theme.palette.error.text
? theme.palette.error.contrastText
: chartHovered
? theme.palette.error.light
: theme.palette.error.text
: theme.palette.error.contrastText
}
onMouseEnter={() => {
setHoveredBarIndex(index);
@ -246,24 +246,24 @@ const ResponseGaugeChart = ({ data }) => {
? {
category: "Excellent",
main: theme.palette.success.main,
bg: theme.palette.success.bg,
bg: theme.palette.success.contrastText,
}
: responseTime <= 500
? {
category: "Fair",
main: theme.palette.success.main,
bg: theme.palette.success.bg,
bg: theme.palette.success.contrastText,
}
: responseTime <= 600
? {
category: "Acceptable",
main: theme.palette.warning.main,
bg: theme.palette.warning.bg,
bg: theme.palette.warning.dark,
}
: {
category: "Poor",
main: theme.palette.error.text,
bg: theme.palette.error.bg,
main: theme.palette.error.contrastText,
bg: theme.palette.error.dark,
};
return (

View File

@ -1,10 +1,11 @@
import { useTheme } from "@emotion/react";
import { Box, Stack, Typography } from "@mui/material";
import { Box, Stack } from "@mui/material";
import Search from "../../../../Components/Inputs/Search";
import MemoizedMonitorTable from "../MonitorTable";
import { useState } from "react";
import useDebounce from "../../../../Utils/debounce";
import PropTypes from "prop-types";
import { Heading } from "../../../../Components/Heading";
const CurrentMonitoring = ({ totalMonitors, monitors, isAdmin }) => {
const theme = useTheme();
@ -18,26 +19,15 @@ const CurrentMonitoring = ({ totalMonitors, monitors, isAdmin }) => {
return (
<Box
flex={1}
px={theme.spacing(10)}
py={theme.spacing(8)}
border={1}
borderColor={theme.palette.border.light}
borderRadius={theme.shape.borderRadius}
backgroundColor={theme.palette.background.main}
>
<Stack
direction="row"
alignItems="center"
mb={theme.spacing(8)}
>
<Typography
component="h2"
variant="h2"
fontWeight={500}
letterSpacing={-0.2}
>
Actively monitoring
</Typography>
<Heading component="h2">Actively monitoring</Heading>
<Box
className="current-monitors-counter"
color={theme.palette.text.primary}

View File

@ -3,6 +3,7 @@ const ROWS_NUMBER = 7;
const ROWS_ARRAY = Array.from({ length: ROWS_NUMBER }, (_, i) => i);
const TableBodySkeleton = () => {
/* TODO Skeleton does not follow light and dark theme */
return (
<>
{ROWS_ARRAY.map((row) => (

View File

@ -1,4 +1,15 @@
import PropTypes from "prop-types";
import { useState, useEffect, memo, useCallback, useRef } from "react";
import { useNavigate } from "react-router";
import { useDispatch, useSelector } from "react-redux";
import { useTheme } from "@emotion/react";
import useUtils from "../../utils";
import { setRowsPerPage } from "../../../../Features/UI/uiSlice";
import { logger } from "../../../../Utils/Logger";
import { jwtDecode } from "jwt-decode";
import { networkService } from "../../../../main";
import {
TableContainer,
Table,
@ -8,106 +19,18 @@ import {
TableBody,
Paper,
Box,
TablePagination,
Stack,
Typography,
Button,
CircularProgress,
} from "@mui/material";
import ActionsMenu from "../actionsMenu";
import Host from "../host";
import { StatusLabel } from "../../../../Components/Label";
import { TableBodySkeleton } from "./Skeleton";
import BarChart from "../../../../Components/Charts/BarChart";
import ArrowDownwardRoundedIcon from "@mui/icons-material/ArrowDownwardRounded";
import ArrowUpwardRoundedIcon from "@mui/icons-material/ArrowUpwardRounded";
import { setRowsPerPage } from "../../../../Features/UI/uiSlice";
import { useState, useEffect, memo, useCallback, useRef } from "react";
import { useNavigate } from "react-router";
import { logger } from "../../../../Utils/Logger";
import Host from "../host";
import { StatusLabel } from "../../../../Components/Label";
import { jwtDecode } from "jwt-decode";
import { useDispatch, useSelector } from "react-redux";
import { networkService } from "../../../../main";
import { useTheme } from "@emotion/react";
import BarChart from "../../../../Components/Charts/BarChart";
import LeftArrowDouble from "../../../../assets/icons/left-arrow-double.svg?react";
import RightArrowDouble from "../../../../assets/icons/right-arrow-double.svg?react";
import LeftArrow from "../../../../assets/icons/left-arrow.svg?react";
import RightArrow from "../../../../assets/icons/right-arrow.svg?react";
import SelectorVertical from "../../../../assets/icons/selector-vertical.svg?react";
import ActionsMenu from "../actionsMenu";
import useUtils from "../../utils";
import { TableBodySkeleton } from "./Skeleton";
/**
* Component for pagination actions (first, previous, next, last).
*
* @component
* @param {Object} props
* @param {number} props.count - Total number of items.
* @param {number} props.page - Current page number.
* @param {number} props.rowsPerPage - Number of rows per page.
* @param {function} props.onPageChange - Callback function to handle page change.
*
* @returns {JSX.Element} Pagination actions component.
*/
const TablePaginationActions = (props) => {
const { count, page, rowsPerPage, onPageChange } = props;
const handleFirstPageButtonClick = (event) => {
onPageChange(event, 0);
};
const handleBackButtonClick = (event) => {
onPageChange(event, page - 1);
};
const handleNextButtonClick = (event) => {
onPageChange(event, page + 1);
};
const handleLastPageButtonClick = (event) => {
onPageChange(event, Math.max(0, Math.ceil(count / rowsPerPage) - 1));
};
return (
<Box sx={{ flexShrink: 0, ml: "24px" }}>
<Button
variant="group"
onClick={handleFirstPageButtonClick}
disabled={page === 0}
aria-label="first page"
>
<LeftArrowDouble />
</Button>
<Button
variant="group"
onClick={handleBackButtonClick}
disabled={page === 0}
aria-label="previous page"
>
<LeftArrow />
</Button>
<Button
variant="group"
onClick={handleNextButtonClick}
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
aria-label="next page"
>
<RightArrow />
</Button>
<Button
variant="group"
onClick={handleLastPageButtonClick}
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
aria-label="last page"
>
<RightArrowDouble />
</Button>
</Box>
);
};
TablePaginationActions.propTypes = {
count: PropTypes.number.isRequired,
page: PropTypes.number.isRequired,
rowsPerPage: PropTypes.number.isRequired,
onPageChange: PropTypes.func.isRequired,
};
import { Pagination } from "../../../Infrastructure/components/TablePagination";
const MonitorTable = ({ isAdmin, filter, setIsSearching, isSearching }) => {
const theme = useTheme();
@ -117,6 +40,7 @@ const MonitorTable = ({ isAdmin, filter, setIsSearching, isSearching }) => {
const { rowsPerPage } = useSelector((state) => state.ui.monitors);
const authState = useSelector((state) => state.auth);
const [page, setPage] = useState(0);
const [monitors, setMonitors] = useState([]);
const [monitorCount, setMonitorCount] = useState(0);
@ -150,7 +74,7 @@ const MonitorTable = ({ isAdmin, filter, setIsSearching, isSearching }) => {
authToken,
teamId: user.teamId,
limit: 25,
types: ["http", "ping"],
types: ["http", "ping", "docker"],
status: null,
checkOrder: "desc",
normalize: true,
@ -191,16 +115,6 @@ const MonitorTable = ({ isAdmin, filter, setIsSearching, isSearching }) => {
prevFilter.current = filter;
}, [filter, fetchPage]);
/**
* Helper function to calculate the range of displayed rows.
* @returns {string}
*/
const getRange = () => {
let start = page * rowsPerPage + 1;
let end = Math.min(page * rowsPerPage + rowsPerPage, monitorCount);
return `${start} - ${end}`;
};
const handleSort = async (field) => {
let order = "";
if (sort.field !== field) {
@ -360,7 +274,10 @@ const MonitorTable = ({ isAdmin, filter, setIsSearching, isSearching }) => {
<TableCell>
<Host
key={monitor._id}
params={params}
url={params.url}
title={params.title}
percentageColor={params.percentageColor}
percentage={params.percentage}
/>
</TableCell>
<TableCell>
@ -392,76 +309,13 @@ const MonitorTable = ({ isAdmin, filter, setIsSearching, isSearching }) => {
</TableBody>
</Table>
</TableContainer>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
px={theme.spacing(4)}
>
<Typography
px={theme.spacing(2)}
variant="body2"
sx={{ opacity: 0.7 }}
>
Showing {getRange()} of {monitorCount} monitor(s)
</Typography>
<TablePagination
component="div"
count={monitorCount}
page={page}
onPageChange={handleChangePage}
rowsPerPage={rowsPerPage}
rowsPerPageOptions={[5, 10, 15, 25]}
onRowsPerPageChange={handleChangeRowsPerPage}
ActionsComponent={TablePaginationActions}
labelRowsPerPage="Rows per page"
labelDisplayedRows={({ page, count }) =>
`Page ${page + 1} of ${Math.max(0, Math.ceil(count / rowsPerPage))}`
}
slotProps={{
select: {
MenuProps: {
keepMounted: true,
disableScrollLock: true,
PaperProps: {
className: "pagination-dropdown",
sx: {
mt: 0,
mb: theme.spacing(2),
},
},
transformOrigin: { vertical: "bottom", horizontal: "left" },
anchorOrigin: { vertical: "top", horizontal: "left" },
sx: { mt: theme.spacing(-2) },
},
inputProps: { id: "pagination-dropdown" },
IconComponent: SelectorVertical,
sx: {
ml: theme.spacing(4),
mr: theme.spacing(12),
minWidth: theme.spacing(20),
textAlign: "left",
"&.Mui-focused > div": {
backgroundColor: theme.palette.background.main,
},
},
},
}}
sx={{
mt: theme.spacing(6),
color: theme.palette.text.secondary,
"& svg path": {
stroke: theme.palette.text.tertiary,
strokeWidth: 1.3,
},
"& .MuiSelect-select": {
border: 1,
borderColor: theme.palette.border.light,
borderRadius: theme.shape.borderRadius,
},
}}
/>
</Stack>
<Pagination
monitorCount={monitorCount}
page={page}
rowsPerPage={rowsPerPage}
handleChangePage={handleChangePage}
handleChangeRowsPerPage={handleChangeRowsPerPage}
/>
</Box>
);
};

View File

@ -25,7 +25,7 @@ const StatusBox = ({ title, value }) => {
</Box>
);
} else if (title === "down") {
color = theme.palette.error.text;
color = theme.palette.error.main;
icon = (
<Box sx={{ ...sharedStyles, transform: "rotate(180deg)", top: 5 }}>
<Arrow />

View File

@ -109,7 +109,7 @@ const ActionsMenu = ({ monitor, isAdmin, updateCallback }) => {
"& ul": { p: theme.spacing(2.5) },
"& li": { m: 0 },
"& li:last-of-type": {
color: theme.palette.error.text,
color: theme.palette.error.main,
},
},
},

View File

@ -6,12 +6,14 @@ import PropTypes from "prop-types";
*
* @component
* @param {Object} params - An object containing the following properties:
* @param {string} params.url - The URL of the host.
* @param {string} params.title - The name of the host.
* @param {string} params.percentageColor - The color of the percentage text.
* @param {number} params.percentage - The percentage to display.
* @returns {React.ElementType} Returns a div element with the host details.
*/
const Host = ({ params }) => {
const Host = ({ url, title, percentageColor, percentage }) => {
const noTitle = title === undefined || title === url;
return (
<Box className="host">
<Box
@ -32,30 +34,30 @@ const Host = ({ params }) => {
},
}}
>
{params.title}
{title}
</Box>
<Typography
component="span"
sx={{
color: params.percentageColor,
fontWeight: 500,
ml: "15px",
}}
>
{params.percentage}%
</Typography>
<Box sx={{ opacity: 0.6 }}>{params.url}</Box>
{percentageColor && percentage && (
<Typography
component="span"
sx={{
color: percentageColor,
fontWeight: 500,
ml: "15px",
}}
>
{percentage}%
</Typography>
)}
{!noTitle && <Box sx={{ opacity: 0.6 }}>{url}</Box>}
</Box>
);
};
Host.propTypes = {
params: PropTypes.shape({
title: PropTypes.string,
percentageColor: PropTypes.string,
percentage: PropTypes.string,
url: PropTypes.string,
}).isRequired,
title: PropTypes.string,
percentageColor: PropTypes.string,
percentage: PropTypes.string,
url: PropTypes.string,
};
export default Host;

View File

@ -1,3 +1,4 @@
/* TODO take these from here and declare using emotion. Plus, the values should live in the theme */
.monitors .MuiStack-root > button:not(.MuiIconButton-root) {
font-size: var(--env-var-font-size-medium);
height: var(--env-var-height-2);

View File

@ -1,13 +1,15 @@
import { useTheme } from "@mui/material";
const useUtils = () => {
const theme = useTheme();
const determineState = (monitor) => {
if (monitor.isActive === false) return "paused";
if (monitor?.status === undefined) return "pending";
return monitor?.status == true ? "up" : "down";
};
/* TODO Refactor: from here on shouldn't live in a custom hook, but on theme, or constants */
const theme = useTheme();
const statusColor = {
up: theme.palette.success.main,
down: theme.palette.error.main,
@ -28,20 +30,20 @@ const useUtils = () => {
};
const statusStyles = {
up: {
backgroundColor: theme.palette.success.bg,
background: `linear-gradient(340deg, ${theme.palette.success.light} -60%, ${theme.palette.success.bg} 35%)`,
backgroundColor: theme.palette.success.dark,
background: `linear-gradient(340deg, ${theme.palette.success.dark} -60%, ${theme.palette.success.light} 35%)`,
borderColor: theme.palette.success.light,
"& h2": { color: theme.palette.success.main },
},
down: {
backgroundColor: theme.palette.error.bg,
background: `linear-gradient(340deg, ${theme.palette.error.light} -60%, ${theme.palette.error.bg} 35%)`,
backgroundColor: theme.palette.error.dark,
background: `linear-gradient(340deg, ${theme.palette.error.light} -60%, ${theme.palette.error.dark} 35%)`,
borderColor: theme.palette.error.light,
"& h2": { color: theme.palette.error.main },
},
paused: {
backgroundColor: theme.palette.warning.bg,
background: `linear-gradient(340deg, ${theme.palette.warning.light} -60%, ${theme.palette.warning.bg} 35%)`,
backgroundColor: theme.palette.warning.dark,
background: `linear-gradient(340deg, ${theme.palette.warning.light} -60%, ${theme.palette.warning.dark} 35%)`,
borderColor: theme.palette.warning.light,
"& h2": { color: theme.palette.warning.main },
},
@ -54,22 +56,22 @@ const useUtils = () => {
};
const pagespeedStyles = {
up: {
bg: theme.palette.success.bg,
bg: theme.palette.success.dark,
light: theme.palette.success.light,
stroke: theme.palette.success.main,
},
down: {
bg: theme.palette.error.bg,
bg: theme.palette.error.dark,
light: theme.palette.error.light,
stroke: theme.palette.error.main,
},
paused: {
bg: theme.palette.warning.bg,
bg: theme.palette.warning.dark,
light: theme.palette.warning.light,
stroke: theme.palette.warning.main,
},
pending: {
bg: theme.palette.warning.bg,
bg: theme.palette.warning.dark,
light: theme.palette.warning.light,
stroke: theme.palette.warning.main,
},

View File

@ -14,7 +14,7 @@ import { monitorValidation } from "../../../Validation/validation";
import { createToast } from "../../../Utils/toastUtils";
import { logger } from "../../../Utils/Logger";
import { ConfigBox } from "../../Monitors/styled";
import Field from "../../../Components/Inputs/Field";
import TextInput from "../../../Components/Inputs/TextInput";
import Select from "../../../Components/Inputs/Select";
import Checkbox from "../../../Components/Inputs/Checkbox";
import PauseCircleOutlineIcon from "@mui/icons-material/PauseCircleOutline";
@ -321,17 +321,18 @@ const PageSpeedConfigure = () => {
},
}}
>
<Field
<TextInput
type="url"
id="monitor-url"
label="URL"
placeholder="random.website.com"
value={monitor?.url?.replace("http://", "") || ""}
value={monitor?.url || ""}
onChange={handleChange}
error={errors.url}
error={errors.url ? true : false}
helperText={errors.url}
disabled={true}
/>
<Field
<TextInput
type="text"
id="monitor-name"
label="Monitor display name"
@ -339,7 +340,8 @@ const PageSpeedConfigure = () => {
isOptional={true}
value={monitor?.name || ""}
onChange={handleChange}
error={errors.name}
error={errors.name ? true : false}
helperText={errors.name}
/>
</Stack>
</ConfigBox>
@ -383,7 +385,7 @@ const PageSpeedConfigure = () => {
(notification) => notification.type === "emails"
) ? (
<Box mx={theme.spacing(16)}>
<Field
<TextInput
id="notify-email-list"
type="text"
placeholder="name@gmail.com"

View File

@ -1,28 +1,33 @@
import { useState } from "react";
import { Box, Button, ButtonGroup, Stack, Typography } from "@mui/material";
import LoadingButton from '@mui/lab/LoadingButton';
import LoadingButton from "@mui/lab/LoadingButton";
import { useSelector, useDispatch } from "react-redux";
import { monitorValidation } from "../../../Validation/validation";
import { useNavigate } from "react-router-dom";
import { useTheme } from "@emotion/react";
import { createPageSpeed, checkEndpointResolution } from "../../../Features/PageSpeedMonitor/pageSpeedMonitorSlice";
import {
createPageSpeed,
checkEndpointResolution,
} from "../../../Features/PageSpeedMonitor/pageSpeedMonitorSlice";
import { createToast } from "../../../Utils/toastUtils";
import { logger } from "../../../Utils/Logger";
import { ConfigBox } from "../../Monitors/styled";
import Radio from "../../../Components/Inputs/Radio";
import Field from "../../../Components/Inputs/Field";
import TextInput from "../../../Components/Inputs/TextInput";
import { HttpAdornment } from "../../../Components/Inputs/TextInput/Adornments";
import Select from "../../../Components/Inputs/Select";
import Checkbox from "../../../Components/Inputs/Checkbox";
import Breadcrumbs from "../../../Components/Breadcrumbs";
import { parseDomainName } from "../../../Utils/monitorUtils";
import "./index.css";
const CreatePageSpeed = () => {
const MS_PER_MINUTE = 60000;
const { user, authToken } = useSelector((state) => state.auth);
const { isLoading } = useSelector((state) => state.pageSpeedMonitors);
const dispatch = useDispatch();
const navigate = useNavigate();
const theme = useTheme();
const MS_PER_MINUTE = 60000;
const { user, authToken } = useSelector((state) => state.auth);
const { isLoading } = useSelector((state) => state.pageSpeedMonitors);
const dispatch = useDispatch();
const navigate = useNavigate();
const theme = useTheme();
const idMap = {
"monitor-url": "url",
@ -91,6 +96,16 @@ const CreatePageSpeed = () => {
}
};
const onUrlBlur = (event) => {
const { value } = event.target;
if (monitor.name === "") {
setMonitor((prev) => ({
...prev,
name: parseDomainName(value),
}));
}
};
const handleCreateMonitor = async (event) => {
event.preventDefault();
//obj to submit
@ -105,252 +120,263 @@ const CreatePageSpeed = () => {
abortEarly: false,
});
if (error) {
const newErrors = {};
error.details.forEach((err) => {
newErrors[err.path[0]] = err.message;
});
setErrors(newErrors);
createToast({ body: "Error validation data." });
} else {
const checkEndpointAction = await dispatch(
checkEndpointResolution({ authToken, monitorURL: form.url })
)
if (checkEndpointAction.meta.requestStatus === "rejected") {
createToast({ body: "The endpoint you entered doesn't resolve. Check the URL again." });
setErrors({ url: "The entered URL is not reachable." });
return;
}
if (error) {
const newErrors = {};
error.details.forEach((err) => {
newErrors[err.path[0]] = err.message;
});
setErrors(newErrors);
createToast({ body: "Error validation data." });
} else {
const checkEndpointAction = await dispatch(
checkEndpointResolution({ authToken, monitorURL: form.url })
);
if (checkEndpointAction.meta.requestStatus === "rejected") {
createToast({
body: "The endpoint you entered doesn't resolve. Check the URL again.",
});
setErrors({ url: "The entered URL is not reachable." });
return;
}
form = {
...form,
description: form.name,
teamId: user.teamId,
userId: user._id,
notifications: monitor.notifications,
};
const action = await dispatch(
createPageSpeed({ authToken, monitor: form })
);
if (action.meta.requestStatus === "fulfilled") {
createToast({ body: "Monitor created successfully!" });
navigate("/pagespeed");
} else {
createToast({ body: "Failed to create monitor." });
}
}
};
form = {
...form,
description: form.name,
teamId: user.teamId,
userId: user._id,
notifications: monitor.notifications,
};
const action = await dispatch(createPageSpeed({ authToken, monitor: form }));
if (action.meta.requestStatus === "fulfilled") {
createToast({ body: "Monitor created successfully!" });
navigate("/pagespeed");
} else {
createToast({ body: "Failed to create monitor." });
}
}
};
//select values
const frequencies = [
{ _id: 3, name: "3 minutes" },
{ _id: 5, name: "5 minutes" },
{ _id: 10, name: "10 minutes" },
{ _id: 20, name: "20 minutes" },
{ _id: 60, name: "1 hour" },
{ _id: 1440, name: "1 day" },
{ _id: 10080, name: "1 week" },
];
return (
<Box
className="create-monitor"
sx={{
"& h1": {
color: theme.palette.text.primary,
},
}}
>
<Breadcrumbs
list={[
{ name: "pagespeed", path: "/pagespeed" },
{ name: "create", path: `/pagespeed/create` },
]}
/>
<Stack
component="form"
className="create-monitor-form"
onSubmit={handleCreateMonitor}
noValidate
spellCheck="false"
gap={theme.spacing(12)}
mt={theme.spacing(6)}
>
<Typography component="h1" variant="h1">
<Typography component="span" fontSize="inherit">
Create your{" "}
</Typography>
<Typography
component="span"
fontSize="inherit"
fontWeight="inherit"
color={theme.palette.text.secondary}
>
pagespeed monitor
</Typography>
</Typography>
<ConfigBox>
<Box>
<Typography component="h2">General settings</Typography>
<Typography component="p">
Here you can select the URL of the host, together with the type of
monitor.
</Typography>
</Box>
<Stack gap={theme.spacing(15)}>
<Field
type={"url"}
id="monitor-url"
label="URL to monitor"
https={https}
placeholder="google.com"
value={monitor.url}
onChange={handleChange}
error={errors["url"]}
/>
<Field
type="text"
id="monitor-name"
label="Display name"
isOptional={true}
placeholder="Google"
value={monitor.name}
onChange={handleChange}
error={errors["name"]}
/>
</Stack>
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h2">Checks to perform</Typography>
<Typography component="p">
You can always add or remove checks after adding your site.
</Typography>
</Box>
<Stack gap={theme.spacing(12)}>
<Stack gap={theme.spacing(6)}>
<Radio
id="monitor-checks-http"
title="Website monitoring"
desc="Use HTTP(s) to monitor your website or API endpoint."
size="small"
value="http"
checked={monitor.type === "pagespeed"}
onChange={(event) => handleChange(event)}
/>
<ButtonGroup sx={{ ml: "32px" }}>
<Button
variant="group"
filled={https.toString()}
onClick={() => setHttps(true)}
>
HTTPS
</Button>
<Button
variant="group"
filled={(!https).toString()}
onClick={() => setHttps(false)}
>
HTTP
</Button>
</ButtonGroup>
</Stack>
{errors["type"] ? (
<Box className="error-container">
<Typography
component="p"
className="input-error"
color={theme.palette.error.text}
>
{errors["type"]}
</Typography>
</Box>
) : (
""
)}
</Stack>
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h2">Incident notifications</Typography>
<Typography component="p">
When there is an incident, notify users.
</Typography>
</Box>
<Stack gap={theme.spacing(6)}>
<Typography component="p">When there is a new incident,</Typography>
<Checkbox
id="notify-sms"
label="Notify via SMS (coming soon)"
isChecked={false}
value=""
onChange={() => logger.warn("disabled")}
isDisabled={true}
/>
<Checkbox
id="notify-email-default"
label={`Notify via email (to ${user.email})`}
isChecked={monitor.notifications.some(
(notification) => notification.type === "email"
)}
value={user?.email}
onChange={(event) => handleChange(event)}
/>
<Checkbox
id="notify-email"
label="Also notify via email to multiple addresses (coming soon)"
isChecked={false}
value=""
onChange={() => logger.warn("disabled")}
isDisabled={true}
/>
{monitor.notifications.some(
(notification) => notification.type === "emails"
) ? (
<Box mx={theme.spacing(16)}>
<Field
id="notify-email-list"
type="text"
placeholder="name@gmail.com"
value=""
onChange={() => logger.warn("disabled")}
/>
<Typography mt={theme.spacing(4)}>
You can separate multiple emails with a comma
</Typography>
</Box>
) : (
""
)}
</Stack>
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h2">Advanced settings</Typography>
</Box>
<Stack gap={theme.spacing(12)}>
<Select
id="monitor-interval"
label="Check frequency"
value={monitor.interval || 3}
onChange={(event) => handleChange(event, "interval")}
items={frequencies}
/>
</Stack>
</ConfigBox>
<Stack direction="row" justifyContent="flex-end">
<LoadingButton
variant="contained"
color="primary"
onClick={handleCreateMonitor}
disabled={Object.keys(errors).length !== 0 && true}
loading={isLoading}
>
Create monitor
</LoadingButton>
</Stack>
</Stack>
</Box>
);
//select values
const frequencies = [
{ _id: 3, name: "3 minutes" },
{ _id: 5, name: "5 minutes" },
{ _id: 10, name: "10 minutes" },
{ _id: 20, name: "20 minutes" },
{ _id: 60, name: "1 hour" },
{ _id: 1440, name: "1 day" },
{ _id: 10080, name: "1 week" },
];
return (
<Box
className="create-monitor"
sx={{
"& h1": {
color: theme.palette.text.primary,
},
}}
>
<Breadcrumbs
list={[
{ name: "pagespeed", path: "/pagespeed" },
{ name: "create", path: `/pagespeed/create` },
]}
/>
<Stack
component="form"
className="create-monitor-form"
onSubmit={handleCreateMonitor}
noValidate
spellCheck="false"
gap={theme.spacing(12)}
mt={theme.spacing(6)}
>
<Typography
component="h1"
variant="h1"
>
<Typography
component="span"
fontSize="inherit"
>
Create your{" "}
</Typography>
<Typography
component="span"
fontSize="inherit"
fontWeight="inherit"
color={theme.palette.text.secondary}
>
pagespeed monitor
</Typography>
</Typography>
<ConfigBox>
<Box>
<Typography component="h2">General settings</Typography>
<Typography component="p">
Here you can select the URL of the host, together with the type of monitor.
</Typography>
</Box>
<Stack gap={theme.spacing(15)}>
<TextInput
type={"url"}
id="monitor-url"
label="URL to monitor"
startAdornment={<HttpAdornment https={https} />}
placeholder="google.com"
value={monitor.url}
onChange={handleChange}
onBlur={onUrlBlur}
error={errors["url"] ? true : false}
helperText={errors["url"]}
/>
<TextInput
type="text"
id="monitor-name"
label="Display name"
isOptional={true}
placeholder="Google"
value={monitor.name}
onChange={handleChange}
error={errors["name"] ? true : false}
helperText={errors["name"]}
/>
</Stack>
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h2">Checks to perform</Typography>
<Typography component="p">
You can always add or remove checks after adding your site.
</Typography>
</Box>
<Stack gap={theme.spacing(12)}>
<Stack gap={theme.spacing(6)}>
<Radio
id="monitor-checks-http"
title="Website monitoring"
desc="Use HTTP(s) to monitor your website or API endpoint."
size="small"
value="http"
checked={monitor.type === "pagespeed"}
onChange={(event) => handleChange(event)}
/>
<ButtonGroup sx={{ ml: "32px" }}>
<Button
variant="group"
filled={https.toString()}
onClick={() => setHttps(true)}
>
HTTPS
</Button>
<Button
variant="group"
filled={(!https).toString()}
onClick={() => setHttps(false)}
>
HTTP
</Button>
</ButtonGroup>
</Stack>
{errors["type"] ? (
<Box className="error-container">
<Typography
component="p"
className="input-error"
color={theme.palette.error.contrastText}
>
{errors["type"]}
</Typography>
</Box>
) : (
""
)}
</Stack>
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h2">Incident notifications</Typography>
<Typography component="p">
When there is an incident, notify users.
</Typography>
</Box>
<Stack gap={theme.spacing(6)}>
<Typography component="p">When there is a new incident,</Typography>
<Checkbox
id="notify-sms"
label="Notify via SMS (coming soon)"
isChecked={false}
value=""
onChange={() => logger.warn("disabled")}
isDisabled={true}
/>
<Checkbox
id="notify-email-default"
label={`Notify via email (to ${user.email})`}
isChecked={monitor.notifications.some(
(notification) => notification.type === "email"
)}
value={user?.email}
onChange={(event) => handleChange(event)}
/>
<Checkbox
id="notify-email"
label="Also notify via email to multiple addresses (coming soon)"
isChecked={false}
value=""
onChange={() => logger.warn("disabled")}
isDisabled={true}
/>
{monitor.notifications.some(
(notification) => notification.type === "emails"
) ? (
<Box mx={theme.spacing(16)}>
<TextInput
id="notify-email-list"
type="text"
placeholder="name@gmail.com"
value=""
onChange={() => logger.warn("disabled")}
/>
<Typography mt={theme.spacing(4)}>
You can separate multiple emails with a comma
</Typography>
</Box>
) : (
""
)}
</Stack>
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h2">Advanced settings</Typography>
</Box>
<Stack gap={theme.spacing(12)}>
<Select
id="monitor-interval"
label="Check frequency"
value={monitor.interval || 3}
onChange={(event) => handleChange(event, "interval")}
items={frequencies}
/>
</Stack>
</ConfigBox>
<Stack
direction="row"
justifyContent="flex-end"
>
<LoadingButton
variant="contained"
color="primary"
onClick={handleCreateMonitor}
disabled={Object.keys(errors).length !== 0 && true}
loading={isLoading}
>
Create monitor
</LoadingButton>
</Stack>
</Stack>
</Box>
);
};
export default CreatePageSpeed;

View File

@ -127,22 +127,22 @@ const PieChart = ({ audits }) => {
return {
stroke: theme.palette.success.main,
strokeBg: theme.palette.success.light,
text: theme.palette.success.text,
bg: theme.palette.success.bg,
text: theme.palette.success.contrastText,
bg: theme.palette.success.dark,
};
else if (value >= 50 && value < 90)
return {
stroke: theme.palette.warning.main,
strokeBg: theme.palette.warning.light,
text: theme.palette.warning.text,
bg: theme.palette.warning.bg,
text: theme.palette.warning.contrastText,
bg: theme.palette.warning.dark,
};
else if (value >= 0 && value < 50)
return {
stroke: theme.palette.error.text,
stroke: theme.palette.error.contrastText,
strokeBg: theme.palette.error.light,
text: theme.palette.error.text,
bg: theme.palette.error.bg,
text: theme.palette.error.contrastText,
bg: theme.palette.error.dark,
};
return {
stroke: theme.palette.unresolved.main,

View File

@ -362,7 +362,7 @@ const PageSpeedDetails = ({ isAdmin }) => {
: score >= 50
? theme.palette.warning.main
: score >= 0
? theme.palette.error.text
? theme.palette.error.contrastText
: theme.palette.unresolved.main;
// Find the position where the number ends and the unit begins

View File

@ -8,7 +8,6 @@ import "./index.css";
import { useNavigate } from "react-router";
import PropTypes from "prop-types";
import Breadcrumbs from "../../Components/Breadcrumbs";
import Greeting from "../../Utils/greeting";
import SkeletonLayout from "./skeleton";
import Card from "./card";
import { networkService } from "../../main";
@ -82,11 +81,10 @@ const PageSpeed = ({ isAdmin }) => {
<Breadcrumbs list={[{ name: `pagespeed`, path: "/pagespeed" }]} />
<Stack
direction="row"
justifyContent="space-between"
justifyContent="end"
alignItems="center"
mt={theme.spacing(5)}
>
<Greeting type="pagespeed" />
{isAdmin && (
<Button
variant="contained"

View File

@ -1 +0,0 @@
#Pages Folder

View File

@ -1,6 +1,6 @@
import { useTheme } from "@emotion/react";
import { Box, Stack, Typography, Button } from "@mui/material";
import Field from "../../Components/Inputs/Field";
import TextInput from "../../Components/Inputs/TextInput";
import Link from "../../Components/Link";
import Select from "../../Components/Inputs/Select";
import { logger } from "../../Utils/Logger";
@ -240,13 +240,14 @@ const Settings = ({ isAdmin }) => {
</Typography>
</Box>
<Stack gap={theme.spacing(20)}>
<Field
<TextInput
id="ttl"
label="The days you want to keep monitoring history."
optionalLabel="0 for infinite"
value={form.ttl}
onChange={handleChange}
error={errors.ttl}
error={errors.ttl ? true : false}
helperText={errors.ttl}
/>
<Box>
<Typography>Clear all stats. This is irreversible.</Typography>
@ -348,14 +349,14 @@ const Settings = ({ isAdmin }) => {
<Typography component="h1">About</Typography>
</Box>
<Box>
<Typography component="h2">BlueWave Uptime {version}</Typography>
<Typography component="h2">Checkmate {version}</Typography>
<Typography sx={{ mt: theme.spacing(2), mb: theme.spacing(6), opacity: 0.6 }}>
Developed by Bluewave Labs.
</Typography>
<Link
level="secondary"
url="https://github.com/bluewave-labs/bluewave-uptime"
label="https://github.com/bluewave-labs/bluewave-uptime"
url="https://github.com/bluewave-labs/checkmate"
label="https://github.com/bluewave-labs/checkmate"
/>
</Box>
</ConfigBox>

View File

@ -44,6 +44,14 @@ class Logger {
this.log = NO_OP;
return;
}
if (logLevel === "debug") {
this.error = console.error.bind(console);
this.warn = console.warn.bind(console);
this.info = console.info.bind(console);
this.log = console.log.bind(console);
return;
}
}
cleanup() {

View File

@ -0,0 +1,249 @@
const typographyBase = 14;
/* TODO
Check for px in codebase. All font sizes should be in REM and should live here.
Rest should be checked on each case
*/
const typographyLevels = {
base: typographyBase,
xs: `${(typographyBase - 4) / 16}rem`,
s: `${(typographyBase - 2) / 16}rem`,
m: `${typographyBase / 16}rem`,
l: `${(typographyBase + 2) / 16}rem`,
xl: `${(typographyBase + 10) / 16}rem`,
};
/* TODO Review color palette and semantic colors */
const paletteColors = {
white: "#FFFFFF",
gray50: "#FEFEFE",
gray60: "#FEFDFE",
gray70: "#FDFDFD",
gray80: "#FDFCFD",
gray90: "#FCFCFD",
gray100: "#F4F4F4",
gray150: "#EFEFEF",
gray200: "#E3E3E3",
gray300: "#A2A3A3",
gray500: "#838C99",
gray600: "#454546",
gray750: "#36363E",
gray800: "#2D2D33",
gray850: "#131315",
gray860: "#111113",
gray870: "#0F0F11",
gray880: "#0C0C0E",
gray890: "#09090B",
blueGray20: "#E8F0FE",
blueGray150: "#667085",
blueGray200: "#475467",
blueGray400: "#344054",
blueGray900: "#1c2130",
blueBlueWave: "#1570EF",
blue700: "#4E5BA6",
purple300: "#664EFF",
purple400: "#3A1BFF",
green50: "#D4F4E1",
green150: "#45BB7A",
green400: "#079455",
green800: "#1C4428",
green900: "#12261E",
red50: "#F9ECED",
red100: "#FBD1D1",
red200: "#F04438",
red300: "#D32F2F",
red700: "#542426",
red800: "#301A1F",
orange50: "#FEF8EA",
orange100: "#FFECBC",
orange300: "#FDB022",
orange400: "#FF9F00",
orange500: "#E88C30",
orange600: "#DC6803",
orange800: "#624711",
};
const semanticColors = {
primary: {
main: {
light: paletteColors.blueBlueWave,
dark: paletteColors.blueBlueWave,
},
// TODO we dont have primary light, dark or contrast text
},
secondary: {
main: {
light: paletteColors.gray100,
dark: paletteColors.gray800,
},
contrastText: {
light: paletteColors.blueGray200,
dark: paletteColors.blueGray200,
},
light: {
light: paletteColors.gray200,
dark: paletteColors.gray200,
},
dark: {
light: paletteColors.gray200,
dark: paletteColors.gray200,
},
},
success: {
main: {
light: paletteColors.green400,
dark: paletteColors.green400,
},
contrastText: {
light: paletteColors.green50,
dark: paletteColors.green900,
},
light: {
light: paletteColors.green50,
dark: paletteColors.green800,
},
dark: {
light: paletteColors.green400,
dark: paletteColors.green900,
},
},
error: {
main: {
light: paletteColors.red300,
dark: paletteColors.red300,
},
contrastText: {
light: paletteColors.gray50,
dark: paletteColors.gray50,
},
light: {
light: paletteColors.red100,
dark: paletteColors.red700,
},
dark: {
light: paletteColors.red50,
dark: paletteColors.red800,
},
},
warning: {
main: {
light: paletteColors.orange300,
dark: paletteColors.orange400,
},
contrastText: {
light: paletteColors.orange600,
dark: paletteColors.orange500,
},
light: {
light: paletteColors.orange100,
dark: paletteColors.orange800,
},
dark: {
light: paletteColors.orange50,
dark: paletteColors.gray800,
},
},
/* From this part on, try to create semantic structure, not feature based structure */
gradient: {
color1: {
light: paletteColors.gray90,
dark: paletteColors.gray890,
},
color2: {
light: paletteColors.gray80,
dark: paletteColors.gray880,
},
color3: {
light: paletteColors.gray70,
dark: paletteColors.gray870,
},
color4: {
light: paletteColors.gray60,
dark: paletteColors.gray860,
},
color5: {
light: paletteColors.gray50,
dark: paletteColors.gray850,
},
},
background: {
main: {
light: paletteColors.white,
dark: paletteColors.gray890,
},
alt: {
light: paletteColors.gray90,
dark: paletteColors.gray890,
},
fill: {
light: paletteColors.gray100,
dark: paletteColors.gray800,
},
accent: {
light: paletteColors.gray100,
dark: paletteColors.gray850,
},
},
text: {
primary: {
light: paletteColors.blueGray900,
dark: paletteColors.gray100,
},
secondary: {
light: paletteColors.blueGray400,
dark: paletteColors.gray200,
},
tertiary: {
light: paletteColors.blueGray200,
dark: paletteColors.gray300,
},
accent: {
light: paletteColors.gray500,
dark: paletteColors.gray500,
},
},
border: {
light: {
light: paletteColors.gray200,
dark: paletteColors.gray800,
/* TODO this should live in a different key (border.disabled.light and .dark) */
disabled: paletteColors.gray150,
},
dark: {
light: paletteColors.gray200,
dark: paletteColors.gray750,
/* TODO this should live in a different key (border.disabled.light and .dark) */
disabled: paletteColors.gray150,
},
},
unresolved: {
main: {
light: paletteColors.blue700,
dark: paletteColors.purple300,
},
light: {
light: paletteColors.blueGray20,
dark: paletteColors.purple400,
},
bg: {
light: paletteColors.gray100,
dark: paletteColors.gray100,
},
},
other: {
icon: {
light: paletteColors.blueGray150,
},
line: {
light: paletteColors.gray200,
},
grid: {
light: paletteColors.gray300,
dark: paletteColors.gray600,
},
autofill: {
light: paletteColors.blueGray20,
},
},
};
export { typographyLevels, semanticColors as colors };

View File

@ -1,255 +1,111 @@
import { createTheme } from "@mui/material";
import { baseTheme } from "./globalTheme";
import { colors } from "./constants";
const text = {
primary: "#fafafa",
secondary: "#e6e6e6",
tertiary: "#a1a1aa",
accent: "#8e8e8f",
disabled: "rgba(172, 172, 172, 0.3)",
};
const background = {
main: "#151518",
alt: "#09090b",
fill: "#2D2D33",
accent: "#18181a",
};
const border = { light: "#27272a", dark: "#36363e" };
const {
primary,
secondary,
success,
error,
warning,
gradient: {
color1: { dark: color1 },
color2: { dark: color2 },
color3: { dark: color3 },
color4: { dark: color4 },
color5: { dark: color5 },
},
background,
text,
border,
unresolved,
other,
} = colors;
const fontFamilyDefault =
'"Inter","system-ui", "Avenir", "Helvetica", "Arial", sans-serif';
const shadow =
"0px 4px 24px -4px rgba(255, 255, 255, 0.03), 0px 3px 3px -3px rgba(255, 255, 255, 0.01)";
const palette = {
action: {
disabled: border.dark.disabled,
},
primary: { main: primary.main.dark },
secondary: {
main: secondary.main.dark,
contrastText: secondary.contrastText.dark,
light: secondary.dark.dark,
dark: secondary.dark.dark,
},
success: {
main: success.main.dark,
contrastText: success.contrastText.dark,
light: success.light.dark,
dark: success.dark.dark,
},
error: {
main: error.main.dark,
contrastText: error.contrastText.dark,
light: error.light.dark,
dark: error.dark.dark,
},
warning: {
main: warning.main.dark,
light: warning.light.dark,
contrastText: warning.contrastText.dark,
dark: warning.dark.dark,
},
/* From this part on, try to create semantic structure, not feature based structure */
percentage: {
uptimePoor: error.main.dark,
uptimeFair: warning.contrastText.dark,
uptimeGood: warning.main.dark /* Change for a success color? ?*/,
uptimeExcellent: success.main.dark,
},
unresolved: {
main: unresolved.main.dark,
light: unresolved.light.dark,
bg: unresolved.bg.dark,
},
divider: border.light.dark,
other: {
icon: text.secondary.dark,
line: border.light.dark,
fill: background.accent.dark,
grid: other.grid.dark,
autofill: secondary.main.dark,
},
gradient: {
color1,
color2,
color3,
color4,
color5,
},
text: {
primary: text.primary.dark,
secondary: text.secondary.dark,
tertiary: text.tertiary.dark,
accent: text.accent.dark,
},
background: {
main: background.main.dark,
alt: background.alt.dark,
fill: background.fill.dark,
accent: background.accent.dark,
},
border: {
light: border.light.dark,
dark: border.dark.dark,
},
info: {
text: text.primary.dark,
main: text.secondary.dark,
bg: background.main.dark,
light: background.main.dark,
border: border.light.dark,
},
};
const darkTheme = createTheme({
typography: {
fontFamily: fontFamilyDefault,
fontSize: 13,
h1: { fontSize: 22, color: text.primary, fontWeight: 500 },
h2: { fontSize: 14.5, color: text.secondary, fontWeight: 400 },
body1: { fontSize: 13, color: text.tertiary, fontWeight: 400 },
body2: { fontSize: 12, color: text.tertiary, fontWeight: 400 },
},
palette: {
mode: "dark",
primary: { main: "#1570ef" },
secondary: { main: "#2D2D33" },
text: text,
background: background,
border: border,
info: {
text: text.primary,
main: text.secondary,
bg: background.main,
light: background.main,
border: border.light,
},
success: {
text: "#079455",
main: "#45bb7a",
light: "#1c4428",
bg: "#12261e",
},
error: {
text: "#f04438",
main: "#d32f2f",
light: "#542426",
bg: "#301a1f",
dark: "#932020",
border: "#f04438",
},
warning: {
text: "#e88c30",
main: "#FF9F00",
light: "#624711",
bg: "#262115",
border: "#e88c30",
},
percentage: {
uptimePoor: "#d32f2f",
uptimeFair: "#e88c30",
uptimeGood: "#ffd600",
uptimeExcellent: "#079455",
},
unresolved: { main: "#664eff", light: "#3a1bff", bg: "#f2f4f7" },
divider: border.light,
other: {
icon: "#e6e6e6",
line: "#27272a",
fill: "#18181a",
grid: "#454546",
autofill: "#2d2d33",
},
},
spacing: 2,
components: {
MuiCssBaseline: {
styleOverrides: {
body: {
backgroundImage:
"radial-gradient(circle, #09090b, #0c0c0e, #0f0f11, #111113, #131315, #131315, #131315, #131315, #111113, #0f0f11, #0c0c0e, #09090b)",
lineHeight: "inherit",
paddingLeft: "calc(100vw - 100%)",
},
},
},
MuiButton: {
defaultProps: {
disableRipple: true,
},
styleOverrides: {
root: ({ theme }) => ({
variants: [
{
props: (props) => props.variant === "group",
style: {
color: theme.palette.secondary.contrastText,
backgroundColor: theme.palette.background.main,
border: 1,
borderStyle: "solid",
borderColor: theme.palette.border.light,
},
},
{
props: (props) => props.variant === "group" && props.filled === "true",
style: {
backgroundColor: theme.palette.secondary.main,
},
},
{
props: (props) =>
props.variant === "contained" && props.color === "secondary",
style: {
border: 1,
borderStyle: "solid",
borderColor: theme.palette.border.dark,
},
},
],
fontWeight: 400,
borderRadius: 4,
boxShadow: "none",
textTransform: "none",
"&:focus": {
outline: "none",
},
"&:hover": {
boxShadow: "none",
},
}),
},
},
MuiIconButton: {
styleOverrides: {
root: {
padding: 4,
transition: "none",
"&:hover": {
backgroundColor: border.light,
},
},
},
},
MuiPaper: {
styleOverrides: {
root: {
marginTop: 4,
padding: 0,
border: 1,
borderStyle: "solid",
borderColor: border.light,
borderRadius: 4,
boxShadow: shadow,
backgroundColor: background.main,
backgroundImage: "none",
},
},
},
MuiList: {
styleOverrides: {
root: {
padding: 0,
},
},
},
MuiListItemButton: {
styleOverrides: {
root: {
transition: "none",
},
},
},
MuiMenuItem: {
styleOverrides: {
root: {
borderRadius: 4,
backgroundColor: "inherit",
padding: "4px 6px",
color: text.secondary,
fontSize: 13,
margin: 2,
minWidth: 100,
"&:hover, &.Mui-selected, &.Mui-selected:hover, &.Mui-selected.Mui-focusVisible":
{
backgroundColor: background.fill,
},
},
},
},
MuiTableCell: {
styleOverrides: {
root: {
borderBottomColor: border.light,
},
},
},
MuiTableHead: {
styleOverrides: {
root: {
backgroundColor: background.accent,
},
},
},
MuiPagination: {
styleOverrides: {
root: {
backgroundColor: background.main,
border: 1,
borderStyle: "solid",
borderColor: border.light,
"& button": {
color: text.tertiary,
borderRadius: 4,
},
"& li:first-of-type button, & li:last-of-type button": {
border: 1,
borderStyle: "solid",
borderColor: border.light,
},
},
},
},
MuiPaginationItem: {
styleOverrides: {
root: {
"&:not(.MuiPaginationItem-ellipsis):hover, &.Mui-selected": {
backgroundColor: background.fill,
},
},
},
},
MuiSkeleton: {
styleOverrides: {
root: {
backgroundColor: "#151518",
},
},
},
},
shape: {
borderRadius: 2,
borderThick: 2,
boxShadow: shadow,
},
palette,
...baseTheme(palette),
});
export default darkTheme;

View File

@ -0,0 +1,273 @@
import { typographyLevels } from "./constants";
const fontFamilyPrimary = '"Inter" , sans-serif';
// const fontFamilySecondary = '"Avenir", sans-serif';
/* TODO take the color out from here */
const shadow =
"0px 4px 24px -4px rgba(16, 24, 40, 0.08), 0px 3px 3px -3px rgba(16, 24, 40, 0.03)";
const baseTheme = (palette) => ({
typography: {
fontFamily: fontFamilyPrimary,
fontSize: 14,
h1: {
fontSize: typographyLevels.xl,
color: palette.text.primary,
fontWeight: 500,
},
h2: {
fontSize: typographyLevels.l,
color: palette.text.secondary,
fontWeight: 400,
},
body1: {
fontSize: typographyLevels.m,
color: palette.text.tertiary,
fontWeight: 400,
},
body2: {
fontSize: typographyLevels.s,
color: palette.text.tertiary,
fontWeight: 400,
},
},
/* TODO we can skip using the callback functions on the next lines since we are already accessing it on line 10. That was the last thing I managed to do, so we are sort of doing it twice*/
spacing: 2,
/* TODO All these should live inside of a component*/
components: {
MuiButton: {
defaultProps: {
disableRipple: true,
},
styleOverrides: {
root: ({ theme }) => ({
variants: [
{
props: (props) => props.variant === "group",
style: {
color: theme.palette.secondary.contrastText,
backgroundColor: theme.palette.background.main,
border: 1,
borderStyle: "solid",
borderColor: theme.palette.border.light,
},
},
{
props: (props) => props.variant === "group" && props.filled === "true",
style: {
backgroundColor: theme.palette.secondary.main,
},
},
{
props: (props) =>
props.variant === "contained" && props.color === "secondary",
style: {
border: 1,
borderStyle: "solid",
borderColor: theme.palette.border.light,
},
},
{
props: (props) => {
return (
props.variant === "contained" &&
props.disabled &&
props.classes.loadingIndicator === undefined // Do not apply to loading button
);
},
style: {
backgroundColor: `${theme.palette.secondary.main} !important`,
color: `${theme.palette.secondary.contrastText} !important`,
},
},
],
fontWeight: 400,
borderRadius: 4,
boxShadow: "none",
textTransform: "none",
"&:focus": {
outline: "none",
},
"&:hover": {
boxShadow: "none",
},
"&.MuiLoadingButton-root": {
"&:disabled": {
backgroundColor: theme.palette.secondary.main,
color: theme.palette.text.primary,
},
},
"&.MuiLoadingButton-loading": {
"& .MuiLoadingButton-label": {
color: "transparent",
},
"& .MuiLoadingButton-loadingIndicator": {
color: "inherit",
},
},
}),
},
},
MuiIconButton: {
styleOverrides: {
root: ({ theme }) => ({
padding: 4,
transition: "none",
"&:hover": {
backgroundColor: theme.palette.background.fill,
},
}),
},
},
MuiPaper: {
styleOverrides: {
root: ({ theme }) => {
return {
marginTop: 4,
padding: 0,
border: 1,
borderStyle: "solid",
borderColor: theme.palette.border.light,
borderRadius: 4,
boxShadow: shadow,
backgroundColor: theme.palette.background.main,
backgroundImage: "none",
};
},
},
},
MuiList: {
styleOverrides: {
root: {
padding: 0,
},
},
},
MuiListItemButton: {
styleOverrides: {
root: {
transition: "none",
},
},
},
MuiMenuItem: {
styleOverrides: {
root: ({ theme }) => ({
borderRadius: 4,
backgroundColor: "inherit",
padding: "4px 6px",
color: theme.palette.text.secondary,
fontSize: 13,
margin: 2,
minWidth: 100,
"&:hover, &.Mui-selected, &.Mui-selected:hover, &.Mui-selected.Mui-focusVisible":
{
backgroundColor: theme.palette.background.fill,
},
}),
},
},
MuiTableCell: {
styleOverrides: {
root: ({ theme }) => ({
borderBottomColor: theme.palette.border.light,
}),
},
},
MuiTableHead: {
styleOverrides: {
root: ({ theme }) => ({
backgroundColor: theme.palette.background.accent,
}),
},
},
MuiPagination: {
styleOverrides: {
root: ({ theme }) => ({
backgroundColor: theme.palette.background.main,
border: 1,
borderStyle: "solid",
borderColor: theme.palette.border.light,
"& button": {
color: theme.palette.text.tertiary,
borderRadius: 4,
},
"& li:first-of-type button, & li:last-of-type button": {
border: 1,
borderStyle: "solid",
borderColor: theme.palette.border.light,
},
}),
},
},
MuiPaginationItem: {
styleOverrides: {
root: ({ theme }) => ({
"&:not(.MuiPaginationItem-ellipsis):hover, &.Mui-selected": {
backgroundColor: theme.palette.background.fill,
},
}),
},
},
MuiSkeleton: {
styleOverrides: {
root: ({ theme }) => ({
backgroundColor: theme.palette.unresolved.bg,
}),
},
},
MuiTextField: {
styleOverrides: {
root: ({ theme }) => ({
"& fieldset": {
borderColor: theme.palette.border.dark,
borderRadius: theme.shape.borderRadius,
},
"& .MuiInputBase-input": {
padding: ".75em",
minHeight: "var(--env-var-height-2)",
fontSize: "var(--env-var-font-size-medium)",
fontWeight: 400,
color: palette.text.secondary,
"&.Mui-disabled": {
opacity: 0.3,
WebkitTextFillColor: "unset",
},
},
"& .MuiInputBase-input.MuiOutlinedInput-input": {
padding: "0 var(--env-var-spacing-1-minus) !important",
},
"& .MuiOutlinedInput-root": {
borderRadius: 4,
},
"& .MuiOutlinedInput-notchedOutline": {
borderRadius: 4,
},
"& .MuiFormHelperText-root": {
color: palette.error.main,
opacity: 0.8,
fontSize: "var(--env-var-font-size-medium)",
marginLeft: 0,
},
"& .MuiFormHelperText-root.Mui-error": {
opacity: 0.8,
fontSize: "var(--env-var-font-size-medium)",
color: palette.error.main,
},
}),
},
},
},
shape: {
borderRadius: 2,
borderThick: 2,
boxShadow: shadow,
},
});
export { baseTheme };

View File

@ -1,252 +1,118 @@
import { createTheme } from "@mui/material";
import { baseTheme } from "./globalTheme";
import { colors } from "./constants";
const text = {
primary: "#1c2130",
secondary: "#344054",
tertiary: "#475467",
accent: "#838c99",
/*
TODO
Next step: check if all keys here are being used in the codebase. e.g.: Search codebase for palette.primary; also check for destructuring palette ('= theme.palette')
*/
const {
primary,
secondary,
success,
error,
warning,
gradient: {
color1: { light: color1 },
color2: { light: color2 },
color3: { light: color3 },
color4: { light: color4 },
color5: { light: color5 },
},
background,
text,
border,
unresolved,
other,
} = colors;
const palette = {
/* TODO check if we need the addition of a new color gray150 for this. Also, this color would probably fit for primary contrastText */
action: {
disabled: border.light.disabled,
},
primary: { main: primary.main.light },
secondary: {
main: secondary.main.light,
contrastText: secondary.contrastText.light,
light: secondary.dark.light,
dark: secondary.dark.light,
},
success: {
main: success.main.light,
contrastText: success.contrastText.light,
light: success.light.light,
dark: success.dark.light,
},
error: {
main: error.main.light,
contrastText: error.contrastText.light,
light: error.light.light,
dark: error.dark.light,
},
warning: {
main: warning.main.light,
light: warning.light.light,
contrastText: warning.contrastText.light,
dark: warning.dark.light,
},
/* From this part on, try to create semantic structure, not feature based structure */
percentage: {
uptimePoor: error.main.light,
uptimeFair: warning.contrastText.light,
uptimeGood: warning.main.light /* Change for a success color? */,
uptimeExcellent: success.main.light,
},
unresolved: {
main: unresolved.main.light,
light: unresolved.light.light,
bg: unresolved.bg.light,
},
divider: border.light.light,
other: {
icon: other.icon.light,
line: other.line.light,
fill: secondary.dark.light,
grid: other.grid.light,
autofill: other.autofill.light,
},
gradient: {
color1,
color2,
color3,
color4,
color5,
},
text: {
primary: text.primary.light,
secondary: text.secondary.light,
tertiary: text.tertiary.light,
accent: text.accent.light,
},
background: {
main: background.main.light,
alt: background.alt.light,
fill: background.fill.light,
accent: background.accent.light,
},
border: {
light: border.light.light,
dark: border.dark.light,
},
info: {
text: text.primary.light,
main: text.tertiary.light,
bg: background.main.light,
light: background.main.light,
border: border.dark.light,
},
};
const background = {
main: "#FFFFFF",
alt: "#FCFCFD",
fill: "#F4F4F4",
accent: "#f9fafb",
};
const border = { light: "#eaecf0", dark: "#d0d5dd" };
const fontFamilyDefault =
'"Inter","system-ui", "Avenir", "Helvetica", "Arial", sans-serif';
const shadow =
"0px 4px 24px -4px rgba(16, 24, 40, 0.08), 0px 3px 3px -3px rgba(16, 24, 40, 0.03)";
/* TODO I figured out we could have just one theme by passing mode as parameter for theme function. Implement later */
const lightTheme = createTheme({
typography: {
fontFamily: fontFamilyDefault,
fontSize: 13,
h1: { fontSize: 22, color: text.primary, fontWeight: 500 },
h2: { fontSize: 14.5, color: text.secondary, fontWeight: 400 },
body1: { fontSize: 13, color: text.tertiary, fontWeight: 400 },
body2: { fontSize: 12, color: text.tertiary, fontWeight: 400 },
},
palette: {
primary: { main: "#1570EF" },
secondary: { main: "#F4F4F4", dark: "#e3e3e3", contrastText: "#475467" },
text: text,
background: background,
border: border,
info: {
text: text.primary,
main: text.tertiary,
bg: background.main,
light: background.main,
border: border.dark,
},
success: {
text: "#079455",
main: "#17b26a",
light: "#d4f4e1",
bg: "#ecfdf3",
},
error: {
text: "#f04438",
main: "#d32f2f",
light: "#fbd1d1",
bg: "#f9eced",
border: "#f04438",
},
warning: {
text: "#DC6803",
main: "#fdb022",
light: "#ffecbc",
bg: "#fef8ea",
border: "#fec84b",
},
percentage: {
uptimePoor: "#d32f2f",
uptimeFair: "#ec8013",
uptimeGood: "#ffb800",
uptimeExcellent: "#079455",
},
unresolved: { main: "#4e5ba6", light: "#e2eaf7", bg: "#f2f4f7" },
divider: border.light,
other: {
icon: "#667085",
line: "#d6d9dd",
fill: "#e3e3e3",
grid: "#a2a3a3",
autofill: "#e8f0fe",
},
},
spacing: 2,
components: {
MuiCssBaseline: {
styleOverrides: {
body: {
backgroundImage:
"radial-gradient(circle, #fcfcfd, #fdfcfd, #fdfdfd, #fefdfe, #fefefe, #fefefe, #fefefe, #fefefe, #fefdfe, #fdfdfd, #fdfcfd, #fcfcfd)",
lineHeight: "inherit",
paddingLeft: "calc(100vw - 100%)",
},
},
},
MuiButton: {
defaultProps: {
disableRipple: true,
},
styleOverrides: {
root: ({ theme }) => ({
variants: [
{
props: (props) => props.variant === "group",
style: {
color: theme.palette.secondary.contrastText,
backgroundColor: theme.palette.background.main,
border: 1,
borderStyle: "solid",
borderColor: theme.palette.border.light,
},
},
{
props: (props) => props.variant === "group" && props.filled === "true",
style: {
backgroundColor: theme.palette.secondary.main,
},
},
{
props: (props) =>
props.variant === "contained" && props.color === "secondary",
style: {
border: 1,
borderStyle: "solid",
borderColor: theme.palette.border.light,
},
},
],
fontWeight: 400,
borderRadius: 4,
boxShadow: "none",
textTransform: "none",
"&:focus": {
outline: "none",
},
"&:hover": {
boxShadow: "none",
},
}),
},
},
MuiIconButton: {
styleOverrides: {
root: {
padding: 4,
transition: "none",
"&:hover": {
backgroundColor: background.fill,
},
},
},
},
MuiPaper: {
styleOverrides: {
root: {
marginTop: 4,
padding: 0,
border: 1,
borderStyle: "solid",
borderColor: border.light,
borderRadius: 4,
boxShadow: shadow,
backgroundColor: background.main,
backgroundImage: "none",
},
},
},
MuiList: {
styleOverrides: {
root: {
padding: 0,
},
},
},
MuiListItemButton: {
styleOverrides: {
root: {
transition: "none",
},
},
},
MuiMenuItem: {
styleOverrides: {
root: {
borderRadius: 4,
backgroundColor: "inherit",
padding: "4px 6px",
color: text.secondary,
fontSize: 13,
margin: 2,
minWidth: 100,
"&:hover, &.Mui-selected, &.Mui-selected:hover, &.Mui-selected.Mui-focusVisible":
{
backgroundColor: background.fill,
},
},
},
},
MuiTableCell: {
styleOverrides: {
root: {
borderBottomColor: border.light,
},
},
},
MuiTableHead: {
styleOverrides: {
root: {
backgroundColor: background.accent,
},
},
},
MuiPagination: {
styleOverrides: {
root: {
backgroundColor: background.main,
border: 1,
borderStyle: "solid",
borderColor: border.light,
"& button": {
color: text.tertiary,
borderRadius: 4,
},
"& li:first-of-type button, & li:last-of-type button": {
border: 1,
borderStyle: "solid",
borderColor: border.light,
},
},
},
},
MuiPaginationItem: {
styleOverrides: {
root: {
"&:not(.MuiPaginationItem-ellipsis):hover, &.Mui-selected": {
backgroundColor: background.fill,
},
},
},
},
MuiSkeleton: {
styleOverrides: {
root: {
backgroundColor: "#f2f4f7",
},
},
},
},
shape: {
borderRadius: 2,
borderThick: 2,
boxShadow: shadow,
},
palette,
...baseTheme(palette),
});
export default lightTheme;

View File

@ -1,3 +1,5 @@
import { capitalizeFirstLetter } from "./stringUtils";
/**
* Helper function to get duration since last check or the last date checked
* @param {Array} checks Array of check objects.
@ -15,3 +17,22 @@ export const getLastChecked = (checks, duration = true) => {
}
return new Date() - new Date(checks[0].createdAt);
};
export const parseDomainName = (url) => {
url = url.replace(/^https?:\/\//, "");
// Remove leading/trailing dots
url = url.replace(/^\.+|\.+$/g, "");
// Split by dots
const parts = url.split(".");
// Remove common prefixes and empty parts and exclude the last element of the array (the last element should be the TLD)
const cleanParts = parts.filter((part) => part !== "www" && part !== "").slice(0, -1);
// If there's more than one part, append the two words and capitalize the first letters (e.g. ["api", "test"] -> "Api Test")
const domainPart =
cleanParts.length > 1
? cleanParts.map((part) => capitalizeFirstLetter(part)).join(" ")
: capitalizeFirstLetter(cleanParts[0]);
if (domainPart) return domainPart;
return url;
};

View File

@ -0,0 +1,17 @@
/**
* Helper function to get first letter capitalized string
* @param {string} str String whose first letter is to be capitalized
* @returns A string with first letter capitalized
*/
export const capitalizeFirstLetter = (str) => {
if (str === null || str === undefined) {
return "";
}
if (typeof str !== "string") {
throw new TypeError("Input must be a string");
}
if (str.length === 0) {
return "";
}
return str.charAt(0).toUpperCase() + str.slice(1);
};

View File

@ -1,13 +1,38 @@
const buildErrors = (prev, id, error) => {
const updatedErrors = { ...prev };
if (error) {
updatedErrors[id] = error.details[0].message?? "Validation error";
updatedErrors[id] = error.details[0].message ?? "Validation error";
} else {
delete updatedErrors[id];
}
return updatedErrors;
};
/**
* Processes Joi validation errors and returns a filtered object of error messages for fields that have been touched.
*
* @param {Object} validation - The Joi validation result object.
* @param {Object} validation.error - The error property of the validation result containing details of validation failures.
* @param {Object[]} validation.error.details - An array of error details from the Joi validation. Each item contains information about the path and the message.
* @param {Object} touchedErrors - An object representing which fields have been interacted with. Keys are field IDs (field names), and values are booleans indicating whether the field has been touched.
* @returns {Object} - An object where keys are the field IDs (if they exist in `touchedErrors` and are in the error details) and values are their corresponding error messages.
*/
const getTouchedFieldErrors = (validation, touchedErrors) => {
let newErrors = {};
if (validation?.error) {
newErrors = validation.error.details.reduce((errors, detail) => {
const fieldId = detail.path[0];
if (touchedErrors[fieldId] && !(fieldId in errors)) {
errors[fieldId] = detail.message;
}
return errors;
}, {});
}
return newErrors;
};
const hasValidationErrors = (form, validation, setErrors) => {
const { error } = validation.validate(form, {
abortEarly: false,
@ -22,16 +47,35 @@ const hasValidationErrors = (form, validation, setErrors) => {
"dbConnectionString",
"refreshTokenTTL",
"jwtTTL",
"notify-email-list",
].includes(err.path[0])
) {
newErrors[err.path[0]] = err.message ?? "Validation error";
}
// Handle conditionally usage number required cases
if (!form.cpu || form.usage_cpu) {
newErrors["usage_cpu"] = null;
}
if (!form.memory || form.usage_memory) {
newErrors["usage_memory"] = null;
}
if (!form.disk || form.usage_disk) {
newErrors["usage_disk"] = null;
}
if (!form.temperature || form.usage_temperature) {
newErrors["usage_temperature"] = null;
}
});
if (Object.keys(newErrors).length > 0) {
console.log("newErrors", newErrors);
if (Object.values(newErrors).some((v) => v)) {
setErrors(newErrors);
return true;
} else return false;
} else {
setErrors({});
return false;
}
}
return false;
};
export { buildErrors, hasValidationErrors };
export { buildErrors, hasValidationErrors, getTouchedFieldErrors };

View File

@ -1,6 +1,8 @@
import joi from "joi";
import dayjs from "dayjs";
const THRESHOLD_COMMON_BASE_MSG = "Threshold must be a number.";
const nameSchema = joi
.string()
.max(50)
@ -16,25 +18,37 @@ const passwordSchema = joi
.string()
.trim()
.min(8)
.custom((value, helpers) => {
if (!/[A-Z]/.test(value)) {
return helpers.error("uppercase");
}
return value;
})
.custom((value, helpers) => {
if (!/[a-z]/.test(value)) {
return helpers.error("lowercase");
}
return value;
})
.custom((value, helpers) => {
if (!/\d/.test(value)) {
return helpers.error("number");
}
return value;
})
.custom((value, helpers) => {
if (!/[!?@#$%^&*()\-_=+[\]{};:'",.<>~`|\\/]/.test(value)) {
return helpers.error("special");
}
return value;
})
.messages({
"string.empty": "Password is required",
"string.min": "Password must be at least 8 characters long",
})
.custom((value, helpers) => {
if (!/[A-Z]/.test(value)) {
return helpers.message("Password must contain at least one uppercase letter");
}
if (!/[a-z]/.test(value)) {
return helpers.message("Password must contain at least one lowercase letter");
}
if (!/\d/.test(value)) {
return helpers.message("Password must contain at least one number");
}
if (!/[!@#$%^&*]/.test(value)) {
return helpers.message("Password must contain at least one special character");
}
return value;
uppercase: "Password must contain at least one uppercase letter",
lowercase: "Password must contain at least one lowercase letter",
number: "Password must contain at least one number",
special: "Password must contain at least one special character",
});
const credentials = joi.object({
@ -60,15 +74,16 @@ const credentials = joi.object({
confirm: joi
.string()
.trim()
.messages({
"string.empty": "Password confirmation is required",
})
.custom((value, helpers) => {
const { password } = helpers.prefs.context;
if (value !== password) {
return helpers.message("Passwords do not match");
return helpers.error("different");
}
return value;
})
.messages({
"string.empty": "This field can't be empty",
different: "Passwords do not match",
}),
role: joi.array(),
teamId: joi.string().allow("").optional(),
@ -160,11 +175,49 @@ const advancedSettingsValidation = joi.object({
pagespeedApiKey: joi.string().allow(""),
});
const infrastructureMonitorValidation = joi.object({
url: joi.string().uri({ allowRelative: true }).trim().messages({
"string.empty": "This field is required.",
"string.uri": "The URL you provided is not valid.",
}),
name: joi.string().trim().max(50).allow("").messages({
"string.max": "This field should not exceed the 50 characters limit.",
}),
secret: joi.string().trim().messages({ "string.empty": "This field is required." }),
usage_cpu: joi.number().messages({
"number.base": THRESHOLD_COMMON_BASE_MSG,
}),
cpu: joi.boolean(),
memory: joi.boolean(),
disk: joi.boolean(),
temperature: joi.boolean(),
usage_memory: joi.number().messages({
"number.base": THRESHOLD_COMMON_BASE_MSG,
}),
usage_disk: joi.number().messages({
"number.base": THRESHOLD_COMMON_BASE_MSG,
}),
usage_temperature: joi.number().messages({
"number.base": "Temperature must be a number.",
}),
// usage_system: joi.number().messages({
// "number.base": "System load must be a number.",
// }),
// usage_swap: joi.number().messages({
// "number.base": "Swap used must be a number.",
// }),
interval: joi.number().messages({
"number.base": "Frequency must be a number.",
"any.required": "Frequency is required.",
}),
});
export {
credentials,
imageValidation,
monitorValidation,
settingsValidation,
maintenanceWindowValidation,
advancedSettingsValidation
advancedSettingsValidation,
infrastructureMonitorValidation,
};

View File

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9 2V4M15 2V4M9 20V22M15 20V22M20 9H22M20 14H22M2 9H4M2 14H4M8.8 20H15.2C16.8802 20 17.7202 20 18.362 19.673C18.9265 19.3854 19.3854 18.9265 19.673 18.362C20 17.7202 20 16.8802 20 15.2V8.8C20 7.11984 20 6.27976 19.673 5.63803C19.3854 5.07354 18.9265 4.6146 18.362 4.32698C17.7202 4 16.8802 4 15.2 4H8.8C7.11984 4 6.27976 4 5.63803 4.32698C5.07354 4.6146 4.6146 5.07354 4.32698 5.63803C4 6.27976 4 7.11984 4 8.8V15.2C4 16.8802 4 17.7202 4.32698 18.362C4.6146 18.9265 5.07354 19.3854 5.63803 19.673C6.27976 20 7.11984 20 8.8 20ZM10.6 15H13.4C13.9601 15 14.2401 15 14.454 14.891C14.6422 14.7951 14.7951 14.6422 14.891 14.454C15 14.2401 15 13.9601 15 13.4V10.6C15 10.0399 15 9.75992 14.891 9.54601C14.7951 9.35785 14.6422 9.20487 14.454 9.10899C14.2401 9 13.9601 9 13.4 9H10.6C10.0399 9 9.75992 9 9.54601 9.10899C9.35785 9.20487 9.20487 9.35785 9.10899 9.54601C9 9.75992 9 10.0399 9 10.6V13.4C9 13.9601 9 14.2401 9.10899 14.454C9.20487 14.6422 9.35785 14.7951 9.54601 14.891C9.75992 15 10.0399 15 10.6 15Z" stroke="#667085" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -20,6 +20,8 @@ html {
--env-var-width-1: 100vw;
--env-var-width-2: 360px;
--env-var-width-3: 250px;
--env-var-width-4: 100px;
--env-var-height-1: 100vh;
--env-var-height-2: 34px;

View File

@ -1,6 +1,7 @@
import { configureStore, combineReducers } from "@reduxjs/toolkit";
import uptimeMonitorsReducer from "./Features/UptimeMonitors/uptimeMonitorsSlice";
import infrastructureMonitorsReducer from "./Features/InfrastructureMonitors/infrastructureMonitorsSlice";
import pageSpeedMonitorReducer from "./Features/PageSpeedMonitor/pageSpeedMonitorSlice";
import authReducer from "./Features/Auth/authSlice";
import uiReducer from "./Features/UI/uiSlice";
@ -28,6 +29,7 @@ const persistConfig = {
const rootReducer = combineReducers({
uptimeMonitors: uptimeMonitorsReducer,
infrastructureMonitors: infrastructureMonitorsReducer,
auth: authReducer,
pageSpeedMonitors: pageSpeedMonitorReducer,
ui: uiReducer,

View File

@ -18,6 +18,8 @@ services:
environment:
- DB_CONNECTION_STRING=mongodb://mongodb:27017/uptime_db
- REDIS_HOST=redis
# volumes:
# - /var/run/docker.sock:/var/run/docker.sock:ro
redis:
image: bluewaveuptime/uptime_redis:latest
ports:

View File

@ -0,0 +1,19 @@
version: '3'
services:
webserver:
image: nginx:latest
ports:
- 80:80
- 443:443
restart: always
volumes:
- ./nginx/conf.d/:/etc/nginx/conf.d/:ro
- ./certbot/www/:/var/www/certbot/:ro
certbot:
image: certbot/certbot:latest
volumes:
- ./certbot/www/:/var/www/certbot/:rw
- ./certbot/conf/:/etc/letsencrypt/:rw
depends_on:
- webserver

View File

@ -0,0 +1,15 @@
server {
listen 80;
listen [::]:80;
server_name checkmate-demo.bluewavelabs.ca www.checkmate-demo.bluewavelabs.ca;
server_tokens off;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://[domain-name]$request_uri;
}
}

View File

@ -2,7 +2,7 @@ server {
listen 80;
listen [::]:80;
server_name uptime-demo.bluewavelabs.ca;
server_name checkmate-demo.bluewavelabs.ca;
server_tokens off;
location /.well-known/acme-challenge/ {
@ -38,10 +38,10 @@ server {
listen 443 default_server ssl http2;
listen [::]:443 ssl http2;
server_name uptime-demo.bluewavelabs.ca;
server_name checkmate-demo.bluewavelabs.ca;
ssl_certificate /etc/nginx/ssl/live/uptime-demo.bluewavelabs.ca/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/live/uptime-demo.bluewavelabs.ca/privkey.pem;
ssl_certificate /etc/nginx/ssl/live/checkmate-demo.bluewavelabs.ca/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/live/checkmate-demo.bluewavelabs.ca/privkey.pem;
location / {
root /usr/share/nginx/html;

26
Docker/test/build_images.sh Executable file
View File

@ -0,0 +1,26 @@
#!/bin/bash
# Change directory to root directory for correct Docker Context
cd "$(dirname "$0")"
cd ../..
# Define an array of services and their Dockerfiles
declare -A services=(
["uptime_client"]="./Docker/prod/client.Dockerfile"
["uptime_database_mongo"]="./Docker/prod/mongoDB.Dockerfile"
["uptime_redis"]="./Docker/prod/redis.Dockerfile"
["uptime_server"]="./Docker/prod/server.Dockerfile"
)
# Loop through each service and build the corresponding image
for service in "${!services[@]}"; do
docker build -f "${services[$service]}" -t "$service" .
# Check if the build succeeded
if [ $? -ne 0 ]; then
echo "Error building $service image. Exiting..."
exit 1
fi
done
echo "All images built successfully"

Some files were not shown because too many files have changed in this diff Show More