diff --git a/portal-ui/src/common/SecureComponent/permissions.ts b/portal-ui/src/common/SecureComponent/permissions.ts index e8e6a7002..813f33f62 100644 --- a/portal-ui/src/common/SecureComponent/permissions.ts +++ b/portal-ui/src/common/SecureComponent/permissions.ts @@ -181,6 +181,8 @@ export const IAM_PAGES = { "/namespaces/:tenantNamespace/tenants/:tenantName/trace", NAMESPACE_TENANT_POOLS: "/namespaces/:tenantNamespace/tenants/:tenantName/pools", + NAMESPACE_TENANT_POOLS_ADD: + "/namespaces/:tenantNamespace/tenants/:tenantName/add-pool", NAMESPACE_TENANT_VOLUMES: "/namespaces/:tenantNamespace/tenants/:tenantName/volumes", NAMESPACE_TENANT_LICENSE: diff --git a/portal-ui/src/screens/Console/Console.tsx b/portal-ui/src/screens/Console/Console.tsx index 7b74193b4..1997e3760 100644 --- a/portal-ui/src/screens/Console/Console.tsx +++ b/portal-ui/src/screens/Console/Console.tsx @@ -113,6 +113,9 @@ const License = React.lazy(() => import("./License/License")); const ConfigurationOptions = React.lazy( () => import("./Configurations/ConfigurationPanels/ConfigurationOptions") ); +const AddPool = React.lazy( + () => import("./Tenants/TenantDetails/Pools/AddPool") +); const styles = (theme: Theme) => createStyles({ @@ -431,6 +434,11 @@ const Console = ({ path: IAM_PAGES.NAMESPACE_TENANT_POOLS, forceDisplay: true, }, + { + component: AddPool, + path: IAM_PAGES.NAMESPACE_TENANT_POOLS_ADD, + forceDisplay: true, + }, { component: TenantDetails, path: IAM_PAGES.NAMESPACE_TENANT_VOLUMES, diff --git a/portal-ui/src/screens/Console/Tenants/ListTenants/types.ts b/portal-ui/src/screens/Console/Tenants/ListTenants/types.ts index fc068adf4..5cf6ccd74 100644 --- a/portal-ui/src/screens/Console/Tenants/ListTenants/types.ts +++ b/portal-ui/src/screens/Console/Tenants/ListTenants/types.ts @@ -15,7 +15,7 @@ // along with this program. If not, see . import { SubnetInfo } from "../../License/types"; -import { IAffinityModel } from "../../../../common/types"; +import {IAffinityModel, IResourceModel, ITolerationModel} from "../../../../common/types"; import { ICertificateInfo, ISecurityContext, @@ -60,6 +60,8 @@ export interface IAddPoolRequest { volumes_per_server: number; volume_configuration: IVolumeConfiguration; affinity?: IAffinityModel; + tolerations?: ITolerationModel[]; + securityContext?: ISecurityContext | null; } export interface IVolumeConfiguration { diff --git a/portal-ui/src/screens/Console/Tenants/TenantDetails/AddPoolModal.tsx b/portal-ui/src/screens/Console/Tenants/TenantDetails/AddPoolModal.tsx deleted file mode 100644 index 23c262a66..000000000 --- a/portal-ui/src/screens/Console/Tenants/TenantDetails/AddPoolModal.tsx +++ /dev/null @@ -1,258 +0,0 @@ -import React, { useEffect, useState } from "react"; -import get from "lodash/get"; -import ModalWrapper from "../../Common/ModalWrapper/ModalWrapper"; -import { Theme } from "@mui/material/styles"; -import createStyles from "@mui/styles/createStyles"; -import withStyles from "@mui/styles/withStyles"; -import { - formFieldStyles, - modalStyleUtils, -} from "../../Common/FormComponents/common/styleLibrary"; -import InputBoxWrapper from "../../Common/FormComponents/InputBoxWrapper/InputBoxWrapper"; -import Grid from "@mui/material/Grid"; -import { generatePoolName, niceBytes } from "../../../../common/utils"; -import { Button, LinearProgress, SelectChangeEvent } from "@mui/material"; -import api from "../../../../common/api"; -import { IAddPoolRequest, ITenant } from "../ListTenants/types"; -import { ErrorResponseHandler, IAffinityModel } from "../../../../common/types"; -import { getDefaultAffinity } from "./utils"; - -import SelectWrapper from "../../Common/FormComponents/SelectWrapper/SelectWrapper"; -import { IQuotaElement, IQuotas, Opts } from "../ListTenants/utils"; -import { NewPoolIcon } from "../../../../icons"; - -interface IAddPoolProps { - tenant: ITenant; - classes: any; - open: boolean; - onClosePoolAndReload: (shouldReload: boolean) => void; -} - -const styles = (theme: Theme) => - createStyles({ - buttonContainer: { - textAlign: "right", - }, - bottomContainer: { - display: "flex", - flexGrow: 1, - alignItems: "center", - margin: "auto", - justifyContent: "center", - "& div": { - width: 150, - "@media (max-width: 900px)": { - flexFlow: "column", - }, - }, - }, - factorElements: { - display: "flex", - justifyContent: "flex-start", - marginLeft: 30, - }, - sizeNumber: { - fontSize: 35, - fontWeight: 700, - textAlign: "center", - }, - sizeDescription: { - fontSize: 14, - color: "#777", - textAlign: "center", - }, - ...formFieldStyles, - ...modalStyleUtils, - }); - -const AddPoolModal = ({ - tenant, - classes, - open, - onClosePoolAndReload, -}: IAddPoolProps) => { - const [addSending, setAddSending] = useState(false); - const [numberOfNodes, setNumberOfNodes] = useState(0); - const [volumeSize, setVolumeSize] = useState(0); - const [volumesPerServer, setVolumesPerSever] = useState(0); - const [selectedStorageClass, setSelectedStorageClass] = useState(""); - const [storageClasses, setStorageClasses] = useState([]); - - const instanceCapacity: number = volumeSize * 1073741824 * volumesPerServer; - const totalCapacity: number = instanceCapacity * numberOfNodes; - - useEffect(() => { - setSelectedStorageClass(""); - - setStorageClasses([]); - api - .invoke( - "GET", - `/api/v1/namespaces/${tenant.namespace}/resourcequotas/${tenant.namespace}-storagequota` - ) - .then((res: IQuotas) => { - const elements: IQuotaElement[] = get(res, "elements", []); - - const newStorage = elements.map((storageClass: any) => { - const name = get(storageClass, "name", "").split( - ".storageclass.storage.k8s.io/requests.storage" - )[0]; - - return { label: name, value: name }; - }); - - setStorageClasses(newStorage); - if (newStorage.length > 0) { - setSelectedStorageClass(newStorage[0].value); - } - }) - .catch((err: ErrorResponseHandler) => { - console.error(err); - }); - }, [tenant]); - - return ( - onClosePoolAndReload(false)} - modalOpen={open} - title="Add Pool" - titleIcon={} - > -
) => { - e.preventDefault(); - setAddSending(true); - - const poolName = generatePoolName(tenant.pools); - - const defaultAffinity: IAffinityModel = getDefaultAffinity( - tenant.name, - poolName - ); - - const data: IAddPoolRequest = { - name: poolName, - servers: numberOfNodes, - volumes_per_server: volumesPerServer, - volume_configuration: { - size: volumeSize * 1073741824, - storage_class_name: selectedStorageClass, - labels: null, - }, - affinity: defaultAffinity, - }; - - api - .invoke( - "POST", - `/api/v1/namespaces/${tenant.namespace}/tenants/${tenant.name}/pools`, - data - ) - .then(() => { - setAddSending(false); - onClosePoolAndReload(true); - }) - .catch((err: ErrorResponseHandler) => { - setAddSending(false); - // setDeleteError(err); - }); - }} - > - - ) => { - setNumberOfNodes(parseInt(e.target.value)); - }} - label="Number of Nodes" - value={numberOfNodes.toString(10)} - /> - - - ) => { - setVolumeSize(parseInt(e.target.value)); - }} - label="Volume Size (Gi)" - value={volumeSize.toString(10)} - /> - - - ) => { - setVolumesPerSever(parseInt(e.target.value)); - }} - label="Volumes per Server" - value={volumesPerServer.toString(10)} - /> - - - ) => { - setSelectedStorageClass(e.target.value as string); - }} - label="Storage Class" - value={selectedStorageClass} - options={storageClasses} - disabled={storageClasses.length < 1} - /> - - - -
-
-
- {niceBytes(instanceCapacity.toString(10))} -
-
Instance Capacity
-
-
-
- {niceBytes(totalCapacity.toString(10))} -
-
Total Capacity
-
-
-
- - - - - {addSending && ( - - - - )} -
-
- ); -}; - -export default withStyles(styles)(AddPoolModal); diff --git a/portal-ui/src/screens/Console/Tenants/TenantDetails/Pools/AddPool.tsx b/portal-ui/src/screens/Console/Tenants/TenantDetails/Pools/AddPool.tsx new file mode 100644 index 000000000..9fab9fdd8 --- /dev/null +++ b/portal-ui/src/screens/Console/Tenants/TenantDetails/Pools/AddPool.tsx @@ -0,0 +1,332 @@ +// This file is part of MinIO Console Server +// Copyright (c) 2022 MinIO, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import React, { Fragment, useEffect, useState } from "react"; +import { Theme } from "@mui/material/styles"; +import createStyles from "@mui/styles/createStyles"; +import withStyles from "@mui/styles/withStyles"; +import { + formFieldStyles, + modalStyleUtils, +} from "../../../Common/FormComponents/common/styleLibrary"; +import Grid from "@mui/material/Grid"; +import { generatePoolName, niceBytes } from "../../../../../common/utils"; +import { LinearProgress } from "@mui/material"; +import { IAddPoolRequest, ITenant } from "../../ListTenants/types"; +import PageHeader from "../../../Common/PageHeader/PageHeader"; +import PageLayout from "../../../Common/Layout/PageLayout"; +import GenericWizard from "../../../Common/GenericWizard/GenericWizard"; +import { IWizardElement } from "../../../Common/GenericWizard/types"; +import history from "../../../../../history"; +import PoolResources from "./PoolResources"; +import ScreenTitle from "../../../Common/ScreenTitle/ScreenTitle"; +import TenantsIcon from "../../../../../icons/TenantsIcon"; +import { + isPoolPageValid, + resetPoolForm, + setPoolField, + setTenantDetailsLoad, +} from "../../actions"; +import { AppState } from "../../../../../store"; +import { connect } from "react-redux"; +import PoolConfiguration from "./PoolConfiguration"; +import PoolPodPlacement from "./PoolPodPlacement"; +import { + ErrorResponseHandler, + ITolerationModel, +} from "../../../../../common/types"; +import { getDefaultAffinity, getNodeSelector } from "../utils"; +import api from "../../../../../common/api"; +import { ISecurityContext } from "../../types"; +import BackLink from "../../../../../common/BackLink"; +import { setErrorSnackMessage } from "../../../../../actions"; + +interface IAddPoolProps { + tenant: ITenant | null; + classes: any; + open: boolean; + match: any; + selectedStorageClass: string; + validPages: string[]; + numberOfNodes: number; + volumeSize: number; + volumesPerServer: number; + affinityType: string; + nodeSelectorLabels: string; + withPodAntiAffinity: boolean; + securityContextEnabled: boolean; + tolerations: ITolerationModel[]; + securityContext: ISecurityContext; + resetPoolForm: typeof resetPoolForm; + setErrorSnackMessage: typeof setErrorSnackMessage; + setTenantDetailsLoad: typeof setTenantDetailsLoad; +} + +const styles = (theme: Theme) => + createStyles({ + buttonContainer: { + textAlign: "right", + }, + bottomContainer: { + display: "flex", + flexGrow: 1, + alignItems: "center", + margin: "auto", + justifyContent: "center", + "& div": { + width: 150, + "@media (max-width: 900px)": { + flexFlow: "column", + }, + }, + }, + factorElements: { + display: "flex", + justifyContent: "flex-start", + marginLeft: 30, + }, + sizeNumber: { + fontSize: 35, + fontWeight: 700, + textAlign: "center", + }, + sizeDescription: { + fontSize: 14, + color: "#777", + textAlign: "center", + }, + pageBox: { + border: "1px solid #EAEAEA", + borderTop: 0, + }, + addPoolTitle: { + border: "1px solid #EAEAEA", + borderBottom: 0, + }, + ...formFieldStyles, + ...modalStyleUtils, + }); + +const requiredPages = ["setup", "affinity", "configure"]; + +const AddPool = ({ + tenant, + classes, + resetPoolForm, + selectedStorageClass, + validPages, + numberOfNodes, + volumeSize, + affinityType, + nodeSelectorLabels, + withPodAntiAffinity, + tolerations, + securityContextEnabled, + securityContext, + volumesPerServer, + setTenantDetailsLoad, +}: IAddPoolProps) => { + const [addSending, setAddSending] = useState(false); + + const poolsURL = `/namespaces/${tenant?.namespace || ""}/tenants/${ + tenant?.name || "" + }/pools`; + + useEffect(() => { + if (addSending && tenant) { + const poolName = generatePoolName(tenant.pools); + + let affinityObject = {}; + + switch (affinityType) { + case "default": + affinityObject = { + affinity: getDefaultAffinity(tenant.name, poolName), + }; + break; + case "nodeSelector": + affinityObject = { + affinity: getNodeSelector( + nodeSelectorLabels, + withPodAntiAffinity, + tenant.name, + poolName + ), + }; + break; + } + + const tolerationValues = tolerations.filter( + (toleration) => toleration.key.trim() !== "" + ); + + const data: IAddPoolRequest = { + name: poolName, + servers: numberOfNodes, + volumes_per_server: volumesPerServer, + volume_configuration: { + size: volumeSize * 1073741824, + storage_class_name: selectedStorageClass, + labels: null, + }, + tolerations: tolerationValues, + securityContext: securityContextEnabled ? securityContext : null, + ...affinityObject, + }; + + api + .invoke( + "POST", + `/api/v1/namespaces/${tenant.namespace}/tenants/${tenant.name}/pools`, + data + ) + .then(() => { + setAddSending(false); + resetPoolForm(); + setTenantDetailsLoad(true); + history.push(poolsURL); + }) + .catch((err: ErrorResponseHandler) => { + setAddSending(false); + setErrorSnackMessage(err); + }); + } + }, [ + addSending, + poolsURL, + resetPoolForm, + setTenantDetailsLoad, + affinityType, + nodeSelectorLabels, + numberOfNodes, + securityContext, + securityContextEnabled, + selectedStorageClass, + tenant, + tolerations, + volumeSize, + volumesPerServer, + withPodAntiAffinity, + ]); + + const cancelButton = { + label: "Cancel", + type: "other", + enabled: true, + action: () => { + resetPoolForm(); + history.push(poolsURL); + }, + }; + + const createButton = { + label: "Create", + type: "submit", + enabled: + !addSending && + selectedStorageClass !== "" && + requiredPages.every((v) => validPages.includes(v)), + action: () => { + setAddSending(true); + }, + }; + + const wizardSteps: IWizardElement[] = [ + { + label: "Setup", + componentRender: , + buttons: [cancelButton, createButton], + }, + { + label: "Configuration", + advancedOnly: true, + componentRender: , + buttons: [cancelButton, createButton], + }, + { + label: "Pod Placement", + advancedOnly: true, + componentRender: , + buttons: [cancelButton, createButton], + }, + ]; + + return ( + + + + + + } + /> + + + } + title={`Add New Pool to ${tenant?.name || ""}`} + subTitle={ + + Namespace: {tenant?.namespace || ""} / Current Capacity:{" "} + {niceBytes((tenant?.total_size || 0).toString(10))} + + } + /> + + + {addSending && ( + + + + )} + + + + + + + ); +}; + +const mapState = (state: AppState) => { + const addPool = state.tenants.addPool; + return { + tenant: state.tenants.tenantDetails.tenantInfo, + selectedStorageClass: addPool.fields.setup.storageClass, + validPages: addPool.validPages, + storageClasses: addPool.storageClasses, + numberOfNodes: addPool.fields.setup.numberOfNodes, + volumeSize: addPool.fields.setup.volumeSize, + volumesPerServer: addPool.fields.setup.volumesPerServer, + affinityType: addPool.fields.affinity.podAffinity, + nodeSelectorLabels: addPool.fields.affinity.nodeSelectorLabels, + withPodAntiAffinity: addPool.fields.affinity.withPodAntiAffinity, + tolerations: addPool.fields.tolerations, + securityContextEnabled: addPool.fields.configuration.securityContextEnabled, + securityContext: addPool.fields.configuration.securityContext, + }; +}; + +const connector = connect(mapState, { + resetPoolForm, + setPoolField, + isPoolPageValid, + setErrorSnackMessage, + setTenantDetailsLoad, +}); + +export default withStyles(styles)(connector(AddPool)); diff --git a/portal-ui/src/screens/Console/Tenants/TenantDetails/Pools/PoolConfiguration.tsx b/portal-ui/src/screens/Console/Tenants/TenantDetails/Pools/PoolConfiguration.tsx new file mode 100644 index 000000000..5eb65b709 --- /dev/null +++ b/portal-ui/src/screens/Console/Tenants/TenantDetails/Pools/PoolConfiguration.tsx @@ -0,0 +1,293 @@ +// This file is part of MinIO Console Server +// Copyright (c) 2022 MinIO, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import React, { useCallback, useEffect, useState } from "react"; +import { connect } from "react-redux"; +import { Theme } from "@mui/material/styles"; +import createStyles from "@mui/styles/createStyles"; +import withStyles from "@mui/styles/withStyles"; +import { Grid, Paper } from "@mui/material"; +import { + createTenantCommon, + modalBasic, + wizardCommon, +} from "../../../Common/FormComponents/common/styleLibrary"; +import { isPoolPageValid, setPoolField } from "../../actions"; +import { AppState } from "../../../../../store"; +import { clearValidationError } from "../../utils"; +import { + commonFormValidation, + IValidation, +} from "../../../../../utils/validationFunctions"; +import FormSwitchWrapper from "../../../Common/FormComponents/FormSwitchWrapper/FormSwitchWrapper"; +import InputBoxWrapper from "../../../Common/FormComponents/InputBoxWrapper/InputBoxWrapper"; +import { ISecurityContext } from "../../types"; + +interface IConfigureProps { + setPoolField: typeof setPoolField; + isPoolPageValid: typeof isPoolPageValid; + classes: any; + securityContextEnabled: boolean; + securityContext: ISecurityContext; +} + +const styles = (theme: Theme) => + createStyles({ + configSectionItem: { + marginRight: 15, + + "& .multiContainer": { + border: "1px solid red", + }, + }, + tenantCustomizationFields: { + marginLeft: 30, // 2nd Level(15+15) + width: "88%", + margin: "auto", + }, + containerItem: { + marginRight: 15, + }, + fieldGroup: { + ...createTenantCommon.fieldGroup, + paddingTop: 15, + marginBottom: 25, + }, + responsiveSectionItem: { + "@media (max-width: 900px)": { + flexFlow: "column", + alignItems: "flex-start", + + "& div > div": { + marginBottom: 5, + marginRight: 0, + }, + }, + }, + + fieldSpaceTop: { + marginTop: 15, + }, + + ...modalBasic, + ...wizardCommon, + }); + +const PoolConfiguration = ({ + classes, + setPoolField, + securityContextEnabled, + isPoolPageValid, + securityContext, +}: IConfigureProps) => { + const [validationErrors, setValidationErrors] = useState({}); + + // Common + const updateField = useCallback( + (field: string, value: any) => { + setPoolField("configuration", field, value); + }, + [setPoolField] + ); + + // Validation + useEffect(() => { + let customAccountValidation: IValidation[] = []; + if (securityContextEnabled) { + customAccountValidation = [ + ...customAccountValidation, + { + fieldKey: "pool_securityContext_runAsUser", + required: true, + value: securityContext.runAsUser, + customValidation: + securityContext.runAsUser === "" || + parseInt(securityContext.runAsUser) < 0, + customValidationMessage: `runAsUser must be present and be 0 or more`, + }, + { + fieldKey: "pool_securityContext_runAsGroup", + required: true, + value: securityContext.runAsGroup, + customValidation: + securityContext.runAsGroup === "" || + parseInt(securityContext.runAsGroup) < 0, + customValidationMessage: `runAsGroup must be present and be 0 or more`, + }, + { + fieldKey: "pool_securityContext_fsGroup", + required: true, + value: securityContext.fsGroup, + customValidation: + securityContext.fsGroup === "" || + parseInt(securityContext.fsGroup) < 0, + customValidationMessage: `fsGroup must be present and be 0 or more`, + }, + ]; + } + + const commonVal = commonFormValidation(customAccountValidation); + + isPoolPageValid("configure", Object.keys(commonVal).length === 0); + + setValidationErrors(commonVal); + }, [isPoolPageValid, securityContextEnabled, securityContext]); + + const cleanValidation = (fieldName: string) => { + setValidationErrors(clearValidationError(validationErrors, fieldName)); + }; + + return ( + +
+

Configure

+ + Aditional Configurations for the new Pool + +
+ + { + const targetD = e.target; + const checked = targetD.checked; + + updateField("securityContextEnabled", checked); + }} + label={"Security Context"} + /> + + {securityContextEnabled && ( + +
+ + Pool's Security Context + + +
+
+ ) => { + updateField("securityContext", { + ...securityContext, + runAsUser: e.target.value, + }); + cleanValidation("pool_securityContext_runAsUser"); + }} + label="Run As User" + value={securityContext.runAsUser} + required + error={ + validationErrors["pool_securityContext_runAsUser"] || "" + } + min="0" + /> +
+
+ ) => { + updateField("securityContext", { + ...securityContext, + runAsGroup: e.target.value, + }); + cleanValidation("pool_securityContext_runAsGroup"); + }} + label="Run As Group" + value={securityContext.runAsGroup} + required + error={ + validationErrors["pool_securityContext_runAsGroup"] || + "" + } + min="0" + /> +
+
+ ) => { + updateField("securityContext", { + ...securityContext, + fsGroup: e.target.value, + }); + cleanValidation("pool_securityContext_fsGroup"); + }} + label="FsGroup" + value={securityContext.fsGroup} + required + error={ + validationErrors["pool_securityContext_fsGroup"] || "" + } + min="0" + /> +
+
+
+
+ +
+ { + const targetD = e.target; + const checked = targetD.checked; + updateField("securityContext", { + ...securityContext, + runAsNonRoot: checked, + }); + }} + label={"Do not run as Root"} + /> +
+
+
+
+ )} +
+ ); +}; + +const mapState = (state: AppState) => { + const configuration = state.tenants.addPool.fields.configuration; + + return { + securityContextEnabled: configuration.securityContextEnabled, + securityContext: configuration.securityContext, + }; +}; + +const connector = connect(mapState, { + setPoolField, + isPoolPageValid, +}); + +export default withStyles(styles)(connector(PoolConfiguration)); diff --git a/portal-ui/src/screens/Console/Tenants/TenantDetails/Pools/PoolPodPlacement.tsx b/portal-ui/src/screens/Console/Tenants/TenantDetails/Pools/PoolPodPlacement.tsx new file mode 100644 index 000000000..2d9aa7508 --- /dev/null +++ b/portal-ui/src/screens/Console/Tenants/TenantDetails/Pools/PoolPodPlacement.tsx @@ -0,0 +1,537 @@ +// This file is part of MinIO Console Server +// Copyright (c) 2022 MinIO, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import React, { Fragment, useCallback, useEffect, useState } from "react"; +import { connect } from "react-redux"; +import { Theme } from "@mui/material/styles"; +import createStyles from "@mui/styles/createStyles"; +import withStyles from "@mui/styles/withStyles"; +import { Grid, IconButton, Paper, SelectChangeEvent } from "@mui/material"; +import { AppState } from "../../../../../store"; +import { + isPoolPageValid, + setPoolField, + setPoolTolerationInfo, + addNewPoolToleration, + removePoolToleration, + setPoolKeyValuePairs, +} from "../../actions"; +import { setModalErrorSnackMessage } from "../../../../../actions"; +import { + modalBasic, + wizardCommon, +} from "../../../Common/FormComponents/common/styleLibrary"; +import { + commonFormValidation, + IValidation, +} from "../../../../../utils/validationFunctions"; +import { + ErrorResponseHandler, + ITolerationModel, +} from "../../../../../common/types"; +import { LabelKeyPair } from "../../types"; +import RadioGroupSelector from "../../../Common/FormComponents/RadioGroupSelector/RadioGroupSelector"; +import FormSwitchWrapper from "../../../Common/FormComponents/FormSwitchWrapper/FormSwitchWrapper"; +import api from "../../../../../common/api"; +import InputBoxWrapper from "../../../Common/FormComponents/InputBoxWrapper/InputBoxWrapper"; +import AddIcon from "../../../../../icons/AddIcon"; +import RemoveIcon from "../../../../../icons/RemoveIcon"; +import SelectWrapper from "../../../Common/FormComponents/SelectWrapper/SelectWrapper"; +import TolerationSelector from "../../../Common/TolerationSelector/TolerationSelector"; + +interface IAffinityProps { + classes: any; + podAffinity: string; + nodeSelectorLabels: string; + withPodAntiAffinity: boolean; + keyValuePairs: LabelKeyPair[]; + tolerations: ITolerationModel[]; + setModalErrorSnackMessage: typeof setModalErrorSnackMessage; + setPoolField: typeof setPoolField; + isPoolPageValid: typeof isPoolPageValid; + setPoolKeyValuePairs: typeof setPoolKeyValuePairs; + setPoolTolerationInfo: typeof setPoolTolerationInfo; + removePoolToleration: typeof removePoolToleration; + addNewPoolToleration: typeof addNewPoolToleration; +} + +const styles = (theme: Theme) => + createStyles({ + overlayAction: { + marginLeft: 10, + display: "flex", + alignItems: "center", + "& svg": { + maxWidth: 15, + maxHeight: 15, + }, + "& button": { + background: "#EAEAEA", + }, + }, + affinityConfigField: { + display: "flex", + }, + affinityFieldLabel: { + display: "flex", + flexFlow: "column", + flex: 1, + }, + radioField: { + display: "flex", + alignItems: "flex-start", + marginTop: 10, + "& div:first-child": { + display: "flex", + flexFlow: "column", + alignItems: "baseline", + textAlign: "left !important", + }, + }, + affinityLabelKey: { + "& div:first-child": { + marginBottom: 0, + }, + }, + affinityLabelValue: { + marginLeft: 10, + "& div:first-child": { + marginBottom: 0, + }, + }, + rowActions: { + display: "flex", + alignItems: "center", + }, + fieldContainer: { + marginBottom: 0, + }, + affinityRow: { + marginBottom: 10, + display: "flex", + }, + ...modalBasic, + ...wizardCommon, + }); + +interface OptionPair { + label: string; + value: string; +} + +const Affinity = ({ + classes, + podAffinity, + nodeSelectorLabels, + withPodAntiAffinity, + setModalErrorSnackMessage, + keyValuePairs, + setPoolKeyValuePairs, + setPoolField, + isPoolPageValid, + tolerations, + setPoolTolerationInfo, + removePoolToleration, + addNewPoolToleration, +}: IAffinityProps) => { + const [validationErrors, setValidationErrors] = useState({}); + const [loading, setLoading] = useState(true); + const [keyValueMap, setKeyValueMap] = useState<{ [key: string]: string[] }>( + {} + ); + const [keyOptions, setKeyOptions] = useState([]); + + // Common + const updateField = useCallback( + (field: string, value: any) => { + setPoolField("affinity", field, value); + }, + [setPoolField] + ); + + useEffect(() => { + if (loading) { + api + .invoke("GET", `/api/v1/nodes/labels`) + .then((res: { [key: string]: string[] }) => { + setLoading(false); + setKeyValueMap(res); + let keys: OptionPair[] = []; + for (let k in res) { + keys.push({ + label: k, + value: k, + }); + } + setKeyOptions(keys); + }) + .catch((err: ErrorResponseHandler) => { + setLoading(false); + setModalErrorSnackMessage(err); + setKeyValueMap({}); + }); + } + }, [setModalErrorSnackMessage, loading]); + + useEffect(() => { + if (keyValuePairs) { + const vlr = keyValuePairs + .filter((kvp) => kvp.key !== "") + .map((kvp) => `${kvp.key}=${kvp.value}`) + .filter((kvs, i, a) => a.indexOf(kvs) === i); + const vl = vlr.join("&"); + updateField("nodeSelectorLabels", vl); + } + }, [keyValuePairs, updateField]); + + // Validation + useEffect(() => { + let customAccountValidation: IValidation[] = []; + + if (podAffinity === "nodeSelector") { + let valid = true; + + const splittedLabels = nodeSelectorLabels.split("&"); + + if (splittedLabels.length === 1 && splittedLabels[0] === "") { + valid = false; + } + + splittedLabels.forEach((item: string, index: number) => { + const splitItem = item.split("="); + + if (splitItem.length !== 2) { + valid = false; + } + + if (index + 1 !== splittedLabels.length) { + if (splitItem[0] === "" || splitItem[1] === "") { + valid = false; + } + } + }); + + customAccountValidation = [ + ...customAccountValidation, + { + fieldKey: "labels", + required: true, + value: nodeSelectorLabels, + customValidation: !valid, + customValidationMessage: + "You need to add at least one label key-pair", + }, + ]; + } + + const commonVal = commonFormValidation(customAccountValidation); + + isPoolPageValid("affinity", Object.keys(commonVal).length === 0); + + setValidationErrors(commonVal); + }, [isPoolPageValid, podAffinity, nodeSelectorLabels]); + + const updateToleration = (index: number, field: string, value: any) => { + const alterToleration = { ...tolerations[index], [field]: value }; + + setPoolTolerationInfo(index, alterToleration); + }; + + return ( + +
+

