From 9fcd1c4b5059e30e9868c6ba34d9a5b3f137f716 Mon Sep 17 00:00:00 2001 From: yaacov Date: Sun, 9 Jun 2024 17:36:29 +0300 Subject: [PATCH] Add a plan details item to edit boot disk Signed-off-by: yaacov --- .../en/plugin__forklift-console-plugin.json | 11 + .../FilterableSelect/FilterableSelect.tsx | 299 ++++++++++++++++++ .../SettingsSection/SettingsSection.tsx | 5 + .../components/RootDiskDetailsItem.tsx | 53 ++++ .../SettingsSection/components/index.ts | 1 + .../modals/EditRootDisk/EditRootDisk.tsx | 117 +++++++ .../EditRootDisk/editRootDiskModalAlert.tsx | 15 + .../EditRootDisk/editRootDiskModalBody.tsx | 30 ++ .../EditRootDisk/getRootDiskLabelByKey.ts | 84 +++++ .../modals/EditRootDisk/index.ts | 4 + 10 files changed, 619 insertions(+) create mode 100644 packages/forklift-console-plugin/src/components/FilterableSelect/FilterableSelect.tsx create mode 100644 packages/forklift-console-plugin/src/modules/Plans/views/details/components/SettingsSection/components/RootDiskDetailsItem.tsx create mode 100644 packages/forklift-console-plugin/src/modules/Plans/views/details/components/SettingsSection/modals/EditRootDisk/EditRootDisk.tsx create mode 100644 packages/forklift-console-plugin/src/modules/Plans/views/details/components/SettingsSection/modals/EditRootDisk/editRootDiskModalAlert.tsx create mode 100644 packages/forklift-console-plugin/src/modules/Plans/views/details/components/SettingsSection/modals/EditRootDisk/editRootDiskModalBody.tsx create mode 100644 packages/forklift-console-plugin/src/modules/Plans/views/details/components/SettingsSection/modals/EditRootDisk/getRootDiskLabelByKey.ts create mode 100644 packages/forklift-console-plugin/src/modules/Plans/views/details/components/SettingsSection/modals/EditRootDisk/index.ts diff --git a/packages/forklift-console-plugin/locales/en/plugin__forklift-console-plugin.json b/packages/forklift-console-plugin/locales/en/plugin__forklift-console-plugin.json index ae8fb3b11..098097d61 100644 --- a/packages/forklift-console-plugin/locales/en/plugin__forklift-console-plugin.json +++ b/packages/forklift-console-plugin/locales/en/plugin__forklift-console-plugin.json @@ -48,6 +48,13 @@ "Assessment": "Assessment", "Authentication type": "Authentication type", "Bandwidth": "Bandwidth", + "Boot from first root device": "Boot from first root device", + "Boot from the first hard drive": "Boot from the first hard drive", + "Boot from the first partition on the first hard drive": "Boot from the first partition on the first hard drive", + "Boot from the first partition on the second hard drive": "Boot from the first partition on the second hard drive", + "Boot from the second hard drive": "Boot from the second hard drive", + "Boot from the second partition on the first hard drive": "Boot from the second partition on the first hard drive", + "Boot from the second partition on the second hard drive": "Boot from the second partition on the second hard drive", "CA certificate": "CA certificate", "CA certificate - disabled when 'Skip certificate validation' is selected": "CA certificate - disabled when 'Skip certificate validation' is selected", "CA certificate - leave empty to use system CA certificates": "CA certificate - leave empty to use system CA certificates", @@ -60,6 +67,7 @@ "Cannot retrieve certificate": "Cannot retrieve certificate", "Category": "Category", "Certificate change detected": "Certificate change detected", + "Choose the root filesystem to be converted.": "Choose the root filesystem to be converted.", "Clear all filters": "Clear all filters", "Click the pencil for setting provider web UI link": "Click the pencil for setting provider web UI link", "Click the update credentials button to save your changes, button is disabled until a change is detected.": "Click the update credentials button to save your changes, button is disabled until a change is detected.", @@ -142,6 +150,7 @@ "Edit Provider Credentials": "Edit Provider Credentials", "Edit provider credentials.\n Use this link to edit the providers credentials instead of editing the secret directly.": "Edit provider credentials.\n Use this link to edit the providers credentials instead of editing the secret directly.", "Edit provider web UI link": "Edit provider web UI link", + "Edit root device": "Edit root device", "Edit Snapshot polling interval (seconds)": "Edit Snapshot polling interval (seconds)", "Edit StorageMap": "Edit StorageMap", "Edit target namespace": "Edit target namespace", @@ -179,6 +188,7 @@ "Filter by template": "Filter by template", "Filter by tenant": "Filter by tenant", "Filter provider": "Filter provider", + "First root device": "First root device", "Flavor": "Flavor", "Folder": "Folder", "GPUs/Host Devices": "GPUs/Host Devices", @@ -382,6 +392,7 @@ "Restore default columns": "Restore default columns", "Return to the providers list page": "Return to the providers list page", "Reveal values": "Reveal values", + "Root device": "Root device", "Run the migration plan.": "Run the migration plan.", "Running": "Running", "Running virtual machines": "Running virtual machines", diff --git a/packages/forklift-console-plugin/src/components/FilterableSelect/FilterableSelect.tsx b/packages/forklift-console-plugin/src/components/FilterableSelect/FilterableSelect.tsx new file mode 100644 index 000000000..82506ffc8 --- /dev/null +++ b/packages/forklift-console-plugin/src/components/FilterableSelect/FilterableSelect.tsx @@ -0,0 +1,299 @@ +import React from 'react'; + +import { + Button, + Divider, + MenuToggle, + MenuToggleElement, + Text, + TextInputGroup, + TextInputGroupMain, + TextInputGroupUtilities, +} from '@patternfly/react-core'; +import { Select, SelectList, SelectOption, SelectOptionProps } from '@patternfly/react-core/next'; +import TimesIcon from '@patternfly/react-icons/dist/esm/icons/times-icon'; + +/** + * Props for the FilterableSelect component. + */ +export interface FilterableSelectProps { + /** Array of options to display in the select dropdown */ + selectOptions: SelectOptionProps[]; + /** The currently selected value */ + value: string; + /** Callback function when an option is selected */ + onSelect: (value: string | number) => void; + /** Whether the user can create new options */ + canCreate?: boolean; + /** Placeholder text for the input field */ + placeholder?: string; + /** Label to display when no results are found */ + noResultFoundLabel?: string; + /** Label to display for the option to create a new item */ + createNewOptionLabel?: string; +} + +/** + * A filterable select component that allows users to select from a list of options, + * with the ability to filter the options and create new ones if `canCreate` is enabled. + * + * @param {FilterableSelectProps} props The props for the FilterableSelect component. + * @returns {JSX.Element} The rendered FilterableSelect component. + */ +export const FilterableSelect: React.FunctionComponent = ({ + selectOptions: initialSelectOptions, + value, + onSelect: onSelect, + canCreate, + placeholder = 'Select item', + noResultFoundLabel = 'No results found', + createNewOptionLabel = 'Create new option:', +}) => { + const [isOpen, setIsOpen] = React.useState(false); + const [selectedItem, setSelectedItem] = React.useState(value); + /** + * inputValue: The current value displayed in the input field. + * This is the value the user types in. + */ + const [inputValue, setInputValue] = React.useState(value); + /** + * filterValue: The value used to filter the options. + * This is typically synchronized with inputValue, but they can be different if needed. + */ + const [filterValue, setFilterValue] = React.useState(''); + const [selectOptions, setSelectOptions] = + React.useState(initialSelectOptions); + const [focusedItemIndex, setFocusedItemIndex] = React.useState(null); + + const menuRef = React.useRef(null); + const textInputRef = React.useRef(); + + /** + * Sets the selected item and triggers the onSelect callback. + * + * @param {string} value The value to set as selected. + */ + const setSelected = (value: string) => { + setSelectedItem(value); + setFilterValue(''); + + // Call the external on select hook. + onSelect(value); + }; + + /** + * Updates the select options based on the filter value. + */ + React.useEffect(() => { + let newSelectOptions: SelectOptionProps[] = initialSelectOptions; + + // Filter menu items based on the text input value when one exists + if (filterValue) { + newSelectOptions = initialSelectOptions.filter((menuItem) => + String(menuItem.itemId).toLowerCase().includes(filterValue.toLowerCase()), + ); + + // When no options are found after filtering, display 'No results found' + if (!newSelectOptions.length) { + newSelectOptions = [{ isDisabled: true, children: noResultFoundLabel }]; + } + } + + setSelectOptions(newSelectOptions); + }, [filterValue, initialSelectOptions, noResultFoundLabel]); + + /** + * Toggles the open state of the select dropdown. + */ + const onToggleClick = () => { + setIsOpen(!isOpen); + }; + + /** + * Handles item selection from the dropdown. + * + * @param {React.MouseEvent | undefined} _event The click event. + * @param {string | number | undefined} itemId The id of the selected item. + */ + const onItemSelect = ( + _event: React.MouseEvent | undefined, + itemId: string | number | undefined, + ) => { + if (itemId !== undefined) { + setInputValue(itemId as string); + setFilterValue(itemId as string); + setSelected(itemId as string); + } + setIsOpen(false); + setFocusedItemIndex(null); + }; + + /** + * Handles changes in the text input. + * + * @param {React.FormEvent} _event The input event. + * @param {string} value The new input value. + */ + const onTextInputChange = (_event: React.FormEvent, value: string) => { + setInputValue(value); + setFilterValue(value); + }; + + /** + * Handles arrow key navigation within the dropdown. + * + * @param {string} key The key pressed. + */ + const handleMenuArrowKeys = (key: string) => { + let indexToFocus; + + if (isOpen) { + if (key === 'ArrowUp') { + // When no index is set or at the first index, focus to the last, otherwise decrement focus index + if (focusedItemIndex === null || focusedItemIndex === 0) { + indexToFocus = selectOptions.length - 1; + } else { + indexToFocus = focusedItemIndex - 1; + } + } + + if (key === 'ArrowDown') { + // When no index is set or at the last index, focus to the first, otherwise increment focus index + if (focusedItemIndex === null || focusedItemIndex === selectOptions.length - 1) { + indexToFocus = 0; + } else { + indexToFocus = focusedItemIndex + 1; + } + } + + setFocusedItemIndex(indexToFocus); + } + }; + + /** + * Handles keydown events in the text input. + * + * @param {React.KeyboardEvent} event The keyboard event. + */ + const onInputKeyDown = (event: React.KeyboardEvent) => { + const enabledMenuItems = selectOptions.filter((menuItem) => !menuItem.isDisabled); + const [firstMenuItem] = enabledMenuItems; + const focusedItem = focusedItemIndex ? enabledMenuItems[focusedItemIndex] : firstMenuItem; + + switch (event.key) { + // Select the first available option + case 'Enter': + event.preventDefault(); + + if (isOpen) { + setInputValue(String(focusedItem?.itemId || filterValue)); + setSelected(String(focusedItem?.itemId || filterValue)); + } + + setIsOpen((prevIsOpen) => !prevIsOpen); + setFocusedItemIndex(null); + + break; + case 'Tab': + case 'Escape': + setIsOpen(false); + break; + case 'ArrowUp': + case 'ArrowDown': + handleMenuArrowKeys(event.key); + break; + default: + !isOpen && setIsOpen(true); + } + }; + + /** + * Renders the toggle component for the dropdown. + * + * @param {React.Ref} toggleRef The reference to the toggle component. + * @returns {JSX.Element} The rendered toggle component. + */ + const toggle = (toggleRef: React.Ref) => ( + + + + + + {!!inputValue && ( + + )} + + + + ); + + return ( + + ); +}; diff --git a/packages/forklift-console-plugin/src/modules/Plans/views/details/components/SettingsSection/SettingsSection.tsx b/packages/forklift-console-plugin/src/modules/Plans/views/details/components/SettingsSection/SettingsSection.tsx index 13ce26d9b..cc8ceb860 100644 --- a/packages/forklift-console-plugin/src/modules/Plans/views/details/components/SettingsSection/SettingsSection.tsx +++ b/packages/forklift-console-plugin/src/modules/Plans/views/details/components/SettingsSection/SettingsSection.tsx @@ -9,6 +9,7 @@ import { DescriptionList } from '@patternfly/react-core'; import { PreserveClusterCpuModelDetailsItem, PreserveStaticIPsDetailsItem, + RootDiskDetailsItem, SetLUKSEncryptionPasswordsDetailsItem, TargetNamespaceDetailsItem, TransferNetworkDetailsItem, @@ -75,6 +76,10 @@ export const SettingsSectionInternal: React.FC = ({ obj, p {['vsphere'].includes(sourceProvider?.spec?.type) && ( )} + + {['vsphere'].includes(sourceProvider?.spec?.type) && ( + + )} ); diff --git a/packages/forklift-console-plugin/src/modules/Plans/views/details/components/SettingsSection/components/RootDiskDetailsItem.tsx b/packages/forklift-console-plugin/src/modules/Plans/views/details/components/SettingsSection/components/RootDiskDetailsItem.tsx new file mode 100644 index 000000000..9c771fd97 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/Plans/views/details/components/SettingsSection/components/RootDiskDetailsItem.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { useModal } from 'src/modules/Providers/modals'; +import { DetailsItem } from 'src/modules/Providers/utils'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { Label } from '@patternfly/react-core'; + +import { PlanDetailsItemProps } from '../../DetailsSection'; +import { VIRT_V2V_HELP_LINK } from '../modals'; +import { getRootDiskLabelByKey } from '../modals/EditRootDisk'; +import { EditRootDisk } from '../modals/EditRootDisk/EditRootDisk'; + +export const RootDiskDetailsItem: React.FC = ({ + resource, + canPatch, + helpContent, +}) => { + const { t } = useForkliftTranslation(); + const { showModal } = useModal(); + + const defaultHelpContent = t(`Choose the root filesystem to be converted.`); + + const rootDisk = resource?.spec?.vms?.[0].rootDisk; + + return ( + showModal())} + /> + ); +}; + +/** + * Generates a label component for the given disk key. + * @param {string} diskKey - The key representing the disk option. + * @returns {JSX.Element} The label component for the disk. + */ +const getDiskLabel = (diskKey: string) => { + const diskLabel = getRootDiskLabelByKey(diskKey); + + // First root disk is the default option + const color = !diskKey ? 'green' : 'grey'; + + return ( + + ); +}; diff --git a/packages/forklift-console-plugin/src/modules/Plans/views/details/components/SettingsSection/components/index.ts b/packages/forklift-console-plugin/src/modules/Plans/views/details/components/SettingsSection/components/index.ts index 0425c7ac1..a55ddae0e 100644 --- a/packages/forklift-console-plugin/src/modules/Plans/views/details/components/SettingsSection/components/index.ts +++ b/packages/forklift-console-plugin/src/modules/Plans/views/details/components/SettingsSection/components/index.ts @@ -1,6 +1,7 @@ // @index(['./*', /style/g], f => `export * from '${f.path}';`) export * from './PreserveClusterCpuModelDetailsItem'; export * from './PreserveStaticIPsDetailsItem'; +export * from './RootDiskDetailsItem'; export * from './SetLUKSEncryptionPasswordsDetailsItem'; export * from './TargetNamespaceDetailsItem'; export * from './TransferNetworkDetailsItem'; diff --git a/packages/forklift-console-plugin/src/modules/Plans/views/details/components/SettingsSection/modals/EditRootDisk/EditRootDisk.tsx b/packages/forklift-console-plugin/src/modules/Plans/views/details/components/SettingsSection/modals/EditRootDisk/EditRootDisk.tsx new file mode 100644 index 000000000..0e8c67971 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/Plans/views/details/components/SettingsSection/modals/EditRootDisk/EditRootDisk.tsx @@ -0,0 +1,117 @@ +import React from 'react'; +import { FilterableSelect } from 'src/components/FilterableSelect/FilterableSelect'; +import { + EditModal, + EditModalProps, + ModalInputComponentType, + OnConfirmHookType, +} from 'src/modules/Providers/modals'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { Modify, PlanModel, V1beta1Plan } from '@kubev2v/types'; +import { K8sModel, k8sPatch } from '@openshift-console/dynamic-plugin-sdk'; +import { HelperText, HelperTextItem, Text } from '@patternfly/react-core'; + +import { editRootDiskModalAlert } from './editRootDiskModalAlert'; +import { editRootDiskModalBody } from './editRootDiskModalBody'; +import { diskOptions, getRootDiskLabelByKey } from './getRootDiskLabelByKey'; + +const onConfirm: OnConfirmHookType = async ({ resource, model, newValue }) => { + const plan = resource as V1beta1Plan; + + const resourceValue = plan?.spec?.vms; + const op = resourceValue ? 'replace' : 'add'; + const newVMs = resourceValue.map((vm) => ({ + ...vm, + rootDisk: newValue || undefined, + })); + + const obj = await k8sPatch({ + model: model, + resource: resource, + data: [ + { + op, + path: '/spec/vms', + value: newVMs || undefined, + }, + ], + }); + + return obj; +}; + +interface DropdownRendererProps { + value: string | number; + onChange: (string) => void; +} + +const RootDiskInputFactory: () => ModalInputComponentType = () => { + const DropdownRenderer: React.FC = ({ value, onChange }) => { + const { t } = useForkliftTranslation(); + const options = diskOptions(t); + + const dropdownItems = options.map((option) => ({ + itemId: option.key, + children: ( + <> + {getRootDiskLabelByKey(option.key)} + {option.description && ( + + {option.description} + + )} + + ), + })); + + return ( + + ); + }; + + return DropdownRenderer; +}; + +export const EditRootDisk: React.FC = (props) => { + const { t } = useForkliftTranslation(); + + const plan = props.resource; + const rootDisk = plan.spec.vms?.[0]?.rootDisk; + const allVMsHasMatchingRootDisk = plan.spec.vms.every((vm) => vm?.rootDisk === rootDisk); + + return ( + obj?.spec?.vms?.[0]?.rootDisk} + title={props?.title || t('Edit root device')} + label={props?.label || t('Root device')} + model={PlanModel} + onConfirmHook={onConfirm} + body={ + <> + {editRootDiskModalBody} + {!allVMsHasMatchingRootDisk && editRootDiskModalAlert} + + } + InputComponent={RootDiskInputFactory()} + /> + ); +}; + +export type EditRootDiskProps = Modify< + EditModalProps, + { + resource: V1beta1Plan; + title?: string; + label?: string; + model?: K8sModel; + jsonPath?: string | string[]; + } +>; diff --git a/packages/forklift-console-plugin/src/modules/Plans/views/details/components/SettingsSection/modals/EditRootDisk/editRootDiskModalAlert.tsx b/packages/forklift-console-plugin/src/modules/Plans/views/details/components/SettingsSection/modals/EditRootDisk/editRootDiskModalAlert.tsx new file mode 100644 index 000000000..2280692c9 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/Plans/views/details/components/SettingsSection/modals/EditRootDisk/editRootDiskModalAlert.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { AlertMessageForModals } from 'src/modules/Providers/modals'; + +export const editRootDiskModalAlert = ( + +

Warning: not all virtual machines are configures using the same root disk number,

+

updating the root disk number will override the current configuration.

+ + } + /> +); diff --git a/packages/forklift-console-plugin/src/modules/Plans/views/details/components/SettingsSection/modals/EditRootDisk/editRootDiskModalBody.tsx b/packages/forklift-console-plugin/src/modules/Plans/views/details/components/SettingsSection/modals/EditRootDisk/editRootDiskModalBody.tsx new file mode 100644 index 000000000..dcc7d7078 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/Plans/views/details/components/SettingsSection/modals/EditRootDisk/editRootDiskModalBody.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { ForkliftTrans } from 'src/utils'; + +import { ExternalLink } from '@kubev2v/common'; + +import { VIRT_V2V_HELP_LINK } from '../EditLUKSEncryptionPasswords'; + +export const editRootDiskModalBody = ( + <> + +

Choose the root filesystem to be converted.

+
+

+ Default behavior is to choose the first root device in the case of a multi-boot operating + system. Since this is a heuristic, it may sometimes choose the wrong one. +

+
+

+ When using a multi-boot VM, you can also name a specific root device, eg.{' '} + sda2 would mean to use the second partition on the first hard drive. If the + named root device does not exist or was not detected as a root device, the migration will + fail.{' '} + + Learn more + + . +

+
+ +); diff --git a/packages/forklift-console-plugin/src/modules/Plans/views/details/components/SettingsSection/modals/EditRootDisk/getRootDiskLabelByKey.ts b/packages/forklift-console-plugin/src/modules/Plans/views/details/components/SettingsSection/modals/EditRootDisk/getRootDiskLabelByKey.ts new file mode 100644 index 000000000..69c8fc547 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/Plans/views/details/components/SettingsSection/modals/EditRootDisk/getRootDiskLabelByKey.ts @@ -0,0 +1,84 @@ +/** + * Type definition for DiskOption. + * @typedef {Object} DiskOption + * @property {string} key - The key representing the disk option. + * @property {string} description - The description of the disk option. + */ +export type DiskOption = { + key: string; + description: string; +}; + +/** + * Generates an array of disk options. + * @param {Function} [t=(text: string) => text] - Translation function. + * @returns {DiskOption[]} Array of disk options. + */ +export const diskOptions = (t = (text: string) => text): DiskOption[] => [ + { key: '', description: t('Boot from first root device') }, + { key: 'sda', description: t('Boot from the first hard drive') }, + { + key: 'sda1', + description: t('Boot from the first partition on the first hard drive'), + }, + { + key: 'sda2', + description: t('Boot from the second partition on the first hard drive'), + }, + { key: 'sdb', description: t('Boot from the second hard drive') }, + { + key: 'sdb1', + description: t('Boot from the first partition on the second hard drive'), + }, + { + key: 'sdb2', + description: t('Boot from the second partition on the second hard drive'), + }, +]; + +/** + * Gets the label for a root disk by its key. + * @param {string | number} key_ - The key representing the disk option. + * @returns {string} The label for the root disk. + */ +export const getRootDiskLabelByKey = (key_: string | number): string => { + const diskLetters = 'abcdefghijklmnopqrstuvwxyz'; + const partitionNumbers = '0123456789'; + + // Default is first root disk + if (!key_) { + return 'First root device'; + } + + const key = key_.toString(); + + if (key.startsWith('sd') && key.length >= 3) { + const diskLetter = key[2]; + const partitionNumber = key.length > 3 ? key.slice(3) : ''; + + const diskIndex = diskLetters.indexOf(diskLetter); + if (diskIndex === -1 || (partitionNumber && !partitionNumbers.includes(partitionNumber[0]))) { + // If format is unrecognized, just return the key as label + return key; + } + + const diskPosition = [ + 'First', + 'Second', + 'Third', + 'Fourth', + 'Fifth', + 'Sixth', + 'Seventh', + 'Eighth', + 'Ninth', + 'Tenth', + ][diskIndex]; + const partitionPosition = partitionNumber ? `${partitionNumber} partition` : ''; + + return `${diskPosition} HD${partitionPosition ? ` ${partitionPosition}` : ''} (${key})`; + } else { + // If format is unrecognized, just return the key as label + return key; + } +}; diff --git a/packages/forklift-console-plugin/src/modules/Plans/views/details/components/SettingsSection/modals/EditRootDisk/index.ts b/packages/forklift-console-plugin/src/modules/Plans/views/details/components/SettingsSection/modals/EditRootDisk/index.ts new file mode 100644 index 000000000..98e22d0ff --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/Plans/views/details/components/SettingsSection/modals/EditRootDisk/index.ts @@ -0,0 +1,4 @@ +// @index(['./*', /style/g], f => `export * from '${f.path}';`) +export * from './EditRootDisk'; +export * from './getRootDiskLabelByKey'; +// @endindex