diff --git a/locales/en/plugin__odf-console.json b/locales/en/plugin__odf-console.json index 2800d05f9..6434c4e98 100644 --- a/locales/en/plugin__odf-console.json +++ b/locales/en/plugin__odf-console.json @@ -441,6 +441,7 @@ "Only showing PVCs that are being mounted on an active pod": "Only showing PVCs that are being mounted on an active pod", "This card shows the requested capacity for different Kubernetes resources. The figures shown represent the usable storage, meaning that data replication is not taken into consideration.": "This card shows the requested capacity for different Kubernetes resources. The figures shown represent the usable storage, meaning that data replication is not taken into consideration.", "Internal": "Internal", + "Disaster recovery optimisation": "Disaster recovery optimisation", "Raw capacity is the absolute total disk space available to the array subsystem.": "Raw capacity is the absolute total disk space available to the array subsystem.", "Troubleshoot": "Troubleshoot", "Active health checks": "Active health checks", @@ -460,6 +461,9 @@ "Go To PVC List": "Go To PVC List", "Save": "Save", "BlockPool Update Form": "BlockPool Update Form", + "Optimize": "Optimize", + "Optimise cluster for Regional-DR?": "Optimise cluster for Regional-DR?", + "Configure the cluster for a Regional-DR setup by migrating OSDs. Migration may take sometime depending on several factors. To learn more about OSDs migration best practices and its consequences refer to the documentation.": "Configure the cluster for a Regional-DR setup by migrating OSDs. Migration may take sometime depending on several factors. To learn more about OSDs migration best practices and its consequences refer to the documentation.", "Filesystem name": "Filesystem name", "Enter filesystem name": "Enter filesystem name", "CephFS filesystem name into which the volume shall be created": "CephFS filesystem name into which the volume shall be created", diff --git a/packages/ocs/dashboards/persistent-internal/OptimizeModal.spec.tsx b/packages/ocs/dashboards/persistent-internal/OptimizeModal.spec.tsx new file mode 100644 index 000000000..142ebdd7f --- /dev/null +++ b/packages/ocs/dashboards/persistent-internal/OptimizeModal.spec.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { screen, render, fireEvent, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; +import { OSDMigrationModal } from '../../modals/osd-migration/osdMigrationModal'; +import * as migrationStatus from '../../utils/osd-migration'; + +jest.mock('@odf/shared/selectors', () => ({ + getName: jest.fn().mockReturnValue('test'), +})); + +// Mocking getOSDMigrationStatus +jest.mock('../../utils/osd-migration'); + +jest.mock('@odf/shared/hooks/useK8sList', () => ({ + __esModule: true, + useK8sList: () => [ + [ + { + metadata: { + name: 'test', + }, + }, + ], + true, + undefined, + ], +})); + +describe('OptimizeModal Component', () => { + test('renders without errors', async () => { + // Mock getOSDMigrationStatus to return 'Completed' for this test + migrationStatus.getOSDMigrationStatus.mockReturnValue('Completed'); + + render(); + + // Wait for the component to finish rendering (use async/await) + await waitFor(() => { + expect(screen.queryByText('Optimize')).not.toBeInTheDocument(); + }); + }); + + test('renders with migration pending', async () => { + // Mock getOSDMigrationStatus to return 'Completed' for this test + migrationStatus.getOSDMigrationStatus.mockReturnValue('Pending'); + + render(); + + // Wait for the component to finish rendering (use async/await) + await waitFor(() => { + expect(screen.queryByText('Optimize')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText(/Optimize/i)); + + // Wait for modal to be visible + await waitFor(() => { + expect( + screen.queryByText('Optimise cluster for Regional-DR?') + ).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Close')); + + // Wait for modal to be closed + await waitFor(() => { + expect( + screen.queryByText('Configure cluster for Regional-DR?') + ).not.toBeInTheDocument(); + }); + }); +}); diff --git a/packages/ocs/dashboards/persistent-internal/details-card.tsx b/packages/ocs/dashboards/persistent-internal/details-card.tsx index e634b49df..ef192cfef 100644 --- a/packages/ocs/dashboards/persistent-internal/details-card.tsx +++ b/packages/ocs/dashboards/persistent-internal/details-card.tsx @@ -5,11 +5,16 @@ import { useK8sGet } from '@odf/shared/hooks/k8s-get-hook'; import { useFetchCsv } from '@odf/shared/hooks/use-fetch-csv'; import { useK8sList } from '@odf/shared/hooks/useK8sList'; import { + CephClusterModel, ClusterServiceVersionModel, InfrastructureModel, } from '@odf/shared/models'; import { getName } from '@odf/shared/selectors'; -import { K8sResourceKind, StorageClusterKind } from '@odf/shared/types'; +import { + CephClusterKind, + K8sResourceKind, + StorageClusterKind, +} from '@odf/shared/types'; import { useCustomTranslation } from '@odf/shared/useCustomTranslationHook'; import { getInfrastructurePlatform, @@ -20,17 +25,25 @@ import { OverviewDetailItem as DetailItem } from '@openshift-console/plugin-shar import { Link } from 'react-router-dom'; import { Card, CardBody, CardHeader, CardTitle } from '@patternfly/react-core'; import { CEPH_NS } from '../../constants'; +import { OSDMigrationModal } from '../../modals/osd-migration/osdMigrationModal'; import { StorageClusterModel } from '../../models'; import { getNetworkEncryption } from '../../utils'; const DetailsCard: React.FC = () => { const { t } = useCustomTranslation(); + const [infrastructure, infrastructureLoaded, infrastructureError] = useK8sGet(InfrastructureModel, 'cluster'); const [ocsData, ocsLoaded, ocsError] = useK8sList( StorageClusterModel, CEPH_NS ); + + const [cephData, cephLoaded, cephLoadError] = useK8sList( + CephClusterModel, + ocsData?.[0].metadata?.namespace + ); + const [csv, csvLoaded, csvError] = useFetchCsv({ specName: ODF_OPERATOR, namespace: CEPH_STORAGE_NAMESPACE, @@ -100,6 +113,14 @@ const DetailsCard: React.FC = () => { > {inTransitEncryptionStatus} + + + diff --git a/packages/ocs/modals/osd-migration/osdMigrationModal.tsx b/packages/ocs/modals/osd-migration/osdMigrationModal.tsx new file mode 100644 index 000000000..b4d721dc9 --- /dev/null +++ b/packages/ocs/modals/osd-migration/osdMigrationModal.tsx @@ -0,0 +1,113 @@ +import * as React from 'react'; +import { DISASTER_RECOVERY_TARGET_ANNOTATION } from '@odf/core/constants'; +import { CephClusterModel } from '@odf/shared/models'; +import { getName, getNamespace } from '@odf/shared/selectors'; +import { CephClusterKind } from '@odf/shared/types'; +import { useCustomTranslation } from '@odf/shared/useCustomTranslationHook'; +import { + RedExclamationCircleIcon, + k8sPatch, +} from '@openshift-console/dynamic-plugin-sdk'; +import { + Modal, + ModalVariant, + Button, + ModalBoxHeader, + ModalBoxBody, + Title, +} from '@patternfly/react-core'; +import { + FAILED, + PENDING, + getOSDMigrationStatus, +} from '../../utils/osd-migration'; + +export const OSDMigrationModal: React.FC = ({ + cephData, +}) => { + const { t } = useCustomTranslation(); + const dRSetupStatus: string = getOSDMigrationStatus(cephData); + const [isOpen, setOpen] = React.useState(false); + const [errorMessage, setErrorMessage] = React.useState(null); + const openModal = () => setOpen(true); + + const closeModal = () => { + setOpen(false); + setErrorMessage(null); + }; + + const handleOptimize = () => { + const patch = [ + { + op: 'add', + path: `/metadata/annotations/${DISASTER_RECOVERY_TARGET_ANNOTATION}`, + value: 'true', + }, + ]; + + k8sPatch({ + model: CephClusterModel, + resource: { + metadata: { + name: getName(cephData), + namespace: getNamespace(cephData), + }, + }, + data: patch, + }) + .then(() => { + closeModal(); + }) + .catch((err) => { + setErrorMessage(err.message); + }); + }; + + return ( +
+
+

+ {dRSetupStatus === FAILED && } + {t(dRSetupStatus)} +

+ {dRSetupStatus === PENDING && ( + {t('Optimize')} + )} +
+
+ + Close + , + , + ]} + > +

