diff --git a/frontend/packages/console-app/package.json b/frontend/packages/console-app/package.json index 84fabb20d736..f1c7c106c5bc 100644 --- a/frontend/packages/console-app/package.json +++ b/frontend/packages/console-app/package.json @@ -16,6 +16,7 @@ "@console/internal": "0.0.0-fixed", "@console/insights-plugin": "0.0.0-fixed", "@console/knative-plugin": "0.0.0-fixed", + "@console/kubevirt-plugin": "0.0.0-fixed", "@console/local-storage-operator-plugin": "0.0.0-fixed", "@console/metal3-plugin": "0.0.0-fixed", "@console/network-attachment-definition-plugin": "0.0.0-fixed", diff --git a/frontend/packages/kubevirt-plugin/OWNERS b/frontend/packages/kubevirt-plugin/OWNERS new file mode 100644 index 000000000000..ec8bfdb43b84 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/OWNERS @@ -0,0 +1,16 @@ +reviewers: + - vojtechszocs + - pcbailey + - gouyang + - metalice + - avivtur + - upalatucci +approvers: + - vojtechszocs + - pcbailey + - gouyang + - metalice + - avivtur + - upalatucci +labels: + - component/kubevirt diff --git a/frontend/packages/kubevirt-plugin/console-extensions.json b/frontend/packages/kubevirt-plugin/console-extensions.json new file mode 100644 index 000000000000..b75586e5c2c9 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/console-extensions.json @@ -0,0 +1,150 @@ +[ + { + "type": "console.flag", + "properties": { + "handler": { "$codeRef": "kubevirtFlags.detectKubevirtVirtualMachines" } + } + }, + { + "type": "console.flag/model", + "properties": { + "flag": "KUBEVIRT_CDI", + "model": { + "group": "cdi.kubevirt.io", + "version": "v1beta1", + "kind": "CDIConfig" + } + } + }, + { + "type": "console.topology/component/factory", + "properties": { + "getFactory": { + "$codeRef": "topology.componentFactory" + } + }, + "flags": { + "required": ["KUBEVIRT"] + } + }, + { + "type": "console.topology/data/factory", + "properties": { + "id": "kubevirt-topology-model-factory", + "priority": 200, + "resources": { + "virtualmachines": { + "model": { "kind": "VirtualMachine", "group": "kubevirt.io" }, + "opts": { + "isList": true, + "optional": true + } + }, + "virtualmachineinstances": { + "model": { "kind": "VirtualMachineInstance", "group": "kubevirt.io" }, + "opts": { + "isList": true, + "optional": true + } + }, + "virtualmachinetemplates": { + "opts": { + "kind": "Template", + "isList": true, + "optional": true, + "selector": { + "matchLabels": { + "template.kubevirt.io/type": "base" + } + } + } + }, + "migrations": { + "model": { "kind": "VirtualMachineInstanceMigration", "group": "kubevirt.io" }, + "opts": { + "isList": true, + "optional": true + } + }, + "dataVolumes": { + "model": { "kind": "DataVolume", "group": "cdi.kubevirt.io" }, + "opts": { + "isList": true, + "optional": true + } + }, + "pods": { + "opts": { + "isList": true, + "kind": "Pod", + "optional": true + } + } + }, + "getDataModel": { + "$codeRef": "topology.getDataModel" + }, + "isResourceDepicted": { + "$codeRef": "topology.isResourceDepicted" + } + }, + "flags": { + "required": ["KUBEVIRT"] + } + }, + { + "type": "console.topology/details/tab-section", + "properties": { + "id": "topology-tab-section-vm-details", + "tab": "topology-side-bar-tab-details", + "provider": { + "$codeRef": "topologySidebar.useVmSidePanelDetailsTabSection" + } + }, + "flags": { + "required": ["KUBEVIRT"] + } + }, + { + "type": "console.topology/adapter/pod", + "properties": { + "adapt": { + "$codeRef": "topologySidebar.getVmSidePanelPodsAdapter" + } + }, + "flags": { + "required": ["KUBEVIRT"] + } + }, + { + "type": "console.topology/adapter/network", + "properties": { + "adapt": { + "$codeRef": "topologySidebar.getVmSidePanelNetworkAdapter" + } + }, + "flags": { + "required": ["KUBEVIRT"] + } + }, + { + "type": "console.topology/details/resource-link", + "properties": { + "priority": 100, + "link": { "$codeRef": "topologySidebar.getVmSideBarResourceLink" } + }, + "flags": { + "required": ["KUBEVIRT"] + } + }, + { + "type": "console.action/provider", + "properties": { + "contextId": "topology-actions", + "provider": { "$codeRef": "actions.useModifyApplicationActionProvider" } + }, + "flags": { + "required": ["KUBEVIRT"] + } + } +] diff --git a/frontend/packages/kubevirt-plugin/locales/en/kubevirt-plugin.json b/frontend/packages/kubevirt-plugin/locales/en/kubevirt-plugin.json new file mode 100644 index 000000000000..1bf3f935449a --- /dev/null +++ b/frontend/packages/kubevirt-plugin/locales/en/kubevirt-plugin.json @@ -0,0 +1,152 @@ +{ + "Add": "Add", + "Pending Changes": "Pending Changes", + "You must restart your virtual machine to implement your changes.": "You must restart your virtual machine to implement your changes.", + "If you make changes to the following settings, you must to restart the virtual machine in order for them to apply.": "If you make changes to the following settings, you must to restart the virtual machine in order for them to apply.", + "Restart required to apply changes": "Restart required to apply changes", + "Select a boot source": "Select a boot source", + "Add source": "Add source", + "All sources selected": "All sources selected", + "No resource selected": "No resource selected", + "VM will attempt to boot from disks by order of apearance in YAML file": "VM will attempt to boot from disks by order of apearance in YAML file", + "VM Boot Order List": "VM Boot Order List", + "Hide default boot disks": "Hide default boot disks", + "Show default boot disks": "Show default boot disks", + "View details": "View details", + "Hide details": "Hide details", + "Name": "Name", + "Device name": "Device name", + "Mediated devices": "Mediated devices", + "PCI Host devices": "PCI Host devices", + "Select Hardware device": "Select Hardware device", + "Filter by resource name..": "Filter by resource name..", + "Name is Empty": "Name is Empty", + "Name is already taken by another device in this VM": "Name is already taken by another device in this VM", + "Virtual machine boot order": "Virtual machine boot order", + "Save": "Save", + "Boot order has been updated outside this flow.": "Boot order has been updated outside this flow.", + "Saving these changes will override any boot order previously saved.<1>To see the updated order <4>reload the content.": "Saving these changes will override any boot order previously saved.<1>To see the updated order <4>reload the content.", + "Name is already used by another virtual machine in this namespace": "Name is already used by another virtual machine in this namespace", + "The VM {{vmName}} is still running. It will be powered off while cloning.": "The VM {{vmName}} is still running. It will be powered off while cloning.", + "Clone Virtual Machine": "Clone Virtual Machine", + "new VM name": "new VM name", + "Description": "Description", + "Namespace": "Namespace", + "Start virtual machine on clone": "Start virtual machine on clone", + "Configuration": "Configuration", + "Clone": "Clone", + "{{flavor}}: {{count}} CPU | {{memory}} Memory": "{{flavor}}: {{count}} CPU | {{memory}} Memory", + "Edit Description": "Edit Description", + "description text area": "description text area", + "No GPU devices found": "No GPU devices found", + "GPU devices": "GPU devices", + "Add GPU device": "Add GPU device", + "Adding and removing hardware devices require permissions for Hyperconverged CRD. ": "Adding and removing hardware devices require permissions for Hyperconverged CRD. ", + "Learn more": "Learn more", + "No Host devices found": "No Host devices found", + "Host devices": "Host devices", + "Add Host device": "Add Host device", + "Confirm": "Confirm", + "Cancel": "Cancel", + "Delete {{modelLabel}}?": "Delete {{modelLabel}}?", + "Are you sure you want to delete <1>{{name}} in namespace <3>{{namespace}}?": "Are you sure you want to delete <1>{{name}} in namespace <3>{{namespace}}?", + "The following resources will be deleted along with this virtual machine. Unchecked items will not be deleted.": "The following resources will be deleted along with this virtual machine. Unchecked items will not be deleted.", + "Delete Disks ({{ownedVolumeResourcesLength}}x)": "Delete Disks ({{ownedVolumeResourcesLength}}x)", + "Delete {{vmImportModelLabel}} Resource": "Delete {{vmImportModelLabel}} Resource", + "<0>Warning: All snapshots of this virtual machine will be deleted as well.": "<0>Warning: All snapshots of this virtual machine will be deleted as well.", + "Delete Virtual Machine alert": "Delete Virtual Machine alert", + "Delete": "Delete", + "Delete Virtual Machine Instance?": "Delete Virtual Machine Instance?", + "The following resources will be deleted along with this virtual machine instance. Unchecked items will not be deleted.": "The following resources will be deleted along with this virtual machine instance. Unchecked items will not be deleted.", + "Delete Virtual Machine Instance alert": "Delete Virtual Machine Instance alert", + "<0>{{count}} User currently logged in to this VM. Proceeding with this operation may cause logged in users to lose data._one": "<0>{{count}} User currently logged in to this VM. Proceeding with this operation may cause logged in users to lose data.", + "<0>{{count}} User currently logged in to this VM. Proceeding with this operation may cause logged in users to lose data._other": "<0>{{count}} User currently logged in to this VM. Proceeding with this operation may cause logged in users to lose data.", + "Alert": "Alert", + "An error occurred": "An error occurred", + "Save and Restart": "Save and Restart", + "Permissions required": "Permissions required", + "Close": "Close", + "Restart Virtual Machine": "Restart Virtual Machine", + "Restart Virtual Machine alert": "Restart Virtual Machine alert", + "restart": "restart", + "Restart": "Restart", + "Edit pause state": "Edit pause state", + "This VM is paused. To unpause it, click the Unpause button below. For further details, check with your system administrator.": "This VM is paused. To unpause it, click the Unpause button below. For further details, check with your system administrator.", + "Unpause": "Unpause", + "SSH access is using a node port. Node port requires additional port resources.": "SSH access is using a node port. Node port requires additional port resources.", + "SSH access": "SSH access", + "Expose SSH access for <2>": "Expose SSH access for <2>", + "Node port": "Node port", + "The following hot-plugged disks will be removed from the virtual machine": "The following hot-plugged disks will be removed from the virtual machine", + "<0>{diskList}": "<0>{diskList}", + "Are you sure you want to {actionLabel} <3>{name} in namespace <6>{namespace}?": "Are you sure you want to {actionLabel} <3>{name} in namespace <6>{namespace}?", + "<0>This virtual machine will start as soon as the import is complete. If you proceed, you will not be able to change this option.Are you sure you want to start <2>{name} in namespace <5>{namespace} after it has imported?": "<0>This virtual machine will start as soon as the import is complete. If you proceed, you will not be able to change this option.Are you sure you want to start <2>{name} in namespace <5>{namespace} after it has imported?", + "Start": "Start", + " Start": " Start", + "Stop": "Stop", + "Stop alert": "Stop alert", + "stop": "stop", + "Restart alert": "Restart alert", + "Pause": "Pause", + "pause": "pause", + "Resume": "Resume", + "unpause": "unpause", + "Do you wish to migrate <1>{name} vmi to another node?": "Do you wish to migrate <1>{name} vmi to another node?", + "Migrate Node to Node": "Migrate Node to Node", + "Are you sure you want to cancel <1>{name} migration in <4>{namespace} namespace?": "Are you sure you want to cancel <1>{name} migration in <4>{namespace} namespace?", + "Cancel Virtual Machine Migration": "Cancel Virtual Machine Migration", + "Cancel Migration": "Cancel Migration", + "Open Console": "Open Console", + "Delete Virtual Machine": "Delete Virtual Machine", + "Delete Virtual Machine Instance": "Delete Virtual Machine Instance", + "Cancel Import?": "Cancel Import?", + "Are you sure you want to cancel importing {{vmImportElem}}? It will also delete the newly created {{vmElem}} in the {{nsElem}} namespace.": "Are you sure you want to cancel importing {{vmImportElem}}? It will also delete the newly created {{vmElem}} in the {{nsElem}} namespace.", + "Are you sure you want to cancel importing {{vmImportElem}} in the {{nsElem}} namespace?": "Are you sure you want to cancel importing {{vmImportElem}} in the {{nsElem}} namespace?", + "Cancel Import": "Cancel Import", + "Not available": "Not available", + "View Pending Changes": "View Pending Changes", + "Template": "Template", + "Status": "Status", + "Pod": "Pod", + "Boot order": "Boot order", + "IP address": "IP address", + "Hostname": "Hostname", + "Time zone": "Time zone", + "Node": "Node", + "Workload profile": "Workload profile", + "User credentials": "User credentials", + "user: {{user}}": "user: {{user}}", + "Requires SSH service": "Requires SSH service", + "Virtual machine not running": "Virtual machine not running", + "port: {{port}}": "port: {{port}}", + "SSH service disabled": "SSH service disabled", + "Hardware devices": "Hardware devices", + "Attach GPU device to VM": "Attach GPU device to VM", + "You do not have permissions to attach GPU devices. Contact your system administrator for more information.": "You do not have permissions to attach GPU devices. Contact your system administrator for more information.", + "{{gpusCount}} GPU devices": "{{gpusCount}} GPU devices", + "Attach Host device to VM": "Attach Host device to VM", + "You do not have permissions to attach Host devices. Contact your system administrator for more information.": "You do not have permissions to attach Host devices. Contact your system administrator for more information.", + "{{hostDevicesCount}} Host devices": "{{hostDevicesCount}} Host devices", + "IP Addresses ({{ips}})": "IP Addresses ({{ips}})", + "+{{ips}} more": "+{{ips}} more", + "Operating system": "Operating system", + "Migration - Pending": "Migration - Pending", + "Migration - Scheduling": "Migration - Scheduling", + "Migration - Preparing Target": "Migration - Preparing Target", + "Migration - Scheduled": "Migration - Scheduled", + "Migration - Target Ready": "Migration - Target Ready", + "Migration - Running": "Migration - Running", + "virtio": "virtio", + "Optimized for best performance. Supported by most Linux distributions. Windows requires additional drivers to use this model": "Optimized for best performance. Supported by most Linux distributions. Windows requires additional drivers to use this model", + "sata": "sata", + "Supported by most operating systems including Windows out of the box. Offers lower performance compared to virtio. Consider using it for CD-ROM devices": "Supported by most operating systems including Windows out of the box. Offers lower performance compared to virtio. Consider using it for CD-ROM devices", + "scsi": "scsi", + "Paravirtualized iSCSI HDD driver offers similar functionality to the virtio-block device, with some additional enhancements. In particular, this driver supports adding hundreds of devices, and names devices using the standard SCSI device naming scheme": "Paravirtualized iSCSI HDD driver offers similar functionality to the virtio-block device, with some additional enhancements. In particular, this driver supports adding hundreds of devices, and names devices using the standard SCSI device naming scheme", + "Guest agent required": "Guest agent required", + "VM name cannot be empty": "VM name cannot be empty", + "VM name name can contain only alphanumeric characters": "VM name name can contain only alphanumeric characters", + "VM name must start/end with alphanumeric characters": "VM name must start/end with alphanumeric characters", + "VM name cannot contain uppercase characters": "VM name cannot contain uppercase characters", + "VM name is too long": "VM name is too long", + "VM name is too short": "VM name is too short" +} \ No newline at end of file diff --git a/frontend/packages/kubevirt-plugin/package.json b/frontend/packages/kubevirt-plugin/package.json new file mode 100644 index 000000000000..459b6fb576e3 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/package.json @@ -0,0 +1,57 @@ +{ + "name": "@console/kubevirt-plugin", + "version": "0.0.0-fixed", + "description": "Kubevirt - Virtual machines addon for Kubernetes", + "private": true, + "scripts": { + "lint": "yarn --cwd ../.. eslint packages/kubevirt-plugin" + }, + "dependencies": { + "@console/internal": "0.0.0-fixed", + "@console/plugin-sdk": "0.0.0-fixed", + "@console/shared": "0.0.0-fixed", + "@console/topology": "0.0.0-fixed", + "@console/dev-console": "0.0.0-fixed", + "unique-names-generator": "4.3.1" + }, + "consolePlugin": { + "entry": "src/plugin.tsx", + "exposedModules": { + "models": "src/models/index.ts", + "standaloneConsole": "src/components/vms/vm-console/StandaloneVMConsolePage.tsx", + "icons": "src/utils/icons.tsx", + "createVM": "src/components/create-vm", + "contextProvider": "src/components/cdi-upload-provider/cdi-upload-provider.tsx", + "pvcSelectors": "src/selectors/pvc/selectors.ts", + "pvcAlert": "src/components/cdi-upload-provider/pvc-alert-extension.tsx", + "pvcUploadStatus": "src/components/cdi-upload-provider/upload-pvc-popover.tsx", + "pvcCloneStatus": "src/components/pvc-status/clone-pvc-status.tsx", + "pvcDelete": "src/components/cdi-upload-provider/pvc-delete-extension.tsx", + "reduxReducer": "src/redux/index.ts", + "UploadPVCPage": "src/components/cdi-upload-provider/upload-pvc-form/upload-pvc-form.tsx", + "VMCreateYAML": "src/components/vms/vm-create-yaml.tsx", + "CreateVMWizardPage": "src/components/create-vm-wizard/create-vm-wizard.tsx", + "CreateVM": "src/components/create-vm/create-vm.tsx", + "VirtualizationWizardNavigator": "src/components/VirtualizationWizardNavigator/VirtualizationWizardNavigator.tsx", + "VirtualMachinesDetailsPage": "src/components/vms/vm-details-page.tsx", + "VirtualMachinesInstanceDetailsPage": "src/components/vms/vmi-details-page.tsx", + "VirtualizationPage": "src/components/vms/virtualization.tsx", + "VMTemplateDetailsPage": "src/components/vm-templates/vm-template-details-page.tsx", + "VirtualMachineTemplatesPage": "src/components/vm-templates/vm-template.tsx", + "SnapshotDetailsPage": "src/components/vm-snapshots/vm-snapshot-details.tsx", + "DevConsoleCreateVmForm": "src/components/create-vm/dev-console/dev-console-create-vm-form.tsx", + "CustomizeSourceForm": "src/components/vm-templates/customize-source/CustomizeSourceForm.tsx", + "CustomizeSource": "src/components/vm-templates/customize-source/CustomizeSource.tsx", + "yamlTemplates": "src/models/templates/index.ts", + "dashboardHealth": "src/components/dashboards-page/overview-dashboard/health.ts", + "dashboardInventory": "src/components/dashboards-page/overview-dashboard/inventory.tsx", + "dashboardActivity": "src/components/dashboards-page/overview-dashboard/activity.tsx", + "topology": "src/topology/topology-plugin.ts", + "kubevirtFlags": "src/flags", + "topologySidebar": "src/topology/vm-tab-sections.tsx", + "actions": "src/actions/provider.ts", + "HardwareDevicesPage": "src/components/hardware-devices/Page/HardwareDevicesPage.tsx", + "VirtualizationOverviewPage": "src/components/virtualization-overview/VirtualizationOverviewPage.tsx" + } + } +} diff --git a/frontend/packages/kubevirt-plugin/src/actions/provider.ts b/frontend/packages/kubevirt-plugin/src/actions/provider.ts new file mode 100644 index 000000000000..c300fca7b4b5 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/actions/provider.ts @@ -0,0 +1,106 @@ +import * as React from 'react'; +import { GraphElement } from '@patternfly/react-topology'; +import { CommonActionFactory } from '@console/app/src/actions/creators/common-factory'; +import { useK8sWatchResource } from '@console/internal/components/utils/k8s-watch-hook'; +import { modelFor, referenceFor } from '@console/internal/module/k8s'; +import { useK8sModel } from '@console/shared/src/hooks/useK8sModel'; +import { getModifyApplicationAction } from '@console/topology/src/actions'; +import { + VmActionFactory, + VmiActionFactory, +} from '../kubevirt-dependencies/components/vm/menu-actions'; +import { useVMStatus } from '../kubevirt-dependencies/hooks/useVMStatus'; +import { VirtualMachineInstanceModel } from '../kubevirt-dependencies/models'; +import { kubevirtReferenceForModel } from '../kubevirt-dependencies/models/kubevirtReferenceForModel'; +import { isVMRunningOrExpectedRunning } from '../kubevirt-dependencies/selectors/vm/selectors'; +import { isVMIPaused } from '../kubevirt-dependencies/selectors/vmi'; +import { VMKind } from '../kubevirt-dependencies/types/vm'; +import { VMIKind } from '../kubevirt-dependencies/types/vmi'; +import { TYPE_VIRTUAL_MACHINE } from '../topology/components/const'; + +export const useVmActionsProvider = (vm: VMKind) => { + const [k8sModel, inFlight] = useK8sModel(referenceFor(vm)); + const { + metadata: { name, namespace }, + } = vm; + const [vmi] = useK8sWatchResource({ + kind: kubevirtReferenceForModel(VirtualMachineInstanceModel), + name, + namespace, + isList: false, + }); + const vmStatusBundle = useVMStatus(name, namespace); + + const actions = React.useMemo(() => { + const start = + !isVMRunningOrExpectedRunning(vm, vmi) || isVMIPaused(vmi) + ? VmActionFactory.Start(k8sModel, vm, { vmi, vmStatusBundle }) + : VmActionFactory.Stop(k8sModel, vm, { vmStatusBundle, vmi }); + + const migrate = + !vmStatusBundle || !vmStatusBundle?.status?.isMigrating() + ? VmActionFactory.Migrate(k8sModel, vm, { vmi }) + : VmActionFactory.CancelMigration(k8sModel, vm, { vmStatusBundle }); + + const pause = + !vmi || !isVMIPaused(vmi) + ? VmActionFactory.Pause(k8sModel, vm, { vmi, vmStatusBundle }) + : VmActionFactory.Unpause(k8sModel, vm, { vmi, vmStatusBundle }); + + return vmStatusBundle + ? [ + start, + VmActionFactory.Restart(k8sModel, vm, { vmi }), + pause, + VmActionFactory.Clone(k8sModel, vm, { vmi, vmStatusBundle }), + migrate, + VmActionFactory.OpenConsole(k8sModel, vm, { vmi }), + // disabled until https://issues.redhat.com/browse/CNV-9746 is implemented + // VmActionFactory.CopySSH(k8sModel, vm, { vmi }), + CommonActionFactory.ModifyLabels(k8sModel, vm), + CommonActionFactory.ModifyAnnotations(k8sModel, vm), + VmActionFactory.Delete(k8sModel, vm, { vmi }), + ] + : []; + }, [k8sModel, vm, vmStatusBundle, vmi]); + return React.useMemo(() => [actions, !inFlight, undefined], [actions, inFlight]); +}; + +export const useVmiActionsProvider = (vm: VMKind) => { + const [k8sModel, inFlight] = useK8sModel(referenceFor(vm)); + const { + metadata: { name, namespace }, + } = vm; + const [vmi] = useK8sWatchResource({ + kind: kubevirtReferenceForModel(VirtualMachineInstanceModel), + name, + namespace, + isList: false, + }); + const vmStatusBundle = useVMStatus(name, namespace); + const actions = React.useMemo(() => { + return vmStatusBundle + ? [ + ...(vmi ? [VmActionFactory.OpenConsole(k8sModel, vm, { vmi })] : []), + CommonActionFactory.ModifyLabels(k8sModel, vm), + CommonActionFactory.ModifyAnnotations(k8sModel, vm), + ...(vmi ? [VmiActionFactory.Delete(k8sModel, vmi)] : []), + ] + : []; + }, [k8sModel, vm, vmStatusBundle, vmi]); + return React.useMemo(() => [actions, !inFlight, undefined], [actions, inFlight]); +}; + +export const useModifyApplicationActionProvider = (element: GraphElement) => { + const actions = React.useMemo(() => { + if (element.getType() !== TYPE_VIRTUAL_MACHINE) return undefined; + const resource = element.getData().resources.obj; + const k8sKind = modelFor(referenceFor(resource)); + return [getModifyApplicationAction(k8sKind, resource, 'vm-action-start')]; + }, [element]); + + return React.useMemo(() => { + if (!actions) return [[], true, undefined]; + return [actions, true, undefined]; + }, [actions]); +}; diff --git a/frontend/packages/kubevirt-plugin/src/flags/const.ts b/frontend/packages/kubevirt-plugin/src/flags/const.ts new file mode 100644 index 000000000000..359732d9b5f6 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/flags/const.ts @@ -0,0 +1,6 @@ +export const FLAG_KUBEVIRT = 'KUBEVIRT'; +export const FLAG_KUBEVIRT_HAS_V1_API = 'KUBEVIRT_HAS_v1_API'; +export const FLAG_KUBEVIRT_HAS_V1ALPHA3_API = 'KUBEVIRT_HAS_v1alpha3_API'; +export const FLAG_KUBEVIRT_HAS_PRINTABLESTATUS = 'KUBEVIRT_HAS_printableStatus'; + +export const FLAG_KUBEVIRT_CDI = 'KUBEVIRT_CDI'; diff --git a/frontend/packages/kubevirt-plugin/src/flags/detect-kubevirt-virtual-machines.ts b/frontend/packages/kubevirt-plugin/src/flags/detect-kubevirt-virtual-machines.ts new file mode 100644 index 000000000000..1e69da2de6bf --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/flags/detect-kubevirt-virtual-machines.ts @@ -0,0 +1,42 @@ +import { SetFeatureFlag } from '@console/dynamic-plugin-sdk'; +import { fetchSwagger } from '@console/internal/module/k8s'; +import { + FLAG_KUBEVIRT, + FLAG_KUBEVIRT_HAS_PRINTABLESTATUS, + FLAG_KUBEVIRT_HAS_V1_API, + FLAG_KUBEVIRT_HAS_V1ALPHA3_API, +} from './const'; + +export const detectKubevirtVirtualMachines = async (setFeatureFlag: SetFeatureFlag) => { + let hasV1APIVersion = false; + let hasV1Alpha3APIVersion = false; + let hasPrintableStatus = false; + let id = null; + + // Get swagger schema + const updateKubevirtFlags = () => { + fetchSwagger() + .then((yamlOpenAPI) => { + // Check for known apiVersions + hasV1APIVersion = !!yamlOpenAPI['io.kubevirt.v1.VirtualMachine']; + hasV1Alpha3APIVersion = !!yamlOpenAPI['io.kubevirt.v1alpha3.VirtualMachine']; + + // Check for shchema features + if (hasV1APIVersion) { + hasPrintableStatus = !!yamlOpenAPI['io.kubevirt.v1.VirtualMachine']?.properties?.status + ?.properties?.printableStatus; + } + + setFeatureFlag(FLAG_KUBEVIRT, hasV1APIVersion || hasV1Alpha3APIVersion); + setFeatureFlag(FLAG_KUBEVIRT_HAS_V1_API, hasV1APIVersion); + setFeatureFlag(FLAG_KUBEVIRT_HAS_V1ALPHA3_API, hasV1Alpha3APIVersion && !hasV1APIVersion); + setFeatureFlag(FLAG_KUBEVIRT_HAS_PRINTABLESTATUS, hasPrintableStatus); + }) + .catch(() => { + clearInterval(id); + }); + }; + + updateKubevirtFlags(); + id = setInterval(updateKubevirtFlags, 10 * 1000); +}; diff --git a/frontend/packages/kubevirt-plugin/src/flags/index.ts b/frontend/packages/kubevirt-plugin/src/flags/index.ts new file mode 100644 index 000000000000..42503a965040 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/flags/index.ts @@ -0,0 +1 @@ +export { detectKubevirtVirtualMachines } from './detect-kubevirt-virtual-machines'; diff --git a/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/AddButton/AddButton.tsx b/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/AddButton/AddButton.tsx new file mode 100644 index 000000000000..85fc87d72265 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/AddButton/AddButton.tsx @@ -0,0 +1,27 @@ +import * as React from 'react'; +import { Button } from '@patternfly/react-core'; +import { PlusCircleIcon } from '@patternfly/react-icons/dist/esm/icons/plus-circle-icon'; +import { useTranslation } from 'react-i18next'; + +export type AddButtonProps = { + onClick: () => void; + isDisabled?: boolean; + btnText?: string; +}; + +const AddButton: React.FC = ({ onClick, isDisabled, btnText }) => { + const { t } = useTranslation(); + return ( + + ); +}; + +export default AddButton; diff --git a/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/FilteredSelect/FilteredSelect.tsx b/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/FilteredSelect/FilteredSelect.tsx new file mode 100644 index 000000000000..7e68902631f7 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/FilteredSelect/FilteredSelect.tsx @@ -0,0 +1,50 @@ +/* eslint-disable array-callback-return, consistent-return */ +import * as React from 'react'; +import { + Select as SelectDeprecated, + SelectProps as SelectPropsDeprecated, + SelectVariant as SelectVariantDeprecated, +} from '@patternfly/react-core/deprecated'; + +const FilteredSelect: React.FC = (props) => { + const { isGrouped, children } = props; + const options = children; + + const onFilter = (_, textInput) => { + if (textInput === '') { + return options; + } + + if (isGrouped) { + return options + .map((option) => { + const filteredGroup = React.cloneElement(option, { + children: option.props.children.filter((item) => { + return item.props.value.toLowerCase().includes(textInput.toLowerCase()); + }), + }); + if (filteredGroup.props.children.length > 0) { + return filteredGroup; + } + }) + ?.filter(Boolean); + } + + return options?.filter((option) => + option.props.value.toLowerCase().includes(textInput.toLowerCase()), + ); + }; + + return ( + + {options} + + ); +}; + +export default FilteredSelect; diff --git a/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/HardwareDevicesList/HardwareDevicesList.tsx b/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/HardwareDevicesList/HardwareDevicesList.tsx new file mode 100644 index 000000000000..88d9d17eb74c --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/HardwareDevicesList/HardwareDevicesList.tsx @@ -0,0 +1,103 @@ +import * as React from 'react'; +import { GridItem, Split, SplitItem, Text, TextVariants } from '@patternfly/react-core'; +import { useTranslation } from 'react-i18next'; +import './hardware-devices.scss'; +import AddButton from '../AddButton/AddButton'; +import { HardwareDevicesListRow, HardwareDevicesListRowProps } from './HardwareDevicesListRow'; +import { + HardwareDevicesListRowAddDevice, + HardwareDevicesListRowAddDeviceProps, +} from './HardwareDevicesListRowAddDevice'; + +export type HardwareDevice = { + // Common denominator of V1GPU and V1HostDevice + deviceName: string; // 'deviceName' is the resource name of the host device exposed by a device plugin + name: string; // Name of the host device as exposed by a device plugin +}; + +export type HardwareDevicesListProps = { + devices: HardwareDevice[]; + noDevicesFoundText?: string; + addDeviceText?: string; + onAttachHandler?: () => void; + showAddDeviceRow?: boolean; + emptyState?: React.ReactNode; +} & HardwareDevicesListRowProps & + HardwareDevicesListRowAddDeviceProps; + +const HardwareDevicesList: React.FC = ({ + devices, + onDetachHandler, + onCancelAttachHandler, + showAddDeviceRow, + name, + onNameChange, + onValidateName, + onResetValidateName, + deviceName, + emptyState = 'No devices found', + addDeviceText, + onAttachHandler, + onDeviceNameChange, + isDisabled, +}) => { + const { t } = useTranslation(); + const showEmptyState = !(devices?.length > 0 || showAddDeviceRow); + + const headers = ( + <> + + {t('kubevirt-plugin~Name')} + + + {t('kubevirt-plugin~Device name')} + + + ); + + const addButton = ( + + + + + + ); + + return ( + <> + {showEmptyState ? ( + emptyState + ) : ( + <> + {headers} + {devices?.map((device) => ( + onDetachHandler(device?.name)} + isDisabled={isDisabled} + /> + ))} + {showAddDeviceRow && ( + + )} + + )} + {addButton} + + ); +}; + +export default HardwareDevicesList; diff --git a/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/HardwareDevicesList/HardwareDevicesListRow.tsx b/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/HardwareDevicesList/HardwareDevicesListRow.tsx new file mode 100644 index 000000000000..13bcd496b66d --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/HardwareDevicesList/HardwareDevicesListRow.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; +import { Button, GridItem, TextInput } from '@patternfly/react-core'; +import { MinusCircleIcon } from '@patternfly/react-icons/dist/esm/icons/minus-circle-icon'; + +export type HardwareDevicesListRowProps = { + name?: string; + deviceName?: string; + onDetachHandler?: (id?: any) => void; + isDisabled?: boolean; +}; + +export const HardwareDevicesListRow: React.FC = ({ + name, + deviceName, + onDetachHandler, + isDisabled, +}) => { + return ( + <> + + + + + + + {!isDisabled && ( + + + + )} + + ); +}; diff --git a/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/HardwareDevicesList/HardwareDevicesListRowAddDevice.tsx b/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/HardwareDevicesList/HardwareDevicesListRowAddDevice.tsx new file mode 100644 index 000000000000..0ca96976f8c2 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/HardwareDevicesList/HardwareDevicesListRowAddDevice.tsx @@ -0,0 +1,134 @@ +import * as React from 'react'; +import { Button, GridItem, HelperText, HelperTextItem, TextInput } from '@patternfly/react-core'; +import { + SelectGroup as SelectGroupDeprecated, + SelectOption as SelectOptionDeprecated, +} from '@patternfly/react-core/deprecated'; +import { MinusCircleIcon } from '@patternfly/react-icons/dist/esm/icons/minus-circle-icon'; +import { isEmpty } from 'lodash'; +import { useTranslation } from 'react-i18next'; +import { useHyperconvergedCR } from '../../hooks/useHyperconvergedResource'; +import FilteredSelect from '../FilteredSelect/FilteredSelect'; +import HWContext from '../modals/hardware-devices/hardware-devices-context'; + +export type HardwareDevicesListRowAddDeviceProps = { + name?: string; + deviceName?: string; + onCancelAttachHandler?: () => void; + onNameChange?: React.Dispatch>; + onValidateName?: () => void; + onResetValidateName?: () => void; + onDeviceNameChange?: React.Dispatch>; +}; + +export const HardwareDevicesListRowAddDevice: React.FC = ({ + name, + deviceName, + onCancelAttachHandler, + onNameChange, + onValidateName, + onResetValidateName, + onDeviceNameChange, +}) => { + const { t } = useTranslation(); + const [isOpen, setIsOpen] = React.useState(false); + const { isBlur, isNameUsed, isNameEmpty } = React.useContext(HWContext); + + const [hc] = useHyperconvergedCR(); + const resourcesGroups = React.useMemo(() => { + const pciHostDevs = hc?.spec?.permittedHostDevices?.pciHostDevices?.map( + (dev) => dev?.resourceName, + ); + const medDevs = hc?.spec?.permittedHostDevices?.mediatedDevices?.map( + (dev) => dev?.resourceName, + ); + + let temp = []; + if (!isEmpty(medDevs)) { + temp = [ + { options: [...medDevs], label: t('kubevirt-plugin~Mediated devices'), key: 'mediated' }, + ]; + } + + if (!isEmpty(pciHostDevs)) { + temp = isEmpty(temp) + ? [ + { + options: [...pciHostDevs], + label: t('kubevirt-plugin~PCI Host devices'), + key: 'pcihost', + }, + ] + : [ + ...temp, + { + options: [...pciHostDevs], + label: t('kubevirt-plugin~PCI Host devices'), + key: 'pcihost', + }, + ]; + } + + return temp?.map((group) => ( + + {group?.options?.map((option, index) => ( + // eslint-disable-next-line react/no-array-index-key + + ))} + + )); + }, [hc, t]); + + const onSelect = (e, devName: string) => { + setIsOpen(false); + onDeviceNameChange(devName); + }; + + return ( + <> + + onNameChange(value)} + onBlur={onValidateName} + onFocus={onResetValidateName} + /> + + + setIsOpen(isExpanded)} + isOpen={isOpen} + selections={deviceName} + onSelect={onSelect} + > + {resourcesGroups} + + + + + + {isBlur && isNameEmpty && ( + + + {t('kubevirt-plugin~Name is Empty')} + + + )} + {isBlur && isNameUsed && ( + + + {t('kubevirt-plugin~Name is already taken by another device in this VM')} + + + )} + + ); +}; diff --git a/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/HardwareDevicesList/hardware-devices.scss b/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/HardwareDevicesList/hardware-devices.scss new file mode 100644 index 000000000000..03ac25feefe0 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/HardwareDevicesList/hardware-devices.scss @@ -0,0 +1,15 @@ +.kv-hardware__remove-button .kv-hardware__device { + margin-left: var(--pf-v5-global--spacer--md); +} + +.kv-hardware__name { + margin-right: var(--pf-v5-global--spacer--md); +} + +.kv-hardware__row { + margin-bottom: var(--pf-v5-global--spacer--sm); +} + +.kv-hardware__form { + max-width: 70vh; +} diff --git a/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/alerts/PendingChangesAlert.scss b/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/alerts/PendingChangesAlert.scss new file mode 100644 index 000000000000..d2feedfb3095 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/alerts/PendingChangesAlert.scss @@ -0,0 +1,3 @@ +.kv__pending_changes-alert { + margin-bottom: var(--pf-v5-global--spacer--sm); +} diff --git a/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/alerts/PendingChangesAlerts.tsx b/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/alerts/PendingChangesAlerts.tsx new file mode 100644 index 000000000000..9d3543e4db60 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/alerts/PendingChangesAlerts.tsx @@ -0,0 +1,52 @@ +import * as React from 'react'; +import { Alert, AlertVariant } from '@patternfly/react-core'; +import { useTranslation } from 'react-i18next'; + +import './PendingChangesAlert.scss'; + +type PendingChangesAlertProps = { + warningMsg?: string; + isWarning?: boolean; + title?: string; +}; + +export const PendingChangesAlert: React.FC = ({ + warningMsg, + isWarning, + title, + children, +}) => { + const { t } = useTranslation(); + return ( + + {warningMsg || children} + + ); +}; + +type ModalPendingChangesAlertProps = { + isChanged: boolean; +}; + +export const ModalPendingChangesAlert: React.FC = ({ + isChanged, +}) => { + const { t } = useTranslation(); + const modalMsg = isChanged + ? t('kubevirt-plugin~You must restart your virtual machine to implement your changes.') + : t( + 'kubevirt-plugin~If you make changes to the following settings, you must to restart the virtual machine in order for them to apply.', + ); + return ( + + ); +}; diff --git a/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/boot-order/add-device-button.tsx b/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/boot-order/add-device-button.tsx new file mode 100644 index 000000000000..c9aa458298ac --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/boot-order/add-device-button.tsx @@ -0,0 +1,32 @@ +import * as React from 'react'; +import { Button, Text, TextVariants } from '@patternfly/react-core'; +import { PlusCircleIcon } from '@patternfly/react-icons/dist/esm/icons/plus-circle-icon'; + +export const AddDeviceButton: React.FC = ({ + id, + message, + disabledMessage, + isDisabled, + onClick, +}) => + isDisabled ? ( + {disabledMessage} + ) : ( + + ); + +export type AddDeviceButtonType = { + id: string; + message: string; + disabledMessage: string; + isDisabled: boolean; + onClick: () => void; +}; diff --git a/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/boot-order/add-device-form-select.tsx b/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/boot-order/add-device-form-select.tsx new file mode 100644 index 000000000000..543b1e7c302b --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/boot-order/add-device-form-select.tsx @@ -0,0 +1,48 @@ +import * as React from 'react'; +import { Button, FormSelect, FormSelectOption } from '@patternfly/react-core'; +import { MinusCircleIcon } from '@patternfly/react-icons/dist/esm/icons/minus-circle-icon'; +import * as _ from 'lodash'; +import { BootableDeviceType } from '../../types/types'; +import { deviceKey, deviceLabel } from './constants'; + +export const AddDeviceFormSelect: React.FC = ({ + id, + options, + label, + onAdd, + onDelete, +}) => ( + <> + onAdd(value)} + className="kubevirt-boot-order__add-device-select" + > + + {_.orderBy(options, ['type', 'value.name']).map((option) => ( + + ))} + + + +); + +export type AddDeviceFormSelectProps = { + id: string; + options: BootableDeviceType[]; + label: string; + onDelete: () => void; + /** onAdd moves items from the options list to the sources list, key = "-". */ + onAdd: (key: string) => void; +}; diff --git a/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/boot-order/add-device.tsx b/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/boot-order/add-device.tsx new file mode 100644 index 000000000000..6540e35b94d2 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/boot-order/add-device.tsx @@ -0,0 +1,44 @@ +import * as React from 'react'; +import { useTranslation } from 'react-i18next'; +import { BootableDeviceType } from '../../types/types'; +import { AddDeviceButton } from './add-device-button'; +import { AddDeviceFormSelect } from './add-device-form-select'; + +export type AddDeviceProps = { + devices: BootableDeviceType[]; + isEditMode: boolean; + /** onAdd moves items from the options list to the sources list, key = "-". */ + onAdd: (key: string) => void; + setEditMode: (boolean) => void; +}; + +export const AddDevice = ({ devices, onAdd, isEditMode, setEditMode }: AddDeviceProps) => { + const { t } = useTranslation(); + const options = devices.filter((device) => !device.value.bootOrder); + + const canAddItem = options.length > 0; + const selectID = 'add-device-select'; + const buttontID = 'add-device-btm'; + + return ( +
+ {isEditMode && canAddItem ? ( + setEditMode(false)} + /> + ) : ( + setEditMode(true)} + /> + )} +
+ ); +}; diff --git a/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/boot-order/boot-order-empty.tsx b/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/boot-order/boot-order-empty.tsx new file mode 100644 index 000000000000..e4f40c54e3e6 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/boot-order/boot-order-empty.tsx @@ -0,0 +1,49 @@ +import * as React from 'react'; +import { + Alert, + Button, + EmptyState, + EmptyStateBody, + EmptyStateVariant, + EmptyStateHeader, + EmptyStateFooter, +} from '@patternfly/react-core'; + +// Display and empty with a Call to add new source if no sources are defined. +export const BootOrderEmpty: React.FC = ({ + title, + message, + addItemMessage, + addItemIsDisabled, + addItemDisabledMessage, + onClick, +}) => ( + + {title}} headingLevel="h5" /> + {message} + + {!addItemIsDisabled ? ( + + ) : ( + + )} + + +); + +export type BootOrderEmptyProps = { + title: string; + message: string; + addItemMessage: string; + addItemIsDisabled: boolean; + addItemDisabledMessage?: string; + onClick: () => void; +}; + +export default BootOrderEmpty; diff --git a/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/boot-order/boot-order.scss b/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/boot-order/boot-order.scss new file mode 100644 index 000000000000..363d8ec46645 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/boot-order/boot-order.scss @@ -0,0 +1,35 @@ +.kubevirt-boot-order-summary__expandable.pf-v5-c-expandable .pf-v5-c-expandable__content { + margin-top: 0; +} + +.kubevirt-boot-order-summary__empty-text { + margin-bottom: 0; +} + +.kubevirt-boot-order__boot-order-empty-btn { + margin-top: var(--pf-v5-global--spacer--md); +} + +.pf-v5-c-button.pf-m-link.kubevirt-boot-order__add-device-delete-btn { + --pf-v5-c-button--PaddingTop: 0; + --pf-v5-c-button--PaddingRight: 0; + --pf-v5-c-button--PaddingBottom: 0; + --pf-v5-c-button--PaddingLeft: 0; + --pf-v5-c-button--m-link--Color: var(--pf-v5-global--Color--100); +} + +.kubevirt-boot-order__add-device-select { + margin-left: var(--pf-v5-global--spacer--lg); + margin-right: var(--pf-v5-global--spacer--lg); +} + +.kubevirt-boot-order__add-device { + display: flex; + padding-top: var(--pf-v5-global--spacer--md); + padding-left: var(--pf-v5-global--spacer--lg); + padding-right: var(--pf-v5-global--spacer--lg); +} + +.kubevirt-boot-order__data-list-item { + background: var(--pf-v5-global--BackgroundColor--100); +} diff --git a/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/boot-order/boot-order.tsx b/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/boot-order/boot-order.tsx new file mode 100644 index 000000000000..6160b44750ea --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/boot-order/boot-order.tsx @@ -0,0 +1,116 @@ +import * as React from 'react'; +import { Text, TextVariants } from '@patternfly/react-core'; +import * as _ from 'lodash'; +import { useTranslation } from 'react-i18next'; +import './boot-order.scss'; +import { BootableDeviceType } from '../../types/types'; +import { DNDDataList } from '../dnd-list/dnd-data-list'; +import { DNDDataListItem } from '../dnd-list/dnd-data-list-item'; +import { AddDevice } from './add-device'; +// eslint-disable-next-line import/no-named-as-default +import BootOrderEmpty from './boot-order-empty'; +import { deviceKey, deviceLabel } from './constants'; + +export const BootOrder = ({ devices, setDevices }: BootOrderProps) => { + const { t } = useTranslation(); + const sources = _.sortBy( + devices.filter((device) => device.value.bootOrder), + 'value.bootOrder', + ); + const options = devices.filter((device) => !device.value.bootOrder); + const [isEditMode, setEditMode] = React.useState(false); + + // Relax bootOrder and use setDevice to update the parent componenet. + const updateDevices = (newDevices: BootableDeviceType[]): void => { + _.filter(newDevices, (device) => device.value.bootOrder).forEach((source, i) => { + source.value.bootOrder = i + 1; + }); + + setDevices(newDevices); + setEditMode(false); + }; + + // Remove a bootOrder from a device by index. + const onDelete = (index: number) => { + const newDevices = _.cloneDeep(devices); + + const key = deviceKey(sources[index]); + delete newDevices.find((device) => deviceKey(device) === key).value.bootOrder; + + updateDevices(newDevices); + }; + + // Move a source from one index to another. + const onMove = (index: number, toIndex: number) => { + const unMovedSources = [...sources.slice(0, index), ...sources.slice(index + 1)]; + + // Create an ordered copy of the sources. + const newSources = _.cloneDeep([ + ...unMovedSources.slice(0, toIndex), + sources[index], + ...unMovedSources.slice(toIndex), + ]); + + updateDevices([...newSources, ...options]); + }; + + // Add a bootOrder to a device by key, item key is "->name>". + const onAdd = (key: string): void => { + const newOptions = _.cloneDeep(options); + newOptions.find((option) => deviceKey(option) === key).value.bootOrder = sources.length + 1; + + updateDevices([...sources, ...newOptions]); + }; + + const showEmpty = sources.length === 0 && !isEditMode; + const dataListID = 'VMBootOrderList'; + + return ( + <> + {showEmpty ? ( + { + setEditMode(true); + }} + /> + ) : ( + <> + + {sources.map((source, index) => ( + + + {deviceLabel(source)} + + + ))} + + + + )} + + ); +}; + +export type BootOrderProps = { + devices: BootableDeviceType[]; + setDevices: (devices: BootableDeviceType[]) => void; +}; diff --git a/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/boot-order/constants.ts b/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/boot-order/constants.ts new file mode 100644 index 000000000000..085f0a85de45 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/boot-order/constants.ts @@ -0,0 +1,13 @@ +import { BootableDeviceType } from '../../types/types'; + +export const deviceKey = (device: BootableDeviceType) => `${device.type}-${device.value.name}`; + +export const deviceLabel = (device: BootableDeviceType) => { + const name = device?.value?.name; + + if (name.match(/^\$\{[A-Z_]+\}$/)) { + return `${name} (${device.typeLabel}), template parameter`; + } + + return `${name} (${device.typeLabel})`; +}; diff --git a/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/boot-order/summary/boot-order-empty-summary.tsx b/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/boot-order/summary/boot-order-empty-summary.tsx new file mode 100644 index 000000000000..ea57cad8af48 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/boot-order/summary/boot-order-empty-summary.tsx @@ -0,0 +1,48 @@ +import * as React from 'react'; +import { ExpandableSection, Text, TextVariants } from '@patternfly/react-core'; +import { useTranslation } from 'react-i18next'; +import { BootableDeviceType } from '../../../types/types'; +import { deviceKey, deviceLabel } from '../constants'; + +export const BootOrderEmptySummary: React.FC = ({ devices }) => { + const { t } = useTranslation(); + const [isExpanded, setIsExpanded] = React.useState(false); + const options = devices.filter((device) => !device.value.bootOrder); + const onToggle = React.useCallback(() => setIsExpanded(!isExpanded), [isExpanded]); + + // Note(Yaacov): + // className='text-secondary' is a hack to fix TextVariants being overriden. + // Using
    because '@patternfly/react-core' currently miss isOrder parameter. + return ( + <> + + {t('kubevirt-plugin~No resource selected')} + + + {t('kubevirt-plugin~VM will attempt to boot from disks by order of apearance in YAML file')} + + {options.length > 0 && ( + +
      + {options.map((option) => ( +
    1. {deviceLabel(option)}
    2. + ))} +
    +
    + )} + + ); +}; + +export type BootOrderEmptySummaryProps = { + devices: BootableDeviceType[]; +}; diff --git a/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/boot-order/summary/boot-order-summary.tsx b/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/boot-order/summary/boot-order-summary.tsx new file mode 100644 index 000000000000..b3e547d4a276 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/boot-order/summary/boot-order-summary.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; +import * as _ from 'lodash'; +import { BootableDeviceType } from '../../../types/types'; +import { deviceKey, deviceLabel } from '../constants'; +import { BootOrderEmptySummary } from './boot-order-empty-summary'; + +// NOTE(yaacov): using
      because '@patternfly/react-core' currently miss isOrder parameter. +export const BootOrderSummary: React.FC = ({ devices }) => { + const sources = _.sortBy( + devices.filter((device) => device.value.bootOrder), + 'value.bootOrder', + ); + + return ( + <> + {sources.length === 0 ? ( + + ) : ( +
        + {sources.map((source) => ( +
      1. {deviceLabel(source)}
      2. + ))} +
      + )} + + ); +}; + +export type BootOrderSummaryProps = { + devices: BootableDeviceType[]; +}; diff --git a/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/cdi-upload-provider/consts.ts b/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/cdi-upload-provider/consts.ts new file mode 100644 index 000000000000..e3c21eee6877 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/cdi-upload-provider/consts.ts @@ -0,0 +1 @@ +export const CDI_UPLOAD_POD_NAME_ANNOTATION = 'cdi.kubevirt.io/storage.uploadPodName'; diff --git a/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/create-vm-wizard/types.ts b/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/create-vm-wizard/types.ts new file mode 100644 index 000000000000..bf6d5ca0ab0f --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/create-vm-wizard/types.ts @@ -0,0 +1,57 @@ +import { V1alpha1DataVolume, V1Disk, V1PersistentVolumeClaim, V1Volume } from '../../types/api'; +import { UINetworkEditConfig, UINetworkInterfaceValidation } from '../../types/ui/nic'; +import { UIStorageEditConfig, UIStorageValidation } from '../../types/ui/storage'; +import { DataSourceKind, V1Network, V1NetworkInterface } from '../../types/vm'; + +export enum VMWizardNetworkType { + V2V_OVIRT_IMPORT = 'V2V_OVIRT_IMPORT', + V2V_VMWARE_IMPORT = 'V2V_VMWARE_IMPORT', + TEMPLATE = 'TEMPLATE', + UI_DEFAULT_POD_NETWORK = 'UI_DEFAULT_POD_NETWORK', + UI_INPUT = 'UI_INPUT', +} + +export type VMWizardNetwork = { + id?: string; + type: VMWizardNetworkType; + network: V1Network; + networkInterface: V1NetworkInterface; + validation?: UINetworkInterfaceValidation; + editConfig?: UINetworkEditConfig; + importData?: { + id?: string; + vnicID?: string; + networksWithSameVnicID?: []; + }; +}; + +export enum VMWizardStorageType { + TEMPLATE = 'TEMPLATE', + TEMPLATE_CLOUD_INIT = 'TEMPLATE_CLOUD_INIT', + PROVISION_SOURCE_TEMPLATE_DISK = 'PROVISION_SOURCE_TEMPLATE_DISK', + PROVISION_SOURCE_DISK = 'PROVISION_SOURCE_DISK', + PROVISION_SOURCE_ADDITIONAL_DISK = 'PROVISION_SOURCE_ADDITIONAL_DISK', + UI_INPUT = 'UI_INPUT', + V2V_VMWARE_IMPORT = 'V2V_VMWARE_IMPORT', + V2V_OVIRT_IMPORT = 'V2V_OVIRT_IMPORT', + WINDOWS_GUEST_TOOLS = 'WINDOWS_GUEST_TOOLS', + WINDOWS_GUEST_TOOLS_TEMPLATE = 'WINDOWS_GUEST_TOOLS_TEMPLATE', +} + +export type VMWizardStorage = { + id?: string; + type: VMWizardStorageType; + disk?: V1Disk; + volume?: V1Volume; + dataVolume?: V1alpha1DataVolume; + validation?: UIStorageValidation; + persistentVolumeClaim?: V1PersistentVolumeClaim; + editConfig?: UIStorageEditConfig; + importData?: { + id?: string; + mountPath?: string; + devicePath?: string; + fileName?: string; + }; + sourceRef?: DataSourceKind; +}; diff --git a/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/dnd-list/dnd-data-list-item.tsx b/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/dnd-list/dnd-data-list-item.tsx new file mode 100644 index 000000000000..e9d22aed7de6 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/dnd-list/dnd-data-list-item.tsx @@ -0,0 +1,106 @@ +import * as React from 'react'; +import { + DataListCell, + DataListItem, + DataListItemCells, + DataListItemProps, + DataListItemRow, +} from '@patternfly/react-core'; +import { GripVerticalIcon } from '@patternfly/react-icons/dist/esm/icons/grip-vertical-icon'; +import { MinusCircleIcon } from '@patternfly/react-icons/dist/esm/icons/minus-circle-icon'; +import { useDrag, useDrop } from 'react-dnd'; + +const DNDDataListItemTypeName = 'dnd-row'; +const DNDDataListCellMoveStyle = { cursor: 'move' }; +const DNDDataListCellSDeleteStyle = { cursor: 'pointer' }; + +export interface DNDDataListItemProps extends DataListItemProps { + /** Order index of rendered item. */ + index: number; + /** Action when delete icon is pressed. */ + onDelete: (index: number) => void; + /** Action when item is moved from one order index to anoter. */ + onMove: (index: number, toIndex: number) => void; +} + +export const DNDDataListItem: React.FC = ({ + index, + onDelete, + onMove, + 'aria-labelledby': ariaLabelledby, + children, + ...props +}) => { + // Create a drag item copy. + const [, drag, preview] = useDrag({ + item: { type: DNDDataListItemTypeName, id: `${DNDDataListItemTypeName}-${index}`, index }, + }); + // Move item when hover over onoter item. + const [{ opacity }, drop] = useDrop({ + accept: DNDDataListItemTypeName, + collect: (monitor) => ({ + opacity: monitor.isOver() ? 0 : 1, + }), + hover(item: any) { + if (item.index === index) { + return; + } + + onMove(item.index, index); + item.index = index; + }, + }); + + // Action when item is focused and key is pressed: + // ArrowUp: move item one order index down. + // ArrowDown: move item one order index up. + // '-': delete an item. + const onKeyDown = (e: React.KeyboardEvent) => { + switch (e.key) { + case 'ArrowUp': + if (index > 0) onMove(index, index - 1); + break; + case 'ArrowDown': + onMove(index, index + 1); + break; + case '-': + onDelete(index); + break; + default: + // We only accept up, down and minus. + } + }; + + const cellKey = (i: number | string) => `item-${i}`; + const dataListCell = [ + +
      + +
      +
      , + ...React.Children.map(children, (cell, i) => ( + + {cell} + + )), + onDelete(index)} + > + + , + ]; + + return ( +
      preview(drop(node))} style={{ opacity }}> + + + + + +
      + ); +}; diff --git a/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/dnd-list/dnd-data-list.tsx b/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/dnd-list/dnd-data-list.tsx new file mode 100644 index 000000000000..ef9dce697851 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/dnd-list/dnd-data-list.tsx @@ -0,0 +1,18 @@ +import * as React from 'react'; +import { DataList } from '@patternfly/react-core'; +import { DndProvider } from 'react-dnd'; +import HTML5Backend from 'react-dnd-html5-backend'; + +export type DNDDataListProps = { + id: string; + 'aria-label': string; + children: React.ReactNode; +}; + +export const DNDDataList: React.FC = ({ children, ...props }) => ( + + {children} + +); + +// export type DNDDataListProps = DataListProps; diff --git a/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/errors/errors.scss b/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/errors/errors.scss new file mode 100644 index 000000000000..218f9ac16acb --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/errors/errors.scss @@ -0,0 +1,17 @@ +.kubevirt-errors__error-group--item { + margin-bottom: 0.5em; +} + +.kubevirt-errors__error-group--end { + margin-bottom: 1.5em; +} + +.kubevirt-errors__expendable { + background-color: var(--pf-v5-global--palette--red-50); + white-space: pre-wrap; + border-width: 0; +} + +.kubevirt-errors__detailed-message { + margin-bottom: var(--pf-v5-global--spacer--xs); +} diff --git a/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/errors/errors.tsx b/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/errors/errors.tsx new file mode 100644 index 000000000000..0dfb40a4fe7c --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/errors/errors.tsx @@ -0,0 +1,55 @@ +import * as React from 'react'; +import { Alert, AlertVariant, ExpandableSection } from '@patternfly/react-core'; +import * as classNames from 'classnames'; +import { useTranslation } from 'react-i18next'; + +import './errors.scss'; + +export type Error = { + message?: React.ReactNode; + detail?: React.ReactNode; + variant?: AlertVariant; + title: React.ReactNode; + key?: string; +}; + +type ErrorsProps = { + errors: Error[]; + endMargin?: boolean; +}; + +export const Errors: React.FC = ({ errors, endMargin }) => { + const { t } = useTranslation(); + return ( + <> + {errors && + errors.map(({ message, key, title, detail, variant }, idx, arr) => { + return ( + + {!detail && message} + {detail && ( +
      +
      {message}
      + +
      {detail}
      +
      +
      + )} +
      + ); + })} + + ); +}; diff --git a/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/form/size-units-utils.ts b/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/form/size-units-utils.ts new file mode 100644 index 000000000000..80beadb564b0 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/form/size-units-utils.ts @@ -0,0 +1,76 @@ +import { convertToBaseValue } from '@console/internal/components/utils'; +import { getStringEnumValues } from '../../utils/types'; +import { assureEndsWith } from '../../utils/utils'; + +export enum BinaryUnit { + B = 'B', + Ki = 'Ki', + Mi = 'Mi', + Gi = 'Gi', + Ti = 'Ti', +} + +export const getReasonableUnits = (originalUnit: BinaryUnit) => { + const result = [BinaryUnit.Mi, BinaryUnit.Gi, BinaryUnit.Ti]; + if (originalUnit === BinaryUnit.B) { + result.unshift(BinaryUnit.B, BinaryUnit.Ki); + } else if (originalUnit === BinaryUnit.Ki) { + result.unshift(BinaryUnit.Ki); + } + return result; +}; + +type Result = { + value: number; + unit: BinaryUnit; + str: string; +}; + +export const stringValueUnitSplit = (combinedVal) => { + const index = combinedVal.search(/([a-zA-Z]+)/g); + let value; + let unit; + if (index === -1) { + value = combinedVal; + } else { + value = combinedVal.slice(0, index); + unit = combinedVal.slice(index); + } + return [value, unit]; +}; + +export const convertToHighestUnit = (value: number, unit: BinaryUnit): Result => { + const units = getStringEnumValues(BinaryUnit); + const sliceIndex = units.indexOf(unit); + const slicedUnits = sliceIndex === -1 ? units : units.slice(sliceIndex); + + let nextValue = value; + let nextUnit = slicedUnits.shift(); + while (nextValue !== 0 && nextValue % 1024 === 0 && slicedUnits.length > 0) { + nextValue /= 1024; + nextUnit = slicedUnits.shift(); + } + return { value: nextValue, unit: nextUnit, str: `${nextValue}${nextUnit}` }; +}; + +export const convertToBytes = (value: string): number => { + if (!value || BinaryUnit[value]) { + return null; + } + + const result = convertToBaseValue(value); + + if (!result && value.match(/^[0-9.]+B$/)) { + const [v] = stringValueUnitSplit(value); + return v; + } + + return result; +}; + +export const convertToHighestUnitFromUnknown = (value: string): Result => { + const result = convertToBytes(value); + return result && convertToHighestUnit(result, BinaryUnit.B); +}; + +export const toIECUnit = (unit: BinaryUnit | string) => assureEndsWith(unit, 'B'); diff --git a/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/modals/boot-order-modal/boot-order-modal.tsx b/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/modals/boot-order-modal/boot-order-modal.tsx new file mode 100644 index 000000000000..cf3feb9fc326 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/modals/boot-order-modal/boot-order-modal.tsx @@ -0,0 +1,215 @@ +import * as React from 'react'; +import { Button, ButtonVariant } from '@patternfly/react-core'; +import * as _ from 'lodash'; +import { Trans, useTranslation } from 'react-i18next'; +import { k8sPatch } from '@console/dynamic-plugin-sdk/dist/core/lib/utils/k8s'; +import { + createModalLauncher, + ModalBody, + ModalComponentProps, + ModalTitle, +} from '@console/internal/components/factory'; +import { + Firehose, + FirehoseResult, + HandlePromiseProps, + withHandlePromise, +} from '@console/internal/components/utils'; +import { DeviceType } from '../../../constants/vm/constants'; +import { PatchBuilder } from '../../../k8s/helpers/patch'; +import { getVMLikePatches } from '../../../k8s/patches/vm-template'; +import { VMWrapper } from '../../../k8s/wrapper/vm/vm-wrapper'; +import { VMIWrapper } from '../../../k8s/wrapper/vm/vmi-wrapper'; +import { VirtualMachineInstanceModel } from '../../../models'; +import { kubevirtReferenceForModel } from '../../../models/kubevirtReferenceForModel'; +import { getName, getNamespace } from '../../../selectors/k8sCommon'; +import { isBootOrderChanged } from '../../../selectors/vm-like/next-run-changes'; +import { + getBootableDevices, + getBootableDevicesInOrder, + getTransformedDevices, +} from '../../../selectors/vm/devices'; +import { isVMRunningOrExpectedRunning } from '../../../selectors/vm/selectors'; +import { asVM } from '../../../selectors/vm/vm'; +import { getVMLikeModel } from '../../../selectors/vm/vmLike'; +import { BootableDeviceType } from '../../../types/types'; +import { VMLikeEntityKind } from '../../../types/vm-like'; +import { VMIKind } from '../../../types/vmi'; +import { createBasicLookup, getLoadedData } from '../../../utils/utils'; +import { ModalPendingChangesAlert } from '../../alerts/PendingChangesAlerts'; +import { BootOrder } from '../../boot-order/boot-order'; +import { deviceKey } from '../../boot-order/constants'; +import { ModalFooter } from '../modal/modal-footer'; +import { saveAndRestartModal } from '../save-and-restart-modal/save-and-restart-modal'; + +const BootOrderModalComponent = withHandlePromise( + ({ + vmLikeEntity, + cancel, + close, + handlePromise, + inProgress, + errorMessage, + vmi: vmiProp, + }: BootOrderModalProps) => { + const bootableDevices = getBootableDevices(vmLikeEntity); + const { t } = useTranslation(); + const [devices, setDevices] = React.useState(bootableDevices); + const [initialDeviceList, setInitialDeviceList] = React.useState( + bootableDevices, + ); + const [showUpdatedAlert, setUpdatedAlert] = React.useState(false); + const [showPatchError, setPatchError] = React.useState(false); + const vm = asVM(vmLikeEntity); + const vmi = getLoadedData(vmiProp); + const isVMRunning = isVMRunningOrExpectedRunning(vm, vmi); + + const onReload = React.useCallback(() => { + const updatedDevices = bootableDevices; + + setInitialDeviceList(updatedDevices); + setDevices(updatedDevices); + setUpdatedAlert(false); + setPatchError(false); + }, [vmLikeEntity]); // eslint-disable-line react-hooks/exhaustive-deps + + const isChanged = + isBootOrderChanged(new VMWrapper(vm), new VMIWrapper(vmi)) || + !_.isEqual( + getBootableDevicesInOrder(vm, devices), + getBootableDevicesInOrder(vm, bootableDevices), + ); + + // Inform user on vmLikeEntity. + React.useEffect(() => { + // Compare only bootOrder from initialDeviceList to current device list. + const devicesMap = createBasicLookup(getBootableDevices(vmLikeEntity), deviceKey); + const updated = + initialDeviceList.length && + initialDeviceList.some((d) => { + // Find the initial device in the updated list. + const device = devicesMap[deviceKey(d)]; + + // If a device bootOrder changed, or it was deleted, set alert. + return !device || device.value.bootOrder !== d.value.bootOrder; + }); + + setUpdatedAlert(updated); + }, [vmLikeEntity]); // eslint-disable-line react-hooks/exhaustive-deps + + const saveChanges = () => { + // Copy only bootOrder from devices to current device list. + const currentDevices = _.cloneDeep(getTransformedDevices(vmLikeEntity)); + const devicesMap = createBasicLookup(currentDevices, deviceKey); + devices.forEach((d) => { + // Find the device to update. + const device = devicesMap[deviceKey(d)]; + + // Update device bootOrder. + if (device && d.value.bootOrder) { + device.value.bootOrder = d.value.bootOrder; + } + if (device && device.value.bootOrder && !d.value.bootOrder) { + delete device.value.bootOrder; + } + }); + + // Filter disks and interfaces from devices list. + const disks = [ + ...currentDevices + .filter((source) => source.type === DeviceType.DISK) + .map((source) => source.value), + ]; + + const interfaces = [ + ...currentDevices + .filter((source) => source.type === DeviceType.NIC) + .map((source) => source.value), + ]; + + // Patch k8s. + const patches = [ + new PatchBuilder('/spec/template/spec/domain/devices/disks').replace(disks).build(), + new PatchBuilder('/spec/template/spec/domain/devices/interfaces') + .replace(interfaces) + .build(), + ]; + const promise = k8sPatch( + getVMLikeModel(vmLikeEntity), + vmLikeEntity, + getVMLikePatches(vmLikeEntity, () => patches), + ); + + handlePromise( + promise, + () => close(), + () => setPatchError(true), + ); + }; + + // Send new bootOrder to k8s. + const onSubmit = async (event) => { + event.preventDefault(); + saveChanges(); + }; + + return ( +
      + {t('kubevirt-plugin~Virtual machine boot order')} + + {isVMRunning && } + + + cancel()} + submitButtonText={t('kubevirt-plugin~Save')} + infoTitle={ + showUpdatedAlert && t('kubevirt-plugin~Boot order has been updated outside this flow.') + } + infoMessage={ + + Saving these changes will override any boot order previously saved. +
      + To see the updated order{' '} + + . +
      + } + onSaveAndRestart={() => saveAndRestartModal(vm, vmi, saveChanges)} + /> +
      + ); + }, +); + +export type BootOrderModalProps = HandlePromiseProps & + ModalComponentProps & { + vmLikeEntity: VMLikeEntityKind; + vmi?: FirehoseResult; + }; + +const BootOrderModalFirehost = (props) => { + const { vmLikeEntity } = props; + const resources = []; + + resources.push({ + kind: kubevirtReferenceForModel(VirtualMachineInstanceModel), + namespace: getNamespace(vmLikeEntity), + name: getName(vmLikeEntity), + prop: 'vmi', + }); + + return ( + + + + ); +}; + +export const BootOrderModal = createModalLauncher(BootOrderModalFirehost); diff --git a/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/modals/clone-vm-modal/clone-vm-modal.scss b/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/modals/clone-vm-modal/clone-vm-modal.scss new file mode 100644 index 000000000000..16140b39b640 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/modals/clone-vm-modal/clone-vm-modal.scss @@ -0,0 +1,19 @@ +@import "../../../../../../../public/style/vars"; + +.kubevirt-clone-vm-modal__configuration-summary { + overflow-y: auto; + max-height: 450px; + dt { + color: $color-text-muted; + text-transform: capitalize; + } +} + +.kubevirt-clone-vm-modal__start_vm_checkbox > input[type="checkbox"] { + margin: -0.25em 0 0 !important; +} + +.kubevirt-clone-vm-modal__description { + min-height: 3em; + resize: vertical; +} diff --git a/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/modals/clone-vm-modal/clone-vm-modal.tsx b/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/modals/clone-vm-modal/clone-vm-modal.tsx new file mode 100644 index 000000000000..e552904f893a --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/kubevirt-dependencies/components/modals/clone-vm-modal/clone-vm-modal.tsx @@ -0,0 +1,336 @@ +import * as React from 'react'; +import { + Checkbox, + Form, + FormGroup, + FormHelperText, + FormSelect, + FormSelectOption, + HelperText, + HelperTextItem, + TextArea, + TextInput, +} from '@patternfly/react-core'; +import { useTranslation } from 'react-i18next'; +import { connect } from 'react-redux'; +import { RedExclamationCircleIcon } from '@console/dynamic-plugin-sdk'; +import { + createModalLauncher, + ModalBody, + ModalComponentProps, + ModalTitle, +} from '@console/internal/components/factory'; +import { + Firehose, + FirehoseResource, + FirehoseResult, + HandlePromiseProps, + withHandlePromise, +} from '@console/internal/components/utils'; +import { useK8sWatchResource } from '@console/internal/components/utils/k8s-watch-hook'; +import { NamespaceModel, PersistentVolumeClaimModel, ProjectModel } from '@console/internal/models'; +import { K8sResourceKind, PersistentVolumeClaimKind } from '@console/internal/module/k8s'; +import { cloneVM } from '../../../k8s/requests/vm/clone'; +import { DataVolumeModel, VirtualMachineModel } from '../../../models'; +import { kubevirtReferenceForModel } from '../../../models/kubevirtReferenceForModel'; +import { getDescription, getName, getNamespace } from '../../../selectors/k8sCommon'; +import { ValidationErrorType } from '../../../selectors/types'; +import { getVolumes, isVMExpectedRunning } from '../../../selectors/vm/selectors'; +import { + getVolumeDataVolumeName, + getVolumePersistentVolumeClaimName, +} from '../../../selectors/vm/volume'; +import { V1alpha1DataVolume } from '../../../types/api'; +import { VMKind } from '../../../types/vm'; +import { VMIKind } from '../../../types/vmi'; +import { COULD_NOT_LOAD_DATA } from '../../../utils/strings'; +import { getLoadedData, getLoadError, prefixedID } from '../../../utils/utils'; +import { validateVmLikeEntityName } from '../../../utils/validations/vm/vm'; +import { Errors } from '../../errors/errors'; +import { ModalFooter } from '../modal/modal-footer'; +import { ConfigurationSummary } from './configuration-summary'; + +import './clone-vm-modal.scss'; + +export const CloneVMModal = withHandlePromise((props) => { + const { + vm, + vmi, + namespace, + vmNamespace, + onNamespaceChanged, + namespaces, + virtualMachines, + persistentVolumeClaims, + dataVolumes, + requestsDataVolumes, + requestsPVCs, + inProgress, + errorMessage, + handlePromise, + close, + cancel, + } = props; + const { t } = useTranslation(); + const asId = prefixedID.bind(null, 'clone-dialog-vm'); + + const [name, setName] = React.useState(`${getName(vm)}-clone`); + const [description, setDescription] = React.useState(getDescription(vm)); + const [startVM, setStartVM] = React.useState(false); + + const namespacesError = getLoadError(namespaces, NamespaceModel); + const pvcsError = requestsPVCs + ? getLoadError(persistentVolumeClaims, PersistentVolumeClaimModel) + : null; + const dataVolumesError = requestsDataVolumes ? getLoadError(dataVolumes, DataVolumeModel) : null; + + const persistentVolumeClaimsData = getLoadedData( + persistentVolumeClaims, + [], + ); + const dataVolumesData = getLoadedData(dataVolumes, []); + + const nameError = validateVmLikeEntityName(name, namespace, getLoadedData(virtualMachines, []), { + // t('kubevirt-plugin~Name is already used by another virtual machine in this namespace') + existsErrorMessage: + 'kubevirt-plugin~Name is already used by another virtual machine in this namespace', + }); + + const dataVolumesValid = !(dataVolumesError || (requestsDataVolumes && !dataVolumes.loaded)); + const pvcsValid = !(pvcsError || (requestsPVCs && !persistentVolumeClaims.loaded)); + + const isValid = + !nameError && dataVolumesValid && pvcsValid && !namespacesError && name && namespace; + + const [pvcs] = useK8sWatchResource({ + kind: PersistentVolumeClaimModel.kind, + namespace: vmNamespace, + isList: true, + }); + + const submit = (e) => { + e.preventDefault(); + + const promise = cloneVM( + { + vm, + vmi, + dataVolumes: dataVolumesData, + persistentVolumeClaims: persistentVolumeClaimsData, + }, + { name, namespace, description, startVM }, + pvcs, + ); + handlePromise(promise, close); + }; + + const onCancelClick = (e) => { + e.stopPropagation(); + cancel(); + }; + + const vmRunningWarning = + isVMExpectedRunning(vm, vmi) && + t('kubevirt-plugin~The VM {{vmName}} is still running. It will be powered off while cloning.', { + vmName: getName(vm), + }); + + return ( +
      + {t('kubevirt-plugin~Clone Virtual Machine')} + + err.message)} + /> +
      + + setName(v)} + aria-label={t('kubevirt-plugin~new VM name')} + /> + + {nameError?.type === ValidationErrorType.Error && ( + + + }> + {t(nameError?.messageKey)} + + + + )} + + +