Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions locales/en/plugin__forklift-console-plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,8 @@
"Add Label to specify qualifying nodes": "Add Label to specify qualifying nodes",
"Add mapping": "Add mapping",
"Add passphrase": "Add passphrase",
"Add virtual machines": "Add virtual machines",
"Add virtual machines to migration plan": "Add virtual machines to migration plan",
"Additional setup": "Additional setup",
"Affinity rules": "Affinity rules",
"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.",
Expand Down Expand Up @@ -1139,6 +1141,7 @@
"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:",
"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.",
"The mapping data from the inventory is not available, {{resourcesError}}.": "The mapping data from the inventory is not available, {{resourcesError}}.",
"The migration plan is not editable.": "The migration plan is not editable.",
"The OpenShift cluster you want to migrate your virtual machines to.": "The OpenShift cluster you want to migrate your virtual machines to.",
"The password for the ESXi host admin": "The password for the ESXi host admin",
"The plan cannot be duplicated": "The plan cannot be duplicated",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { type FC, useMemo } from 'react';
import { isPlanEditable } from 'src/plans/details/components/PlanStatus/utils/utils';
import { useForkliftTranslation } from 'src/utils/i18n';

import { useModal } from '@openshift-console/dynamic-plugin-sdk';
import { ToolbarItem } from '@patternfly/react-core';

import VMsActionButton from '../VMsActionButton';

import type { AddVirtualMachineProps } from './utils/types';
import AddVirtualMachinesModal from './AddVirtualMachinesModal';

const AddVirtualMachinesButton: FC<AddVirtualMachineProps> = ({ plan }) => {
const { t } = useForkliftTranslation();
const launcher = useModal();

const onClick = (): void => {
launcher<AddVirtualMachineProps>(AddVirtualMachinesModal, { plan });
};

const reason = useMemo((): string | null => {
if (!isPlanEditable(plan)) {
return t('The migration plan is not editable.');
}
return null;
}, [plan, t]);

return (
<ToolbarItem>
<VMsActionButton onClick={onClick} disabledReason={reason}>
{t('Add virtual machines')}
</VMsActionButton>
</ToolbarItem>
);
};

export default AddVirtualMachinesButton;
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { useCallback, useRef, useState } from 'react';
import usePlanSourceProvider from 'src/plans/details/hooks/usePlanSourceProvider';
import type { VmData } from 'src/providers/details/tabs/VirtualMachines/components/VMCellProps';
import { PROVIDER_TYPES } from 'src/providers/utils/constants';
import { useForkliftTranslation } from 'src/utils/i18n';

import ModalForm from '@components/ModalForm/ModalForm';
import { ADD, REPLACE } from '@components/ModalForm/utils/constants';
import { PlanModel, type V1beta1PlanSpecVms } from '@forklift-ui/types';
import { k8sPatch } from '@openshift-console/dynamic-plugin-sdk';
import type { ModalComponent } from '@openshift-console/dynamic-plugin-sdk/lib/app/modal-support/ModalProvider';
import { ModalVariant } from '@patternfly/react-core';
import { getPlanVirtualMachines } from '@utils/crds/plans/selectors';
import { isEmpty } from '@utils/helpers';

import AddVirtualMachinesTable from './components/AddVirtualMachinesTable';
import type { AddVirtualMachineProps } from './utils/types';