+ {t( + 'Configure the cluster for a Regional-DR setup by migrating OSDs. Migration may take sometime depending on several factors. To learn more about OSDs migration best practices and its consequences refer to the documentation.' + )} +

+ {errorMessage && ( + + + Error + + + )} + {errorMessage} +
+
+
+ ); +}; + +type OSDMigrationModalProps = { + cephData?: CephClusterKind; +}; diff --git a/packages/ocs/utils/osd-migration.ts b/packages/ocs/utils/osd-migration.ts new file mode 100644 index 000000000..ff9fc4133 --- /dev/null +++ b/packages/ocs/utils/osd-migration.ts @@ -0,0 +1,35 @@ +import { DISASTER_RECOVERY_TARGET_ANNOTATION } from '@odf/core/constants'; +import { getAnnotations } from '@odf/shared/selectors'; +import { CephClusterKind } from '@odf/shared/types'; + +export const IN_PROGRESS = 'In Progress'; +export const PENDING = 'Pending'; +export const COMPLETED = 'Completed'; +export const FAILED = 'Failed'; +export const BLUESTORE_RDR = 'bluestore-rdr'; +export const BLUESTORE = 'bluestore'; + +export const getOSDMigrationStatus = (ceph: CephClusterKind) => { + if (!!ceph) { + const bluestoreCount = ceph?.status?.storage?.osd?.storeType?.[BLUESTORE]; + const bluestoreRdrCount = + ceph?.status?.storage?.osd?.storeType?.[BLUESTORE_RDR]; + + const isDisasterRecoveryTarget = + getAnnotations(ceph)?.[DISASTER_RECOVERY_TARGET_ANNOTATION] === 'true'; + + if (bluestoreCount > 0) { + if (bluestoreRdrCount > 0 || isDisasterRecoveryTarget) { + return IN_PROGRESS; + } else { + return PENDING; + } + } else if (bluestoreRdrCount > 0) { + return COMPLETED; + } + } else { + return FAILED; + } // TODO Add condition for migration failure + + return ''; +}; diff --git a/packages/shared/src/types/storage.ts b/packages/shared/src/types/storage.ts index 4bc1f2f36..d67fa3093 100644 --- a/packages/shared/src/types/storage.ts +++ b/packages/shared/src/types/storage.ts @@ -105,6 +105,12 @@ type CephDeviceClass = { export type CephClusterKind = K8sResourceCommon & { status?: { storage: { + osd: { + storeType: { + bluestore: number; + 'bluestore-rdr': number; + }; + }; deviceClasses: CephDeviceClass[]; }; ceph?: {