Skip to content

Commit

Permalink
Merge pull request #1046 from yaacov/refactor-plan-actions
Browse files Browse the repository at this point in the history
🐞 Fix plan phase, successfull before warn
  • Loading branch information
yaacov authored Apr 1, 2024
2 parents 6a154d7 + b19a20d commit de53ef3
Show file tree
Hide file tree
Showing 13 changed files with 366 additions and 44 deletions.
1 change: 1 addition & 0 deletions packages/eslint-plugin/cspell.wordlist.txt
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,4 @@ storagedomains
esxi
KJUR
millicores
inputgroup
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@
"Credentials": "Credentials",
"Critical": "Critical",
"Critical concerns": "Critical concerns",
"Cutover": "Cutover",
"Data centers": "Data centers",
"Data is loading, please wait.": "Data is loading, please wait.",
"Data stores": "Data stores",
Expand Down Expand Up @@ -299,6 +300,8 @@
"Persistent TPM/EFI": "Persistent TPM/EFI",
"Plan details": "Plan details",
"Plan name": "Plan name",
"Plan running": "Plan running",
"Plane not ready": "Plane not ready",
"Plans": "Plans",
"Plans for virtualization": "Plans for virtualization",
"Please choose a NetworkAttachmentDefinition for data transfer.": "Please choose a NetworkAttachmentDefinition for data transfer.",
Expand Down Expand Up @@ -338,9 +341,12 @@
"Reason": "Reason",
"Region": "Region",
"Regions": "Regions",
"Remove cutover": "Remove cutover",
"Remove virtual machines": "Remove virtual machines",
"Reorder": "Reorder",
"Resources": "Resources",
"Restart": "Restart",
"Restart migration": "Restart migration",
"Restore default columns": "Restore default columns",
"Return to the providers list page": "Return to the providers list page",
"Reveal values": "Reveal values",
Expand All @@ -362,6 +368,7 @@
"Selected columns will be displayed in the table.": "Selected columns will be displayed in the table.",
"Selected VMs": "Selected VMs",
"Service account bearer token": "Service account bearer token",
"Set cutover": "Set cutover",
"Set default transfer network": "Set default transfer network",
"Set to preserve the CPU model": "Set to preserve the CPU model",
"Set warm migration": "Set warm migration",
Expand All @@ -381,6 +388,7 @@
"Specifies the duration for retaining 'must gather' reports before they are automatically deleted. The default value is -1, which implies automatic cleanup is disabled.": "Specifies the duration for retaining 'must gather' reports before they are automatically deleted. The default value is -1, which implies automatic cleanup is disabled.",
"Specify the type of source provider. Allowed values are ova, ovirt, vsphere,\n openshift, and openstack. This label is needed to verify the credentials are correct when the remote system is accessible and, for RHV, to retrieve the Manager CA certificate when\n a third-party certificate is specified.": "Specify the type of source provider. Allowed values are ova, ovirt, vsphere,\n openshift, and openstack. This label is needed to verify the credentials are correct when the remote system is accessible and, for RHV, to retrieve the Manager CA certificate when\n a third-party certificate is specified.",
"Staging": "Staging",
"start": "start",
"Start": "Start",
"Start migration": "Start migration",
"Started at": "Started at",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,14 @@ import { useForkliftTranslation } from 'src/utils/i18n';
import { PlanModel, PlanModelRef } from '@kubev2v/types';
import { DropdownItem } from '@patternfly/react-core';

import { ArchiveModal, DuplicateModal, PlanDeleteModal, PlanStartMigrationModal } from '../modals';
import { getPlanPhase, PlanData } from '../utils';
import {
ArchiveModal,
DuplicateModal,
PlanCutoverMigrationModal,
PlanDeleteModal,
PlanStartMigrationModal,
} from '../modals';
import { canPlanReStart, canPlanStart, getPlanPhase, isPlanExecuting, PlanData } from '../utils';

