From 613b1e376f96e8c99b70e613804fca1bf7731e68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alfonso=20Mart=C3=ADnez?= Date: Wed, 8 Nov 2023 16:16:09 +0100 Subject: [PATCH] Create StorageSystem: add perfomance mode selector --- cypress/support.ts | 7 +- locales/en/plugin__odf-console.json | 31 ++- .../odf/components/actions/csv-actions.ts | 33 ++- .../bucket-class/wizard-pages/review-page.tsx | 3 +- .../create-storage-system/create-steps.tsx | 1 + .../capacity-and-nodes-step.tsx | 49 +++- .../configure-performance.scss | 3 + .../configure-performance.spec.tsx | 111 ++++++++ .../configure-performance.tsx | 184 +++++++++++++ .../review-and-create-step.tsx | 5 + .../security-and-network-step/encryption.tsx | 6 +- .../create-storage-system.tsx | 6 +- .../create-storage-system/footer.tsx | 18 +- .../create-storage-system/payloads.ts | 14 +- .../create-storage-system/reducer.ts | 10 + .../select-nodes-table-footer.tsx | 2 +- .../select-nodes-table.spec.tsx | 93 +++++++ .../select-nodes-table/select-nodes-table.tsx | 17 +- .../system-list/odf-system-list.tsx | 10 + .../utils/common-odf-install-el.tsx | 40 +-- packages/odf/components/utils/common.ts | 36 ++- packages/odf/constants/common.ts | 24 ++ packages/odf/constants/tooltips.tsx | 30 +++ .../configure-performance-modal.scss | 3 + .../configure-performance-modal.tsx | 245 ++++++++++++++++++ packages/odf/types/common.ts | 40 +-- packages/odf/utils/ocs.ts | 24 +- packages/shared/src/types/storage.ts | 2 + 28 files changed, 943 insertions(+), 104 deletions(-) create mode 100644 packages/odf/components/create-storage-system/create-storage-system-steps/capacity-and-nodes-step/configure-performance.scss create mode 100644 packages/odf/components/create-storage-system/create-storage-system-steps/capacity-and-nodes-step/configure-performance.spec.tsx create mode 100644 packages/odf/components/create-storage-system/create-storage-system-steps/capacity-and-nodes-step/configure-performance.tsx create mode 100644 packages/odf/components/create-storage-system/select-nodes-table/select-nodes-table.spec.tsx create mode 100644 packages/odf/modals/configure-performance/configure-performance-modal.scss create mode 100644 packages/odf/modals/configure-performance/configure-performance-modal.tsx diff --git a/cypress/support.ts b/cypress/support.ts index 00744bd11..6c8ad3de4 100644 --- a/cypress/support.ts +++ b/cypress/support.ts @@ -47,7 +47,12 @@ Cypress.Commands.add('install', () => { 'be.visible' ); cy.get('button').contains('Next').click(); - cy.get('input[type="checkbox"]').first().uncheck(); + // @TODO: Do we still want to uncheck the already unchecked 'Taint nodes' checkbox? + // If yes, we should scroll down (needed after adding the performance profile selection) + // and then scroll up again to still be able to select nodes + // (or put this action after nodes' selection). + //cy.get('input[type="checkbox"]').first().uncheck(); + cy.get('table').get('input[type="checkbox"]').first().check(); cy.get('button').contains('Next').click(); cy.get('button').contains('Next').click(); diff --git a/locales/en/plugin__odf-console.json b/locales/en/plugin__odf-console.json index 210f953ea..a8ce49cd9 100644 --- a/locales/en/plugin__odf-console.json +++ b/locales/en/plugin__odf-console.json @@ -669,6 +669,13 @@ "Available raw capacity": "Available raw capacity", "The available capacity is based on all attached disks associated with the selected StorageClass <3>{{storageClassName}}": "The available capacity is based on all attached disks associated with the selected StorageClass <3>{{storageClassName}}", "Selected nodes": "Selected nodes", + "Configure performance": "Configure performance", + "CPUs required": "CPUs required", + "CPUs": "CPUs", + "Memory required": "Memory required", + "GiB": "GiB", + "Customize your Data Foundation cluster's performance by selecting a performance profile that meets your specific needs.": "Customize your Data Foundation cluster's performance by selecting a performance profile that meets your specific needs.", + "Select a performance mode from the list": "Select a performance mode from the list", "Role": "Role", "CPU": "CPU", "Memory": "Memory", @@ -745,10 +752,11 @@ "Capacity and nodes": "Capacity and nodes", "Cluster capacity: {{capacity}}": "Cluster capacity: {{capacity}}", "Selected nodes: {{nodeCount, number}} node_one": "Selected nodes: {{nodeCount, number}} node", - "Selected nodes: {{nodeCount, number}} node_other": "Selected nodes: {{nodeCount, number}} node", + "Selected nodes: {{nodeCount, number}} node_other": "Selected nodes: {{nodeCount, number}} nodes", "CPU and memory: {{cpu, number}} CPU and {{memory}} memory": "CPU and memory: {{cpu, number}} CPU and {{memory}} memory", + "Performance profile: {{resourceProfile}}": "Performance profile: {{resourceProfile}}", "Zone: {{zoneCount, number}} zone_one": "Zone: {{zoneCount, number}} zone", - "Zone: {{zoneCount, number}} zone_other": "Zone: {{zoneCount, number}} zone", + "Zone: {{zoneCount, number}} zone_other": "Zone: {{zoneCount, number}} zones", "Arbiter zone: {{zone}}": "Arbiter zone: {{zone}}", "Taint nodes: {{ocsTaintsStatus}}": "Taint nodes: {{ocsTaintsStatus}}", "Replica-1 pool: {{singleReplicaPoolStatus}}": "Replica-1 pool: {{singleReplicaPoolStatus}}", @@ -794,10 +802,10 @@ "Create StorageSystem": "Create StorageSystem", "Create a StorageSystem to represent your Data Foundation system and all its required storage and computing resources.": "Create a StorageSystem to represent your Data Foundation system and all its required storage and computing resources.", "{{nodeCount, number}} node_one": "{{nodeCount, number}} node", - "{{nodeCount, number}} node_other": "{{nodeCount, number}} node", - "selected ({{cpu}} CPU and {{memory}} on ": "selected ({{cpu}} CPU and {{memory}} on ", + "{{nodeCount, number}} node_other": "{{nodeCount, number}} nodes", + "selected ({{cpu}} CPUs and {{memory}} on ": "selected ({{cpu}} CPUs and {{memory}} on ", "{{zoneCount, number}} zone_one": "{{zoneCount, number}} zone", - "{{zoneCount, number}} zone_other": "{{zoneCount, number}} zone", + "{{zoneCount, number}} zone_other": "{{zoneCount, number}} zones", "Node Table": "Node Table", "Connection name": "Connection name", "An unique name for the key management service within the project. Name must only include alphanumeric characters, \"-\", \"_\" or \".\"": "An unique name for the key management service within the project. Name must only include alphanumeric characters, \"-\", \"_\" or \".\"", @@ -946,8 +954,9 @@ "No StorageCluster found": "No StorageCluster found", "Set up a storage cluster to view the topology": "Set up a storage cluster to view the topology", "A minimal cluster deployment will be performed.": "A minimal cluster deployment will be performed.", - "The selected nodes do not match Data Foundation's StorageCluster requirement of an aggregated 30 CPUs and 72 GiB of RAM. If the selection cannot be modified a minimal cluster will be deployed.": "The selected nodes do not match Data Foundation's StorageCluster requirement of an aggregated 30 CPUs and 72 GiB of RAM. If the selection cannot be modified a minimal cluster will be deployed.", "Back to nodes selection": "Back to nodes selection", + "Aggregate resource requirements for the selected performance profile not met.": "Aggregate resource requirements for the selected performance profile not met.", + "Select nodes with sufficient CPU and memory that meet the specified minimum requirements, and try again, or choose a different performance profile to proceed.": "Select nodes with sufficient CPU and memory that meet the specified minimum requirements, and try again, or choose a different performance profile to proceed.", "Select a StorageClass to continue": "Select a StorageClass to continue", "This is a required field. The StorageClass will be used to request storage from the underlying infrastructure to create the backing PersistentVolumes that will be used to provide the Data Foundation service.": "This is a required field. The StorageClass will be used to request storage from the underlying infrastructure to create the backing PersistentVolumes that will be used to provide the Data Foundation service.", "Create new StorageClass": "Create new StorageClass", @@ -982,6 +991,8 @@ "If you wish to use the Arbiter stretch cluster, a minimum of 4 nodes (2 different zones, 2 nodes per zone) and 1 additional zone with 1 node is required. All nodes must be pre-labeled with zones in order to be validated on cluster creation.": "If you wish to use the Arbiter stretch cluster, a minimum of 4 nodes (2 different zones, 2 nodes per zone) and 1 additional zone with 1 node is required. All nodes must be pre-labeled with zones in order to be validated on cluster creation.", "Selected nodes are based on the StorageClass <1>{{scName}} and with a recommended requirement of 14 CPU and 34 GiB RAM per node.": "Selected nodes are based on the StorageClass <1>{{scName}} and with a recommended requirement of 14 CPU and 34 GiB RAM per node.", "Selected nodes are based on the StorageClass <1>{{scName}} and fulfill the stretch cluster requirements with a recommended requirement of 14 CPU and 34 GiB RAM per node.": "Selected nodes are based on the StorageClass <1>{{scName}} and fulfill the stretch cluster requirements with a recommended requirement of 14 CPU and 34 GiB RAM per node.", + "<0>Performance profiles:<1><0>Balanced mode: Optimized for a well-rounded blend of CPU and memory resources to support diverse workloads.<2><0>Lean mode: Minimizes resource consumption by allocating fewer CPUs and less memory for resource-efficient operations.<3><0>Performance mode: Tailored for high-performance, allocating ample CPUs and memory to ensure optimal execution of demanding workloads.": "<0>Performance profiles:<1><0>Balanced mode: Optimized for a well-rounded blend of CPU and memory resources to support diverse workloads.<2><0>Lean mode: Minimizes resource consumption by allocating fewer CPUs and less memory for resource-efficient operations.<3><0>Performance mode: Tailored for high-performance, allocating ample CPUs and memory to ensure optimal execution of demanding workloads.", + "The number of CPUs and memory resources needed to optimize your Data Foundation cluster for enhanced performance is determined by taking into account the cluster's specific environment, size and various other factors.": "The number of CPUs and memory resources needed to optimize your Data Foundation cluster for enhanced performance is determined by taking into account the cluster's specific environment, size and various other factors.", "Backing Store": "Backing Store", "Bucket Class": "Bucket Class", "Namespace Store": "Namespace Store", @@ -1019,6 +1030,10 @@ "Attach OBC to a Deployment": "Attach OBC to a Deployment", "Deployment Name": "Deployment Name", "Attach": "Attach", + "and": "and", + "GiB RAM": "GiB RAM", + "Configure Performance": "Configure Performance", + "Save changes": "Save changes", "hr": "hr", "min": "min", "Select at least 2 Backing Store resources": "Select at least 2 Backing Store resources", @@ -1139,7 +1154,6 @@ "Receive bandwidth": "Receive bandwidth", "Node details": "Node details", "Instance type": "Instance type", - "Rack": "Rack", "External ID": "External ID", "Node addresses": "Node addresses", "Machine": "Machine", @@ -1186,5 +1200,6 @@ "Cannot change resource name (original: \"{{name}}\", updated: \"{{newName}}\").": "Cannot change resource name (original: \"{{name}}\", updated: \"{{newName}}\").", "Cannot change resource namespace (original: \"{{namespace}}\", updated: \"{{newNamespace}}\").": "Cannot change resource namespace (original: \"{{namespace}}\", updated: \"{{newNamespace}}\").", "Cannot change resource kind (original: \"{{original}}\", updated: \"{{updated}}\").": "Cannot change resource kind (original: \"{{original}}\", updated: \"{{updated}}\").", - "Cannot change API group (original: \"{{apiGroup}}\", updated: \"{{newAPIGroup}}\").": "Cannot change API group (original: \"{{apiGroup}}\", updated: \"{{newAPIGroup}}\")." + "Cannot change API group (original: \"{{apiGroup}}\", updated: \"{{newAPIGroup}}\").": "Cannot change API group (original: \"{{apiGroup}}\", updated: \"{{newAPIGroup}}\").", + "Rack": "Rack" } diff --git a/packages/odf/components/actions/csv-actions.ts b/packages/odf/components/actions/csv-actions.ts index 5f23c29d6..16162dfde 100644 --- a/packages/odf/components/actions/csv-actions.ts +++ b/packages/odf/components/actions/csv-actions.ts @@ -1,4 +1,6 @@ import { useMemo } from 'react'; +import AddSSCapacityModal from '@odf/core/modals/add-capacity/add-capacity-modal'; +import ConfigureSSPerformanceModal from '@odf/core/modals/configure-performance/configure-performance-modal'; import { ODFStorageSystem } from '@odf/shared/models'; import { StorageSystemKind } from '@odf/shared/types'; import { @@ -14,7 +16,6 @@ import { useModal, } from '@openshift-console/dynamic-plugin-sdk'; import { LaunchModal } from '@openshift-console/dynamic-plugin-sdk/lib/app/modal-support/ModalProvider'; -import AddSSCapacityModal from '../../modals/add-capacity/add-capacity-modal'; export const useCsvActions = ({ resource, @@ -31,7 +32,16 @@ export const useCsvActions = ({ () => referenceForModel(k8sModel) === referenceForModel(ODFStorageSystem) && isOCSStorageSystem(resource as StorageSystemKind) - ? [AddCapacityStorageSystem(resource as StorageSystemKind, launchModal)] + ? [ + AddCapacityStorageSystem( + resource as StorageSystemKind, + launchModal + ), + ConfigurePerformanceStorageSystem( + resource as StorageSystemKind, + launchModal + ), + ] : [], [k8sModel, resource, launchModal] @@ -40,7 +50,7 @@ export const useCsvActions = ({ return useMemo(() => [actions, !inFlight, undefined], [actions, inFlight]); }; -export const AddCapacityStorageSystem = ( +const AddCapacityStorageSystem = ( resource: StorageSystemKind, launchModal: LaunchModal ): Action => { @@ -56,3 +66,20 @@ export const AddCapacityStorageSystem = ( }, }; }; + +const ConfigurePerformanceStorageSystem = ( + resource: StorageSystemKind, + launchModal: LaunchModal +): Action => { + return { + id: 'configure-performance-storage-system', + label: 'Configure performance', + insertAfter: 'add-capacity-storage-system', + cta: () => { + launchModal(ConfigureSSPerformanceModal as any, { + extraProps: { resource }, + isOpen: true, + }); + }, + }; +}; diff --git a/packages/odf/components/bucket-class/wizard-pages/review-page.tsx b/packages/odf/components/bucket-class/wizard-pages/review-page.tsx index d5b9505c2..b38969b2a 100644 --- a/packages/odf/components/bucket-class/wizard-pages/review-page.tsx +++ b/packages/odf/components/bucket-class/wizard-pages/review-page.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import { BucketClassType, ValidationType } from '@odf/core/types'; import { LoadingInline } from '@odf/shared/generic/Loading'; import { getName } from '@odf/shared/selectors'; import { useCustomTranslation } from '@odf/shared/useCustomTranslationHook'; @@ -20,12 +21,10 @@ import { TextContent, } from '@patternfly/react-core'; import { NamespacePolicyType } from '../../../constants'; -import { BucketClassType } from '../../../types'; import { convertTime, getTimeUnitString } from '../../../utils'; import { VALIDATIONS, ValidationMessage, - ValidationType, } from '../../utils/common-odf-install-el'; import { StoreCard } from '../review-utils'; import { State } from '../state'; diff --git a/packages/odf/components/create-storage-system/create-steps.tsx b/packages/odf/components/create-storage-system/create-steps.tsx index d97d073b4..67e47cbf5 100644 --- a/packages/odf/components/create-storage-system/create-steps.tsx +++ b/packages/odf/components/create-storage-system/create-steps.tsx @@ -53,6 +53,7 @@ export const createSteps = ( storageClass={storageClass} volumeSetName={createLocalVolumeSet.volumeSetName} nodes={nodes} + resourceProfile={capacityAndNodes.resourceProfile} /> ), }, diff --git a/packages/odf/components/create-storage-system/create-storage-system-steps/capacity-and-nodes-step/capacity-and-nodes-step.tsx b/packages/odf/components/create-storage-system/create-storage-system-steps/capacity-and-nodes-step/capacity-and-nodes-step.tsx index 59a9030ac..7ff0838cd 100644 --- a/packages/odf/components/create-storage-system/create-storage-system-steps/capacity-and-nodes-step/capacity-and-nodes-step.tsx +++ b/packages/odf/components/create-storage-system/create-storage-system-steps/capacity-and-nodes-step/capacity-and-nodes-step.tsx @@ -20,7 +20,7 @@ import { attachDevicesWithArbiter, } from '@odf/core/constants'; import { pvResource, nodeResource } from '@odf/core/resources'; -import { NodesPerZoneMap } from '@odf/core/types'; +import { NodesPerZoneMap, ResourceProfile } from '@odf/core/types'; import { getSCAvailablePVs, getAssociatedNodes } from '@odf/core/utils'; import { calcPVsCapacity } from '@odf/core/utils'; import { FieldLevelHelp } from '@odf/shared/generic/FieldLevelHelp'; @@ -29,6 +29,7 @@ import { K8sResourceKind, NodeKind } from '@odf/shared/types'; import { useCustomTranslation } from '@odf/shared/useCustomTranslationHook'; import { humanizeBinaryBytes } from '@odf/shared/utils'; import { useK8sWatchResource } from '@openshift-console/dynamic-plugin-sdk'; +import * as _ from 'lodash-es'; import { Trans } from 'react-i18next'; import { Checkbox, @@ -47,10 +48,23 @@ import { ValidationMessage } from '../../../utils/common-odf-install-el'; import { ErrorHandler } from '../../error-handler'; import { WizardDispatch, WizardNodeState, WizardState } from '../../reducer'; import { SelectNodesTable } from '../../select-nodes-table/select-nodes-table'; +import ConfigurePerformance, { + PerformanceHeaderText, + ProfileRequirementsText, +} from './configure-performance'; import { SelectedNodesTable } from './selected-nodes-table'; import { StretchCluster } from './stretch-cluster'; import './capacity-and-nodes.scss'; +const onResourceProfileChange = _.curry( + (dispatch: WizardDispatch, newProfile: ResourceProfile): void => { + dispatch({ + type: 'wizard/setResourceProfile', + payload: newProfile, + }); + } +); + const SelectNodesText: React.FC = React.memo( ({ text }) => { const { t } = useCustomTranslation(); @@ -142,6 +156,7 @@ type SelectCapacityAndNodesProps = { nodes: WizardState['nodes']; enableTaint: WizardState['capacityAndNodes']['enableTaint']; enableSingleReplicaPool: WizardState['capacityAndNodes']['enableSingleReplicaPool']; + resourceProfile: WizardState['capacityAndNodes']['resourceProfile']; }; const SelectCapacityAndNodes: React.FC = ({ @@ -150,6 +165,7 @@ const SelectCapacityAndNodes: React.FC = ({ nodes, enableTaint, enableSingleReplicaPool, + resourceProfile, }) => { const { t } = useCustomTranslation(); @@ -166,6 +182,10 @@ const SelectCapacityAndNodes: React.FC = ({ }, [dispatch] ); + const onProfileChange = React.useCallback( + (profile) => onResourceProfileChange(dispatch)(profile), + [dispatch] + ); const replicas = getReplicasFromSelectedNodes(nodes); @@ -219,6 +239,13 @@ const SelectCapacityAndNodes: React.FC = ({ + = ({ dispatch, nodes, enableSingleReplicaPool, + resourceProfile, }) => { const { t } = useCustomTranslation(); const [pv, pvLoaded, pvLoadError] = @@ -313,6 +341,10 @@ const SelectedCapacityAndNodes: React.FC = ({ }), [dispatch] ); + const onProfileChange = React.useCallback( + (profile) => onResourceProfileChange(dispatch)(profile), + [dispatch] + ); return ( = ({ + = ({ @@ -408,6 +448,7 @@ export const CapacityAndNodes: React.FC = ({ storageClass, volumeSetName, nodes, + resourceProfile, }) => { const { capacity, @@ -421,7 +462,8 @@ export const CapacityAndNodes: React.FC = ({ const validations = capacityAndNodesValidate( nodes, enableArbiter, - isNoProvisioner + isNoProvisioner, + resourceProfile ); return ( @@ -436,6 +478,7 @@ export const CapacityAndNodes: React.FC = ({ nodes={nodes} capacity={capacity} enableSingleReplicaPool={enableSingleReplicaPool} + resourceProfile={resourceProfile} /> ) : ( = ({ capacity={capacity} nodes={nodes} enableSingleReplicaPool={enableSingleReplicaPool} + resourceProfile={resourceProfile} /> )} {!!validations.length && @@ -459,6 +503,7 @@ type CapacityAndNodesProps = { state: WizardState['capacityAndNodes']; storageClass: WizardState['storageClass']; nodes: WizardState['nodes']; + resourceProfile: WizardState['capacityAndNodes']['resourceProfile']; volumeSetName: WizardState['createLocalVolumeSet']['volumeSetName']; dispatch: WizardDispatch; }; diff --git a/packages/odf/components/create-storage-system/create-storage-system-steps/capacity-and-nodes-step/configure-performance.scss b/packages/odf/components/create-storage-system/create-storage-system-steps/capacity-and-nodes-step/configure-performance.scss new file mode 100644 index 000000000..16134fa88 --- /dev/null +++ b/packages/odf/components/create-storage-system/create-storage-system-steps/capacity-and-nodes-step/configure-performance.scss @@ -0,0 +1,3 @@ +.odf-configure-performance__selector { + width: 18rem; +} diff --git a/packages/odf/components/create-storage-system/create-storage-system-steps/capacity-and-nodes-step/configure-performance.spec.tsx b/packages/odf/components/create-storage-system/create-storage-system-steps/capacity-and-nodes-step/configure-performance.spec.tsx new file mode 100644 index 000000000..8db4cd72a --- /dev/null +++ b/packages/odf/components/create-storage-system/create-storage-system-steps/capacity-and-nodes-step/configure-performance.spec.tsx @@ -0,0 +1,111 @@ +import * as React from 'react'; +import { createWizardNodeState } from '@odf/core/components/utils'; +import { ResourceProfile } from '@odf/core/types'; +import { NodeKind } from '@odf/shared/types'; +import { useK8sWatchResource } from '@openshift-console/dynamic-plugin-sdk/lib/api/dynamic-core-api'; +import { render, screen } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import ConfigurePerformance from './configure-performance'; + +jest.mock( + '@openshift-console/dynamic-plugin-sdk/lib/api/dynamic-core-api', + () => ({ + useK8sWatchResource: jest.fn(), + }) +); +const onResourceProfileChange = jest.fn(); + +const createFakeNodes = ( + amount: number, + cpu: number, + memory: string +): NodeKind[] => + Array.from( + Array(amount), + (): NodeKind => ({ + status: { capacity: { cpu: cpu }, allocatable: { memory: memory } }, + metadata: {}, + }) + ); +const errorIconSelector = '[class$="select__toggle-status-icon"]'; + +describe('Configure Performance', () => { + beforeEach(() => { + onResourceProfileChange.mockClear(); + }); + + it('renders default profile and selects Performance profile', async () => { + const cpu = 12; + const memory = String(32 * 1000 * 1000 * 1000); + const nodes: NodeKind[] = createFakeNodes(3, cpu, memory); + const selectedNodes = createWizardNodeState(nodes); + (useK8sWatchResource as jest.Mock).mockReturnValueOnce([nodes, true, null]); + + const user = userEvent.setup(); + const { container } = render( + + ); + + const dropdown = screen.getByRole('button', { + name: /options menu/i, + }); + expect(dropdown).toHaveTextContent('Balanced (default)'); + + const errorIcon = container.querySelector(errorIconSelector); + expect(errorIcon).toBeFalsy(); + + await user.click(dropdown); + const performanceOption = screen.getByRole('option', { + name: /performance cpus required/i, + }); + await user.click(performanceOption); + expect(onResourceProfileChange).toHaveBeenNthCalledWith( + 1, + ResourceProfile.Performance + ); + }); + + it('forces Lean when selectable nodes do not allow higher profiles', () => { + (useK8sWatchResource as jest.Mock).mockReturnValueOnce([[], true, null]); + + render( + + ); + expect(onResourceProfileChange).toHaveBeenNthCalledWith( + 1, + ResourceProfile.Lean + ); + }); + + it('shows error icon in the dropdown when resource requirements are not enough', () => { + const cpu = 12; + const memory = String(32 * 1000 * 1000 * 1000); + const nodes: NodeKind[] = createFakeNodes(3, cpu, memory); + const selectedNodes = createWizardNodeState(nodes); + (useK8sWatchResource as jest.Mock).mockReturnValueOnce([nodes, true, null]); + + const { container } = render( + + ); + const dropdown = screen.getByRole('button', { + name: /options menu/i, + }); + expect(dropdown).toHaveTextContent('Performance'); + + const errorIcon = container.querySelector(errorIconSelector); + expect(errorIcon).toBeVisible(); + expect(onResourceProfileChange).toHaveBeenCalledTimes(0); + }); +}); diff --git a/packages/odf/components/create-storage-system/create-storage-system-steps/capacity-and-nodes-step/configure-performance.tsx b/packages/odf/components/create-storage-system/create-storage-system-steps/capacity-and-nodes-step/configure-performance.tsx new file mode 100644 index 000000000..2db8c13a0 --- /dev/null +++ b/packages/odf/components/create-storage-system/create-storage-system-steps/capacity-and-nodes-step/configure-performance.tsx @@ -0,0 +1,184 @@ +import * as React from 'react'; +import { WizardNodeState } from '@odf/core/components/create-storage-system/reducer'; +import { + createWizardNodeState, + getTotalCpu, + getTotalMemoryInGiB, +} from '@odf/core/components/utils'; +import { + RESOURCE_PROFILE_REQUIREMENTS_MAP, + resourceProfileTooltip, + resourceRequirementsTooltip, +} from '@odf/core/constants'; +import { ResourceProfile } from '@odf/core/types'; +import { isResourceProfileAllowed, nodesWithoutTaints } from '@odf/core/utils'; +import { SingleSelectDropdown } from '@odf/shared/dropdown'; +import { FieldLevelHelp } from '@odf/shared/generic/FieldLevelHelp'; +import { NodeModel } from '@odf/shared/models'; +import { NodeKind } from '@odf/shared/types'; +import { useCustomTranslation } from '@odf/shared/useCustomTranslationHook'; +import { useK8sWatchResource } from '@openshift-console/dynamic-plugin-sdk'; +import { TFunction } from 'i18next'; +import { + SelectOption, + Text, + TextVariants, + TextContent, +} from '@patternfly/react-core'; +import './configure-performance.scss'; + +const selectOptions = (t: TFunction, forceLean: boolean) => + Object.entries(ResourceProfile).map((value: [string, ResourceProfile]) => { + let displayName = value[0]; + let profile = value[1]; + if (profile === ResourceProfile.Balanced) { + displayName = t(`${displayName} (default)`); + } + const { minCpu, minMem } = RESOURCE_PROFILE_REQUIREMENTS_MAP[profile]; + const description = `CPUs required: ${minCpu}, Memory required: ${minMem} GiB`; + const isDisabled = + forceLean && + [ResourceProfile.Balanced, ResourceProfile.Performance].includes(profile); + return ( + + {displayName} + + ); + }); + +export const PerformanceHeaderText: React.FC = () => { + const { t } = useCustomTranslation(); + return ( + + {t('Configure performance')} + {resourceProfileTooltip(t)} + + ); +}; + +type ProfileRequirementsTextProps = { + selectedProfile: ResourceProfile; +}; + +export const ProfileRequirementsText: React.FC = + ({ selectedProfile }) => { + const { t } = useCustomTranslation(); + const { minCpu, minMem } = + RESOURCE_PROFILE_REQUIREMENTS_MAP[selectedProfile]; + return ( + + + + {t(`Aggregate resource requirements for ${selectedProfile} mode`)} + + {selectedProfile === ResourceProfile.Performance && ( + {resourceRequirementsTooltip(t)} + )} + + +
+ + {t('CPUs required')}: + {' '} + + {minCpu} {t('CPUs')} + +
+
+ + {t('Memory required')}: + {' '} + + {minMem} {t('GiB')} + +
+
+
+ ); + }; + +type ConfigurePerformanceProps = { + onResourceProfileChange: (newProfile: ResourceProfile) => void; + resourceProfile: ResourceProfile; + headerText?: React.FC; + profileRequirementsText?: React.FC<{ selectedProfile: ResourceProfile }>; + selectedNodes: WizardNodeState[]; +}; + +const ConfigurePerformance: React.FC = ({ + onResourceProfileChange, + resourceProfile, + headerText: HeaderTextComponent, + profileRequirementsText: ProfileRequirementsTextComponent, + selectedNodes, +}) => { + const { t } = useCustomTranslation(); + const [availableNodes, availableNodesLoaded, availableNodesLoadError] = + useK8sWatchResource({ + kind: NodeModel.kind, + namespaced: false, + isList: true, + }); + + // Force Lean mode when all selectable capacity is not enough for higher profiles. + let forceLean = false; + if (availableNodesLoaded && !availableNodesLoadError) { + const selectableNodes = createWizardNodeState( + nodesWithoutTaints(availableNodes) + ); + const allCpu = getTotalCpu(selectableNodes); + const allMem = getTotalMemoryInGiB(selectableNodes); + if (!isResourceProfileAllowed(ResourceProfile.Balanced, allCpu, allMem)) { + forceLean = true; + } + } + if (forceLean === true && resourceProfile !== ResourceProfile.Lean) { + onResourceProfileChange(ResourceProfile.Lean); + } + + // Set error icon in dropdown when appropriate. + const isProfileAllowed = resourceProfile + ? isResourceProfileAllowed( + resourceProfile, + getTotalCpu(selectedNodes), + getTotalMemoryInGiB(selectedNodes) + ) + : true; + const validated = + selectedNodes.length === 0 || isProfileAllowed ? 'default' : 'error'; + + return ( +
+ + {HeaderTextComponent && } + + {t( + "Customize your Data Foundation cluster's performance by selecting a performance profile that meets your specific needs." + )} + + + + {resourceProfile && ProfileRequirementsTextComponent && ( + + )} +
+ ); +}; + +export default ConfigurePerformance; diff --git a/packages/odf/components/create-storage-system/create-storage-system-steps/review-and-create-step/review-and-create-step.tsx b/packages/odf/components/create-storage-system/create-storage-system-steps/review-and-create-step/review-and-create-step.tsx index 1f450f8ea..74edf71cc 100644 --- a/packages/odf/components/create-storage-system/create-storage-system-steps/review-and-create-step/review-and-create-step.tsx +++ b/packages/odf/components/create-storage-system/create-storage-system-steps/review-and-create-step/review-and-create-step.tsx @@ -170,6 +170,11 @@ export const ReviewAndCreate: React.FC = ({ memory: humanizeBinaryBytes(totalMemory).string, })} + + {t('Performance profile: {{resourceProfile}}', { + resourceProfile: _.capitalize(capacityAndNodes.resourceProfile), + })} + {t('Zone: {{zoneCount, number}} zone', { zoneCount: zones.size, diff --git a/packages/odf/components/create-storage-system/create-storage-system-steps/security-and-network-step/encryption.tsx b/packages/odf/components/create-storage-system/create-storage-system-steps/security-and-network-step/encryption.tsx index d9b7baaee..e8eab0e56 100644 --- a/packages/odf/components/create-storage-system/create-storage-system-steps/security-and-network-step/encryption.tsx +++ b/packages/odf/components/create-storage-system/create-storage-system-steps/security-and-network-step/encryption.tsx @@ -1,14 +1,12 @@ import * as React from 'react'; +import { ValidationType } from '@odf/core/types'; import { AdvancedSubscription } from '@odf/shared/badges/advanced-subscription'; import { FieldLevelHelp } from '@odf/shared/generic/FieldLevelHelp'; import { useCustomTranslation } from '@odf/shared/useCustomTranslationHook'; import { Checkbox, FormGroup, Form } from '@patternfly/react-core'; import { KMSEmptyState } from '../../../../constants'; import { KMSConfigure } from '../../../kms-config/kms-config'; -import { - ValidationMessage, - ValidationType, -} from '../../../utils/common-odf-install-el'; +import { ValidationMessage } from '../../../utils/common-odf-install-el'; import { WizardDispatch, WizardState } from '../../reducer'; import './encryption.scss'; diff --git a/packages/odf/components/create-storage-system/create-storage-system.tsx b/packages/odf/components/create-storage-system/create-storage-system.tsx index 271cec34e..c72851412 100644 --- a/packages/odf/components/create-storage-system/create-storage-system.tsx +++ b/packages/odf/components/create-storage-system/create-storage-system.tsx @@ -38,7 +38,7 @@ const CreateStorageSystem: React.FC = () => { InfrastructureModel, 'cluster' ); - const [extensions, extensionsResloved] = useResolvedExtensions( + const [extensions, extensionsResolved] = useResolvedExtensions( isStorageClassWizardStep ); @@ -49,7 +49,7 @@ const CreateStorageSystem: React.FC = () => { let hasOCS: boolean = false; const supportedExternalStorage: ExternalStorage[] = React.useMemo(() => { - if (extensionsResloved) { + if (extensionsResolved) { return [ ...EXTERNAL_CEPH_STORAGE, ...(extensions?.map( @@ -58,7 +58,7 @@ const CreateStorageSystem: React.FC = () => { ]; } return EXTERNAL_CEPH_STORAGE; - }, [extensions, extensionsResloved]); + }, [extensions, extensionsResolved]); if (ssLoaded && !ssLoadError && infraLoaded && !infraLoadError) { hasOCS = ssList?.items?.some( diff --git a/packages/odf/components/create-storage-system/footer.tsx b/packages/odf/components/create-storage-system/footer.tsx index 428c2c46e..cbf12c594 100644 --- a/packages/odf/components/create-storage-system/footer.tsx +++ b/packages/odf/components/create-storage-system/footer.tsx @@ -22,9 +22,13 @@ import { OCS_INTERNAL_CR_NAME, } from '../../constants'; import { NetworkType, BackingStorageType, DeploymentType } from '../../types'; -import { labelOCSNamespace, getExternalSubSystemName } from '../../utils'; +import { + labelOCSNamespace, + getExternalSubSystemName, + isResourceProfileAllowed, +} from '../../utils'; import { createClusterKmsResources } from '../kms-config/utils'; -import { getExternalStorage } from '../utils'; +import { getExternalStorage, getTotalCpu, getTotalMemoryInGiB } from '../utils'; import { createExternalSubSystem, createNoobaaExternalPostgresResources, @@ -134,7 +138,15 @@ const canJumpToNextStep = ( isValidDeviceType ); case StepsName(t)[Steps.CapacityAndNodes]: - return nodes.length >= MINIMUM_NODES && capacity; + return ( + nodes.length >= MINIMUM_NODES && + capacity && + isResourceProfileAllowed( + capacityAndNodes.resourceProfile, + getTotalCpu(nodes), + getTotalMemoryInGiB(nodes) + ) + ); case StepsName(t)[Steps.SecurityAndNetwork]: if (isExternal && isRHCS) { return canGoToNextStep(connectionDetails, storageClass.name); diff --git a/packages/odf/components/create-storage-system/payloads.ts b/packages/odf/components/create-storage-system/payloads.ts index 49578bcd6..f9c70dc21 100644 --- a/packages/odf/components/create-storage-system/payloads.ts +++ b/packages/odf/components/create-storage-system/payloads.ts @@ -2,7 +2,11 @@ import { getOCSRequestData, capacityAndNodesValidate, } from '@odf/core/components/utils'; -import { DeploymentType, BackingStorageType } from '@odf/core/types'; +import { + DeploymentType, + BackingStorageType, + ValidationType, +} from '@odf/core/types'; import { Payload } from '@odf/odf-plugin-sdk/extensions'; import { SecretModel, getAPIVersion } from '@odf/shared'; import { @@ -28,7 +32,6 @@ import { NO_PROVISIONER, cephStorageLabel, } from '../../constants'; -import { ValidationType } from '../utils/common-odf-install-el'; import { WizardNodeState, WizardState } from './reducer'; export const createStorageSystem = async ( @@ -176,11 +179,10 @@ export const createStorageCluster = async ( const validations = capacityAndNodesValidate( nodes, enableArbiter, - isNoProvisioner + isNoProvisioner, + capacityAndNodes.resourceProfile ); - const isMinimal = validations.includes(ValidationType.MINIMAL); - const isFlexibleScaling = validations.includes( ValidationType.ATTACHED_DEVICES_FLEXIBLE_SCALING ); @@ -198,7 +200,7 @@ export const createStorageCluster = async ( storageClass, storage, encryption, - isMinimal, + resourceProfile: capacityAndNodes.resourceProfile, nodes, flexibleScaling: isFlexibleScaling, publicNetwork, diff --git a/packages/odf/components/create-storage-system/reducer.ts b/packages/odf/components/create-storage-system/reducer.ts index 1f1973766..7cef401c1 100644 --- a/packages/odf/components/create-storage-system/reducer.ts +++ b/packages/odf/components/create-storage-system/reducer.ts @@ -2,6 +2,7 @@ import { ExternalCephState, ExternalCephStateValues, ExternalCephStateKeys, + ResourceProfile, } from '@odf/core/types'; import { ExternalStateValues, @@ -67,6 +68,7 @@ export const initialState: CreateStorageSystemState = { arbiterLocation: '', capacity: null, pvCount: 0, + resourceProfile: ResourceProfile.Balanced, }, createStorageClass: {}, connectionDetails: {}, @@ -142,6 +144,7 @@ type CreateStorageSystemState = { // Requires refactoring osd size dropdown. capacity: string | number; pvCount: number; + resourceProfile: ResourceProfile; }; securityAndNetwork: { encryption: EncryptionType; @@ -293,6 +296,9 @@ export const reducer: WizardReducer = (prevState, action) => { [action.payload.field]: action.payload.value, }; break; + case 'wizard/setResourceProfile': + newState.capacityAndNodes.resourceProfile = action.payload; + break; case 'backingStorage/setType': return setBackingStorageType(newState, action.payload); case 'backingStorage/enableNFS': @@ -419,6 +425,10 @@ export type CreateStorageSystemAction = value: LocalVolumeSet[keyof LocalVolumeSet]; }; } + | { + type: 'wizard/setResourceProfile'; + payload: WizardState['capacityAndNodes']['resourceProfile']; + } | { type: 'backingStorage/setDeployment'; payload: WizardState['backingStorage']['deployment']; diff --git a/packages/odf/components/create-storage-system/select-nodes-table/select-nodes-table-footer.tsx b/packages/odf/components/create-storage-system/select-nodes-table/select-nodes-table-footer.tsx index 76d74ec3e..4625f7e8e 100644 --- a/packages/odf/components/create-storage-system/select-nodes-table/select-nodes-table-footer.tsx +++ b/packages/odf/components/create-storage-system/select-nodes-table/select-nodes-table-footer.tsx @@ -24,7 +24,7 @@ export const SelectNodesTableFooter: React.FC = nodeCount: nodes.length, count: nodes.length, })}{' '} - {t('selected ({{cpu}} CPU and {{memory}} on ', { + {t('selected ({{cpu}} CPUs and {{memory}} on ', { cpu: totalCpu, memory: humanizeBinaryBytes(totalMemory).string, })} diff --git a/packages/odf/components/create-storage-system/select-nodes-table/select-nodes-table.spec.tsx b/packages/odf/components/create-storage-system/select-nodes-table/select-nodes-table.spec.tsx new file mode 100644 index 000000000..376fab766 --- /dev/null +++ b/packages/odf/components/create-storage-system/select-nodes-table/select-nodes-table.spec.tsx @@ -0,0 +1,93 @@ +import * as React from 'react'; +import { createWizardNodeState } from '@odf/core/components/utils'; +import { NodeKind } from '@odf/shared/types'; +import { + useK8sWatchResource, + useListPageFilter, +} from '@openshift-console/dynamic-plugin-sdk/lib/api/dynamic-core-api'; +import { render, screen } from '@testing-library/react'; +import { createMemoryHistory } from 'history'; +import { Router } from 'react-router-dom'; +import { SelectNodesTable } from './select-nodes-table'; + +jest.mock( + '@openshift-console/dynamic-plugin-sdk/lib/api/dynamic-core-api', + () => ({ + ...jest.requireActual( + '@openshift-console/dynamic-plugin-sdk/lib/api/dynamic-core-api' + ), + useK8sWatchResource: jest.fn(), + useListPageFilter: jest.fn(), + }) +); +jest.mock('@odf/core/redux/selectors/odf-namespace', () => ({ + useODFNamespaceSelector: jest.fn(() => ['openshift-storage', true, null]), +})); +const onRowSelected = jest.fn(); + +const createFakeNodes = ( + amount: number, + cpu: number, + memory: string +): NodeKind[] => + Array.from( + Array(amount), + (): NodeKind => ({ + status: { capacity: { cpu: cpu }, allocatable: { memory: memory } }, + metadata: {}, + }) + ); +const cpu = 12; +const memory = String(32 * 1000 * 1000 * 1000); +const nodes: NodeKind[] = createFakeNodes(3, cpu, memory); +const selectedNodes = createWizardNodeState(nodes); +(useK8sWatchResource as jest.Mock).mockReturnValue([nodes, true, null]); +(useListPageFilter as jest.Mock).mockReturnValue([nodes, nodes, jest.fn()]); + +const history = createMemoryHistory(); +// eslint-disable-next-line no-console +const originalError = console.error.bind(console.error); +let consoleSpy: jest.SpyInstance; + +describe('Select Nodes Table', () => { + beforeAll(() => { + // Ignore error messages coming from ListPageBody (third-party dependency). + consoleSpy = jest.spyOn(console, 'error').mockImplementation((...data) => { + if (!data.toString().includes('ListPageBody.js')) { + originalError(...data); + } + }); + }); + + afterAll(() => consoleSpy.mockRestore()); + + it('shows the table including the Select All checkbox', () => { + render( + + + + ); + + const selectAll = screen.getByRole('checkbox', { + name: /select all rows/i, + }); + expect(selectAll).toBeVisible(); + }); + + it('shows the table without the Select All checkbox', () => { + render( + + + + ); + + const selectAll = screen.queryByRole('checkbox', { + name: /select all rows/i, + }); + expect(selectAll).not.toBeInTheDocument(); + }); +}); diff --git a/packages/odf/components/create-storage-system/select-nodes-table/select-nodes-table.tsx b/packages/odf/components/create-storage-system/select-nodes-table/select-nodes-table.tsx index eed9fde11..794c216f6 100644 --- a/packages/odf/components/create-storage-system/select-nodes-table/select-nodes-table.tsx +++ b/packages/odf/components/create-storage-system/select-nodes-table/select-nodes-table.tsx @@ -49,7 +49,8 @@ const getRows = ( setVisibleRows, selectedNodes, setSelectedNodes, - ns + ns, + disableLabeledNodes ) => { const data = nodesData; const storageLabel = cephStorageLabel(ns); @@ -85,6 +86,7 @@ const getRows = ( ]; return { cells, + disableSelection: disableLabeledNodes && hasLabel(node, storageLabel), selected: selectedNodes ? selectedNodes.has(node.metadata.uid) : hasLabel(node, storageLabel), @@ -119,6 +121,7 @@ const InternalNodeTable: React.FC = ({ nodes, onRowSelected, nodesData, + disableLabeledNodes, }) => { const { t } = useCustomTranslation(); @@ -165,6 +168,10 @@ const InternalNodeTable: React.FC = ({ sortedData: rowsData, } = useSortList(nodesData, getColumns, true); + /* Prevent the deselection of the labeled nodes (when that protection is enabled) + through the "Select/Unselect All" checkbox. */ + const canSelectAll = !disableLabeledNodes; + return (
= ({ setVisibleRows, selectedNodes, setSelectedNodes, - odfNamespace + odfNamespace, + disableLabeledNodes )} cells={getColumns} onSelect={onSelect} onSort={onSort} sortBy={{ index, direction }} + canSelectAll={canSelectAll} > @@ -195,11 +204,13 @@ type NodeTableProps = { nodes: Set; onRowSelected: (selectedNodes: NodeKind[]) => void; nodesData: NodeKind[]; + disableLabeledNodes: boolean; }; export const SelectNodesTable: React.FC = ({ nodes, onRowSelected, + disableLabeledNodes = false, }) => { const [nodesData, nodesLoaded, nodesLoadError] = useK8sWatchResource< NodeKind[] @@ -229,6 +240,7 @@ export const SelectNodesTable: React.FC = ({ nodes={new Set(nodes.map(({ uid }) => uid))} onRowSelected={onRowSelected} nodesData={filteredData as NodeKind[]} + disableLabeledNodes={disableLabeledNodes} /> @@ -240,4 +252,5 @@ export const SelectNodesTable: React.FC = ({ type NodeSelectTableProps = { nodes: WizardNodeState[]; onRowSelected: (selectedNodes: NodeKind[]) => void; + disableLabeledNodes?: boolean; }; diff --git a/packages/odf/components/system-list/odf-system-list.tsx b/packages/odf/components/system-list/odf-system-list.tsx index c0d2e2cc6..30ce4ea6d 100644 --- a/packages/odf/components/system-list/odf-system-list.tsx +++ b/packages/odf/components/system-list/odf-system-list.tsx @@ -295,6 +295,16 @@ const StorageSystemRow: React.FC> = ({ () => import('./../../modals/add-capacity/add-capacity-modal') ), }, + { + key: 'CONFIGURE_PERFORMANCE', + value: t('Configure performance'), + component: React.lazy( + () => + import( + '@odf/core/modals/configure-performance/configure-performance-modal' + ) + ), + }, ]} /> diff --git a/packages/odf/components/utils/common-odf-install-el.tsx b/packages/odf/components/utils/common-odf-install-el.tsx index 760886506..4b7f0216d 100644 --- a/packages/odf/components/utils/common-odf-install-el.tsx +++ b/packages/odf/components/utils/common-odf-install-el.tsx @@ -1,4 +1,13 @@ import * as React from 'react'; +import { + CreateStepsSC, + RESOURCE_PROFILE_REQUIREMENTS_MAP, +} from '@odf/core/constants'; +import { + EncryptionType, + ResourceProfile, + ValidationType, +} from '@odf/core/types'; import { useCustomTranslation } from '@odf/shared/useCustomTranslationHook'; import classNames from 'classnames'; import { TFunction } from 'i18next'; @@ -9,8 +18,6 @@ import { AlertActionLink, WizardContextConsumer, } from '@patternfly/react-core'; -import { CreateStepsSC } from '../../constants'; -import { EncryptionType } from '../../types'; import './odf-install.scss'; export type Validation = { @@ -23,23 +30,12 @@ export type Validation = { actionLinkStep?: string; }; -export enum ValidationType { - 'MINIMAL' = 'MINIMAL', - 'INTERNALSTORAGECLASS' = 'INTERNALSTORAGECLASS', - 'BAREMETALSTORAGECLASS' = 'BAREMETALSTORAGECLASS', - 'ALLREQUIREDFIELDS' = 'ALLREQUIREDFIELDS', - 'MINIMUMNODES' = 'MINIMUMNODES', - 'ENCRYPTION' = 'ENCRYPTION', - 'REQUIRED_FIELD_KMS' = 'REQUIRED_FIELD_KMS', - 'NETWORK' = 'NETWORK', - 'INTERNAL_FLEXIBLE_SCALING' = 'INTERNAL_FLEXIBLE_SCALING', - 'ATTACHED_DEVICES_FLEXIBLE_SCALING' = 'ATTACHED_DEVICES_FLEXIBLE_SCALING', -} - export const VALIDATIONS = ( type: keyof typeof ValidationType, t: TFunction ): Validation => { + const { minCpu, minMem } = + RESOURCE_PROFILE_REQUIREMENTS_MAP[ResourceProfile.Balanced]; switch (type) { case ValidationType.MINIMAL: return { @@ -50,7 +46,19 @@ export const VALIDATIONS = ( ), text: t( - "The selected nodes do not match Data Foundation's StorageCluster requirement of an aggregated 30 CPUs and 72 GiB of RAM. If the selection cannot be modified a minimal cluster will be deployed." + `The selected nodes do not match Data Foundation's StorageCluster requirement of an aggregated ${minCpu} CPUs and ${minMem} GiB of RAM. If the selection cannot be modified a minimal cluster will be deployed.` + ), + actionLinkStep: CreateStepsSC.STORAGEANDNODES, + actionLinkText: t('Back to nodes selection'), + }; + case ValidationType.RESOURCE_PROFILE: + return { + variant: AlertVariant.danger, + title: t( + 'Aggregate resource requirements for the selected performance profile not met.' + ), + text: t( + 'Select nodes with sufficient CPU and memory that meet the specified minimum requirements, and try again, or choose a different performance profile to proceed.' ), actionLinkStep: CreateStepsSC.STORAGEANDNODES, actionLinkText: t('Back to nodes selection'), diff --git a/packages/odf/components/utils/common.ts b/packages/odf/components/utils/common.ts index 3c0a69279..fbe77ba05 100644 --- a/packages/odf/components/utils/common.ts +++ b/packages/odf/components/utils/common.ts @@ -2,8 +2,8 @@ import { NodesPerZoneMap, ValidationType, EncryptionType, + ResourceProfile, } from '@odf/core/types'; -import { MIN_SPEC_RESOURCES, MIN_DEVICESET_RESOURCES } from '@odf/core/types'; import { getNodeCPUCapacity, getNodeAllocatableMemory, @@ -11,7 +11,7 @@ import { isFlexibleScaling, getDeviceSetCount, createDeviceSet, - shouldDeployAsMinimal, + isResourceProfileAllowed, } from '@odf/core/utils'; import { StorageClassWizardStepExtensionProps as ExternalStorage } from '@odf/odf-plugin-sdk/extensions'; import { @@ -35,6 +35,7 @@ import { humanizeCpuCores, convertToBaseValue, getRack, + humanizeBinaryBytes, } from '@odf/shared/utils'; import { Base64 } from 'js-base64'; import * as _ from 'lodash-es'; @@ -72,6 +73,9 @@ export const getTotalMemory = (nodes: WizardNodeState[]): number => 0 ); +export const getTotalMemoryInGiB = (nodes: WizardNodeState[]): number => + humanizeBinaryBytes(getTotalMemory(nodes), null, 'GiB').value; + export const getAllZone = (nodes: WizardNodeState[]): Set => nodes.reduce( (total: Set, { zone }) => (zone ? total.add(zone) : total), @@ -141,12 +145,13 @@ export const calculateRadius = (size: number) => { export const capacityAndNodesValidate = ( nodes: WizardNodeState[], enableStretchCluster: boolean, - isNoProvSC: boolean + isNoProvSC: boolean, + resourceProfile: ResourceProfile ): ValidationType[] => { const validations = []; const totalCpu = getTotalCpu(nodes); - const totalMemory = getTotalMemory(nodes); + const totalMemory = getTotalMemoryInGiB(nodes); const zones = getAllZone(nodes); if ( @@ -156,12 +161,19 @@ export const capacityAndNodesValidate = ( ) { validations.push(ValidationType.ATTACHED_DEVICES_FLEXIBLE_SCALING); } - if (shouldDeployAsMinimal(totalCpu, totalMemory, nodes.length)) { - validations.push(ValidationType.MINIMAL); - } if (!enableStretchCluster && nodes.length && nodes.length < MINIMUM_NODES) { validations.push(ValidationType.MINIMUMNODES); + } else if (nodes.length && nodes.length >= MINIMUM_NODES) { + if (!isResourceProfileAllowed(resourceProfile, totalCpu, totalMemory)) { + validations.push(ValidationType.RESOURCE_PROFILE); + } else if ( + resourceProfile === ResourceProfile.Lean && + !isResourceProfileAllowed(ResourceProfile.Balanced, totalCpu, totalMemory) + ) { + validations.push(ValidationType.MINIMAL); + } } + return validations; }; @@ -357,7 +369,7 @@ type OCSRequestData = { storageClass: WizardState['storageClass']; storage: string; encryption: EncryptionType; - isMinimal: boolean; + resourceProfile: ResourceProfile; nodes: WizardNodeState[]; flexibleScaling: boolean; publicNetwork?: NetworkAttachmentDefinitionKind; @@ -381,7 +393,7 @@ export const getOCSRequestData = ({ storageClass, storage, encryption, - isMinimal, + resourceProfile, nodes, flexibleScaling, publicNetwork, @@ -441,7 +453,6 @@ export const getOCSRequestData = ({ requestData.spec = { monDataDirHostPath: isNoProvisioner ? '/var/lib/rook' : '', manageNodes: false, - resources: isMinimal ? MIN_SPEC_RESOURCES : {}, flexibleScaling, arbiter: { enable: stretchClusterChecked, @@ -455,8 +466,7 @@ export const getOCSRequestData = ({ storage, isPortable, deviceSetReplica, - deviceSetCount, - isMinimal ? MIN_DEVICESET_RESOURCES : {} + deviceSetCount ), ], ...Object.assign( @@ -510,6 +520,8 @@ export const getOCSRequestData = ({ } } + requestData.spec.resourceProfile = resourceProfile; + return requestData; }; diff --git a/packages/odf/constants/common.ts b/packages/odf/constants/common.ts index 2d22ed935..07389f2c6 100644 --- a/packages/odf/constants/common.ts +++ b/packages/odf/constants/common.ts @@ -1,3 +1,7 @@ +import { + ResourceProfile, + ResourceProfileRequirementsMap, +} from '@odf/core/types'; import { Toleration, Taint } from '@odf/shared/types'; import { TFunction } from 'i18next'; @@ -20,6 +24,26 @@ export const SECOND = 1000; export const cephStorageLabel = (ns: string) => `cluster.ocs.openshift.io/${ns}`; +/** + * Map between resource profiles and the minimum cpu's and memory (expressed in GiB) required + * for the profile to be selectable. + */ +export const RESOURCE_PROFILE_REQUIREMENTS_MAP: ResourceProfileRequirementsMap = + { + [ResourceProfile.Lean]: { + minCpu: 24, + minMem: 72, + }, + [ResourceProfile.Balanced]: { + minCpu: 30, + minMem: 72, + }, + [ResourceProfile.Performance]: { + minCpu: 45, + minMem: 96, + }, + }; + export enum defaultRequestSize { BAREMETAL = '1', NON_BAREMETAL = '2Ti', diff --git a/packages/odf/constants/tooltips.tsx b/packages/odf/constants/tooltips.tsx index 0c0d7c2c9..a7e5d6f66 100644 --- a/packages/odf/constants/tooltips.tsx +++ b/packages/odf/constants/tooltips.tsx @@ -44,3 +44,33 @@ export const storageCapacityTooltip = (t: TFunction) => { ); }; + +export const resourceProfileTooltip = (t: TFunction) => { + return ( + +

+ Performance profiles: +

+

+ Balanced mode: Optimized + for a well-rounded blend of CPU and memory resources to support diverse + workloads. +

+

+ Lean mode: Minimizes + resource consumption by allocating fewer CPUs and less memory for + resource-efficient operations. +

+

+ Performance mode:{' '} + Tailored for high-performance, allocating ample CPUs and memory to + ensure optimal execution of demanding workloads. +

+
+ ); +}; + +export const resourceRequirementsTooltip = (t: TFunction) => + t( + "plugin__odf-console~The number of CPUs and memory resources needed to optimize your Data Foundation cluster for enhanced performance is determined by taking into account the cluster's specific environment, size and various other factors." + ); diff --git a/packages/odf/modals/configure-performance/configure-performance-modal.scss b/packages/odf/modals/configure-performance/configure-performance-modal.scss new file mode 100644 index 000000000..9ed4e0d52 --- /dev/null +++ b/packages/odf/modals/configure-performance/configure-performance-modal.scss @@ -0,0 +1,3 @@ +.configure-performance-modal--overflow { + overflow: auto; +} diff --git a/packages/odf/modals/configure-performance/configure-performance-modal.tsx b/packages/odf/modals/configure-performance/configure-performance-modal.tsx new file mode 100644 index 000000000..c180fcc9a --- /dev/null +++ b/packages/odf/modals/configure-performance/configure-performance-modal.tsx @@ -0,0 +1,245 @@ +import * as React from 'react'; +import ConfigurePerformance from '@odf/core/components/create-storage-system/create-storage-system-steps/capacity-and-nodes-step/configure-performance'; +import { labelNodes } from '@odf/core/components/create-storage-system/payloads'; +import { WizardNodeState } from '@odf/core/components/create-storage-system/reducer'; +import { + createWizardNodeState, + getTotalCpu, + getTotalMemoryInGiB, +} from '@odf/core/components/utils'; +import { ValidationMessage } from '@odf/core/components/utils/common-odf-install-el'; +import { + RESOURCE_PROFILE_REQUIREMENTS_MAP, + resourceRequirementsTooltip, +} from '@odf/core/constants'; +import { useODFNamespaceSelector } from '@odf/core/redux'; +import { ResourceProfile, ValidationType } from '@odf/core/types'; +import { isResourceProfileAllowed } from '@odf/core/utils'; +import { FieldLevelHelp } from '@odf/shared/generic'; +import { LoadingInline } from '@odf/shared/generic/Loading'; +import { useK8sGet } from '@odf/shared/hooks'; +import { CommonModalProps } from '@odf/shared/modals/common'; +import { ModalBody, ModalFooter, ModalHeader } from '@odf/shared/modals/Modal'; +import { OCSStorageClusterModel } from '@odf/shared/models'; +import { + NodeKind, + StorageClusterKind, + StorageSystemKind, +} from '@odf/shared/types'; +import { useCustomTranslation } from '@odf/shared/useCustomTranslationHook'; +import { Patch, k8sPatch } from '@openshift-console/dynamic-plugin-sdk'; +import { + Alert, + Button, + Modal, + ModalVariant, + Text, + TextContent, +} from '@patternfly/react-core'; +import { SelectNodesTable } from '../../components/create-storage-system/select-nodes-table/select-nodes-table'; +import './configure-performance-modal.scss'; + +const getValidation = ( + profile: ResourceProfile, + nodes: WizardNodeState[] +): ValidationType => { + if (!profile) { + return null; + } + + return isResourceProfileAllowed( + profile, + getTotalCpu(nodes), + getTotalMemoryInGiB(nodes) + ) + ? null + : ValidationType.RESOURCE_PROFILE; +}; + +type ProfileRequirementsModalTextProps = { + selectedProfile: ResourceProfile; +}; + +const ProfileRequirementsModalText: React.FC = + ({ selectedProfile }) => { + const { t } = useCustomTranslation(); + const { minCpu, minMem } = + RESOURCE_PROFILE_REQUIREMENTS_MAP[selectedProfile]; + return ( + + + + {t( + `The aggregate resource requirements for ${selectedProfile} mode is` + )} + + + {minCpu} {t('CPUs')} + {' '} + {t('and')}{' '} + + {minMem} {t('GiB RAM')} + + {selectedProfile === ResourceProfile.Performance && ( + {resourceRequirementsTooltip(t)} + )} + + + ); + }; + +type ConfigurePerformanceModalProps = { + storageCluster: StorageClusterKind; +} & CommonModalProps; + +const ConfigurePerformanceModal: React.FC = ({ + storageCluster, + closeModal, + isOpen, +}) => { + const { t } = useCustomTranslation(); + const { odfNamespace, isNsSafe } = useODFNamespaceSelector(); + const [inProgress, setProgress] = React.useState(false); + const [errorMessage, setError] = React.useState(null); + + const [resourceProfile, setResourceProfile] = React.useState( + storageCluster.spec.resourceProfile + ); + const [selectedNodes, setSelectedNodes] = React.useState( + [] + ); + const [validation, setValidation] = React.useState(null); + const onProfileChange = React.useCallback( + (newProfile: ResourceProfile): void => { + setResourceProfile(newProfile); + setValidation(getValidation(newProfile, selectedNodes)); + }, + [selectedNodes] + ); + const onRowSelected = React.useCallback( + (newNodes: NodeKind[]) => { + const nodes = createWizardNodeState(newNodes); + setSelectedNodes(nodes); + setValidation(getValidation(resourceProfile, nodes)); + }, + [resourceProfile] + ); + + const submit = async (event: React.FormEvent): Promise => { + event.preventDefault(); + setError(null); + setProgress(true); + if (validation) { + setProgress(false); + return; + } + try { + await labelNodes(selectedNodes, odfNamespace); + + const patch: Patch = { + op: 'replace', + path: '/spec/resourceProfile', + value: resourceProfile, + }; + await k8sPatch({ + model: OCSStorageClusterModel, + resource: storageCluster, + data: [patch], + }); + setProgress(false); + closeModal(); + } catch (error) { + setError(error); + setProgress(false); + } + }; + const Header = {t('Configure Performance')}; + return ( + + + + + {validation && ( + + )} + {errorMessage && ( + + {errorMessage.message} + + )} + + + + {!inProgress ? ( + + ) : ( + + )} + + + ); +}; + +type ConfigureSSPerformanceModalProps = CommonModalProps & { + storageSystem?: StorageSystemKind; +}; + +const ConfigureSSPerformanceModal: React.FC = + ({ extraProps: { resource: storageSystem }, ...props }) => { + const [ocs, ocsLoaded, ocsError] = useK8sGet( + OCSStorageClusterModel, + storageSystem.spec.name, + storageSystem.spec.namespace + ); + if (!ocsLoaded || ocsError) { + return null; + } + + return ; + }; + +export default ConfigureSSPerformanceModal; diff --git a/packages/odf/types/common.ts b/packages/odf/types/common.ts index 3e61cc95a..25e5e31ca 100644 --- a/packages/odf/types/common.ts +++ b/packages/odf/types/common.ts @@ -1,4 +1,3 @@ -import { StorageClusterResource, ResourceConstraints } from '@odf/shared/types'; import { WatchK8sResource } from '@openshift-console/dynamic-plugin-sdk'; export type K8sResourceObj = (ns: string) => WatchK8sResource; @@ -16,6 +15,7 @@ export enum DeploymentType { export enum ValidationType { 'MINIMAL' = 'MINIMAL', + 'RESOURCE_PROFILE' = 'RESOURCE_PROFILE', 'INTERNALSTORAGECLASS' = 'INTERNALSTORAGECLASS', 'BAREMETALSTORAGECLASS' = 'BAREMETALSTORAGECLASS', 'ALLREQUIREDFIELDS' = 'ALLREQUIREDFIELDS', @@ -39,36 +39,12 @@ export type NodesPerZoneMap = { [zones: string]: number; }; -export const MIN_SPEC_RESOURCES: StorageClusterResource = { - mds: { - limits: { - cpu: '3', - memory: '8Gi', - }, - requests: { - cpu: '1', - memory: '8Gi', - }, - }, - rgw: { - limits: { - cpu: '2', - memory: '4Gi', - }, - requests: { - cpu: '1', - memory: '4Gi', - }, - }, -}; +export enum ResourceProfile { + Lean = 'lean', // t('Lean') + Balanced = 'balanced', // t('Balanced') + Performance = 'performance', // t('Performance') +} -export const MIN_DEVICESET_RESOURCES: ResourceConstraints = { - limits: { - cpu: '2', - memory: '5Gi', - }, - requests: { - cpu: '1', - memory: '5Gi', - }, +export type ResourceProfileRequirementsMap = { + [key in ResourceProfile]: { minCpu: number; minMem: number }; }; diff --git a/packages/odf/utils/ocs.ts b/packages/odf/utils/ocs.ts index a0f5daa34..7ddcccfbf 100644 --- a/packages/odf/utils/ocs.ts +++ b/packages/odf/utils/ocs.ts @@ -1,3 +1,4 @@ +import { ResourceProfile } from '@odf/core/types'; import { NamespaceModel } from '@odf/shared/models'; import { DeviceSet, @@ -13,7 +14,6 @@ import { convertToBaseValue, getRack, } from '@odf/shared/utils'; -import { humanizeBinaryBytes } from '@odf/shared/utils'; import { k8sPatch, MatchExpression, @@ -24,6 +24,7 @@ import { LABEL_OPERATOR, MINIMUM_NODES, ocsTaint, + RESOURCE_PROFILE_REQUIREMENTS_MAP, OCS_PROVISIONERS, ZONE_LABELS, } from '../constants'; @@ -97,16 +98,21 @@ const getTopologyInfo = (nodes: NodeKind[]) => export const isFlexibleScaling = (nodes: number, zones: number): boolean => !!(nodes >= MINIMUM_NODES && zones < 3); -export const shouldDeployAsMinimal = ( +/** + * Checks if the selected nodes' resources meet the minimum requirements of the selected resource profile. + * @param profile A resource profile. + * @param cpu The amount CPUs. + * @param memory The amount of selected nodes' memory in GiB. + * @returns boolean + */ +export const isResourceProfileAllowed = ( + profile: ResourceProfile, cpu: number, - memory: number, - nodesCount: number + memory: number ): boolean => { - if (nodesCount >= MINIMUM_NODES) { - const humanizedMem = humanizeBinaryBytes(memory, null, 'GiB').value; - return cpu < 30 || humanizedMem < 72; - } - return false; + const { minCpu, minMem } = RESOURCE_PROFILE_REQUIREMENTS_MAP[profile]; + + return cpu >= minCpu && memory >= minMem; }; export const getAssociatedNodes = (pvs: K8sResourceKind[]): string[] => { diff --git a/packages/shared/src/types/storage.ts b/packages/shared/src/types/storage.ts index 815491462..30085c81f 100644 --- a/packages/shared/src/types/storage.ts +++ b/packages/shared/src/types/storage.ts @@ -1,3 +1,4 @@ +import { ResourceProfile } from '@odf/core/types'; import { K8sResourceCommon } from '@openshift-console/dynamic-plugin-sdk'; export type StorageClusterKind = K8sResourceCommon & { @@ -19,6 +20,7 @@ export type StorageClusterKind = K8sResourceCommon & { }; manageNodes?: boolean; storageDeviceSets?: DeviceSet[]; + resourceProfile?: ResourceProfile; resources?: StorageClusterResource; arbiter?: { enable: boolean;