Pod Placement

+ + Configure how pods will be assigned to nodes + +
+ + +
Type
+
+ MinIO supports multiple configurations for Pod Affinity +
+ + { + updateField("podAffinity", e.target.value); + }} + selectorOptions={[ + { label: "None", value: "none" }, + { label: "Default (Pod Anti-Affinnity)", value: "default" }, + { label: "Node Selector", value: "nodeSelector" }, + ]} + /> + +
+
+ {podAffinity === "nodeSelector" && ( + +
+ + { + const targetD = e.target; + const checked = targetD.checked; + + updateField("withPodAntiAffinity", checked); + }} + label={"With Pod Anti-Affinity"} + /> + + +

Labels

+ {validationErrors["labels"]} + + {keyValuePairs && + keyValuePairs.map((kvp, i) => { + return ( + + + {keyOptions.length > 0 && ( + ) => { + const newKey = e.target.value as string; + const arrCp: LabelKeyPair[] = Object.assign( + [], + keyValuePairs + ); + + arrCp[i].key = e.target.value as string; + arrCp[i].value = keyValueMap[newKey][0]; + setPoolKeyValuePairs(arrCp); + }} + id="select-access-policy" + name="select-access-policy" + label={""} + value={kvp.key} + options={keyOptions} + /> + )} + {keyOptions.length === 0 && ( + { + const arrCp: LabelKeyPair[] = Object.assign( + [], + keyValuePairs + ); + arrCp[i].key = e.target.value; + setPoolKeyValuePairs(arrCp); + }} + index={i} + placeholder={"Key"} + /> + )} + + + {keyOptions.length > 0 && ( + ) => { + const arrCp: LabelKeyPair[] = Object.assign( + [], + keyValuePairs + ); + arrCp[i].value = e.target.value as string; + setPoolKeyValuePairs(arrCp); + }} + id="select-access-policy" + name="select-access-policy" + label={""} + value={kvp.value} + options={ + keyValueMap[kvp.key] + ? keyValueMap[kvp.key].map((v) => { + return { label: v, value: v }; + }) + : [] + } + /> + )} + {keyOptions.length === 0 && ( + { + const arrCp: LabelKeyPair[] = Object.assign( + [], + keyValuePairs + ); + arrCp[i].value = e.target.value; + setPoolKeyValuePairs(arrCp); + }} + index={i} + placeholder={"value"} + /> + )} + + +
+ { + const arrCp = Object.assign([], keyValuePairs); + if (keyOptions.length > 0) { + arrCp.push({ + key: keyOptions[0].value, + value: keyValueMap[keyOptions[0].value][0], + }); + } else { + arrCp.push({ key: "", value: "" }); + } + + setPoolKeyValuePairs(arrCp); + }} + > + + +
+ {keyValuePairs.length > 1 && ( +
+ { + const arrCp = keyValuePairs.filter( + (item, index) => index !== i + ); + setPoolKeyValuePairs(arrCp); + }} + > + + +
+ )} +
+
+ ); + })} +
+
+
+ )} + + +

