diff --git a/frontend/packages/ceph-storage-plugin/locales/en/ceph-storage-plugin.json b/frontend/packages/ceph-storage-plugin/locales/en/ceph-storage-plugin.json index d6d31b73fe0..6ba0d95d71f 100644 --- a/frontend/packages/ceph-storage-plugin/locales/en/ceph-storage-plugin.json +++ b/frontend/packages/ceph-storage-plugin/locales/en/ceph-storage-plugin.json @@ -176,6 +176,45 @@ "Zone": "Zone", "Selected nodes table": "Selected nodes table", "Connection details": "Connection details", + "LocalVolumeSet Name": "LocalVolumeSet Name", + "StorageClass Name": "StorageClass Name", + "Filter Disks By": "Filter Disks By", + "Disks on all nodes": "Disks on all nodes", + "{{nodes, number}} node": "{{nodes, number}} node", + "{{nodes, number}} node_plural": "{{nodes, number}} nodes", + "Disks on selected nodes": "Disks on selected nodes", + "Uses the available disks that match the selected filters only on selected nodes.": "Uses the available disks that match the selected filters only on selected nodes.", + "Disk Type": "Disk Type", + "Volume Mode": "Volume Mode", + "Disk Size": "Disk Size", + "Min": "Min", + "Max": "Max", + "Maximum Disks Limit": "Maximum Disks Limit", + "Disks limit will set the maximum number of PVs to create on a node. If the field is empty we will create PVs for all available disks on the matching nodes.": "Disks limit will set the maximum number of PVs to create on a node. If the field is empty we will create PVs for all available disks on the matching nodes.", + "All": "All", + "Uses the available disks that match the selected filters on all nodes selected in the previous step.": "Uses the available disks that match the selected filters on all nodes selected in the previous step.", + "A LocalVolumeSet allows you to filter a set of disks, group them and create a dedicated StorageClass to consume storage from them.": "A LocalVolumeSet allows you to filter a set of disks, group them and create a dedicated StorageClass to consume storage from them.", + "Local Storage Operator not installed": "Local Storage Operator not installed", + "Before we can create a StorageCluster, the Local Storage operator needs to be installed. When installation is finished come back to OpenShift Container Storage to create a StorageCluster.<1><0>Install": "Before we can create a StorageCluster, the Local Storage operator needs to be installed. When installation is finished come back to OpenShift Container Storage to create a StorageCluster.<1><0>Install", + "Minimum Node Requirement": "Minimum Node Requirement", + "A minimum of 3 nodes are required for the initial deployment. Only {{nodes}} node match to the selected filters. Please adjust the filters to include more nodes.": "A minimum of 3 nodes are required for the initial deployment. Only {{nodes}} node match to the selected filters. Please adjust the filters to include more nodes.", + "After the LocalVolumeSet and StorageClass are created you won't be able to go back to this step.": "After the LocalVolumeSet and StorageClass are created you won't be able to go back to this step.", + "Note:": "Note:", + "Create StorageClass": "Create StorageClass", + "Yes": "Yes", + "Are you sure you want to continue?": "Are you sure you want to continue?", + "Node": "Node", + "Model": "Model", + "Capacity": "Capacity", + "Selected Disks": "Selected Disks", + "Disk List": "Disk List", + "Selected Capacity": "Selected Capacity", + "{{nodes, number}} Node": "{{nodes, number}} Node", + "{{nodes, number}} Node_plural": "{{nodes, number}} Nodes", + "{{disks, number}} Disk": "{{disks, number}} Disk", + "{{disks, number}} Disk_plural": "{{disks, number}} Disks", + "Selected versus Available Capacity": "Selected versus Available Capacity", + "Out of {{capacity}}": "Out of {{capacity}}", "StorageClass name": "StorageClass name", "Backing storage": "Backing storage", "StorageClass:": "StorageClass:", @@ -192,6 +231,7 @@ "An error has occurred": "An error has occurred", "Create StorageSystem": "Create StorageSystem", "StorageSystem is an entity of OpenShift Data Foundation. It represents all of the required storage and compute resources.": "StorageSystem is an entity of OpenShift Data Foundation. It represents all of the required storage and compute resources.", + "Not found": "Not found", "Details": "Details", "Replicas": "Replicas", "Inventory": "Inventory", @@ -249,7 +289,6 @@ "Projects": "Projects", "BucketClasses": "BucketClasses", "Service type": "Service type", - "All": "All", "Cluster-wide": "Cluster-wide", "Any NON Object bucket claims that were created via an S3 client or via the NooBaa UI system.": "Any NON Object bucket claims that were created via an S3 client or via the NooBaa UI system.", "Capacity breakdown": "Capacity breakdown", @@ -333,8 +372,6 @@ "Recovery": "Recovery", "Disk State": "Disk State", "OpenShift Data Foundation status": "OpenShift Data Foundation status", - "Model": "Model", - "Capacity": "Capacity", "Filesystem": "Filesystem", "Disks List": "Disks List", "Start Disk Replacement": "Start Disk Replacement", @@ -459,25 +496,7 @@ "Object Bucket Details": "Object Bucket Details", "Object Bucket Claim": "Object Bucket Claim", "OBTableHeader": "OBTableHeader", - "Uses the available disks that match the selected filters on all nodes selected in the previous step.": "Uses the available disks that match the selected filters on all nodes selected in the previous step.", - "A Local Volume Set allows you to filter a set of disks, group them and create a dedicated StorageClass to consume storage from them.": "A Local Volume Set allows you to filter a set of disks, group them and create a dedicated StorageClass to consume storage from them.", - "Minimum Node Requirement": "Minimum Node Requirement", "OpenShift Container Storage's StorageCluster requires a minimum of 3 nodes for the initial deployment. Only {{nodes}} node match to the selected filters. Please adjust the filters to include more nodes.": "OpenShift Container Storage's StorageCluster requires a minimum of 3 nodes for the initial deployment. Only {{nodes}} node match to the selected filters. Please adjust the filters to include more nodes.", - "After the LocalVolumeSet and StorageClass are created you won't be able to go back to this step.": "After the LocalVolumeSet and StorageClass are created you won't be able to go back to this step.", - "Note:": "Note:", - "Create StorageClass": "Create StorageClass", - "Yes": "Yes", - "Are you sure you want to continue?": "Are you sure you want to continue?", - "Node": "Node", - "Selected Disks": "Selected Disks", - "Disk List": "Disk List", - "Selected Capacity": "Selected Capacity", - "{{nodes, number}} Node": "{{nodes, number}} Node", - "{{nodes, number}} Node_plural": "{{nodes, number}} Nodes", - "{{disks, number}} Disk": "{{disks, number}} Disk", - "{{disks, number}} Disk_plural": "{{disks, number}} Disks", - "Selected versus Available Capacity": "Selected versus Available Capacity", - "Out of {{capacity}}": "Out of {{capacity}}", "Review StorageCluster": "Review StorageCluster", "Storage and nodes": "Storage and nodes", "Arbiter zone:": "Arbiter zone:", @@ -493,8 +512,6 @@ "Review and create": "Review and create", "Internal - Attached devices": "Internal - Attached devices", "Can be used on any platform where there are attached devices to the nodes, using the Local Storage Operator. The infrastructure StorageClass is provided by Local Storage Operator, on top of the attached drives.": "Can be used on any platform where there are attached devices to the nodes, using the Local Storage Operator. The infrastructure StorageClass is provided by Local Storage Operator, on top of the attached drives.", - "Local Storage Operator not installed": "Local Storage Operator not installed", - "Before we can create a StorageCluster, the Local Storage operator needs to be installed. When installation is finished come back to OpenShift Container Storage to create a StorageCluster.<1><0>Install": "Before we can create a StorageCluster, the Local Storage operator needs to be installed. When installation is finished come back to OpenShift Container Storage to create a StorageCluster.<1><0>Install", "Node Table": "Node Table", "StorageCluster exists": "StorageCluster exists", "Back to operator page": "Back to operator page", diff --git a/frontend/packages/ceph-storage-plugin/src/components/create-storage-system/create-steps.tsx b/frontend/packages/ceph-storage-plugin/src/components/create-storage-system/create-steps.tsx index 68b2725928c..34a1a0fc60a 100644 --- a/frontend/packages/ceph-storage-plugin/src/components/create-storage-system/create-steps.tsx +++ b/frontend/packages/ceph-storage-plugin/src/components/create-storage-system/create-steps.tsx @@ -6,6 +6,7 @@ import { CreateStorageClass, ConnectionDetails, ReviewAndCreate, + CreateLocalVolumeSet, } from './create-storage-system-steps'; import { WizardDispatch, WizardState } from './reducer'; import { @@ -122,6 +123,13 @@ export const createSteps = ( name: StepsName(t)[Steps.CreateLocalVolumeSet], canJumpTo: stepIdReached >= 2, id: 2, + component: ( + + ), }, { canJumpTo: stepIdReached >= 3, diff --git a/frontend/packages/ceph-storage-plugin/src/components/create-storage-system/create-storage-system-steps/backing-storage-step/backing-storage-step.tsx b/frontend/packages/ceph-storage-plugin/src/components/create-storage-system/create-storage-system-steps/backing-storage-step/backing-storage-step.tsx index 51914df404d..4c67d282ae8 100644 --- a/frontend/packages/ceph-storage-plugin/src/components/create-storage-system/create-storage-system-steps/backing-storage-step/backing-storage-step.tsx +++ b/frontend/packages/ceph-storage-plugin/src/components/create-storage-system/create-storage-system-steps/backing-storage-step/backing-storage-step.tsx @@ -18,6 +18,7 @@ import { } from '../../../../constants/create-storage-system'; import { ErrorHandler } from '../../error-handler'; import { ExternalStorage } from '../../external-storage/types'; +import { NO_PROVISIONER } from '../../../../constants'; const ExternalSystemSelection: React.FC = ({ dispatch, @@ -114,6 +115,8 @@ export const BackingStorage: React.FC = ({ }, ); + const { type, externalStorage, deployment, isAdvancedOpen } = state; + React.useEffect(() => { /* Allow pre selecting the "external connection" option instead of the "existing" option @@ -124,7 +127,17 @@ export const BackingStorage: React.FC = ({ } }, [dispatch, allowedExternalStorage.length, hasOCS]); - const { type, externalStorage, deployment, isAdvancedOpen } = state; + React.useEffect(() => { + /* + Update storage class state when no storage class is used. + */ + if (type === BackingStorageType.LOCAL_DEVICES) { + dispatch({ + type: 'wizard/setStorageClass', + payload: { name: '', provisioner: NO_PROVISIONER }, + }); + } + }, [dispatch, type]); const showExternalStorageSelection = type === BackingStorageType.EXTERNAL && allowedExternalStorage.length; diff --git a/frontend/packages/ceph-storage-plugin/src/components/create-storage-system/create-storage-system-steps/create-local-volume-set/body.scss b/frontend/packages/ceph-storage-plugin/src/components/create-storage-system/create-storage-system-steps/create-local-volume-set/body.scss new file mode 100644 index 00000000000..bf3b15ee374 --- /dev/null +++ b/frontend/packages/ceph-storage-plugin/src/components/create-storage-system/create-storage-system-steps/create-local-volume-set/body.scss @@ -0,0 +1,51 @@ +.odf-create-lvs__all-nodes-radio--padding { + padding-bottom: var(--pf-global--spacer--sm); + } + +.odf-create-lvs__filter-volumes-text--margin { + margin: 0; +} + +.odf-create-lvs__max-disk-limit-help-text--margin { + margin-top: 0; +} + +.odf-create-lvs__node-selection-table--margin { + margin-top: 0; +} + +.odf-create-lvs__disk-mode-dropdown--margin { + margin-bottom: var(--pf-global--spacer--md); +} + +.odf-create-lvs__device-type-dropdown--margin { + margin-bottom: var(--pf-global--spacer--md); +} + +.odf-create-lvs__disk-size-form-group--margin { + margin: var(--pf-global--spacer--lg) 0; +} + +.odf-create-lvs__disk-size-form-group-div { + display: flex; + align-items: flex-end; + justify-content: space-between; + width: 22em; +} + +.odf-create-lvs__disk-size-form-group-max-min-input { + display: flex; + flex-direction: column; +} + +.odf-create-lvs__disk-input { + max-width: 100px; +} + +.odf-create-lvs__select-nodes { +// overrides extra space added by `co-m-nav-title` + .co-m-nav-title { + padding: 0; + margin: 0; + } +} diff --git a/frontend/packages/ceph-storage-plugin/src/components/create-storage-system/create-storage-system-steps/create-local-volume-set/body.tsx b/frontend/packages/ceph-storage-plugin/src/components/create-storage-system/create-storage-system-steps/create-local-volume-set/body.tsx new file mode 100644 index 00000000000..8bf239d2f98 --- /dev/null +++ b/frontend/packages/ceph-storage-plugin/src/components/create-storage-system/create-storage-system-steps/create-local-volume-set/body.tsx @@ -0,0 +1,321 @@ +import * as React from 'react'; +import { useTranslation } from 'react-i18next'; +import { + FormGroup, + TextInput, + Radio, + ExpandableSection, + TextInputTypes, + Text, + TextVariants, + Tooltip, +} from '@patternfly/react-core'; +import { ListPage } from '@console/internal/components/factory'; +import { Dropdown } from '@console/internal/components/utils'; +import { NodeModel } from '@console/internal/models'; +import { NodeKind } from '@console/internal/module/k8s'; +import { getName, MultiSelectDropdown } from '@console/shared'; +import { + deviceTypeDropdownItems, + diskSizeUnitOptions, + diskTypeDropdownItems, +} from '@console/local-storage-operator-plugin/src/constants'; +import { NodesTable } from '@console/local-storage-operator-plugin/src/components/tables/nodes-table'; +import { diskModeDropdownItems, NO_PROVISIONER } from '../../../../constants'; +import { LocalVolumeSet, WizardDispatch, WizardState } from '../../reducer'; + +import './body.scss'; + +export const LocalVolumeSetBody: React.FC = ({ + dispatch, + state, + storageClassName, + taintsFilter, + diskModeOptions = diskModeDropdownItems, + allNodesHelpTxt, + lvsNameHelpTxt, + deviceTypeOptions = deviceTypeDropdownItems, +}) => { + const { t } = useTranslation(); + const formHandler = React.useCallback( + (field: keyof LocalVolumeSet, value: LocalVolumeSet[keyof LocalVolumeSet]) => + dispatch({ type: 'wizard/setCreateLocalVolumeSet', payload: { field, value } }), + [dispatch], + ); + + const diskDropdownOptions = diskTypeDropdownItems(t); + + const INTEGER_MAX_REGEX = /^\+?([1-9]\d*)$/; + const INTEGER_MIN_REGEX = /^\+?([0-9]\d*)$/; + const [activeMinDiskSize, setMinActiveState] = React.useState(false); + const [activeMaxDiskSize, setMaxActiveState] = React.useState(false); + const validMinDiskSize = INTEGER_MIN_REGEX.test(state.minDiskSize || '1'); + const validMaxDiskSize = INTEGER_MAX_REGEX.test(state.maxDiskSize || '1'); + const validMaxDiskLimit = INTEGER_MAX_REGEX.test(state.maxDiskLimit || '1'); + const invalidMinGreaterThanMax = + state.minDiskSize !== '' && + state.maxDiskSize !== '' && + Number(state.minDiskSize) > Number(state.maxDiskSize); + + const toggleShowNodesList = () => formHandler('lvsIsSelectNodes', !state.lvsIsSelectNodes); + + React.useEffect(() => { + if (!validMinDiskSize || !validMaxDiskSize || !validMaxDiskLimit || invalidMinGreaterThanMax) { + formHandler('isValidDiskSize', false); + } else { + formHandler('isValidDiskSize', true); + } + }, [ + dispatch, + validMinDiskSize, + validMaxDiskSize, + validMaxDiskLimit, + invalidMinGreaterThanMax, + formHandler, + ]); + + return ( + <> + + formHandler('volumeSetName', name)} + isRequired + /> + {lvsNameHelpTxt ?

{lvsNameHelpTxt}

: null} +
+ + + dispatch({ + type: 'wizard/setStorageClass', + payload: { name, provisioner: NO_PROVISIONER }, + }) + } + /> + + + {t('ceph-storage-plugin~Filter Disks By')} + + +
+ + {t('ceph-storage-plugin~Disks on all nodes')} + {' ('} + {t('ceph-storage-plugin~{{nodes, number}} node', { + nodes: state.lvsAllNodes.length, + count: state.lvsAllNodes.length, + })} + {')'} + + } + name="nodes-selection" + id="create-lvs-radio-all-nodes" + className="odf-create-lvs__all-nodes-radio--padding" + value="allNodes" + onChange={toggleShowNodesList} + description={allNodesHelpTxt} + checked={!state.lvsIsSelectNodes} + /> + +
+
+ {state.lvsIsSelectNodes && ( +
+ + formHandler('lvsSelectNodes', selectedNodes), + filteredNodes: state.lvsAllNodes.map(getName), + preSelectedNodes: state.lvsSelectNodes.map(getName), + hasOnSelect: true, + taintsFilter, + }} + /> +
+ )} + + formHandler('diskType', type)} + /> + + + + formHandler('diskMode', mode)} + /> + + + formHandler('deviceType', selectedValues)} + defaultSelected={[deviceTypeOptions.DISK, deviceTypeOptions.PART]} + /> + + +
+ + + setMinActiveState(true)} + onBlur={() => setMinActiveState(false)} + onChange={(size: string) => formHandler('minDiskSize', size)} + /> + + +
-
+ + + setMaxActiveState(true)} + onBlur={() => setMaxActiveState(false)} + onChange={(value) => formHandler('maxDiskSize', value)} + /> + + + formHandler('diskSizeUnit', unit)} + /> +
+
+ +