const AddVirtualMachinesModal: ModalComponent<AddVirtualMachineProps> = ({ plan, ...rest }) => {
const { t } = useForkliftTranslation();
const { sourceProvider } = usePlanSourceProvider(plan);
const selectedVmsRef = useRef<VmData[]>([]);
const [hasSelection, setHasSelection] = useState(false);

const handleSelect = useCallback((vms: VmData[]): void => {
selectedVmsRef.current = vms;
setHasSelection(!isEmpty(vms));
}, []);

const handleSave = useCallback(async () => {
const currentVms = getPlanVirtualMachines(plan);
const op = currentVms ? REPLACE : ADD;

const newVmEntries: V1beta1PlanSpecVms[] = selectedVmsRef.current.map((vmData) => ({
id: vmData.vm.id,
name: vmData.vm.name,
...(sourceProvider?.spec?.type === PROVIDER_TYPES.openshift && {
namespace: vmData.namespace,
}),
}));

const updatedVms = [...(currentVms ?? []), ...newVmEntries];

return k8sPatch({
data: [{ op, path: '/spec/vms', value: updatedVms }],
model: PlanModel,
path: '',
resource: plan,
});
}, [plan, sourceProvider]);

return (
<ModalForm
confirmLabel={t('Add virtual machines')}
isDisabled={!hasSelection}
onConfirm={handleSave}
title={t('Add virtual machines to migration plan')}
variant={ModalVariant.large}
{...rest}
>
<AddVirtualMachinesTable onSelect={handleSelect} plan={plan} />
</ModalForm>
);
};

export default AddVirtualMachinesModal;
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { memo, useCallback, useMemo, useState } from 'react';
import usePlanSourceProvider from 'src/plans/details/hooks/usePlanSourceProvider';
import type { ProviderVirtualMachinesListProps } from 'src/providers/details/tabs/VirtualMachines/components/utils/types';
import type { VmData } from 'src/providers/details/tabs/VirtualMachines/components/VMCellProps';
import { HypervVirtualMachinesList } from 'src/providers/details/tabs/VirtualMachines/HypervVirtualMachinesList';
import { OpenShiftVirtualMachinesList } from 'src/providers/details/tabs/VirtualMachines/OpenShiftVirtualMachinesList';
import { OpenStackVirtualMachinesList } from 'src/providers/details/tabs/VirtualMachines/OpenStackVirtualMachinesList';
import { OvaVirtualMachinesList } from 'src/providers/details/tabs/VirtualMachines/OvaVirtualMachinesList';
import { OVirtVirtualMachinesList } from 'src/providers/details/tabs/VirtualMachines/OVirtVirtualMachinesList';
import { getVmId } from 'src/providers/details/tabs/VirtualMachines/utils/helpers/vmProps';
import { VSphereVirtualMachinesList } from 'src/providers/details/tabs/VirtualMachines/VSphereVirtualMachinesList';
import { PROVIDER_TYPES } from 'src/providers/utils/constants';
import { useInventoryVms } from 'src/utils/hooks/useInventoryVms';

import type { V1beta1Plan } from '@forklift-ui/types';
import { getPlanVirtualMachines } from '@utils/crds/plans/selectors';

type AddVirtualMachinesTableProps = {
plan: V1beta1Plan;
onSelect: (selectedVms: VmData[]) => void;
};

const AddVirtualMachinesTable = memo<AddVirtualMachinesTableProps>(({ onSelect, plan }) => {
const { sourceProvider } = usePlanSourceProvider(plan);
const [inventoryVmData, isVmDataLoading] = useInventoryVms({ provider: sourceProvider });
const [selectedIds, setSelectedIds] = useState<string[]>([]);

const existingVmIds = useMemo((): Set<string> => {
const planVms = getPlanVirtualMachines(plan);
return new Set((planVms ?? []).map((vm) => vm.id).filter(Boolean) as string[]);
}, [plan]);

const availableVmData = useMemo(
() => inventoryVmData.filter((vmData) => !existingVmIds.has(vmData.vm.id)),
[inventoryVmData, existingVmIds],
);

const handleSelect = useCallback(
(selectedVmData: VmData[] | undefined): void => {
const vms = selectedVmData ?? [];
setSelectedIds(vms.map(getVmId));
onSelect(vms);
},
[onSelect],
);

const tableProps: ProviderVirtualMachinesListProps = useMemo(
() => ({
hasCriticalConcernFilter: true,
initialSelectedIds: selectedIds,
obj: {
provider: sourceProvider,
vmData: availableVmData,
vmDataLoading: isVmDataLoading,
},
onSelect: handleSelect,
showActions: false,
title: '',
}),
[selectedIds, sourceProvider, availableVmData, isVmDataLoading, handleSelect],
);

switch (sourceProvider?.spec?.type) {
case PROVIDER_TYPES.openshift:
return <OpenShiftVirtualMachinesList {...tableProps} />;
case PROVIDER_TYPES.openstack:
return <OpenStackVirtualMachinesList {...tableProps} />;
case PROVIDER_TYPES.ovirt:
return <OVirtVirtualMachinesList {...tableProps} />;
case PROVIDER_TYPES.ova:
return <OvaVirtualMachinesList {...tableProps} />;
case PROVIDER_TYPES.hyperv:
return <HypervVirtualMachinesList {...tableProps} />;
case PROVIDER_TYPES.vsphere:
return <VSphereVirtualMachinesList {...tableProps} />;
case undefined:
default:
return <></>;
}
});
AddVirtualMachinesTable.displayName = 'AddVirtualMachinesTable';