Tolerations

+ + {validationErrors["tolerations"]} + + + {tolerations && + tolerations.map((tol, i) => { + return ( + + { + updateToleration(i, "effect", value); + }} + tolerationKey={tol.key} + onTolerationKeyChange={(value) => { + updateToleration(i, "key", value); + }} + operator={tol.operator} + onOperatorChange={(value) => { + updateToleration(i, "operator", value); + }} + value={tol.value} + onValueChange={(value) => { + updateToleration(i, "value", value); + }} + tolerationSeconds={tol.tolerationSeconds?.seconds || 0} + onSecondsChange={(value) => { + updateToleration(i, "tolerationSeconds", { + seconds: value, + }); + }} + index={i} + /> +
+ + + +
+ +
+ removePoolToleration(i)} + disabled={tolerations.length <= 1} + > + + +
+
+ ); + })} +
+
+
+
+ ); +}; + +const mapState = (state: AppState) => { + const addPool = state.tenants.addPool; + + return { + podAffinity: addPool.fields.affinity.podAffinity, + nodeSelectorLabels: addPool.fields.affinity.nodeSelectorLabels, + withPodAntiAffinity: addPool.fields.affinity.withPodAntiAffinity, + keyValuePairs: addPool.fields.nodeSelectorPairs, + tolerations: addPool.fields.tolerations, + }; +}; + +const connector = connect(mapState, { + setModalErrorSnackMessage, + setPoolField, + isPoolPageValid, + setPoolKeyValuePairs, + setPoolTolerationInfo, + addNewPoolToleration, + removePoolToleration, +}); + +export default withStyles(styles)(connector(Affinity)); diff --git a/portal-ui/src/screens/Console/Tenants/TenantDetails/Pools/PoolResources.tsx b/portal-ui/src/screens/Console/Tenants/TenantDetails/Pools/PoolResources.tsx new file mode 100644 index 000000000..2ca061401 --- /dev/null +++ b/portal-ui/src/screens/Console/Tenants/TenantDetails/Pools/PoolResources.tsx @@ -0,0 +1,316 @@ +// This file is part of MinIO Console Server +// Copyright (c) 2022 MinIO, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import React, { useEffect, useState } from "react"; +import get from "lodash/get"; +import { Theme } from "@mui/material/styles"; +import createStyles from "@mui/styles/createStyles"; +import withStyles from "@mui/styles/withStyles"; +import { + formFieldStyles, + wizardCommon, +} from "../../../Common/FormComponents/common/styleLibrary"; +import InputBoxWrapper from "../../../Common/FormComponents/InputBoxWrapper/InputBoxWrapper"; +import Grid from "@mui/material/Grid"; +import { niceBytes } from "../../../../../common/utils"; +import { Paper, SelectChangeEvent } from "@mui/material"; +import api from "../../../../../common/api"; +import { ITenant } from "../../ListTenants/types"; +import { ErrorResponseHandler } from "../../../../../common/types"; +import SelectWrapper from "../../../Common/FormComponents/SelectWrapper/SelectWrapper"; +import { IQuotaElement, IQuotas, Opts } from "../../ListTenants/utils"; +import { AppState } from "../../../../../store"; +import { connect } from "react-redux"; +import { + isPoolPageValid, + setPoolField, + setPoolStorageClasses, +} from "../../actions"; +import { + commonFormValidation, + IValidation, +} from "../../../../../utils/validationFunctions"; +import InputUnitMenu from "../../../Common/FormComponents/InputUnitMenu/InputUnitMenu"; + +interface IPoolResourcesProps { + tenant: ITenant | null; + classes: any; + storageClasses: Opts[]; + numberOfNodes: string; + storageClass: string; + volumeSize: string; + volumesPerServer: string; + setPoolField: typeof setPoolField; + isPoolPageValid: typeof isPoolPageValid; + setPoolStorageClasses: typeof setPoolStorageClasses; +} + +const styles = (theme: Theme) => + createStyles({ + buttonContainer: { + textAlign: "right", + }, + bottomContainer: { + display: "flex", + flexGrow: 1, + alignItems: "center", + margin: "auto", + justifyContent: "center", + "& div": { + width: 150, + "@media (max-width: 900px)": { + flexFlow: "column", + }, + }, + }, + factorElements: { + display: "flex", + justifyContent: "flex-start", + marginLeft: 30, + }, + sizeNumber: { + fontSize: 35, + fontWeight: 700, + textAlign: "center", + }, + sizeDescription: { + fontSize: 14, + color: "#777", + textAlign: "center", + }, + ...formFieldStyles, + ...wizardCommon, + }); + +const PoolResources = ({ + tenant, + classes, + storageClasses, + numberOfNodes, + storageClass, + volumeSize, + volumesPerServer, + setPoolField, + setPoolStorageClasses, + isPoolPageValid, +}: IPoolResourcesProps) => { + const [validationErrors, setValidationErrors] = useState({}); + + const instanceCapacity: number = + parseInt(volumeSize) * 1073741824 * parseInt(volumesPerServer); + const totalCapacity: number = instanceCapacity * parseInt(numberOfNodes); + + // Validation + useEffect(() => { + let customAccountValidation: IValidation[] = [ + { + fieldKey: "number_of_nodes", + required: true, + value: numberOfNodes.toString(), + customValidation: + parseInt(numberOfNodes) < 1 || isNaN(parseInt(numberOfNodes)), + customValidationMessage: "Number of servers must be at least 1", + }, + { + fieldKey: "pool_size", + required: true, + value: volumeSize.toString(), + customValidation: + parseInt(volumeSize) < 1 || isNaN(parseInt(volumeSize)), + customValidationMessage: "Pool Size cannot be 0", + }, + { + fieldKey: "volumes_per_server", + required: true, + value: volumesPerServer.toString(), + customValidation: + parseInt(volumesPerServer) < 1 || isNaN(parseInt(volumesPerServer)), + customValidationMessage: "1 volume or more are required", + }, + ]; + + const commonVal = commonFormValidation(customAccountValidation); + + isPoolPageValid("setup", Object.keys(commonVal).length === 0); + + setValidationErrors(commonVal); + }, [ + isPoolPageValid, + numberOfNodes, + volumeSize, + volumesPerServer, + storageClass, + ]); + + useEffect(() => { + if (storageClasses.length === 0 && tenant) { + api + .invoke( + "GET", + `/api/v1/namespaces/${tenant.namespace}/resourcequotas/${tenant.namespace}-storagequota` + ) + .then((res: IQuotas) => { + const elements: IQuotaElement[] = get(res, "elements", []); + + const newStorage = elements.map((storageClass: any) => { + const name = get(storageClass, "name", "").split( + ".storageclass.storage.k8s.io/requests.storage" + )[0]; + + return { label: name, value: name }; + }); + + setPoolField("setup", "storageClass", newStorage[0].value); + + setPoolStorageClasses(newStorage); + }) + .catch((err: ErrorResponseHandler) => { + console.error(err); + }); + } + }, [tenant, storageClasses, setPoolStorageClasses, setPoolField]); + + const setFieldInfo = (fieldName: string, value: any) => { + setPoolField("setup", fieldName, value); + }; + + return ( + +
+