+ {t( + 'ceph-storage-plugin~Disks limit will set the maximum number of PVs to create on a node. If the field is empty we will create PVs for all available disks on the matching nodes.', + )} +

+ + formHandler('maxDiskLimit', maxLimit)} + placeholder={t('ceph-storage-plugin~All')} + /> + +
+
+ + ); +}; + +type LocalVolumeSetBodyProps = { + state: WizardState['createLocalVolumeSet']; + dispatch: WizardDispatch; + storageClassName: string; + diskModeOptions?: { [key: string]: string }; + deviceTypeOptions?: { [key: string]: string }; + allNodesHelpTxt?: string; + lvsNameHelpTxt?: string; + taintsFilter?: (node: NodeKind) => boolean; +}; diff --git a/frontend/packages/ceph-storage-plugin/src/components/create-storage-system/create-storage-system-steps/create-local-volume-set/create-local-volume-set-step.tsx b/frontend/packages/ceph-storage-plugin/src/components/create-storage-system/create-storage-system-steps/create-local-volume-set/create-local-volume-set-step.tsx new file mode 100644 index 00000000000..feacb508036 --- /dev/null +++ b/frontend/packages/ceph-storage-plugin/src/components/create-storage-system/create-storage-system-steps/create-local-volume-set/create-local-volume-set-step.tsx @@ -0,0 +1,285 @@ +import * as React from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import { + Alert, + Button, + Form, + Grid, + GridItem, + Modal, + WizardContext, + WizardContextType, +} from '@patternfly/react-core'; +import { history } from '@console/internal/components/utils'; +import { getLocalVolumeSetRequestData } from '@console/local-storage-operator-plugin/src/components/local-volume-set/request'; +import { + LocalVolumeDiscovery, + LocalVolumeSetModel, +} from '@console/local-storage-operator-plugin/src/models'; +import { getNodesByHostNameLabel } from '@console/local-storage-operator-plugin/src/utils'; +import { useFlag } from '@console/shared/src'; +import { k8sCreate, k8sGet, k8sList, NodeKind } from '@console/internal/module/k8s'; +import { createLocalVolumeDiscovery } from '@console/local-storage-operator-plugin/src/components/local-volume-discovery/request'; +import { NodeModel } from '@console/internal/models'; +import { DISCOVERY_CR_NAME } from '@console/local-storage-operator-plugin/src/constants'; +import { LocalVolumeSetBody } from './body'; +import { SelectedCapacity } from './selected-capacity'; +import { GUARDED_FEATURES } from '../../../../features'; +import { + arbiterText, + diskModeDropdownItems, + LSO_OPERATOR, + MINIMUM_NODES, + OCS_TOLERATION, +} from '../../../../constants'; +import { ErrorHandler } from '../../error-handler'; +import { WizardDispatch, WizardState } from '../../reducer'; +import { useFetchCsv } from '../../use-fetch-csv'; +import { RequestErrors } from '../../../ocs-install/install-wizard/review-and-create'; +import { hasOCSTaint } from '../../../../utils/install'; + +const goToLSOInstallationPage = () => + history.push( + '/operatorhub/all-namespaces?details-item=local-storage-operator-redhat-operators-openshift-marketplace', + ); + +const makeLocalVolumeSetCall = ( + state: WizardState['createLocalVolumeSet'], + storageClassName: string, + setInProgress: React.Dispatch>, + setErrorMessage: React.Dispatch>, + ns: string, + onNext: () => void, + lvsNodes: NodeKind[], +) => { + setInProgress(true); + + const nodes = getNodesByHostNameLabel(lvsNodes); + + const requestData = getLocalVolumeSetRequestData( + { ...state, storageClassName }, + nodes, + ns, + OCS_TOLERATION, + ); + k8sCreate(LocalVolumeSetModel, requestData) + .then(() => { + setInProgress(false); + onNext(); + }) + .catch((err) => { + setErrorMessage(err.message); + setInProgress(false); + }); +}; + +export const CreateLocalVolumeSet: React.FC = ({ + state, + storageClass, + dispatch, +}) => { + const [csv, csvLoaded, csvLoadError] = useFetchCsv(LSO_OPERATOR); + const { t } = useTranslation(); + const [inProgress, setInProgress] = React.useState(false); + const [error, setError] = React.useState(null); + const [lvdInProgress, setLvdInProgress] = React.useState(false); + const [lvdError, setLvdError] = React.useState(null); + + React.useEffect(() => { + const createLvd = async () => { + try { + setLvdInProgress(true); + await k8sGet(LocalVolumeDiscovery, DISCOVERY_CR_NAME, csv?.metadata?.namespace); + } catch (e) { + if (e?.response?.status === 404) { + try { + const nodes = await k8sList(NodeModel); + const nodeByHostNames: string[] = getNodesByHostNameLabel(nodes.items); + await createLocalVolumeDiscovery( + nodeByHostNames, + csv?.metadata?.namespace, + OCS_TOLERATION, + ); + } catch (createError) { + setLvdError(createError.message); + } + } + } finally { + setLvdInProgress(false); + } + }; + if (!csvLoadError && csvLoaded) { + createLvd(); + } + }, [csv, csvLoadError, csvLoaded]); + + const allNodesSelectorTxt = t( + 'ceph-storage-plugin~Uses the available disks that match the selected filters on all nodes selected in the previous step.', + ); + const lvsNameSelectorTxt = t( + 'ceph-storage-plugin~A LocalVolumeSet allows you to filter a set of disks, group them and create a dedicated StorageClass to consume storage from them.', + ); + const lvsNodes = state.lvsIsSelectNodes ? state.lvsSelectNodes : state.lvsAllNodes; + const ns = csv?.metadata?.namespace; + + return ( + + {csvLoadError || csv?.status?.phase !== 'Succeeded' ? ( + + + Before we can create a StorageCluster, the Local Storage operator needs to be installed. + When installation is finished come back to OpenShift Container Storage to create a + StorageCluster. +
+ +
+
+
+ ) : ( + <> + + +
+ + +
+ + + +
+ + {state.chartNodes.size < MINIMUM_NODES && ( + + {t( + 'ceph-storage-plugin~A minimum of 3 nodes are required for the initial deployment. Only {{nodes}} node match to the selected filters. Please adjust the filters to include more nodes.', + { nodes: state.chartNodes.size }, + )} + + )} + + + )} +
+ ); +}; + +type CreateLocalVolumeSetProps = { + state: WizardState['createLocalVolumeSet']; + storageClass: WizardState['storageClass']; + dispatch: WizardDispatch; +}; + +const ConfirmationModal: React.FC = ({ + state, + dispatch, + setInProgress, + setErrorMessage, + storageClassName, + ns, + lvsNodes, +}) => { + const { t } = useTranslation(); + const isArbiterSupported = useFlag(GUARDED_FEATURES.OCS_ARBITER); + const { onNext } = React.useContext(WizardContext); + + const cancel = () => { + dispatch({ + type: 'wizard/setCreateLocalVolumeSet', + payload: { field: 'showConfirmModal', value: false }, + }); + }; + + const makeLVSCall = () => { + cancel(); + makeLocalVolumeSetCall( + state, + storageClassName, + setInProgress, + setErrorMessage, + ns, + onNext, + lvsNodes, + ); + }; + + const description = ( + <> + + {t( + "ceph-storage-plugin~After the LocalVolumeSet and StorageClass are created you won't be able to go back to this step.", + )} + + {isArbiterSupported && ( +

+ {t('ceph-storage-plugin~Note:')} + {arbiterText(t)} +

+ )} + + ); + return ( + + {t('ceph-storage-plugin~Yes')} + , + , + ]} + description={description} + > +