export default AddVirtualMachinesTable;
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import type { V1beta1Plan } from '@forklift-ui/types';

export type AddVirtualMachineProps = {
plan: V1beta1Plan;
};
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ import type { FC } from 'react';
import type { GlobalActionToolbarProps } from '@components/common/utils/types';
import type { V1beta1Plan } from '@forklift-ui/types';

import AddVirtualMachinesButton from '../../AddVirtualMachines/AddVirtualMachinesButton';
import DeleteVirtualMachinesButton from '../../DeleteVirtualMachines/DeleteVirtualMachinesButton';
import type { SpecVirtualMachinePageData } from '../utils/types';

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

export const useSpecVirtualMachinesActions = (plan: V1beta1Plan): PageGlobalActions => {
return [
() => <AddVirtualMachinesButton plan={plan} />,
({ selectedIds }) => (
<DeleteVirtualMachinesButton selectedIds={selectedIds ?? []} plan={plan} />
),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { expect } from '@playwright/test';

import { sharedProviderCustomPlanFixtures as test } from '../../../fixtures/resourceFixtures';
import { PlanDetailsPage } from '../../../page-objects/PlanDetailsPage/PlanDetailsPage';

test.describe('Plan Details - Add Virtual Machines', { tag: '@downstream' }, () => {
test('should add virtual machines to an existing plan via the modal', async ({
page,
createCustomPlan,
resourceManager,
}) => {
// Create a plan with all VMs in the folder
const testPlan = await createCustomPlan({
virtualMachines: [{ folder: 'vm' }],
});

const planDetailsPage = new PlanDetailsPage(page);
const planName = testPlan.metadata.name;
const planNamespace = testPlan.metadata.namespace;

// Remove the last VM from the plan via API so it becomes available for the "add" flow
const plan = await resourceManager.fetchPlan(page, planName, planNamespace);
const vms = plan?.spec?.vms ?? [];
expect(vms.length).toBeGreaterThan(1);

const removedVm = vms[vms.length - 1];
const remainingVms = vms.slice(0, -1);

const patchResult = await resourceManager.patchResource(page, {
kind: 'Plan',
resourceName: planName,
namespace: planNamespace,
patch: [{ op: 'replace', path: '/spec/vms', value: remainingVms }],
patchType: 'json',
});
expect(patchResult).not.toBeNull();

await test.step('1. Verify "Add virtual machines" button is visible and enabled', async () => {
await planDetailsPage.navigate(planName, planNamespace);
await planDetailsPage.virtualMachinesTab.navigateToVirtualMachinesTab();
await planDetailsPage.virtualMachinesTab.verifyTableLoaded();

await expect(planDetailsPage.virtualMachinesTab.addVirtualMachinesButton).toBeVisible();
await planDetailsPage.virtualMachinesTab.verifyAddVirtualMachinesButtonEnabled();
});

await test.step('2. Open the Add VM modal and verify initial state', async () => {
const modal = await planDetailsPage.virtualMachinesTab.clickAddVirtualMachines();

await modal.verifyModalTitle();
await modal.verifySaveButtonDisabled();
await expect(modal.cancelButton).toBeVisible();

await modal.cancel();
});

// Read the name of a VM that is currently in the plan for exclusion checks
const plannedVmName = await planDetailsPage.virtualMachinesTab.getFirstVMName();
expect(plannedVmName).toBeTruthy();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make sure all test logic are within step blocks


await test.step('3. Verify already-planned VMs are excluded from the modal table', async () => {
const modal = await planDetailsPage.virtualMachinesTab.clickAddVirtualMachines();

await modal.verifyVmNotInTable(plannedVmName);

await modal.cancel();
});

await test.step('4. Select a VM and verify confirm button becomes enabled', async () => {
const modal = await planDetailsPage.virtualMachinesTab.clickAddVirtualMachines();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test could possible be optimized by reducing number of times opening and closing of modal.


const modalRowCount = await modal.getRowCount();
expect(modalRowCount).toBeGreaterThan(0);

// Select the removed VM that should now appear in the modal
await modal.selectVirtualMachine(removedVm.name!);

await modal.verifySaveButtonEnabled();

await modal.cancel();
});

await test.step('5. Cancel the modal without saving', async () => {
const initialRowCount = await planDetailsPage.virtualMachinesTab.getRowCount();

const modal = await planDetailsPage.virtualMachinesTab.clickAddVirtualMachines();

await modal.selectVirtualMachine(removedVm.name!);

await modal.cancel();

// Verify row count unchanged
const afterCancelRowCount = await planDetailsPage.virtualMachinesTab.getRowCount();
expect(afterCancelRowCount).toBe(initialRowCount);
});

await test.step('6. Add a VM via the modal (happy path)', async () => {
const initialRowCount = await planDetailsPage.virtualMachinesTab.getRowCount();

const modal = await planDetailsPage.virtualMachinesTab.clickAddVirtualMachines();

// Select the removed VM and save
await modal.selectVirtualMachine(removedVm.name!);
await modal.save();

// Wait for the plan to reconcile and the table to update
await page.waitForLoadState('networkidle');
await planDetailsPage.virtualMachinesTab.verifyTableLoaded();

// Verify the row count increased
const afterAddRowCount = await planDetailsPage.virtualMachinesTab.getRowCount();
expect(afterAddRowCount).toBe(initialRowCount + 1);

// Verify the newly added VM appears in the plan VM table
await planDetailsPage.virtualMachinesTab.verifyRowIsVisible({ Name: removedVm.name! });

// API-level verification: confirm spec.vms contains the new VM
const updatedPlan = await resourceManager.fetchPlan(page, planName, planNamespace);
const planVmNames = (updatedPlan?.spec?.vms ?? []).map((vm) => vm.name);
expect(planVmNames).toContain(removedVm.name);
});

await test.step('7. Verify the added VM is excluded from subsequent add operations', async () => {
const modal = await planDetailsPage.virtualMachinesTab.clickAddVirtualMachines();

await modal.verifyVmNotInTable(removedVm.name!);

await modal.cancel();
});

await test.step('8. Verify button is disabled for non-editable plans (archived)', async () => {
// Archive the plan via API patch
const archiveResult = await resourceManager.patchResource(page, {
kind: 'Plan',
resourceName: planName,
namespace: planNamespace,
patch: { spec: { archived: true } },
patchType: 'merge',
});
expect(archiveResult).not.toBeNull();

// Reload plan details and navigate to VMs tab
await planDetailsPage.navigate(planName, planNamespace);
await planDetailsPage.virtualMachinesTab.navigateToVirtualMachinesTab();

// Wait for the status to reflect the archived state
await page.waitForTimeout(2000);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is waitForTimeout avoidable here?


await planDetailsPage.virtualMachinesTab.verifyAddVirtualMachinesButtonDisabled();
});
});
});
Loading