New Pool Configuration

+ + Configure a new Pool to expand MinIO storage + +
+ + + ) => { + const intValue = parseInt(e.target.value); + + if (e.target.validity.valid && !isNaN(intValue)) { + setFieldInfo("numberOfNodes", intValue); + } else if (isNaN(intValue)) { + setFieldInfo("numberOfNodes", 0); + } + }} + label="Number of Servers" + value={numberOfNodes} + error={validationErrors["number_of_nodes"] || ""} + pattern={"[0-9]*"} + /> + + + ) => { + const intValue = parseInt(e.target.value); + + if (e.target.validity.valid && !isNaN(intValue)) { + setFieldInfo("volumeSize", intValue); + } else if (isNaN(intValue)) { + setFieldInfo("volumeSize", 0); + } + }} + label="Volume Size" + value={volumeSize} + error={validationErrors["pool_size"] || ""} + pattern={"[0-9]*"} + overlayObject={ + {}} + unitSelected={"Gi"} + unitsList={[{ label: "Gi", value: "Gi" }]} + disabled={true} + /> + } + /> + + + ) => { + const intValue = parseInt(e.target.value); + + if (e.target.validity.valid && !isNaN(intValue)) { + setFieldInfo("volumesPerServer", intValue); + } else if (isNaN(intValue)) { + setFieldInfo("volumesPerServer", 0); + } + }} + label="Volumes per Server" + value={volumesPerServer} + error={validationErrors["volumes_per_server"] || ""} + pattern={"[0-9]*"} + /> + + + ) => { + setFieldInfo("storageClasses", e.target.value as string); + }} + label="Storage Class" + value={storageClass} + options={storageClasses} + disabled={storageClasses.length < 1} + /> + + +
+
+
+ {niceBytes(instanceCapacity.toString(10))} +
+
Instance Capacity
+
+
+
+ {niceBytes(totalCapacity.toString(10))} +
+
Total Capacity
+
+
+
+
+ ); +}; + +const mapState = (state: AppState) => { + const setupFields = state.tenants.addPool.fields.setup; + return { + tenant: state.tenants.tenantDetails.tenantInfo, + storageClasses: state.tenants.addPool.storageClasses, + numberOfNodes: setupFields.numberOfNodes.toString(), + storageClass: setupFields.storageClass, + volumeSize: setupFields.volumeSize.toString(), + volumesPerServer: setupFields.volumesPerServer.toString(), + }; +}; + +const connector = connect(mapState, { + setPoolField, + isPoolPageValid, + setPoolStorageClasses, +}); + +export default withStyles(styles)(connector(PoolResources)); diff --git a/portal-ui/src/screens/Console/Tenants/TenantDetails/PoolsSummary.tsx b/portal-ui/src/screens/Console/Tenants/TenantDetails/PoolsSummary.tsx index 6ddafb065..a3529d708 100644 --- a/portal-ui/src/screens/Console/Tenants/TenantDetails/PoolsSummary.tsx +++ b/portal-ui/src/screens/Console/Tenants/TenantDetails/PoolsSummary.tsx @@ -31,7 +31,6 @@ import { AddIcon } from "../../../../icons"; import { IPool, ITenant } from "../ListTenants/types"; import { setErrorSnackMessage } from "../../../../actions"; import TableWrapper from "../../Common/TableWrapper/TableWrapper"; -import AddPoolModal from "./AddPoolModal"; import InputAdornment from "@mui/material/InputAdornment"; import { AppState } from "../../../../store"; import { setTenantDetailsLoad } from "../actions"; @@ -42,6 +41,7 @@ interface IPoolsSummary { classes: any; tenant: ITenant | null; loadingTenant: boolean; + history: any; setErrorSnackMessage: typeof setErrorSnackMessage; setTenantDetailsLoad: typeof setTenantDetailsLoad; } @@ -59,9 +59,9 @@ const PoolsSummary = ({ tenant, loadingTenant, setTenantDetailsLoad, + history, }: IPoolsSummary) => { const [pools, setPools] = useState([]); - const [addPoolOpen, setAddPool] = useState(false); const [filter, setFilter] = useState(""); useEffect(() => { @@ -71,14 +71,6 @@ const PoolsSummary = ({ } }, [tenant]); - const onClosePoolAndRefresh = (reload: boolean) => { - setAddPool(false); - - if (reload) { - setTenantDetailsLoad(true); - } - }; - const filteredPools = pools.filter((pool) => { if (pool.name.toLowerCase().includes(filter.toLowerCase())) { return true; @@ -89,14 +81,6 @@ const PoolsSummary = ({ return ( - {addPoolOpen && tenant !== null && ( - - )} -

Pools

@@ -123,7 +107,11 @@ const PoolsSummary = ({ tooltip={"Expand Tenant"} text={"Expand Tenant"} onClick={() => { - setAddPool(true); + history.push( + `/namespaces/${tenant?.namespace || ""}/tenants/${ + tenant?.name || "" + }/add-pool` + ); }} icon={} color="primary" diff --git a/portal-ui/src/screens/Console/Tenants/TenantDetails/TenantDetails.tsx b/portal-ui/src/screens/Console/Tenants/TenantDetails/TenantDetails.tsx index 9fa446411..80f3160bd 100644 --- a/portal-ui/src/screens/Console/Tenants/TenantDetails/TenantDetails.tsx +++ b/portal-ui/src/screens/Console/Tenants/TenantDetails/TenantDetails.tsx @@ -174,7 +174,6 @@ const TenantDetails = ({ match, history, loadingTenant, - currentTab, selectedTenant, tenantInfo, selectedNamespace, @@ -183,7 +182,6 @@ const TenantDetails = ({ setTenantDetailsLoad, setTenantName, setTenantInfo, - setTenantTab, }: ITenantDetailsProps) => { const [yamlScreenOpen, setYamlScreenOpen] = useState(false); @@ -589,7 +587,6 @@ const TenantDetails = ({ const mapState = (state: AppState) => ({ loadingTenant: state.tenants.tenantDetails.loadingTenant, - currentTab: state.tenants.tenantDetails.currentTab, selectedTenant: state.tenants.tenantDetails.currentTenant, selectedNamespace: state.tenants.tenantDetails.currentNamespace, tenantInfo: state.tenants.tenantDetails.tenantInfo, @@ -601,7 +598,6 @@ const connector = connect(mapState, { setTenantDetailsLoad, setTenantName, setTenantInfo, - setTenantTab, }); export default withStyles(styles)(connector(TenantDetails)); diff --git a/portal-ui/src/screens/Console/Tenants/actions.ts b/portal-ui/src/screens/Console/Tenants/actions.ts index 881ea8288..0a344c311 100644 --- a/portal-ui/src/screens/Console/Tenants/actions.ts +++ b/portal-ui/src/screens/Console/Tenants/actions.ts @@ -39,15 +39,24 @@ import { ADD_TENANT_SET_STORAGE_CLASSES_LIST, ADD_TENANT_SET_STORAGE_TYPE, ADD_TENANT_UPDATE_FIELD, + ADD_TENANT_SET_KEY_PAIR_VALUE, + ADD_TENANT_SET_TOLERATION_VALUE, + ADD_TENANT_ADD_NEW_TOLERATION, + ADD_TENANT_REMOVE_TOLERATION_ROW, TENANT_DETAILS_SET_CURRENT_TENANT, TENANT_DETAILS_SET_LOADING, TENANT_DETAILS_SET_TAB, TENANT_DETAILS_SET_TENANT, - ADD_TENANT_SET_KEY_PAIR_VALUE, - ADD_TENANT_SET_TOLERATION_VALUE, - ADD_TENANT_ADD_NEW_TOLERATION, LabelKeyPair, - ADD_TENANT_REMOVE_TOLERATION_ROW, + ADD_POOL_SET_LOADING, + ADD_POOL_RESET_FORM, + ADD_POOL_SET_VALUE, + IAddPoolFields, + ADD_POOL_SET_PAGE_VALID, + ADD_POOL_SET_POOL_STORAGE_CLASSES, + ADD_POOL_SET_TOLERATION_VALUE, + ADD_POOL_ADD_NEW_TOLERATION, + ADD_POOL_REMOVE_TOLERATION_ROW, ADD_POOL_SET_KEY_PAIR_VALUE, } from "./types"; import { ITolerationModel } from "../../../common/types"; @@ -323,3 +332,77 @@ export const removeToleration = (index: number) => { index, }; }; + +// Add Pool + +export const setPoolLoading = (state: boolean) => { + return { + type: ADD_POOL_SET_LOADING, + state, + }; +}; + +export const resetPoolForm = () => { + return { + type: ADD_POOL_RESET_FORM, + }; +}; + +export const setPoolField = ( + page: keyof IAddPoolFields, + field: string, + value: any +) => { + return { + type: ADD_POOL_SET_VALUE, + page, + field, + value, + }; +}; + +export const isPoolPageValid = (page: string, status: boolean) => { + return { + type: ADD_POOL_SET_PAGE_VALID, + page, + status, + }; +}; + +export const setPoolStorageClasses = (storageClasses: Opts[]) => { + return { + type: ADD_POOL_SET_POOL_STORAGE_CLASSES, + storageClasses, + }; +}; + +export const setPoolTolerationInfo = ( + index: number, + tolerationValue: ITolerationModel +) => { + return { + type: ADD_POOL_SET_TOLERATION_VALUE, + index, + toleration: tolerationValue, + }; +}; + +export const addNewPoolToleration = () => { + return { + type: ADD_POOL_ADD_NEW_TOLERATION, + }; +}; + +export const removePoolToleration = (index: number) => { + return { + type: ADD_POOL_REMOVE_TOLERATION_ROW, + index, + }; +}; + +export const setPoolKeyValuePairs = (newArray: LabelKeyPair[]) => { + return { + type: ADD_POOL_SET_KEY_PAIR_VALUE, + newArray, + }; +}; diff --git a/portal-ui/src/screens/Console/Tenants/reducer.ts b/portal-ui/src/screens/Console/Tenants/reducer.ts index 6eb885e02..3f1321629 100644 --- a/portal-ui/src/screens/Console/Tenants/reducer.ts +++ b/portal-ui/src/screens/Console/Tenants/reducer.ts @@ -42,12 +42,21 @@ import { ADD_TENANT_SET_STORAGE_TYPE, ADD_TENANT_SET_TOLERATION_VALUE, ADD_TENANT_UPDATE_FIELD, - ITenantState, TENANT_DETAILS_SET_CURRENT_TENANT, TENANT_DETAILS_SET_LOADING, TENANT_DETAILS_SET_TAB, TENANT_DETAILS_SET_TENANT, + ADD_POOL_SET_LOADING, + ADD_POOL_SET_VALUE, + ADD_POOL_RESET_FORM, + ITenantState, TenantsManagementTypes, + ADD_POOL_SET_PAGE_VALID, + ADD_POOL_SET_POOL_STORAGE_CLASSES, + ADD_POOL_ADD_NEW_TOLERATION, + ADD_POOL_SET_TOLERATION_VALUE, + ADD_POOL_REMOVE_TOLERATION_ROW, + ADD_POOL_SET_KEY_PAIR_VALUE, } from "./types"; import { KeyPair } from "./ListTenants/utils"; import { getRandomString } from "./utils"; @@ -355,6 +364,44 @@ const initialState: ITenantState = { tenantInfo: null, currentTab: "summary", }, + addPool: { + addPoolLoading: false, + validPages: ["affinity", "configure"], + storageClasses: [], + limitSize: {}, + fields: { + setup: { + numberOfNodes: 0, + storageClass: "", + volumeSize: 0, + volumesPerServer: 0, + }, + affinity: { + nodeSelectorLabels: "", + podAffinity: "default", + withPodAntiAffinity: true, + }, + configuration: { + securityContextEnabled: false, + securityContext: { + runAsUser: "1000", + runAsGroup: "1000", + fsGroup: "1000", + runAsNonRoot: true, + }, + }, + nodeSelectorPairs: [{ key: "", value: "" }], + tolerations: [ + { + key: "", + tolerationSeconds: { seconds: 0 }, + value: "", + effect: ITolerationEffect.NoSchedule, + operator: ITolerationOperator.Equal, + }, + ], + }, + }, }; export function tenantsReducer( @@ -995,7 +1042,7 @@ export function tenantsReducer( const cleanTolerationArray = state.createTenant.tolerations.filter( (_, index) => index !== action.index ); - + return { ...state, createTenant: { @@ -1003,7 +1050,161 @@ export function tenantsReducer( tolerations: [...cleanTolerationArray], }, }; + case ADD_POOL_SET_LOADING: + return { + ...state, + addPool: { + ...state.addPool, + addPoolLoading: action.state, + }, + }; + case ADD_POOL_SET_VALUE: + if (has(newState.addPool.fields, `${action.page}.${action.field}`)) { + const originPageNameItems = get( + newState.addPool.fields, + `${action.page}`, + {} + ); + let newValue: any = {}; + newValue[action.field] = action.value; + + const joinValue = { ...originPageNameItems, ...newValue }; + + newState.addPool.fields[action.page] = { ...joinValue }; + + return { ...newState }; + } + + return state; + case ADD_POOL_SET_PAGE_VALID: + const nvPoolPV = [...state.addPool.validPages]; + + if (action.status) { + if (!nvPoolPV.includes(action.page)) { + nvPoolPV.push(action.page); + + newState.addPool.validPages = [...nvPoolPV]; + } + } else { + const newSetOfPages = nvPoolPV.filter((elm) => elm !== action.page); + + newState.addPool.validPages = [...newSetOfPages]; + } + + return { ...newState }; + case ADD_POOL_SET_POOL_STORAGE_CLASSES: + return { + ...newState, + addPool: { + ...newState.addPool, + storageClasses: action.storageClasses, + }, + }; + case ADD_POOL_SET_TOLERATION_VALUE: + const newPoolTolerationValue = [...state.addPool.fields.tolerations]; + + newPoolTolerationValue[action.index] = action.toleration; + + return { + ...state, + addPool: { + ...state.addPool, + fields: { + ...state.addPool.fields, + tolerations: [...newPoolTolerationValue], + }, + }, + }; + case ADD_POOL_ADD_NEW_TOLERATION: + const newPoolTolerationArray = [ + ...state.addPool.fields.tolerations, + { + key: "", + tolerationSeconds: { seconds: 0 }, + value: "", + effect: ITolerationEffect.NoSchedule, + operator: ITolerationOperator.Equal, + }, + ]; + return { + ...state, + addPool: { + ...state.addPool, + fields: { + ...state.addPool.fields, + tolerations: [...newPoolTolerationArray], + }, + }, + }; + case ADD_POOL_REMOVE_TOLERATION_ROW: + const cleanPoolTolerationArray = state.addPool.fields.tolerations.filter( + (_, index) => index !== action.index + ); + + return { + ...state, + addPool: { + ...state.addPool, + fields: { + ...state.addPool.fields, + tolerations: [...cleanPoolTolerationArray], + }, + }, + }; + case ADD_POOL_SET_KEY_PAIR_VALUE: + return { + ...state, + addPool: { + ...state.addPool, + fields: { + ...state.addPool.fields, + nodeSelectorPairs: action.newArray, + }, + }, + }; + case ADD_POOL_RESET_FORM: + return { + ...state, + addPool: { + addPoolLoading: false, + validPages: ["affinity", "configure"], + storageClasses: [], + limitSize: {}, + fields: { + setup: { + numberOfNodes: 0, + storageClass: "", + volumeSize: 0, + volumesPerServer: 0, + }, + affinity: { + nodeSelectorLabels: "", + podAffinity: "default", + withPodAntiAffinity: true, + }, + configuration: { + securityContextEnabled: false, + securityContext: { + runAsUser: "1000", + runAsGroup: "1000", + fsGroup: "1000", + runAsNonRoot: true, + }, + }, + nodeSelectorPairs: [{ key: "", value: "" }], + tolerations: [ + { + key: "", + tolerationSeconds: { seconds: 0 }, + value: "", + effect: ITolerationEffect.NoSchedule, + operator: ITolerationOperator.Equal, + }, + ], + }, + }, + }; default: return state; } diff --git a/portal-ui/src/screens/Console/Tenants/types.ts b/portal-ui/src/screens/Console/Tenants/types.ts index e7fa1b9a2..b605fe2a6 100644 --- a/portal-ui/src/screens/Console/Tenants/types.ts +++ b/portal-ui/src/screens/Console/Tenants/types.ts @@ -84,6 +84,20 @@ export const TENANT_DETAILS_SET_CURRENT_TENANT = export const TENANT_DETAILS_SET_TENANT = "TENANT_DETAILS/SET_TENANT"; export const TENANT_DETAILS_SET_TAB = "TENANT_DETAILS/SET_TAB"; +// Add Pool +export const ADD_POOL_SET_POOL_STORAGE_CLASSES = + "ADD_POOL/SET_POOL_STORAGE_CLASSES"; +export const ADD_POOL_SET_PAGE_VALID = "ADD_POOL/SET_PAGE_VALID"; +export const ADD_POOL_SET_VALUE = "ADD_POOL/SET_VALUE"; +export const ADD_POOL_SET_LOADING = "ADD_POOL/SET_LOADING"; +export const ADD_POOL_RESET_FORM = "ADD_POOL/RESET_FORM"; +export const ADD_POOL_SET_KEY_PAIR_VALUE = "ADD_POOL/SET_KEY_PAIR_VALUE"; + +// Pool Tolerations +export const ADD_POOL_SET_TOLERATION_VALUE = "ADD_POOL/SET_TOLERATION_VALUE"; +export const ADD_POOL_ADD_NEW_TOLERATION = "ADD_POOL/ADD_NEW_TOLERATION"; +export const ADD_POOL_REMOVE_TOLERATION_ROW = "ADD_POOL/REMOVE_TOLERATION_ROW"; + export interface ICertificateInfo { name: string; serialNumber: string; @@ -355,6 +369,7 @@ export interface ITenantDetails { export interface ITenantState { createTenant: ICreateTenant; tenantDetails: ITenantDetails; + addPool: IAddPool; } export interface ILabelKeyPair { @@ -374,6 +389,34 @@ export interface NodeMaxAllocatableResources { max_allocatable_mem: number; } +export interface IAddPoolSetup { + numberOfNodes: number; + volumeSize: number; + volumesPerServer: number; + storageClass: string; +} + +export interface IPoolConfiguration { + securityContextEnabled: boolean; + securityContext: ISecurityContext; +} + +export interface IAddPoolFields { + setup: IAddPoolSetup; + affinity: ITenantAffinity; + configuration: IPoolConfiguration; + tolerations: ITolerationModel[]; + nodeSelectorPairs: LabelKeyPair[]; +} + +export interface IAddPool { + addPoolLoading: boolean; + validPages: string[]; + storageClasses: Opts[]; + limitSize: any; + fields: IAddPoolFields; +} + interface SetTenantWizardPage { type: typeof ADD_TENANT_SET_CURRENT_PAGE; page: number; @@ -545,6 +588,53 @@ interface RemoveTolerationRow { index: number; } +interface SetPoolLoading { + type: typeof ADD_POOL_SET_LOADING; + state: boolean; +} + +interface ResetPoolForm { + type: typeof ADD_POOL_RESET_FORM; +} + +interface SetFieldValue { + type: typeof ADD_POOL_SET_VALUE; + page: keyof IAddPoolFields; + field: string; + value: any; +} + +interface SetPoolPageValid { + type: typeof ADD_POOL_SET_PAGE_VALID; + page: string; + status: boolean; +} + +interface SetPoolStorageClasses { + type: typeof ADD_POOL_SET_POOL_STORAGE_CLASSES; + storageClasses: Opts[]; +} + +interface SetPoolTolerationValue { + type: typeof ADD_POOL_SET_TOLERATION_VALUE; + index: number; + toleration: ITolerationModel; +} + +interface AddNewPoolToleration { + type: typeof ADD_POOL_ADD_NEW_TOLERATION; +} + +interface RemovePoolTolerationRow { + type: typeof ADD_POOL_REMOVE_TOLERATION_ROW; + index: number; +} + +interface SetPoolSelectorKeyPairValueArray { + type: typeof ADD_POOL_SET_KEY_PAIR_VALUE; + newArray: LabelKeyPair[]; +} + export type FieldsToHandle = INameTenantFields; export type TenantsManagementTypes = @@ -577,4 +667,13 @@ export type TenantsManagementTypes = | SetTenantTab | SetTolerationValue | AddNewToleration - | RemoveTolerationRow; + | RemoveTolerationRow + | SetPoolLoading + | ResetPoolForm + | SetFieldValue + | SetPoolPageValid + | SetPoolStorageClasses + | SetPoolTolerationValue + | AddNewPoolToleration + | RemovePoolTolerationRow + | SetPoolSelectorKeyPairValueArray;