{t('ceph-storage-plugin~Are you sure you want to continue?')}

+
+ ); +}; + +type ConfirmationModalProps = { + state: WizardState['createLocalVolumeSet']; + dispatch: WizardDispatch; + storageClassName: string; + ns: string; + setInProgress: React.Dispatch>; + setErrorMessage: React.Dispatch>; + lvsNodes: NodeKind[]; +}; diff --git a/frontend/packages/ceph-storage-plugin/src/components/create-storage-system/create-storage-system-steps/create-local-volume-set/disk-list-modal.tsx b/frontend/packages/ceph-storage-plugin/src/components/create-storage-system/create-storage-system-steps/create-local-volume-set/disk-list-modal.tsx new file mode 100644 index 00000000000..ad8e8705405 --- /dev/null +++ b/frontend/packages/ceph-storage-plugin/src/components/create-storage-system/create-storage-system-steps/create-local-volume-set/disk-list-modal.tsx @@ -0,0 +1,104 @@ +import * as React from 'react'; +import { useTranslation } from 'react-i18next'; +import * as cx from 'classnames'; +import { Table, TableRow, TableData, RowFunction } from '@console/internal/components/factory'; +import { sortable, SortByDirection } from '@patternfly/react-table'; +import { Button } from '@patternfly/react-core'; +import { Modal } from '@console/shared'; +import { humanizeBinaryBytes } from '@console/internal/components/utils'; +import { DiskMetadata } from '@console/local-storage-operator-plugin/src/components/disks-list/types'; +import { DiscoveredDisk } from '../../../../types'; + +const tableColumnClasses = [ + '', + '', + cx('pf-m-hidden', 'pf-m-visible-on-xl'), + cx('pf-m-hidden', 'pf-m-visible-on-2xl'), + cx('pf-m-hidden', 'pf-m-visible-on-lg'), +]; + +const DiskRow: RowFunction = ({ obj, index, key, style }) => { + return ( + + {obj.path} + {obj.node} + {obj.type || '-'} + + {obj.model || '-'} + + + {humanizeBinaryBytes(obj.size).string || '-'} + + + ); +}; + +export const DiskListModal: React.FC = ({ showDiskList, onCancel, disks }) => { + const { t } = useTranslation(); + + const DiskHeader = () => { + return [ + { + title: t('ceph-storage-plugin~Name'), + sortField: 'path', + transforms: [sortable], + props: { className: tableColumnClasses[0] }, + }, + { + title: t('ceph-storage-plugin~Node'), + sortField: 'node', + transforms: [sortable], + props: { className: tableColumnClasses[1] }, + }, + { + title: t('ceph-storage-plugin~Type'), + sortField: 'type', + transforms: [sortable], + props: { className: tableColumnClasses[2] }, + }, + { + title: t('ceph-storage-plugin~Model'), + sortField: 'model', + transforms: [sortable], + props: { className: tableColumnClasses[3] }, + }, + { + title: t('ceph-storage-plugin~Capacity'), + sortField: 'size', + transforms: [sortable], + props: { className: tableColumnClasses[4] }, + }, + ]; + }; + + return ( + + {t('ceph-storage-plugin~Close')} + , + ]} + > + + + ); +}; + +type DiskListModalProps = { + showDiskList: boolean; + disks: DiskMetadata[]; + onCancel: () => void; +}; diff --git a/frontend/packages/ceph-storage-plugin/src/components/create-storage-system/create-storage-system-steps/create-local-volume-set/selected-capacity.scss b/frontend/packages/ceph-storage-plugin/src/components/create-storage-system/create-storage-system-steps/create-local-volume-set/selected-capacity.scss new file mode 100644 index 00000000000..4393890f34d --- /dev/null +++ b/frontend/packages/ceph-storage-plugin/src/components/create-storage-system/create-storage-system-steps/create-local-volume-set/selected-capacity.scss @@ -0,0 +1,39 @@ +.odf-install__chart-wrapper { + display: flex; + flex-direction: column; + align-items: center; + } + + .odf-install_capacity-header { + text-align: center; + font-size: var(--pf-global--FontSize--xl); + margin-bottom: var(--pf-c-wizard__main-body--PaddingBottom); + } + + .odf-install__stats { + display: flex; + font-size: var(--pf-global--FontSize--md); + margin-bottom: var(--pf-global--spacer--xs); + text-align: center; + } + + .odf-install__node-list-btn { + padding-top: 0; + padding-left: 0; + padding-right: 0; + } + + .odf-install_stats--divider { + border-right: var(--pf-global--BorderWidth--sm) solid var(--pf-global--Color--light-300); + height: var(--pf-global--LineHeight--sm); + margin: 0 var(--pf-global--spacer--sm); + } + + .odf-install__disk-list-btn { + padding-top: 0; + padding-left: 0; + } + + .odf-install__lso_storageclass-donut_spinner { + margin-top: 50%; + } diff --git a/frontend/packages/ceph-storage-plugin/src/components/create-storage-system/create-storage-system-steps/create-local-volume-set/selected-capacity.tsx b/frontend/packages/ceph-storage-plugin/src/components/create-storage-system/create-storage-system-steps/create-local-volume-set/selected-capacity.tsx new file mode 100644 index 00000000000..82c241c6df5 --- /dev/null +++ b/frontend/packages/ceph-storage-plugin/src/components/create-storage-system/create-storage-system-steps/create-local-volume-set/selected-capacity.tsx @@ -0,0 +1,257 @@ +import * as React from 'react'; +import * as _ from 'lodash'; +import { useTranslation } from 'react-i18next'; +import { Button, Spinner } from '@patternfly/react-core'; +import { ChartDonut, ChartLabel } from '@patternfly/react-charts'; + +import { calculateRadius, Modal } from '@console/shared'; +import { convertToBaseValue, humanizeBinaryBytes } from '@console/internal/components/utils/'; +import { NodeModel } from '@console/internal/models'; +import { ListPage } from '@console/internal/components/factory'; +import { getName } from '@console/shared/src/selectors/common'; +import { + DISK_TYPES, + deviceTypeDropdownItems, + LABEL_OPERATOR, +} from '@console/local-storage-operator-plugin/src/constants'; +import { NodesTable } from '@console/local-storage-operator-plugin/src/components/tables/nodes-table'; +import { + useK8sWatchResource, + WatchK8sResource, +} from '@console/internal/components/utils/k8s-watch-hook'; +import { referenceForModel } from '@console/internal/module/k8s'; +import { LABEL_SELECTOR } from '@console/local-storage-operator-plugin/src/constants/disks-list'; +import { LocalVolumeDiscoveryResult } from '@console/local-storage-operator-plugin/src/models'; +import { + DiskMetadata, + LocalVolumeDiscoveryResultKind, +} from '@console/local-storage-operator-plugin/src/components/disks-list/types'; +import { DiskType } from '@console/local-storage-operator-plugin/src/components/local-volume-set/types'; +import { AVAILABLE } from '@console/ceph-storage-plugin/src/constants'; +import { DiscoveredDisk } from '@console/ceph-storage-plugin/src/types'; +import { DiskListModal } from './disk-list-modal'; +import { WizardState, WizardDispatch } from '../../reducer'; +import './selected-capacity.scss'; + +const getTotalCapacity = (disks: DiscoveredDisk[]): number => + disks.reduce((total: number, disk: DiskMetadata) => total + disk.size, 0); + +const isAvailableDisk = (disk: DiscoveredDisk): boolean => + disk?.status?.state === AVAILABLE && + (disk.type === DiskType.RawDisk || disk.type === DiskType.Partition); + +const isValidSize = (disk: DiscoveredDisk, minSize: number, maxSize: number) => + Number(disk.size) >= minSize && (maxSize ? Number(disk.size) <= maxSize : true); + +const isValidDiskProperty = (disk: DiscoveredDisk, property: DiskMetadata['property']) => + property ? property === disk.property : true; + +const isValidDeviceType = (disk: DiscoveredDisk, types: string[]) => + types.includes(deviceTypeDropdownItems[disk.type.toUpperCase()]); + +const addNodesOnAvailableDisks = (disks: DiskMetadata[], node: string) => + disks.reduce((availableDisks: DiscoveredDisk[], disk: DiscoveredDisk) => { + if (isAvailableDisk(disk)) { + disk.node = node; + return [disk, ...availableDisks]; + } + return availableDisks; + }, []); + +const createDiscoveredDiskData = (results: LocalVolumeDiscoveryResultKind[]): DiscoveredDisk[] => + results.reduce((discoveredDisk: DiscoveredDisk[], lvdr) => { + const lvdrDisks = lvdr?.status?.discoveredDevices; + const lvdrNode = lvdr?.spec?.nodeName; + const availableDisks = addNodesOnAvailableDisks(lvdrDisks, lvdrNode); + return [...availableDisks, ...discoveredDisk]; + }, []); + +export const SelectedCapacity: React.FC = ({ ns, state, dispatch }) => { + const allLvsNodes = state.lvsAllNodes.map(getName); + const selectedLvsNodes = state.lvsSelectNodes.map(getName); + const [isLoadingDonutChart, setIsLoadingDonutChart] = React.useState(true); + /** + * Fetching discovery results for all nodes passed + * for local volume set creation. + */ + const lvdResultResource: WatchK8sResource = { + kind: referenceForModel(LocalVolumeDiscoveryResult), + namespace: ns, + isList: true, + selector: { + matchExpressions: [ + { + key: LABEL_SELECTOR, + operator: LABEL_OPERATOR, + values: allLvsNodes, + }, + ], + }, + }; + + const { t } = useTranslation(); + const [lvdResults, lvdResultsLoaded, lvdResultsLoadError] = useK8sWatchResource< + LocalVolumeDiscoveryResultKind[] + >(lvdResultResource); + const [showNodeList, setShowNodeList] = React.useState(false); + const [showDiskList, setShowDiskList] = React.useState(false); + + let filteredDisks: DiscoveredDisk[] = []; + + const minSize: number = state.minDiskSize + ? Number(convertToBaseValue(`${state.minDiskSize} ${state.diskSizeUnit}`)) + : 0; + const maxSize: number = state.maxDiskSize + ? Number(convertToBaseValue(`${state.maxDiskSize} ${state.diskSizeUnit}`)) + : undefined; + + const allDiscoveredDisks: DiscoveredDisk[] = React.useMemo(() => { + if (!lvdResultsLoadError && lvdResultsLoaded && allLvsNodes.length === lvdResults.length) { + setIsLoadingDonutChart(false); + return createDiscoveredDiskData(lvdResults); + } + return []; + }, [allLvsNodes.length, lvdResults, lvdResultsLoadError, lvdResultsLoaded]); + + if (allDiscoveredDisks.length) { + filteredDisks = allDiscoveredDisks.filter( + (disk: DiscoveredDisk) => + state.isValidDiskSize && + isValidSize(disk, minSize, maxSize) && + isValidDiskProperty(disk, DISK_TYPES[state.diskType]?.property) && + isValidDeviceType(disk, state.deviceType), + ); + } + + const chartDisks = state.lvsIsSelectNodes + ? filteredDisks.filter((disk: DiscoveredDisk) => selectedLvsNodes.includes(disk.node)) + : filteredDisks; + const chartNodes: Set = chartDisks.reduce( + (nodes: Set, disk: DiscoveredDisk) => nodes.add(disk.node), + new Set(), + ); + + if (!_.isEqual(chartNodes, state.chartNodes)) { + dispatch({ + type: 'wizard/setCreateLocalVolumeSet', + payload: { field: 'chartNodes', value: chartNodes }, + }); + } + + const totalCapacity = getTotalCapacity(allDiscoveredDisks); + const selectedCapacity = getTotalCapacity(chartDisks); + + const donutData = [ + { x: 'Selected', y: selectedCapacity }, + { + x: 'Available', + y: Number(totalCapacity) - Number(selectedCapacity), + }, + ]; + const { podStatusOuterRadius: radius } = calculateRadius(220); + + return ( +
+
+ {t('ceph-storage-plugin~Selected Capacity')} +
+
+ +
+ +
+ {isLoadingDonutChart ? ( +
+ +
+ ) : ( + `${humanizeBinaryBytes(datum.y).string} ${datum.x}`} + subTitle={t('ceph-storage-plugin~Out of {{capacity}}', { + capacity: humanizeBinaryBytes(totalCapacity).string, + })} + title={humanizeBinaryBytes(selectedCapacity).string} + constrainToVisibleArea + subTitleComponent={ + + } + /> + )} + setShowDiskList(false)} + /> + setShowNodeList(false)} + filteredNodes={[...chartNodes]} + /> +
+ ); +}; + +type SelectedCapacityProps = { + state: WizardState['createLocalVolumeSet']; + dispatch: WizardDispatch; + ns: string; +}; + +const NodeListModal: React.FC = ({ filteredNodes, onCancel, showNodeList }) => { + const { t } = useTranslation(); + + return ( + + {t('ceph-storage-plugin~Close')} + , + ]} + > + + + ); +}; + +type NodeListModalProps = { + showNodeList: boolean; + filteredNodes: string[]; + onCancel: () => void; +}; diff --git a/frontend/packages/ceph-storage-plugin/src/components/create-storage-system/create-storage-system-steps/index.ts b/frontend/packages/ceph-storage-plugin/src/components/create-storage-system/create-storage-system-steps/index.ts index b7d9b7b9f4b..9dabb5964fb 100644 --- a/frontend/packages/ceph-storage-plugin/src/components/create-storage-system/create-storage-system-steps/index.ts +++ b/frontend/packages/ceph-storage-plugin/src/components/create-storage-system/create-storage-system-steps/index.ts @@ -3,3 +3,4 @@ export { CapacityAndNodes } from './capacity-and-nodes-step/capacity-and-nodes-s export { ConnectionDetails } from './connection-details-step'; export { CreateStorageClass } from './create-storage-class-step'; export { ReviewAndCreate } from './review-and-create-step/review-and-create-step'; +export { CreateLocalVolumeSet } from './create-local-volume-set/create-local-volume-set-step'; diff --git a/frontend/packages/ceph-storage-plugin/src/components/create-storage-system/error-handler.tsx b/frontend/packages/ceph-storage-plugin/src/components/create-storage-system/error-handler.tsx index 32dc888b919..04db2e252ef 100644 --- a/frontend/packages/ceph-storage-plugin/src/components/create-storage-system/error-handler.tsx +++ b/frontend/packages/ceph-storage-plugin/src/components/create-storage-system/error-handler.tsx @@ -31,6 +31,6 @@ export const ErrorHandler: React.FC = ({ children, error, loade type WizardStepProps = { children: React.ReactElement; - error: any; loaded: boolean; + error: any; }; diff --git a/frontend/packages/ceph-storage-plugin/src/components/create-storage-system/footer.tsx b/frontend/packages/ceph-storage-plugin/src/components/create-storage-system/footer.tsx index d38f97a7a32..5e89dff0d7c 100644 --- a/frontend/packages/ceph-storage-plugin/src/components/create-storage-system/footer.tsx +++ b/frontend/packages/ceph-storage-plugin/src/components/create-storage-system/footer.tsx @@ -43,9 +43,16 @@ const validateBackingStorageStep = (backingStorage, sc) => { }; const canJumpToNextStep = (name: string, state: WizardState, t: TFunction) => { - const { storageClass, backingStorage, createStorageClass, capacityAndNodes } = state; + const { + storageClass, + backingStorage, + createStorageClass, + capacityAndNodes, + createLocalVolumeSet, + } = state; const { externalStorage } = backingStorage; const { nodes, capacity } = capacityAndNodes; + const { chartNodes, volumeSetName, isValidDiskSize } = createLocalVolumeSet; const { canGoToNextStep } = getExternalStorage(externalStorage) || {}; switch (name) { @@ -60,6 +67,7 @@ const canJumpToNextStep = (name: string, state: WizardState, t: TFunction) => { case StepsName(t)[Steps.ReviewAndCreate]: return nodes.length >= MINIMUM_NODES && capacity; case StepsName(t)[Steps.CreateLocalVolumeSet]: + return chartNodes.size < MINIMUM_NODES || !volumeSetName.trim().length || !isValidDiskSize; case StepsName(t)[Steps.SecurityAndNetwork]: return true; default: diff --git a/frontend/packages/ceph-storage-plugin/src/components/create-storage-system/reducer.ts b/frontend/packages/ceph-storage-plugin/src/components/create-storage-system/reducer.ts index af1baa3d27b..dc5f5f7a461 100644 --- a/frontend/packages/ceph-storage-plugin/src/components/create-storage-system/reducer.ts +++ b/frontend/packages/ceph-storage-plugin/src/components/create-storage-system/reducer.ts @@ -1,4 +1,9 @@ import * as _ from 'lodash'; +import { + deviceTypeDropdownItems, + diskModeDropdownItems, +} from '@console/local-storage-operator-plugin/src/constants'; +import { NodeKind } from 'public/module/k8s'; import { ExternalState, ExternalStateKeys, ExternalStateValues } from './external-storage/types'; import { BackingStorageType, DeploymentType } from '../../constants/create-storage-system'; @@ -37,6 +42,23 @@ export const initialState: CreateStorageSystemState = { }, createStorageClass: {}, connectionDetails: {}, + createLocalVolumeSet: { + lvsIsSelectNodes: false, + lvsAllNodes: [], + lvsSelectNodes: [], + volumeSetName: '', + isValidDiskSize: true, + diskType: 'All', + diskMode: diskModeDropdownItems.BLOCK, + deviceType: [deviceTypeDropdownItems.DISK, deviceTypeDropdownItems.PART], + maxDiskLimit: '', + minDiskSize: '1', + maxDiskSize: '', + diskSizeUnit: 'Gi', + isValidMaxSize: true, + showConfirmModal: false, + chartNodes: new Set(), + }, }; type CreateStorageSystemState = { @@ -55,6 +77,25 @@ type CreateStorageSystemState = { capacity: string; enableArbiter: boolean; }; + createLocalVolumeSet: LocalVolumeSet; +}; + +export type LocalVolumeSet = { + lvsIsSelectNodes: boolean; + lvsAllNodes: NodeKind[]; + lvsSelectNodes: NodeKind[]; + volumeSetName: string; + isValidDiskSize: boolean; + diskType: string; + diskMode: string; + deviceType: string[]; + maxDiskLimit: string; + minDiskSize: string; + maxDiskSize: string; + diskSizeUnit: string; + isValidMaxSize: boolean; + showConfirmModal: boolean; + chartNodes: Set; }; /* Reducer of CreateStorageSystem */ @@ -82,6 +123,12 @@ export const reducer: WizardReducer = (prevState, action) => { [action.payload.field]: action.payload.value, }; break; + case 'wizard/setCreateLocalVolumeSet': + newState.createLocalVolumeSet = { + ...newState.createLocalVolumeSet, + [action.payload.field]: action.payload.value, + }; + break; case 'backingStorage/setType': newState.backingStorage.type = action.payload; break; @@ -114,14 +161,6 @@ export type WizardReducer = ( /* Actions of CreateStorageSystem */ type CreateStorageSystemAction = | { type: 'wizard/setStepIdReached'; payload: number } - | { - type: 'backingStorage/setDeployment'; - payload: WizardState['backingStorage']['deployment']; - } - | { - type: 'backingStorage/setIsAdvancedOpen'; - payload: WizardState['backingStorage']['isAdvancedOpen']; - } | { type: 'wizard/setStorageClass'; payload: WizardState['storageClass']; @@ -134,6 +173,18 @@ type CreateStorageSystemAction = type: 'wizard/setConnectionDetails'; payload: { field: ExternalStateKeys; value: ExternalStateValues }; } + | { + type: 'wizard/setCreateLocalVolumeSet'; + payload: { field: keyof LocalVolumeSet; value: LocalVolumeSet[keyof LocalVolumeSet] }; + } + | { + type: 'backingStorage/setDeployment'; + payload: WizardState['backingStorage']['deployment']; + } + | { + type: 'backingStorage/setIsAdvancedOpen'; + payload: WizardState['backingStorage']['isAdvancedOpen']; + } | { type: 'backingStorage/setType'; payload: WizardState['backingStorage']['type'] } | { type: 'backingStorage/setExternalStorage'; diff --git a/frontend/packages/ceph-storage-plugin/src/components/create-storage-system/use-fetch-csv.tsx b/frontend/packages/ceph-storage-plugin/src/components/create-storage-system/use-fetch-csv.tsx new file mode 100644 index 00000000000..7ede85f97ee --- /dev/null +++ b/frontend/packages/ceph-storage-plugin/src/components/create-storage-system/use-fetch-csv.tsx @@ -0,0 +1,47 @@ +import * as React from 'react'; +import { useTranslation } from 'react-i18next'; +import { useK8sWatchResource } from '@console/internal/components/utils/k8s-watch-hook'; +import { + ClusterServiceVersionKind, + ClusterServiceVersionModel, + SubscriptionKind, +} from '@console/operator-lifecycle-manager/src'; +import { referenceForModel } from '@console/internal/module/k8s'; +import { subscriptionResource } from '../../resources'; + +export const useFetchCsv = (specName: string): UseFetchCsvResult => { + const { t } = useTranslation(); + const [subs, subsLoaded, subsLoadError] = useK8sWatchResource( + subscriptionResource, + ); + const csvName = React.useRef(null); + const csvNamespace = React.useRef(null); + + React.useEffect(() => { + if (subsLoaded && !subsLoadError && subs.length) { + const sub = subs.find((s) => s.spec.name === specName); + csvName.current = sub?.status?.installedCSV; + csvNamespace.current = sub?.metadata?.namespace; + } + }, [specName, subs, subsLoadError, subsLoaded]); + + const [csv, csvLoaded, csvLoadError] = useK8sWatchResource({ + kind: referenceForModel(ClusterServiceVersionModel), + name: csvName.current, + namespaced: true, + namespace: csvNamespace.current, + isList: false, + }); + + if (csvName.current === null || csvNamespace.current === null) { + return [undefined, false, undefined]; + } + + if (!csvName.current || !csvNamespace.current) { + return [undefined, true, new Error(t('ceph-storage-plugin~Not found'))]; + } + + return [csv, csvLoaded, csvLoadError]; +}; + +type UseFetchCsvResult = [ClusterServiceVersionKind, boolean, any]; diff --git a/frontend/packages/ceph-storage-plugin/src/components/ocs-install/attached-devices-mode/install-wizard-steps/create-storage-class/create-storage-class-step.tsx b/frontend/packages/ceph-storage-plugin/src/components/ocs-install/attached-devices-mode/install-wizard-steps/create-storage-class/create-storage-class-step.tsx index 496ada1b7df..7ae416844a8 100644 --- a/frontend/packages/ceph-storage-plugin/src/components/ocs-install/attached-devices-mode/install-wizard-steps/create-storage-class/create-storage-class-step.tsx +++ b/frontend/packages/ceph-storage-plugin/src/components/ocs-install/attached-devices-mode/install-wizard-steps/create-storage-class/create-storage-class-step.tsx @@ -59,7 +59,7 @@ export const CreateStorageClass: React.FC = ({ state, d 'ceph-storage-plugin~Uses the available disks that match the selected filters on all nodes selected in the previous step.', ); const lvsNameSelectorTxt = t( - 'ceph-storage-plugin~A Local Volume Set allows you to filter a set of disks, group them and create a dedicated StorageClass to consume storage from them.', + 'ceph-storage-plugin~A LocalVolumeSet allows you to filter a set of disks, group them and create a dedicated StorageClass to consume storage from them.', ); const lvsNodes = state.lvsIsSelectNodes ? state.lvsSelectNodes : state.lvsAllNodes; diff --git a/frontend/packages/local-storage-operator-plugin/locales/en/lso-plugin.json b/frontend/packages/local-storage-operator-plugin/locales/en/lso-plugin.json index 09412c62303..6c885a4ebb4 100644 --- a/frontend/packages/local-storage-operator-plugin/locales/en/lso-plugin.json +++ b/frontend/packages/local-storage-operator-plugin/locales/en/lso-plugin.json @@ -25,7 +25,7 @@ "Node Selector": "Node Selector", "Local Volume Discovery": "Local Volume Discovery", "Allows you to discover the available disks on all available nodes": "Allows you to discover the available disks on all available nodes", - "Local Volume Set Name": "Local Volume Set Name", + "LocalVolumeSet Name": "LocalVolumeSet Name", "StorageClass Name": "StorageClass Name", "Filter Disks By": "Filter Disks By", "Uses the available disks that match the selected filters only on selected nodes.": "Uses the available disks that match the selected filters only on selected nodes.", diff --git a/frontend/packages/local-storage-operator-plugin/src/components/local-volume-set/body.tsx b/frontend/packages/local-storage-operator-plugin/src/components/local-volume-set/body.tsx index 236416f20ee..db1bcff2058 100644 --- a/frontend/packages/local-storage-operator-plugin/src/components/local-volume-set/body.tsx +++ b/frontend/packages/local-storage-operator-plugin/src/components/local-volume-set/body.tsx @@ -64,7 +64,7 @@ export const LocalVolumeSetBody: React.FC = ({ return ( <>