export const PlanActionsDropdownItems = ({ data }: PlanActionsDropdownItemsProps) => {
const { t } = useForkliftTranslation();
Expand All @@ -24,31 +30,53 @@ export const PlanActionsDropdownItems = ({ data }: PlanActionsDropdownItemsProps

const phase = getPlanPhase(data);

const canStart = canPlanStart(plan);
const canReStart = canPlanReStart(plan);
const isWarmAndExecuting = plan?.spec?.warm && isPlanExecuting(plan);

const buttonStartLabel = canReStart ? t('Restart migration') : t('Start migration');

return [
<DropdownItemLink key="EditPlan" href={planURL}>
{t('Edit Plan')}
</DropdownItemLink>,

<DropdownItem
key="start"
isDisabled={!['Ready', 'Warning', 'Canceled', 'Failed'].includes(phase)}
onClick={() => showModal(<PlanStartMigrationModal resource={plan} model={PlanModel} />)}
isDisabled={!canStart}
onClick={() =>
showModal(
<PlanStartMigrationModal resource={plan} model={PlanModel} title={buttonStartLabel} />,
)
}
>
{buttonStartLabel}
</DropdownItem>,

<DropdownItem
key="cutover"
isDisabled={!isWarmAndExecuting}
onClick={() => showModal(<PlanCutoverMigrationModal resource={plan} />)}
>
{t('Start migration')}
{t('Cutover')}
</DropdownItem>,

<DropdownItem
key="duplicate"
isDisabled={!data?.permissions?.canDelete}
onClick={() => showModal(<DuplicateModal resource={plan} model={PlanModel} />)}
>
{t('Duplicate Plan')}
</DropdownItem>,

<DropdownItem
key="archive"
isDisabled={!data?.permissions?.canDelete || ['Archived', 'Archiving'].includes(phase)}
onClick={() => showModal(<ArchiveModal resource={plan} model={PlanModel} />)}
>
{t('Archive Plan')}
</DropdownItem>,

<DropdownItem
key="delete"
isDisabled={!data?.permissions?.canDelete}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// @index(['./*', /style/g], f => `export * from '${f.path}';`)
export * from './usePlanMigration';
// @endindex
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { MigrationModelGroupVersionKind, V1beta1Migration, V1beta1Plan } from '@kubev2v/types';
import { useK8sWatchResource } from '@openshift-console/dynamic-plugin-sdk';

export const usePlanMigration = (plan: V1beta1Plan) => {
const [migrations, migrationLoaded, migrationLoadError] = useK8sWatchResource<V1beta1Migration[]>(
{
groupVersionKind: MigrationModelGroupVersionKind,
namespaced: true,
isList: true,
namespace: plan?.metadata?.namespace,
},
);

const planMigrations = (
migrations && migrationLoaded && !migrationLoadError ? migrations : []
).filter((m) => m?.metadata?.ownerReferences?.[0]?.uid === plan.metadata.uid);

planMigrations?.sort(
(a, b) =>
new Date(b.metadata.creationTimestamp).getTime() -
new Date(a.metadata.creationTimestamp).getTime(),
);
const lastMigration = planMigrations[0];

return [lastMigration, migrationLoaded, migrationLoadError];
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.forklift-plan-cutover-migration-inputgroup {
padding-top: var(--pf-global--spacer--lg);
padding-bottom: var(--pf-global--spacer--lg);
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import React, { ReactNode, useCallback, useEffect, useState } from 'react';
import { useToggle } from 'src/modules/Providers/hooks';
import { AlertMessageForModals, useModal } from 'src/modules/Providers/modals';
import { ForkliftTrans, useForkliftTranslation } from 'src/utils/i18n';

import { MigrationModel, V1beta1Migration, V1beta1Plan } from '@kubev2v/types';
import { k8sPatch } from '@openshift-console/dynamic-plugin-sdk';
import {
Button,
DatePicker,
InputGroup,
Modal,
ModalVariant,
TimePicker,
yyyyMMddFormat,
} from '@patternfly/react-core';

import { usePlanMigration } from '../hooks/usePlanMigration';

import './PlanCutoverMigrationModal.style.css';

/**
* Props for the DeleteModal component
* @typedef PlanStartMigrationModalProps
* @property {string} title - The title to display in the modal
* @property {V1beta1Plan} resource - The resource object to delete
* @property {K8sModel} model - The model used for deletion
* @property {string} [redirectTo] - Optional redirect URL after deletion
*/
interface PlanCutoverMigrationModalProps {
resource: V1beta1Plan;
title?: string;
}

/**
* A generic delete modal component
* @component
* @param {DeleteModalProps} props - Props for DeleteModal
* @returns {React.Element} The DeleteModal component
*/
export const PlanCutoverMigrationModal: React.FC<PlanCutoverMigrationModalProps> = ({
title,
resource,
}) => {
const { t } = useForkliftTranslation();
const { toggleModal } = useModal();
const [isLoading, toggleIsLoading] = useToggle();
const [cutoverDate, setCutoverDate] = useState<string>();
const [alertMessage, setAlertMessage] = useState<ReactNode>(null);

const title_ = title || t('Cutover');
const { name } = resource?.metadata || {};

const [lastMigration] = usePlanMigration(resource);

useEffect(() => {
const migrationCutoverDate = lastMigration?.spec?.cutover || new Date().toISOString();

setCutoverDate(migrationCutoverDate);
}, [lastMigration]);

const onDateChange = (inputDate, newDate: string) => {
const updatedFromDate = cutoverDate ? new Date(cutoverDate) : new Date();

const [year, month, day] = newDate.split('-').map((num: string) => parseInt(num, 10));

updatedFromDate.setFullYear(year);
updatedFromDate.setMonth(month - 1);
updatedFromDate.setDate(day);

setCutoverDate(updatedFromDate.toISOString());
};

const onTimeChange = (_event, _time, hour: number, minute: number) => {
const updatedFromDate = cutoverDate ? new Date(cutoverDate) : new Date();

updatedFromDate.setHours(hour);
updatedFromDate.setMinutes(minute);

setCutoverDate(updatedFromDate.toISOString());
};

const onCutover = useCallback(async () => {
toggleIsLoading();

try {
await patchMigrationCutover(lastMigration, cutoverDate);

toggleModal();
} catch (err) {
toggleIsLoading();

setAlertMessage(<AlertMessageForModals title={t('Error')} message={err.toString()} />);
}
}, [cutoverDate, lastMigration]);

const onDeleteCutover = useCallback(async () => {
toggleIsLoading();

try {
await patchMigrationCutover(lastMigration, undefined);

toggleModal();
} catch (err) {
toggleIsLoading();

setAlertMessage(<AlertMessageForModals title={t('Error')} message={err.toString()} />);
}
}, [lastMigration]);

const actions = [
<Button key="confirm" onClick={onCutover} isLoading={isLoading}>
{t('Set cutover')}
</Button>,
<Button key="delete" variant="secondary" onClick={onDeleteCutover} isLoading={isLoading}>
{t('Remove cutover')}
</Button>,
<Button key="cancel" variant="secondary" onClick={toggleModal}>
{t('Cancel')}
</Button>,
];

return (
<Modal
title={title_}
position="top"
showClose={false}
variant={ModalVariant.small}
isOpen={true}
onClose={toggleModal}
actions={actions}
>
<>
<ForkliftTrans>
<p>
Schedule the cutover for migration{' '}
<strong className="co-break-word">{{ resourceName: name }}</strong>?
</p>
<br />
<p>
You can schedule cutover for now or a future date and time. VMs included in the
migration plan will be shut down when cutover starts.
</p>
</ForkliftTrans>

<InputGroup className="forklift-plan-cutover-migration-inputgroup">
<DatePicker
onChange={onDateChange}
aria-label="Cutover date"
placeholder="YYYY-MM-DD"
appendTo={document.body}
value={yyyyMMddFormat(cutoverDate ? new Date(cutoverDate) : new Date())}
/>
<TimePicker
aria-label="Cutover time"
style={{ width: '150px' }}
onChange={onTimeChange}
menuAppendTo={document.body}
time={cutoverDate ? new Date(cutoverDate) : new Date()}
/>
</InputGroup>
</>
{alertMessage}
</Modal>
);
};

async function patchMigrationCutover(migration: V1beta1Migration, cutover: string) {
const op = migration?.spec?.cutover ? 'replace' : 'add';

await k8sPatch({
model: MigrationModel,
resource: migration,
data: [
{
op,
path: '/spec/cutover',
value: cutover,
},
],
});
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// @index(['./*', /style/g], f => `export * from '${f.path}';`)
export * from './ArchiveModal';
export * from './DuplicateModal';
export * from './PlanCutoverMigrationModal';
export * from './PlanDeleteModal';
export * from './PlanStartMigrationModal';
// @endindex
Loading

0 comments on commit de53ef3

Please sign in to comment.