Skip to content

Commit 70e0c52

Browse files
authored
Resolves: MTV-4551 | Add virtual machines to existing plan (kubev2v#2261)
* Resolves: MTV-4551 | Add virtual machines to existing plan Signed-off-by: Aviv Turgeman <aturgema@redhat.com> * Resolves: MTV-4551 | fix jpuzz0 comments Signed-off-by: Aviv Turgeman <aturgema@redhat.com> --------- Signed-off-by: Aviv Turgeman <aturgema@redhat.com>
1 parent 241731c commit 70e0c52

File tree

12 files changed

+416
-7
lines changed

12 files changed

+416
-7
lines changed

locales/en/plugin__forklift-console-plugin.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,8 @@
137137
"Add Label to specify qualifying nodes": "Add Label to specify qualifying nodes",
138138
"Add mapping": "Add mapping",
139139
"Add passphrase": "Add passphrase",
140+
"Add virtual machines": "Add virtual machines",
141+
"Add virtual machines to migration plan": "Add virtual machines to migration plan",
140142
"Additional setup": "Additional setup",
141143
"Affinity rules": "Affinity rules",
142144
"Affinity rules allows you to specify hard-and soft-affinity for virtual machines. It is possible to write matching rules against workloads (virtual machines and Pods) and Nodes.": "Affinity rules allows you to specify hard-and soft-affinity for virtual machines. It is possible to write matching rules against workloads (virtual machines and Pods) and Nodes.",
@@ -334,7 +336,6 @@
334336
"Delete virtual machines from migration plan?": "Delete virtual machines from migration plan?",
335337
"Deleting a migration plan does not remove temporary resources, it is recommended to <2>archive</2> the plan first before deleting it, to remove temporary resources.": "Deleting a migration plan does not remove temporary resources, it is recommended to <2>archive</2> the plan first before deleting it, to remove temporary resources.",
336338
"Deleting all virtual machines from a migration plan is not allowed.": "Deleting all virtual machines from a migration plan is not allowed.",
337-
"Deleting virtual machines from an archived migration plan is not allowed.": "Deleting virtual machines from an archived migration plan is not allowed.",
338339
"Dell PowerFlex": "Dell PowerFlex",
339340
"Dell PowerMax": "Dell PowerMax",
340341
"Dell PowerStore": "Dell PowerStore",
@@ -1142,6 +1143,7 @@
11421143
"The following changes will be made when it automatically generates a new VM name:": "The following changes will be made when it automatically generates a new VM name:",
11431144
"The Manager CA certificate unless it was replaced by a third-party certificate, in which case, enter the Manager Apache CA certificate.": "The Manager CA certificate unless it was replaced by a third-party certificate, in which case, enter the Manager Apache CA certificate.",
11441145
"The mapping data from the inventory is not available, {{resourcesError}}.": "The mapping data from the inventory is not available, {{resourcesError}}.",
1146+
"The migration plan is not editable.": "The migration plan is not editable.",
11451147
"The OpenShift cluster you want to migrate your virtual machines to.": "The OpenShift cluster you want to migrate your virtual machines to.",
11461148
"The password for the ESXi host admin": "The password for the ESXi host admin",
11471149
"The plan cannot be duplicated": "The plan cannot be duplicated",
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { type FC, useMemo } from 'react';
2+
import { isPlanEditable } from 'src/plans/details/components/PlanStatus/utils/utils';
3+
import { useForkliftTranslation } from 'src/utils/i18n';
4+
5+
import { useModal } from '@openshift-console/dynamic-plugin-sdk';
6+
import { ToolbarItem } from '@patternfly/react-core';
7+
8+
import VMsActionButton from '../VMsActionButton';
9+
10+
import type { AddVirtualMachineProps } from './utils/types';
11+
import AddVirtualMachinesModal from './AddVirtualMachinesModal';
12+
13+
const AddVirtualMachinesButton: FC<AddVirtualMachineProps> = ({ plan }) => {
14+
const { t } = useForkliftTranslation();
15+
const launcher = useModal();
16+
17+
const onClick = (): void => {
18+
launcher<AddVirtualMachineProps>(AddVirtualMachinesModal, { plan });
19+
};
20+
21+
const reason = useMemo((): string | null => {
22+
if (!isPlanEditable(plan)) {
23+
return t('The migration plan is not editable.');
24+
}
25+
return null;
26+
}, [plan, t]);
27+
28+
return (
29+
<ToolbarItem>
30+
<VMsActionButton onClick={onClick} disabledReason={reason}>
31+
{t('Add virtual machines')}
32+
</VMsActionButton>
33+
</ToolbarItem>
34+
);
35+
};
36+
37+
export default AddVirtualMachinesButton;
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { useCallback, useRef, useState } from 'react';
2+
import usePlanSourceProvider from 'src/plans/details/hooks/usePlanSourceProvider';
3+
import type { VmData } from 'src/providers/details/tabs/VirtualMachines/components/VMCellProps';
4+
import { PROVIDER_TYPES } from 'src/providers/utils/constants';
5+
import { useForkliftTranslation } from 'src/utils/i18n';
6+
7+
import ModalForm from '@components/ModalForm/ModalForm';
8+
import { REPLACE } from '@components/ModalForm/utils/constants';
9+
import { PlanModel, type V1beta1PlanSpecVms } from '@forklift-ui/types';
10+
import { k8sPatch } from '@openshift-console/dynamic-plugin-sdk';
11+
import type { ModalComponent } from '@openshift-console/dynamic-plugin-sdk/lib/app/modal-support/ModalProvider';
12+
import { ModalVariant } from '@patternfly/react-core';
13+
import { getPlanVirtualMachines } from '@utils/crds/plans/selectors';
14+
import { isEmpty } from '@utils/helpers';
15+
16+
import AddVirtualMachinesTable from './components/AddVirtualMachinesTable';
17+
import type { AddVirtualMachineProps } from './utils/types';
18+
19+
const AddVirtualMachinesModal: ModalComponent<AddVirtualMachineProps> = ({ plan, ...rest }) => {
20+
const { t } = useForkliftTranslation();
21+
const { sourceProvider } = usePlanSourceProvider(plan);
22+
const selectedVmsRef = useRef<VmData[]>([]);
23+
const [hasSelection, setHasSelection] = useState(false);
24+
25+
const handleSelect = useCallback((vms: VmData[]): void => {
26+
selectedVmsRef.current = vms;
27+
setHasSelection(!isEmpty(vms));
28+
}, []);
29+
30+
const handleSave = useCallback(async () => {
31+
const currentVms = getPlanVirtualMachines(plan);
32+
33+
const newVmEntries: V1beta1PlanSpecVms[] = selectedVmsRef.current.map((vmData) => ({
34+
id: vmData.vm.id,
35+
name: vmData.vm.name,
36+
...(sourceProvider?.spec?.type === PROVIDER_TYPES.openshift && {
37+
namespace: vmData.namespace,
38+
}),
39+
}));
40+
41+
const updatedVms = [...(currentVms ?? []), ...newVmEntries];
42+
43+
return k8sPatch({
44+
data: [{ op: REPLACE, path: '/spec/vms', value: updatedVms }],
45+
model: PlanModel,
46+
path: '',
47+
resource: plan,
48+
});
49+
}, [plan, sourceProvider]);
50+
51+
return (
52+
<ModalForm
53+
confirmLabel={t('Add virtual machines')}
54+
isDisabled={!hasSelection}
55+
onConfirm={handleSave}
56+
title={t('Add virtual machines to migration plan')}
57+
variant={ModalVariant.large}
58+
{...rest}
59+
>
60+
<AddVirtualMachinesTable
61+
onSelect={handleSelect}
62+
plan={plan}
63+
sourceProvider={sourceProvider}
64+
/>
65+
</ModalForm>
66+
);
67+
};
68+
69+
export default AddVirtualMachinesModal;
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { memo, useCallback, useMemo, useState } from 'react';
2+
import type { ProviderVirtualMachinesListProps } from 'src/providers/details/tabs/VirtualMachines/components/utils/types';
3+
import type { VmData } from 'src/providers/details/tabs/VirtualMachines/components/VMCellProps';
4+
import { HypervVirtualMachinesList } from 'src/providers/details/tabs/VirtualMachines/HypervVirtualMachinesList';
5+
import { OpenShiftVirtualMachinesList } from 'src/providers/details/tabs/VirtualMachines/OpenShiftVirtualMachinesList';
6+
import { OpenStackVirtualMachinesList } from 'src/providers/details/tabs/VirtualMachines/OpenStackVirtualMachinesList';
7+
import { OvaVirtualMachinesList } from 'src/providers/details/tabs/VirtualMachines/OvaVirtualMachinesList';
8+
import { OVirtVirtualMachinesList } from 'src/providers/details/tabs/VirtualMachines/OVirtVirtualMachinesList';
9+
import { getVmId } from 'src/providers/details/tabs/VirtualMachines/utils/helpers/vmProps';
10+
import { VSphereVirtualMachinesList } from 'src/providers/details/tabs/VirtualMachines/VSphereVirtualMachinesList';
11+
import { PROVIDER_TYPES } from 'src/providers/utils/constants';
12+
import { useInventoryVms } from 'src/utils/hooks/useInventoryVms';
13+
import { useForkliftTranslation } from 'src/utils/i18n';
14+
15+
import type { V1beta1Plan, V1beta1Provider } from '@forklift-ui/types';
16+
import { EmptyState, EmptyStateVariant, Spinner, Title } from '@patternfly/react-core';
17+
import { getPlanVirtualMachines } from '@utils/crds/plans/selectors';
18+
19+
type AddVirtualMachinesTableProps = {
20+
plan: V1beta1Plan;
21+
sourceProvider: V1beta1Provider;
22+
onSelect: (selectedVms: VmData[]) => void;
23+
};
24+
25+
const AddVirtualMachinesTable = memo<AddVirtualMachinesTableProps>(
26+
({ onSelect, plan, sourceProvider }) => {
27+
const { t } = useForkliftTranslation();
28+
const [inventoryVmData, isVmDataLoading] = useInventoryVms({ provider: sourceProvider });
29+
const [selectedIds, setSelectedIds] = useState<string[]>([]);
30+
31+
const existingVmIds = useMemo((): Set<string> => {
32+
const planVms = getPlanVirtualMachines(plan);
33+
return new Set((planVms ?? []).map((vm) => vm.id).filter(Boolean) as string[]);
34+
}, [plan]);
35+
36+
const availableVmData = useMemo(
37+
() => inventoryVmData.filter((vmData) => !existingVmIds.has(vmData.vm.id)),
38+
[inventoryVmData, existingVmIds],
39+
);
40+
41+
const handleSelect = useCallback(
42+
(selectedVmData: VmData[] | undefined): void => {
43+
const vms = selectedVmData ?? [];
44+
setSelectedIds(vms.map(getVmId));
45+
onSelect(vms);
46+
},
47+
[onSelect],
48+
);
49+
50+
const tableProps: ProviderVirtualMachinesListProps = useMemo(
51+
() => ({
52+
hasCriticalConcernFilter: true,
53+
initialSelectedIds: selectedIds,
54+
obj: {
55+
provider: sourceProvider,
56+
vmData: availableVmData,
57+
vmDataLoading: isVmDataLoading,
58+
},
59+
onSelect: handleSelect,
60+
showActions: false,
61+
title: '',
62+
}),
63+
[selectedIds, sourceProvider, availableVmData, isVmDataLoading, handleSelect],
64+
);
65+
66+
switch (sourceProvider?.spec?.type) {
67+
case PROVIDER_TYPES.openshift:
68+
return <OpenShiftVirtualMachinesList {...tableProps} />;
69+
case PROVIDER_TYPES.openstack:
70+
return <OpenStackVirtualMachinesList {...tableProps} />;
71+
case PROVIDER_TYPES.ovirt:
72+
return <OVirtVirtualMachinesList {...tableProps} />;
73+
case PROVIDER_TYPES.ova:
74+
return <OvaVirtualMachinesList {...tableProps} />;
75+
case PROVIDER_TYPES.hyperv:
76+
return <HypervVirtualMachinesList {...tableProps} />;
77+
case PROVIDER_TYPES.vsphere:
78+
return <VSphereVirtualMachinesList {...tableProps} />;
79+
case undefined:
80+
default:
81+
return (
82+
<EmptyState
83+
titleText={
84+
<Title headingLevel="h4" size="lg">
85+
{t('Loading virtual machines...')}
86+
</Title>
87+
}
88+
variant={EmptyStateVariant.sm}
89+
>
90+
<Spinner size="xl" />
91+
</EmptyState>
92+
);
93+
}
94+
},
95+
);
96+
AddVirtualMachinesTable.displayName = 'AddVirtualMachinesTable';
97+
98+
export default AddVirtualMachinesTable;
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import type { V1beta1Plan } from '@forklift-ui/types';
2+
3+
export type AddVirtualMachineProps = {
4+
plan: V1beta1Plan;
5+
};

src/plans/details/tabs/VirtualMachines/components/DeleteVirtualMachines/DeleteVirtualMachinesButton.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { type FC, useMemo } from 'react';
2-
import { isPlanArchived } from 'src/plans/details/components/PlanStatus/utils/utils';
2+
import { isPlanEditable } from 'src/plans/details/components/PlanStatus/utils/utils';
33
import { useForkliftTranslation } from 'src/utils/i18n';
44

55
import { useModal } from '@openshift-console/dynamic-plugin-sdk';
@@ -19,8 +19,8 @@ const DeleteVirtualMachinesButton: FC<DeleteVirtualMachineProps> = ({ plan, sele
1919
};
2020

2121
const reason = useMemo(() => {
22-
if (isPlanArchived(plan)) {
23-
return t('Deleting virtual machines from an archived migration plan is not allowed.');
22+
if (!isPlanEditable(plan)) {
23+
return t('The migration plan is not editable.');
2424
}
2525
if (plan?.spec?.vms.length === 1) {
2626
return t('Deleting all virtual machines from a migration plan is not allowed.');

src/plans/details/tabs/VirtualMachines/components/PlanSpecVirtualMachinesList/hooks/useSpecVirtualMachinesActions.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@ import type { FC } from 'react';
33
import type { GlobalActionToolbarProps } from '@components/common/utils/types';
44
import type { V1beta1Plan } from '@forklift-ui/types';
55

6+
import AddVirtualMachinesButton from '../../AddVirtualMachines/AddVirtualMachinesButton';
67
import DeleteVirtualMachinesButton from '../../DeleteVirtualMachines/DeleteVirtualMachinesButton';
78
import type { SpecVirtualMachinePageData } from '../utils/types';
89

910
type PageGlobalActions = FC<GlobalActionToolbarProps<SpecVirtualMachinePageData>>[];
1011

1112
export const useSpecVirtualMachinesActions = (plan: V1beta1Plan): PageGlobalActions => {
1213
return [
14+
() => <AddVirtualMachinesButton plan={plan} />,
1315
({ selectedIds }) => (
1416
<DeleteVirtualMachinesButton selectedIds={selectedIds ?? []} plan={plan} />
1517
),
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { expect } from '@playwright/test';
2+
3+
import { sharedProviderCustomPlanFixtures as test } from '../../../fixtures/resourceFixtures';
4+
import { PlanDetailsPage } from '../../../page-objects/PlanDetailsPage/PlanDetailsPage';
5+
import { V2_12_0 } from '../../../utils/version/constants';
6+
import { requireVersion } from '../../../utils/version/version';
7+
8+
test.describe('Plan Details - Add Virtual Machines', { tag: '@downstream' }, () => {
9+
requireVersion(test, V2_12_0);
10+
11+
test('should add virtual machines to an existing plan via the modal', async ({
12+
page,
13+
createCustomPlan,
14+
resourceManager,
15+
}) => {
16+
const testPlan = await createCustomPlan({
17+
virtualMachines: [{ folder: 'vm' }],
18+
});
19+
20+
const planDetailsPage = new PlanDetailsPage(page);
21+
const planName = testPlan.metadata.name;
22+
const planNamespace = testPlan.metadata.namespace;
23+
24+
// Remove the last VM from the plan via API so it becomes available for the "add" flow
25+
const plan = await resourceManager.fetchPlan(page, planName, planNamespace);
26+
const vms = plan?.spec?.vms ?? [];
27+
expect(vms.length).toBeGreaterThan(1);
28+
29+
const removedVm = vms[vms.length - 1];
30+
const remainingVms = vms.slice(0, -1);
31+
32+
const patchResult = await resourceManager.patchResource(page, {
33+
kind: 'Plan',
34+
resourceName: planName,
35+
namespace: planNamespace,
36+
patch: [{ op: 'replace', path: '/spec/vms', value: remainingVms }],
37+
patchType: 'json',
38+
});
39+
expect(patchResult).not.toBeNull();
40+
41+
await test.step('1. Navigate to VM tab and verify Add button is enabled', async () => {
42+
await planDetailsPage.navigate(planName, planNamespace);
43+
await planDetailsPage.virtualMachinesTab.navigateToVirtualMachinesTab();
44+
await planDetailsPage.virtualMachinesTab.verifyTableLoaded();
45+
46+
await expect(planDetailsPage.virtualMachinesTab.addVirtualMachinesButton).toBeVisible();
47+
await planDetailsPage.virtualMachinesTab.verifyAddVirtualMachinesButtonEnabled();
48+
});
49+
50+
await test.step('2. Open modal, verify initial state and VM exclusion', async () => {
51+
const plannedVmName = await planDetailsPage.virtualMachinesTab.getFirstVMName();
52+
expect(plannedVmName).toBeTruthy();
53+
54+
const modal = await planDetailsPage.virtualMachinesTab.clickAddVirtualMachines();
55+
56+
await modal.verifyModalTitle();
57+
await modal.verifySaveButtonDisabled();
58+
await expect(modal.cancelButton).toBeVisible();
59+
60+
await modal.verifyVmNotInTable(plannedVmName);
61+
await modal.verifyVmInTable(removedVm.name!);
62+
63+
await modal.cancel();
64+
});
65+
66+
await test.step('3. Select a VM, verify confirm enables, then cancel without saving', async () => {
67+
const initialRowCount = await planDetailsPage.virtualMachinesTab.getRowCount();
68+
69+
const modal = await planDetailsPage.virtualMachinesTab.clickAddVirtualMachines();
70+
71+
const modalRowCount = await modal.getRowCount();
72+
expect(modalRowCount).toBeGreaterThan(0);
73+
74+
await modal.selectVirtualMachine(removedVm.name!);
75+
await modal.verifySaveButtonEnabled();
76+
77+
await modal.cancel();
78+
79+
const afterCancelRowCount = await planDetailsPage.virtualMachinesTab.getRowCount();
80+
expect(afterCancelRowCount).toBe(initialRowCount);
81+
});
82+
83+
await test.step('4. Add a VM via the modal (happy path)', async () => {
84+
const initialRowCount = await planDetailsPage.virtualMachinesTab.getRowCount();
85+
86+
const modal = await planDetailsPage.virtualMachinesTab.clickAddVirtualMachines();
87+
88+
await modal.selectVirtualMachine(removedVm.name!);
89+
await modal.save();
90+
91+
await planDetailsPage.virtualMachinesTab.verifyTableLoaded();
92+
await planDetailsPage.virtualMachinesTab.verifyRowIsVisible({ Name: removedVm.name! });
93+
94+
const afterAddRowCount = await planDetailsPage.virtualMachinesTab.getRowCount();
95+
expect(afterAddRowCount).toBe(initialRowCount + 1);
96+
97+
// API-level verification
98+
const updatedPlan = await resourceManager.fetchPlan(page, planName, planNamespace);
99+
const planVmNames = (updatedPlan?.spec?.vms ?? []).map((vm) => vm.name);
100+
expect(planVmNames).toContain(removedVm.name);
101+
});
102+
103+
await test.step('5. Verify the added VM is excluded from subsequent add operations', async () => {
104+
const modal = await planDetailsPage.virtualMachinesTab.clickAddVirtualMachines();
105+
106+
await modal.verifyVmNotInTable(removedVm.name!);
107+
108+
await modal.cancel();
109+
});
110+
111+
await test.step('6. Verify button is disabled for non-editable plans (archived)', async () => {
112+
const archiveResult = await resourceManager.patchResource(page, {
113+
kind: 'Plan',
114+
resourceName: planName,
115+
namespace: planNamespace,
116+
patch: { spec: { archived: true } },
117+
patchType: 'merge',
118+
});
119+
expect(archiveResult).not.toBeNull();
120+
121+
await planDetailsPage.navigate(planName, planNamespace);
122+
await planDetailsPage.virtualMachinesTab.navigateToVirtualMachinesTab();
123+
124+
await planDetailsPage.virtualMachinesTab.verifyAddVirtualMachinesButtonDisabled();
125+
});
126+
});
127+
});

0 commit comments

Comments
 (0)