From b5351a7a39e429b0b4dff36e80d51572e56cca13 Mon Sep 17 00:00:00 2001 From: Gage Krumbach Date: Tue, 17 Sep 2024 15:03:57 -0500 Subject: [PATCH] Storage class dropdown select (#3212) * RHOAIENG-1109 Select Storage Classes in add cluster storage sections * refactor: Remove unnecessary storageClassName parameter in replaceRootVolumesForNotebook function fix tests fix disabled checks pr fixes to state added tests linter fix * fix alert on empty * fixed storage class default --------- Co-authored-by: Dipanshu Gupta --- frontend/src/__mocks__/mockStorageClasses.ts | 19 ++- .../cypress/cypress/pages/clusterStorage.ts | 8 ++ .../mocked/projects/clusterStorage.cy.ts | 22 +++- .../tests/mocked/projects/workbench.cy.ts | 2 + .../storageClasses/storageClasses.cy.ts | 21 +-- frontend/src/api/k8s/pvcs.ts | 5 +- .../detail/storage/ManageStorageModal.tsx | 40 +++++- .../screens/spawner/SpawnerFooter.tsx | 9 +- .../projects/screens/spawner/SpawnerPage.tsx | 12 ++ .../pages/projects/screens/spawner/service.ts | 6 +- .../projects/screens/spawner/spawnerUtils.ts | 5 +- .../storage/CreateNewStorageSection.tsx | 62 +++++---- .../spawner/storage/StorageClassSelect.tsx | 120 ++++++++++++++++++ .../spawner/storage/useDefaultStorageClass.ts | 35 +++++ .../storage/usePreferredStorageClass.ts | 6 +- .../projects/screens/spawner/storage/utils.ts | 7 +- frontend/src/pages/projects/types.ts | 1 + 17 files changed, 306 insertions(+), 74 deletions(-) create mode 100644 frontend/src/pages/projects/screens/spawner/storage/StorageClassSelect.tsx create mode 100644 frontend/src/pages/projects/screens/spawner/storage/useDefaultStorageClass.ts diff --git a/frontend/src/__mocks__/mockStorageClasses.ts b/frontend/src/__mocks__/mockStorageClasses.ts index 3a62b3e3ad..35ac9ee2f6 100644 --- a/frontend/src/__mocks__/mockStorageClasses.ts +++ b/frontend/src/__mocks__/mockStorageClasses.ts @@ -1,4 +1,4 @@ -import { K8sResourceListResult, StorageClassKind } from '~/k8sTypes'; +import { K8sResourceListResult, StorageClassConfig, StorageClassKind } from '~/k8sTypes'; export type MockStorageClass = Omit; @@ -19,6 +19,23 @@ export const mockStorageClassList = ( items: storageClasses, }); +export const buildMockStorageClass = ( + mockStorageClass: MockStorageClass, + config: Partial, +): MockStorageClass => ({ + ...mockStorageClass, + metadata: { + ...mockStorageClass.metadata, + annotations: { + ...mockStorageClass.metadata.annotations, + 'opendatahub.io/sc-config': JSON.stringify({ + ...JSON.parse(String(mockStorageClass.metadata.annotations?.['opendatahub.io/sc-config'])), + ...config, + }), + }, + }, +}); + export const mockStorageClasses: MockStorageClass[] = [ { metadata: { diff --git a/frontend/src/__tests__/cypress/cypress/pages/clusterStorage.ts b/frontend/src/__tests__/cypress/cypress/pages/clusterStorage.ts index a9611a8ba8..285b5e5bee 100644 --- a/frontend/src/__tests__/cypress/cypress/pages/clusterStorage.ts +++ b/frontend/src/__tests__/cypress/cypress/pages/clusterStorage.ts @@ -85,6 +85,14 @@ class ClusterStorageModal extends Modal { findPVSizePlusButton() { return this.findPVSizeField().findByRole('button', { name: 'Plus' }); } + + findStorageClassSelect() { + return this.find().findByTestId('storage-classes-selector'); + } + + findStorageClassDeprecatedWarning() { + return this.find().findByTestId('deprecated-storage-warning'); + } } class ClusterStorage { diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/projects/clusterStorage.cy.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/projects/clusterStorage.cy.ts index 5b71631564..149c6f8f40 100644 --- a/frontend/src/__tests__/cypress/cypress/tests/mocked/projects/clusterStorage.cy.ts +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/projects/clusterStorage.cy.ts @@ -1,4 +1,10 @@ -import { mockK8sResourceList, mockNotebookK8sResource, mockProjectK8sResource } from '~/__mocks__'; +import { + buildMockStorageClass, + mockK8sResourceList, + mockNotebookK8sResource, + mockProjectK8sResource, + mockStorageClasses, +} from '~/__mocks__'; import { mockClusterSettings } from '~/__mocks__/mockClusterSettings'; import { mockPVCK8sResource } from '~/__mocks__/mockPVCK8sResource'; import { mockPodK8sResource } from '~/__mocks__/mockPodK8sResource'; @@ -17,6 +23,7 @@ import { } from '~/__tests__/cypress/cypress/utils/models'; import { mock200Status } from '~/__mocks__/mockK8sStatus'; import { mockPrometheusQueryResponse } from '~/__mocks__/mockPrometheusQueryResponse'; +import { storageClassesTable } from '~/__tests__/cypress/cypress/pages/storageClasses'; type HandlersProps = { isEmpty?: boolean; @@ -45,6 +52,8 @@ const initInterceptors = ({ isEmpty = false }: HandlersProps) => { cy.interceptK8sList(NotebookModel, mockK8sResourceList([mockNotebookK8sResource({})])); }; +const [openshiftDefaultStorageClass, otherStorageClass] = mockStorageClasses; + describe('ClusterStorage', () => { it('Empty state', () => { initInterceptors({ isEmpty: true }); @@ -56,9 +65,20 @@ describe('ClusterStorage', () => { it('Add cluster storage', () => { initInterceptors({ isEmpty: true }); + storageClassesTable.mockGetStorageClasses([ + openshiftDefaultStorageClass, + buildMockStorageClass(otherStorageClass, { isEnabled: true }), + ]); + clusterStorage.visit('test-project'); clusterStorage.findCreateButton().click(); addClusterStorageModal.findNameInput().fill('test-storage'); + + // default selected + addClusterStorageModal.find().findByText('openshift-default-sc').should('exist'); + + // select storage class + addClusterStorageModal.findStorageClassSelect().findSelectOption('Test SC 1').click(); addClusterStorageModal.findSubmitButton().should('be.enabled'); addClusterStorageModal.findDescriptionInput().fill('description'); addClusterStorageModal.findPVSizeMinusButton().click(); diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/projects/workbench.cy.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/projects/workbench.cy.ts index a43ce43ff7..499e484000 100644 --- a/frontend/src/__tests__/cypress/cypress/tests/mocked/projects/workbench.cy.ts +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/projects/workbench.cy.ts @@ -6,6 +6,7 @@ import { mockProjectK8sResource, mockRouteK8sResource, mockSecretK8sResource, + mockStorageClassList, } from '~/__mocks__'; import { mockConfigMap } from '~/__mocks__/mockConfigMap'; import { mockImageStreamK8sResource } from '~/__mocks__/mockImageStreamK8sResource'; @@ -60,6 +61,7 @@ const initIntercepts = ({ }, ], }: HandlersProps) => { + cy.interceptOdh('GET /api/k8s/apis/storage.k8s.io/v1/storageclasses', {}, mockStorageClassList()); cy.interceptOdh( 'GET /api/dsc/status', mockDscStatus({ diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/storageClasses/storageClasses.cy.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/storageClasses/storageClasses.cy.ts index 1eeecdd4b2..94a84bdac2 100644 --- a/frontend/src/__tests__/cypress/cypress/tests/mocked/storageClasses/storageClasses.cy.ts +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/storageClasses/storageClasses.cy.ts @@ -1,5 +1,4 @@ -import type { MockStorageClass } from '~/__mocks__'; -import { mockStorageClassList, mockStorageClasses } from '~/__mocks__'; +import { buildMockStorageClass, mockStorageClassList, mockStorageClasses } from '~/__mocks__'; import { asProductAdminUser } from '~/__tests__/cypress/cypress/utils/mockUsers'; import { pageNotfound } from '~/__tests__/cypress/cypress/pages/pageNotFound'; import { @@ -7,7 +6,6 @@ import { storageClassesPage, storageClassesTable, } from '~/__tests__/cypress/cypress/pages/storageClasses'; -import type { StorageClassConfig } from '~/k8sTypes'; describe('Storage classes', () => { it('shows "page not found" and does not show nav item as a non-admin user', () => { @@ -153,20 +151,3 @@ describe('Storage classes', () => { }); }); }); - -const buildMockStorageClass = ( - mockStorageClass: MockStorageClass, - config: Partial, -) => ({ - ...mockStorageClass, - metadata: { - ...mockStorageClass.metadata, - annotations: { - ...mockStorageClass.metadata.annotations, - 'opendatahub.io/sc-config': JSON.stringify({ - ...JSON.parse(String(mockStorageClass.metadata.annotations?.['opendatahub.io/sc-config'])), - ...config, - }), - }, - }, -}); diff --git a/frontend/src/api/k8s/pvcs.ts b/frontend/src/api/k8s/pvcs.ts index b1249ced3a..7981c1d553 100644 --- a/frontend/src/api/k8s/pvcs.ts +++ b/frontend/src/api/k8s/pvcs.ts @@ -17,11 +17,11 @@ export const assemblePvc = ( data: CreatingStorageObject, namespace: string, editName?: string, - storageClassName?: string, ): PersistentVolumeClaimKind => { const { nameDesc: { name: pvcName, description }, size, + storageClassName, } = data; const name = editName || translateDisplayNameForK8s(pvcName); @@ -68,10 +68,9 @@ export const getDashboardPvcs = (projectName: string): Promise => { - const pvc = assemblePvc(data, namespace, undefined, storageClassName); + const pvc = assemblePvc(data, namespace); return k8sCreateResource( applyK8sAPIOptions({ model: PVCModel, resource: pvc }, opts), diff --git a/frontend/src/pages/projects/screens/detail/storage/ManageStorageModal.tsx b/frontend/src/pages/projects/screens/detail/storage/ManageStorageModal.tsx index f281fef202..a0b145be47 100644 --- a/frontend/src/pages/projects/screens/detail/storage/ManageStorageModal.tsx +++ b/frontend/src/pages/projects/screens/detail/storage/ManageStorageModal.tsx @@ -12,8 +12,10 @@ import useRelatedNotebooks, { import NotebookRestartAlert from '~/pages/projects/components/NotebookRestartAlert'; import useWillNotebooksRestart from '~/pages/projects/notebook/useWillNotebooksRestart'; import DashboardModalFooter from '~/concepts/dashboard/DashboardModalFooter'; -import usePreferredStorageClass from '~/pages/projects/screens/spawner/storage/usePreferredStorageClass'; import { getDescriptionFromK8sResource, getDisplayNameFromK8sResource } from '~/concepts/k8s/utils'; +import { SupportedArea, useIsAreaAvailable } from '~/concepts/areas'; +import usePreferredStorageClass from '~/pages/projects/screens/spawner/storage/usePreferredStorageClass'; +import useDefaultStorageClass from '~/pages/projects/screens/spawner/storage/useDefaultStorageClass'; import ExistingConnectedNotebooks from './ExistingConnectedNotebooks'; type AddStorageModalProps = { @@ -23,6 +25,10 @@ type AddStorageModalProps = { }; const ManageStorageModal: React.FC = ({ existingData, isOpen, onClose }) => { + const isStorageClassesAvailable = useIsAreaAvailable(SupportedArea.STORAGE_CLASSES).status; + const preferredStorageClass = usePreferredStorageClass(); + const defaultStorageClass = useDefaultStorageClass(); + const [createData, setCreateData, resetData] = useCreateStorageObjectForNotebook(existingData); const [actionInProgress, setActionInProgress] = React.useState(false); const [error, setError] = React.useState(); @@ -40,7 +46,22 @@ const ManageStorageModal: React.FC = ({ existingData, isOp createData.forNotebook.name, ]); - const storageClass = usePreferredStorageClass(); + React.useEffect(() => { + if (!existingData && isOpen) { + if (isStorageClassesAvailable) { + setCreateData('storageClassName', defaultStorageClass?.metadata.name); + } else { + setCreateData('storageClassName', preferredStorageClass?.metadata.name); + } + } + }, [ + isStorageClassesAvailable, + defaultStorageClass, + preferredStorageClass, + existingData, + isOpen, + setCreateData, + ]); const onBeforeClose = (submitted: boolean) => { onClose(submitted); @@ -53,8 +74,13 @@ const ManageStorageModal: React.FC = ({ existingData, isOp const hasValidNotebookRelationship = createData.forNotebook.name ? !!createData.forNotebook.mountPath.value && !createData.forNotebook.mountPath.error : true; + + const storageClassSelected = isStorageClassesAvailable ? createData.storageClassName : true; const canCreate = - !actionInProgress && createData.nameDesc.name.trim() && hasValidNotebookRelationship; + !actionInProgress && + createData.nameDesc.name.trim() && + hasValidNotebookRelationship && + storageClassSelected; const runPromiseActions = async (dryRun: boolean) => { const { @@ -66,7 +92,8 @@ const ManageStorageModal: React.FC = ({ existingData, isOp if ( getDisplayNameFromK8sResource(existingData) !== createData.nameDesc.name || getDescriptionFromK8sResource(existingData) !== createData.nameDesc.description || - existingData.spec.resources.requests.storage !== createData.size + existingData.spec.resources.requests.storage !== createData.size || + existingData.spec.storageClassName !== createData.storageClassName ) { pvcPromises.push(updatePvc(createData, existingData, namespace, { dryRun })); } @@ -87,9 +114,7 @@ const ManageStorageModal: React.FC = ({ existingData, isOp } return; } - const createdPvc = await createPvc(createData, namespace, storageClass?.metadata.name, { - dryRun, - }); + const createdPvc = await createPvc(createData, namespace, { dryRun }); if (notebookName) { await attachNotebookPVC(notebookName, namespace, createdPvc.metadata.name, mountPath.value, { dryRun, @@ -145,6 +170,7 @@ const ManageStorageModal: React.FC = ({ existingData, isOp setData={(key, value) => setCreateData(key, value)} currentSize={existingData?.status?.capacity?.storage} autoFocusName + disableStorageClassSelect={!!existingData} /> {createData.hasExistingNotebookConnections && ( diff --git a/frontend/src/pages/projects/screens/spawner/SpawnerFooter.tsx b/frontend/src/pages/projects/screens/spawner/SpawnerFooter.tsx index b040869cbf..68137ca2c9 100644 --- a/frontend/src/pages/projects/screens/spawner/SpawnerFooter.tsx +++ b/frontend/src/pages/projects/screens/spawner/SpawnerFooter.tsx @@ -18,7 +18,6 @@ import { import { useUser } from '~/redux/selectors'; import { ProjectDetailsContext } from '~/pages/projects/ProjectDetailsContext'; import { AppContext } from '~/app/AppContext'; -import usePreferredStorageClass from '~/pages/projects/screens/spawner/storage/usePreferredStorageClass'; import { ProjectSectionID } from '~/pages/projects/screens/detail/types'; import { fireFormTrackingEvent } from '~/concepts/analyticsTracking/segmentIOUtils'; import { @@ -57,7 +56,6 @@ const SpawnerFooter: React.FC = ({ }, } = React.useContext(AppContext); const tolerationSettings = notebookController?.notebookTolerationSettings; - const storageClass = usePreferredStorageClass(); const { notebooks: { data }, dataConnections: { data: existingDataConnections }, @@ -158,7 +156,6 @@ const SpawnerFooter: React.FC = ({ projectName, editNotebook, storageData, - storageClass?.metadata.name, dryRun, ).catch(handleError); @@ -242,11 +239,7 @@ const SpawnerFooter: React.FC = ({ ? [dataConnection.existing] : []; - const pvcDetails = await createPvcDataForNotebook( - projectName, - storageData, - storageClass?.metadata.name, - ).catch(handleError); + const pvcDetails = await createPvcDataForNotebook(projectName, storageData).catch(handleError); const envFrom = await createConfigMapsAndSecretsForNotebook(projectName, [ ...envVariables, ...newDataConnection, diff --git a/frontend/src/pages/projects/screens/spawner/SpawnerPage.tsx b/frontend/src/pages/projects/screens/spawner/SpawnerPage.tsx index aa85e2e012..507422bdd5 100644 --- a/frontend/src/pages/projects/screens/spawner/SpawnerPage.tsx +++ b/frontend/src/pages/projects/screens/spawner/SpawnerPage.tsx @@ -26,6 +26,7 @@ import useNotebookAcceleratorProfile from '~/pages/projects/screens/detail/noteb import { NotebookImageAvailability } from '~/pages/projects/screens/detail/notebooks/const'; import { getDisplayNameFromK8sResource } from '~/concepts/k8s/utils'; import useGenericObjectState from '~/utilities/useGenericObjectState'; +import { SupportedArea, useIsAreaAvailable } from '~/concepts/areas'; import K8sNameDescriptionField, { useK8sNameDescriptionFieldData, } from '~/concepts/k8s/K8sNameDescriptionField/K8sNameDescriptionField'; @@ -47,6 +48,8 @@ import { useNotebookEnvVariables } from './environmentVariables/useNotebookEnvVa import DataConnectionField from './dataConnection/DataConnectionField'; import { useNotebookDataConnection } from './dataConnection/useNotebookDataConnection'; import { useNotebookSizeState } from './useNotebookSizeState'; +import useDefaultStorageClass from './storage/useDefaultStorageClass'; +import usePreferredStorageClass from './storage/usePreferredStorageClass'; type SpawnerPageProps = { existingNotebook?: NotebookKind; @@ -70,10 +73,19 @@ const SpawnerPage: React.FC = ({ existingNotebook }) => { string[] | undefined >(); const [storageDataWithoutDefault, setStorageData] = useStorageDataObject(existingNotebook); + + const defaultStorageClass = useDefaultStorageClass(); + const preferredStorageClass = usePreferredStorageClass(); + const isStorageClassesAvailable = useIsAreaAvailable(SupportedArea.STORAGE_CLASSES).status; + const defaultStorageClassName = isStorageClassesAvailable + ? defaultStorageClass?.metadata.name + : preferredStorageClass?.metadata.name; const storageData = useMergeDefaultPVCName( storageDataWithoutDefault, k8sNameDescriptionData.data.name, + defaultStorageClassName, ); + const [envVariables, setEnvVariables] = useNotebookEnvVariables(existingNotebook); const [dataConnectionData, setDataConnectionData] = useNotebookDataConnection( dataConnections.data, diff --git a/frontend/src/pages/projects/screens/spawner/service.ts b/frontend/src/pages/projects/screens/spawner/service.ts index 3d84738503..bb77d5400d 100644 --- a/frontend/src/pages/projects/screens/spawner/service.ts +++ b/frontend/src/pages/projects/screens/spawner/service.ts @@ -30,14 +30,13 @@ import { fetchNotebookEnvVariables } from './environmentVariables/useNotebookEnv export const createPvcDataForNotebook = async ( projectName: string, storageData: StorageData, - storageClassName?: string, ): Promise<{ volumes: Volume[]; volumeMounts: VolumeMount[] }> => { const { storageType } = storageData; const { volumes, volumeMounts } = getVolumesByStorageData(storageData); if (storageType === StorageType.NEW_PVC) { - const pvc = await createPvc(storageData.creating, projectName, storageClassName); + const pvc = await createPvc(storageData.creating, projectName); const newPvcName = pvc.metadata.name; volumes.push({ name: newPvcName, persistentVolumeClaim: { claimName: newPvcName } }); volumeMounts.push({ mountPath: ROOT_MOUNT_PATH, name: newPvcName }); @@ -49,7 +48,6 @@ export const replaceRootVolumesForNotebook = async ( projectName: string, notebook: NotebookKind, storageData: StorageData, - storageClassName?: string, dryRun?: boolean, ): Promise<{ volumes: Volume[]; volumeMounts: VolumeMount[] }> => { const { @@ -70,7 +68,7 @@ export const replaceRootVolumesForNotebook = async ( }; replacedVolumeMount = { name: existingName, mountPath: ROOT_MOUNT_PATH }; } else { - const pvc = await createPvc(storageData.creating, projectName, storageClassName, { dryRun }); + const pvc = await createPvc(storageData.creating, projectName, { dryRun }); const newPvcName = pvc.metadata.name; replacedVolume = { name: newPvcName, persistentVolumeClaim: { claimName: newPvcName } }; replacedVolumeMount = { mountPath: ROOT_MOUNT_PATH, name: newPvcName }; diff --git a/frontend/src/pages/projects/screens/spawner/spawnerUtils.ts b/frontend/src/pages/projects/screens/spawner/spawnerUtils.ts index 867dd074b3..e661f6fa9e 100644 --- a/frontend/src/pages/projects/screens/spawner/spawnerUtils.ts +++ b/frontend/src/pages/projects/screens/spawner/spawnerUtils.ts @@ -33,6 +33,7 @@ import { FAILED_PHASES, PENDING_PHASES, IMAGE_ANNOTATIONS } from './const'; export const useMergeDefaultPVCName = ( storageData: StorageData, defaultPVCName: string, + defaultStorageClassName?: string, ): StorageData => { const modifiedRef = React.useRef(false); @@ -49,6 +50,7 @@ export const useMergeDefaultPVCName = ( ...storageData.creating.nameDesc, name: storageData.creating.nameDesc.name || defaultPVCName, }, + storageClassName: storageData.creating.storageClassName || defaultStorageClassName, }, }; }; @@ -406,7 +408,8 @@ export const checkRequiredFieldsForNotebookStart = ( image.imageVersion ); - const newStorageFieldInvalid = storageType === StorageType.NEW_PVC && !creating.nameDesc.name; + const newStorageFieldInvalid = + storageType === StorageType.NEW_PVC && (!creating.nameDesc.name || !creating.storageClassName); const existingStorageFieldInvalid = storageType === StorageType.EXISTING_PVC && !existing.storage; const isStorageDataValid = !newStorageFieldInvalid && !existingStorageFieldInvalid; diff --git a/frontend/src/pages/projects/screens/spawner/storage/CreateNewStorageSection.tsx b/frontend/src/pages/projects/screens/spawner/storage/CreateNewStorageSection.tsx index de9c3fc5ec..57117d87ad 100644 --- a/frontend/src/pages/projects/screens/spawner/storage/CreateNewStorageSection.tsx +++ b/frontend/src/pages/projects/screens/spawner/storage/CreateNewStorageSection.tsx @@ -3,6 +3,8 @@ import { Stack, StackItem } from '@patternfly/react-core'; import { CreatingStorageObject, UpdateObjectAtPropAndValue } from '~/pages/projects/types'; import PVSizeField from '~/pages/projects/components/PVSizeField'; import NameDescriptionField from '~/concepts/k8s/NameDescriptionField'; +import { SupportedArea, useIsAreaAvailable } from '~/concepts/areas'; +import StorageClassSelect from './StorageClassSelect'; type CreateNewStorageSectionProps = { data: CreatingStorageObject; @@ -10,6 +12,7 @@ type CreateNewStorageSectionProps = { currentSize?: string; autoFocusName?: boolean; menuAppendTo?: HTMLElement; + disableStorageClassSelect?: boolean; }; const CreateNewStorageSection: React.FC = ({ @@ -18,27 +21,42 @@ const CreateNewStorageSection: React.FC = ({ currentSize, menuAppendTo, autoFocusName, -}) => ( - - - setData('nameDesc', newData)} - autoFocusName={autoFocusName} - /> - - - setData('size', size)} - /> - - -); + disableStorageClassSelect, +}) => { + const isStorageClassesAvailable = useIsAreaAvailable(SupportedArea.STORAGE_CLASSES).status; + + return ( + + + setData('nameDesc', newData)} + autoFocusName={autoFocusName} + /> + + + {isStorageClassesAvailable && ( + setData('storageClassName', name)} + disableStorageClassSelect={disableStorageClassSelect} + menuAppendTo={menuAppendTo} + /> + )} + + + setData('size', size)} + /> + + + ); +}; export default CreateNewStorageSection; diff --git a/frontend/src/pages/projects/screens/spawner/storage/StorageClassSelect.tsx b/frontend/src/pages/projects/screens/spawner/storage/StorageClassSelect.tsx new file mode 100644 index 0000000000..651370a5a9 --- /dev/null +++ b/frontend/src/pages/projects/screens/spawner/storage/StorageClassSelect.tsx @@ -0,0 +1,120 @@ +import { + Split, + SplitItem, + Label, + FormGroup, + Alert, + FormHelperText, + HelperText, + HelperTextItem, +} from '@patternfly/react-core'; +import { ExclamationTriangleIcon } from '@patternfly/react-icons'; +import React from 'react'; +import SimpleSelect, { SimpleSelectOption } from '~/components/SimpleSelect'; +import useStorageClasses from '~/concepts/k8s/useStorageClasses'; +import { getStorageClassConfig } from '~/pages/storageClasses/utils'; + +type StorageClassSelectProps = { + storageClassName?: string; + setStorageClassName: (name: string) => void; + disableStorageClassSelect?: boolean; + menuAppendTo?: HTMLElement; +}; + +const StorageClassSelect: React.FC = ({ + storageClassName, + setStorageClassName, + disableStorageClassSelect, + menuAppendTo, +}) => { + const [storageClasses, storageClassesLoaded] = useStorageClasses(); + + const enabledStorageClasses = storageClasses + .filter((sc) => getStorageClassConfig(sc)?.isEnabled) + .toSorted((a, b) => { + const aConfig = getStorageClassConfig(a); + const bConfig = getStorageClassConfig(b); + if (aConfig?.isDefault) { + return -1; + } + if (bConfig?.isDefault) { + return 1; + } + return (aConfig?.displayName || a.metadata.name).localeCompare( + bConfig?.displayName || b.metadata.name, + ); + }); + + const selectedStorageClass = storageClasses.find((sc) => sc.metadata.name === storageClassName); + const selectedStorageClassConfig = selectedStorageClass + ? getStorageClassConfig(selectedStorageClass) + : undefined; + + const options: SimpleSelectOption[] = ( + disableStorageClassSelect ? storageClasses : enabledStorageClasses + ).map((sc) => { + const config = getStorageClassConfig(sc); + + return { + key: sc.metadata.name, + label: config?.displayName || sc.metadata.name, + description: config?.description, + isDisabled: !config?.isEnabled, + dropdownLabel: ( + + {config?.displayName || sc.metadata.name} + + + {config?.isDefault && ( + + )} + + + ), + }; + }); + + return ( + + { + setStorageClassName(selection); + }} + isDisabled={ + disableStorageClassSelect || !storageClassesLoaded || storageClasses.length <= 1 + } + placeholder="Select storage class" + popperProps={{ appendTo: menuAppendTo }} + /> + + {selectedStorageClassConfig && !selectedStorageClassConfig.isEnabled ? ( + + } + > + The selected storage class is deprecated. + + + ) : ( + + )} + + + ); +}; + +export default StorageClassSelect; diff --git a/frontend/src/pages/projects/screens/spawner/storage/useDefaultStorageClass.ts b/frontend/src/pages/projects/screens/spawner/storage/useDefaultStorageClass.ts new file mode 100644 index 0000000000..934f6247b2 --- /dev/null +++ b/frontend/src/pages/projects/screens/spawner/storage/useDefaultStorageClass.ts @@ -0,0 +1,35 @@ +import * as React from 'react'; +import { SupportedArea, useIsAreaAvailable } from '~/concepts/areas'; +import useStorageClasses from '~/concepts/k8s/useStorageClasses'; +import { StorageClassKind } from '~/k8sTypes'; +import { getStorageClassConfig } from '~/pages/storageClasses/utils'; + +const useDefaultStorageClass = (): StorageClassKind | undefined => { + const isStorageClassesAvailable = useIsAreaAvailable(SupportedArea.STORAGE_CLASSES).status; + const [storageClasses, storageClassesLoaded] = useStorageClasses(); + const [defaultStorageClass, setDefaultStorageClass] = React.useState< + StorageClassKind | undefined + >(); + + React.useEffect(() => { + if (!storageClassesLoaded || !isStorageClassesAvailable) { + return; + } + + const enabledStorageClasses = storageClasses.filter( + (sc) => getStorageClassConfig(sc)?.isEnabled, + ); + + const defaultSc = enabledStorageClasses.find((sc) => getStorageClassConfig(sc)?.isDefault); + + if (!defaultSc && enabledStorageClasses.length > 0) { + setDefaultStorageClass(enabledStorageClasses[0]); + } else { + setDefaultStorageClass(defaultSc); + } + }, [storageClasses, storageClassesLoaded, isStorageClassesAvailable]); + + return defaultStorageClass; +}; + +export default useDefaultStorageClass; diff --git a/frontend/src/pages/projects/screens/spawner/storage/usePreferredStorageClass.ts b/frontend/src/pages/projects/screens/spawner/storage/usePreferredStorageClass.ts index e63aa2e4ff..c83c9c9398 100644 --- a/frontend/src/pages/projects/screens/spawner/storage/usePreferredStorageClass.ts +++ b/frontend/src/pages/projects/screens/spawner/storage/usePreferredStorageClass.ts @@ -1,6 +1,5 @@ import * as React from 'react'; import { AppContext } from '~/app/AppContext'; -import { SupportedArea, useIsAreaAvailable } from '~/concepts/areas'; import { MetadataAnnotation, StorageClassKind } from '~/k8sTypes'; const usePreferredStorageClass = (): StorageClassKind | undefined => { @@ -10,16 +9,13 @@ const usePreferredStorageClass = (): StorageClassKind | undefined => { }, storageClasses, } = React.useContext(AppContext); - const isStorageClassesAvailable = useIsAreaAvailable(SupportedArea.STORAGE_CLASSES).status; const defaultClusterStorageClasses = storageClasses.filter( (storageclass) => storageclass.metadata.annotations?.[MetadataAnnotation.StorageClassIsDefault] === 'true', ); - const configStorageClassName = !isStorageClassesAvailable - ? notebookController?.storageClassName ?? '' - : ''; + const configStorageClassName = notebookController?.storageClassName ?? ''; if (defaultClusterStorageClasses.length !== 0) { return undefined; diff --git a/frontend/src/pages/projects/screens/spawner/storage/utils.ts b/frontend/src/pages/projects/screens/spawner/storage/utils.ts index 54767a2011..9d78b03d5c 100644 --- a/frontend/src/pages/projects/screens/spawner/storage/utils.ts +++ b/frontend/src/pages/projects/screens/spawner/storage/utils.ts @@ -23,6 +23,7 @@ export const useCreateStorageObjectForNotebook = ( resetDefaults: () => void, ] => { const size = useDefaultPvcSize(); + const createDataState = useGenericObjectState({ nameDesc: { name: '', @@ -45,6 +46,7 @@ export const useCreateStorageObjectForNotebook = ( const existingName = existingData ? getDisplayNameFromK8sResource(existingData) : ''; const existingDescription = existingData ? getDescriptionFromK8sResource(existingData) : ''; const existingSize = existingData ? existingData.spec.resources.requests.storage : size; + const existingStorageClassName = existingData?.spec.storageClassName; const { notebooks: relatedNotebooks } = useRelatedNotebooks( ConnectedNotebookContext.REMOVABLE_PVC, existingData ? existingData.metadata.name : undefined, @@ -57,10 +59,9 @@ export const useCreateStorageObjectForNotebook = ( name: existingName, description: existingDescription, }); - setCreateData('hasExistingNotebookConnections', hasExistingNotebookConnections); - setCreateData('size', existingSize); + setCreateData('storageClassName', existingStorageClassName); } }, [ existingName, @@ -68,6 +69,7 @@ export const useCreateStorageObjectForNotebook = ( setCreateData, hasExistingNotebookConnections, existingSize, + existingStorageClassName, ]); return createDataState; @@ -102,6 +104,7 @@ export const useStorageDataObject = ( description: '', }, size, + storageClassName: '', }, existing: { storage: getRootVolumeName(notebook), diff --git a/frontend/src/pages/projects/types.ts b/frontend/src/pages/projects/types.ts index 7a358f1a94..e866646607 100644 --- a/frontend/src/pages/projects/types.ts +++ b/frontend/src/pages/projects/types.ts @@ -27,6 +27,7 @@ export type NameDescType = { export type CreatingStorageObject = { nameDesc: NameDescType; size: string; + storageClassName?: string; }; export type MountPath = {