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 ce06c990f..3c46e4766 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 @@ -1,15 +1,26 @@ { "{{groupCount}} Groups": "{{groupCount}} Groups", + "{{name}} Details": "{{name}} Details", "{{vmDone}} of {{vmCount}} VMs migrated": "{{vmDone}} of {{vmCount}} VMs migrated", "{resourceData.spec.type} provider <2>{resourceData?.metadata?.name} will no longer be selectable as a migration source.": "{resourceData.spec.type} provider <2>{resourceData?.metadata?.name} will no longer be selectable as a migration source.", "{resourceData.spec.type} provider <2>{resourceData?.metadata?.name} will no longer be selectable as a migration target.": "{resourceData.spec.type} provider <2>{resourceData?.metadata?.name} will no longer be selectable as a migration target.", + "Actions": "Actions", "Add source and target providers for the migration.": "Add source and target providers for the migration.", + "Allowed values are openshift, ovirt, vsphere, and openstack.": "Allowed values are openshift, ovirt, vsphere, and openstack.", + "Application Credential ID": "Application Credential ID", + "Application Credential Name": "Application Credential Name", + "Application Credential Secret": "Application Credential Secret", "Archive": "Archive", "Archive plan \"{{name}}\" ?": "Archive plan \"{{name}}\" ?", "Archive plan?": "Archive plan?", "Archived": "Archived", "Archiving": "Archiving", "Are you sure you want to delete <2>{{resourceName}} in namespace <5>{{namespace}}?": "Are you sure you want to delete <2>{{resourceName}} in namespace <5>{{namespace}}?", + "Are you sure you want to delete <2>{{resourceName}}?": "Are you sure you want to delete <2>{{resourceName}}?", + "Authentication type": "Authentication type", + "CA certificate": "CA certificate", + "CA certificate - disabled when skip certificate validation is checked": "CA certificate - disabled when skip certificate validation is checked", + "CA certificate - leave empty to use system certificates": "CA certificate - leave empty to use system certificates", "Cancel": "Cancel", "Cancel scheduled cutover": "Cancel scheduled cutover", "Cannot archive plan": "Cannot archive plan", @@ -18,14 +29,31 @@ "Cannot delete storage mapping": "Cannot delete storage mapping", "Cannot remove provider": "Cannot remove provider", "Clear all filters": "Clear all filters", + "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.", "Close": "Close", "Clusters": "Clusters", + "Concerns": "Concerns", + "Conditions": "Conditions", "Connection Failed": "Connection Failed", + "Copied": "Copied", + "Copy": "Copy", "Create a migration plan and select VMs from the source provider for migration.": "Create a migration plan and select VMs from the source provider for migration.", + "Create by using the form or manually entering YAML or JSON definitions, Provider CR stores attributes that enable MTV to connect to and interact with the source and target providers.": "Create by using the form or manually entering YAML or JSON definitions, Provider CR stores attributes that enable MTV to connect to and interact with the source and target providers.", "Create NetworkMap": "Create NetworkMap", + "Create new provider": "Create new provider", + "Create provider": "Create provider", "Create Provider": "Create Provider", "Create StorageMap": "Create StorageMap", + "Created at": "Created at", + "CreationTimestamp is a timestamp representing the server time when this object was created.\n It is not guaranteed to be set in happens-before order across separate operations.\n Clients may not set this value. It is represented in RFC3339 form and is in UTC.": "CreationTimestamp is a timestamp representing the server time when this object was created.\n It is not guaranteed to be set in happens-before order across separate operations.\n Clients may not set this value. It is represented in RFC3339 form and is in UTC.", + "Credentials": "Credentials", + "Custom certification used to verify the Openstack REST API server, when empty use system certificate.": "Custom certification used to verify the Openstack REST API server, when empty use system certificate.", + "Data centers": "Data centers", + "Data stores": "Data stores", + "Default": "Default", + "Default Transfer Network": "Default Transfer Network", "Delete": "Delete", + "Delete {{model.label}}": "Delete {{model.label}}", "Delete Mapping": "Delete Mapping", "Delete NetworkMap?": "Delete NetworkMap?", "Delete Plan?": "Delete Plan?", @@ -34,29 +62,74 @@ "Delete StorageMap?": "Delete StorageMap?", "Deleting a migration plan does not remove temporary resources, it is recommended to <2>archive the plan first before deleting it, to remove temporary resources.": "Deleting a migration plan does not remove temporary resources, it is recommended to <2>archive the plan first before deleting it, to remove temporary resources.", "Description": "Description", + "Details": "Details", + "Domain": "Domain", "Duplicate": "Duplicate", "Edit": "Edit", + "Edit credentials": "Edit credentials", + "Edit Default Transfer Network": "Edit Default Transfer Network", "Edit Mapping": "Edit Mapping", "Edit NetworkMap": "Edit NetworkMap", "Edit Provider": "Edit Provider", "Edit StorageMap": "Edit StorageMap", + "Edit URL": "Edit URL", + "Edit VDDK Init Image": "Edit VDDK Init Image", + "Empty": "Empty", "Endpoint": "Endpoint", + "Error": "Error", + "Error: CA Certificate must be valid.": "Error: CA Certificate must be valid.", + "Error: Fingerprint is required and must be valid.": "Error: Fingerprint is required and must be valid.", + "Error: Insecure Skip Verify must be a boolean value.": "Error: Insecure Skip Verify must be a boolean value.", + "Error: Name is required and must be a unique and valid Kubernetes name.": "Error: Name is required and must be a unique and valid Kubernetes name.", + "Error: Password is required and must be valid.": "Error: Password is required and must be valid.", + "Error: This field must be a boolean.": "Error: This field must be a boolean.", + "Error: token is a required field, the token must be a valid kubernetes token.": "Error: token is a required field, the token must be a valid kubernetes token.", + "Error: URL is required and must be valid.": "Error: URL is required and must be valid.", + "Error: URL must be valid.": "Error: URL must be valid.", + "Error: Username is required and must be valid.": "Error: Username is required and must be valid.", + "Error: VDDK Init Image must be valid.": "Error: VDDK Init Image must be valid.", + "False": "False", "Filter by endpoint": "Filter by endpoint", "Filter by name": "Filter by name", "Filter by namespace": "Filter by namespace", "From": "From", + "Hide values": "Hide values", "Hooks for virtualization": "Hooks for virtualization", + "Host cluster": "Host cluster", "Hosts": "Hosts", + "ID": "ID", + "If true, the provider's REST API TLS certificate won't be validated.": "If true, the provider's REST API TLS certificate won't be validated.", + "If true, the provider's TLS certificate won't be validated.": "If true, the provider's TLS certificate won't be validated.", + "If you defined a migration transfer network for the OpenShift Virtualization provider and if the network is in the target namespace,\n the network that you defined is the default network for all migration plans. Otherwise, the pod network is used.": "If you defined a migration transfer network for the OpenShift Virtualization provider and if the network is in the target namespace,\n the network that you defined is the default network for all migration plans. Otherwise, the pod network is used.", + "Invalid application credential ID.": "Invalid application credential ID.", + "Invalid application credential name.": "Invalid application credential name.", + "Invalid application credential secret.": "Invalid application credential secret.", + "Invalid domain name.": "Invalid domain name.", + "Invalid password.": "Invalid password.", + "Invalid Project ID.": "Invalid Project ID.", + "Invalid project name.": "Invalid project name.", + "Invalid Project.": "Invalid Project.", + "Invalid region name.": "Invalid region name.", + "Invalid token.": "Invalid token.", + "Invalid User Domain Name.": "Invalid User Domain Name.", + "Invalid User ID.": "Invalid User ID.", + "Invalid username.": "Invalid username.", + "Invalid Username.": "Invalid Username.", + "Inventory": "Inventory", + "Inventory server is not reachable. To troubleshoot, check the Forklift controller pod logs.": "Inventory server is not reachable. To troubleshoot, check the Forklift controller pod logs.", + "List of objects depended by this object. If ALL objects in the list have been deleted, this object will be garbage collected.\n If this object is managed by a controller, then an entry in this list will point to this controller,\n with the controller field set to true. There cannot be more than one managing controller.": "List of objects depended by this object. If ALL objects in the list have been deleted, this object will be garbage collected.\n If this object is managed by a controller, then an entry in this list will point to this controller,\n with the controller field set to true. There cannot be more than one managing controller.", "Loading": "Loading", "Manage Columns": "Manage Columns", "managed": "managed", "Managed": "Managed", "Managed provider cannot be deleted": "Managed provider cannot be deleted", "Managed provider cannot be edited": "Managed provider cannot be edited", + "Managed resource": "Managed resource", "Manged mappings can not be deleted": "Manged mappings can not be deleted", "Manged mappings can not be edited": "Manged mappings can not be edited", "Map source datastores or storage domains or volume types and networks to target storage classes and networks.": "Map source datastores or storage domains or volume types and networks to target storage classes and networks.", "Mapping graph": "Mapping graph", + "Message": "Message", "Migrating virtualization workloads is a multi-step process:": "Migrating virtualization workloads is a multi-step process:", "Migration network maps are used to map network interfaces between source and target virtualization providers, at least one source and one target provider must be available in order to create a migration storage map, <2>Learn more.": "Migration network maps are used to map network interfaces between source and target virtualization providers, at least one source and one target provider must be available in order to create a migration storage map, <2>Learn more.", "Migration networks maps are used to map network interfaces between source and target workloads.": "Migration networks maps are used to map network interfaces between source and target workloads.", @@ -66,53 +139,132 @@ "Migration storage maps are used to map storage interfaces between source and target workloads.": "Migration storage maps are used to map storage interfaces between source and target workloads.", "Migrations for virtualization": "Migrations for virtualization", "Name": "Name", + "Name is primarily intended for creation idempotence and configuration definition. Cannot be updated.": "Name is primarily intended for creation idempotence and configuration definition. Cannot be updated.", "Namespace": "Namespace", + "Namespace defines the space within which each name must be unique.\n An empty namespace is equivalent to the \"default\" namespace, but \"default\" is the canonical representation.\n Not all objects are required to be scoped to a namespace - the value of this field for those objects will be empty.": "Namespace defines the space within which each name must be unique.\n An empty namespace is equivalent to the \"default\" namespace, but \"default\" is the canonical representation.\n Not all objects are required to be scoped to a namespace - the value of this field for those objects will be empty.", + "Namespace is not defined": "Namespace is not defined", + "Network interfaces": "Network interfaces", + "NetworkAttachmentDefinitions": "NetworkAttachmentDefinitions", "NetworkMaps": "NetworkMaps", "NetworkMaps for virtualization": "NetworkMaps for virtualization", "Networks": "Networks", + "No credentials found.": "No credentials found.", + "No hosts found.": "No hosts found.", "No NetworkMaps found in namespace <1>{namespace}.": "No NetworkMaps found in namespace <1>{namespace}.", "No NetworkMaps found.": "No NetworkMaps found.", + "No networks found.": "No networks found.", + "No owner": "No owner", "No Plans found in namespace <1>{namespace}.": "No Plans found in namespace <1>{namespace}.", "No Plans found.": "No Plans found.", "No Providers found in namespace <1>{namespace}.": "No Providers found in namespace <1>{namespace}.", "No Providers found.": "No Providers found.", "No results found": "No results found", "No results match the filter criteria. Clear all filters and try again.": "No results match the filter criteria. Clear all filters and try again.", + "No secret": "No secret", + "No secret found.": "No secret found.", "No StorageMaps found in namespace <1>{namespace}.": "No StorageMaps found in namespace <1>{namespace}.", "No StorageMaps found.": "No StorageMaps found.", + "No virtual machines found.": "No virtual machines found.", "Not Ready": "Not Ready", + "Number of cluster in provider": "Number of cluster in provider", + "Number of data centers in provider": "Number of data centers in provider", + "Number of data stores in provider": "Number of data stores in provider", + "Number of hosts in provider clusters": "Number of hosts in provider clusters", + "Number of network interfaces in provider cluster": "Number of network interfaces in provider cluster", + "Number of projects in Openstack cluster": "Number of projects in Openstack cluster", + "Number of regions in Openstack cluster": "Number of regions in Openstack cluster", + "Number of storage classes in provider cluster": "Number of storage classes in provider cluster", + "Number of storage domains in provider": "Number of storage domains in provider", + "Number of storage types in cluster": "Number of storage types in cluster", + "Number of storage volumes in cluster": "Number of storage volumes in cluster", + "Number of virtual machines in cluster": "Number of virtual machines in cluster", + "Openstack domain for application credential credentials.": "Openstack domain for application credential credentials.", + "Openstack domain for password credentials.": "Openstack domain for password credentials.", + "Openstack project for password credentials.": "Openstack project for password credentials.", + "Openstack project for token credentials.": "Openstack project for token credentials.", + "Openstack project ID for token credentials.": "Openstack project ID for token credentials.", + "Openstack region for password credentials.": "Openstack region for password credentials.", + "Openstack REST API Application Credential ID.": "Openstack REST API Application Credential ID.", + "Openstack REST API Application Credential Name.": "Openstack REST API Application Credential Name.", + "Openstack REST API Application Credential Secret.": "Openstack REST API Application Credential Secret.", + "Openstack REST API password credentials.": "Openstack REST API password credentials.", + "Openstack REST API token credentials.": "Openstack REST API token credentials.", + "Openstack REST API user ID.": "Openstack REST API user ID.", + "Openstack REST API user name.": "Openstack REST API user name.", + "Openstack user domain name for token credentials.": "Openstack user domain name for token credentials.", + "Owner": "Owner", + "Password": "Password", "Plans": "Plans", "Plans for virtualization": "Plans for virtualization", + "Please choose a NetworkAttachmentDefinition for default data transfer.": "Please choose a NetworkAttachmentDefinition for default data transfer.", + "Please enter the URL for oVirt engine server.": "Please enter the URL for oVirt engine server.", + "Please enter URL for OpenStack services REST APIs.": "Please enter URL for OpenStack services REST APIs.", + "Please enter URL for the kubernetes API server, if empty URL default to this cluster.": "Please enter URL for the kubernetes API server, if empty URL default to this cluster.", + "Please enter URL for vSphere REST APIs server.": "Please enter URL for vSphere REST APIs server.", "Pod network": "Pod network", + "Product": "Product", + "Project": "Project", + "Project ID": "Project ID", + "Projects": "Projects", + "Provider details": "Provider details", + "Provider inventory": "Provider inventory", + "Provider Resource Name": "Provider Resource Name", + "Provider YAML": "Provider YAML", "Providers": "Providers", "Providers for virtualization": "Providers for virtualization", "Ready": "Ready", + "Reason": "Reason", + "References a secret containing credentials and other confidential information. Empty may be used for the host provider.": "References a secret containing credentials and other confidential information. Empty may be used for the host provider.", + "Region": "Region", + "Regions": "Regions", "Reorder": "Reorder", "Restart": "Restart", "Restore default columns": "Restore default columns", "Return to the providers list page": "Return to the providers list page", + "Reveal values": "Reveal values", + "RH Virtualization engine REST API password credentials.": "RH Virtualization engine REST API password credentials.", + "RH Virtualization engine REST API user name.": "RH Virtualization engine REST API user name.", "Run the migration plan.": "Run the migration plan.", "Running - performing incremental data copies": "Running - performing incremental data copies", "Running - preparing for cutover": "Running - preparing for cutover", "Running - preparing for incremental data copies": "Running - preparing for incremental data copies", "Running - preparing for migration": "Running - preparing for migration", "Save": "Save", + "Secret": "Secret", "Select a default migration network for the provider. This network will be used for migrating data to all namespaces to which it is attached.": "Select a default migration network for the provider. This network will be used for migrating data to all namespaces to which it is attached.", "Select migration network": "Select migration network", + "Select provider type": "Select provider type", "Selected columns will be displayed in the table.": "Selected columns will be displayed in the table.", + "Service account token": "Service account token", "Show archived": "Show archived", "Show managed": "Show managed", + "Skip certificate validation": "Skip certificate validation", "source": "source", "Source": "Source", + "Source Only": "Source Only", "Source provider": "Source provider", + "Specify the API end point URL, for example, https:///ovirt-engine/api/ for RHV.": "Specify the API end point URL, for example, https:///ovirt-engine/api/ for RHV.", + "Specify the API end point URL, for example, https:///v3 for OpenStack.": "Specify the API end point URL, for example, https:///v3 for OpenStack.", + "Specify the API end point URL, for example, https://:6443 for OpenShift.": "Specify the API end point URL, for example, https://:6443 for OpenShift.", + "Specify the API end point URL, for example, https:///sdk for vSphere.": "Specify the API end point URL, for example, https:///sdk for vSphere.", + "Specify the VDDK image that you created. some functionality will not be available if the VDDK image is left empty": "Specify the VDDK image that you created. some functionality will not be available if the VDDK image is left empty", + "SSHA-1 fingerprint": "SSHA-1 fingerprint", "Staging": "Staging", "Status": "Status", "Storage": "Storage", + "Storage classes": "Storage classes", + "Storage domains": "Storage domains", "StorageMaps": "StorageMaps", "StorageMaps for virtualization": "StorageMaps for virtualization", "target": "target", "Target": "Target", + "Target and Source": "Target and Source", "Target provider": "Target provider", + "The CA certificate is the /etc/pki/ovirt-engine/apache-ca.pem file on the Manager machine.": "The CA certificate is the /etc/pki/ovirt-engine/apache-ca.pem file on the Manager machine.", + "The default network attachment definition that should be used for disk transfer.\n If not available in the target namespace or empty, Pod network will be used": "The default network attachment definition that should be used for disk transfer.\n If not available in the target namespace or empty, Pod network will be used", + "The provider currently requires the SHA-1 fingerprint of the vCenter Server's TLS certificate in all circumstances. vSphere calls this the server's thumbprint.": "The provider currently requires the SHA-1 fingerprint of the vCenter Server's TLS certificate in all circumstances. vSphere calls this the server's thumbprint.", + "The provider is not ready.": "The provider is not ready.", + "The provider URL. Empty may be used for the host provider.": "The provider URL. Empty may be used for the host provider.", "This plan cannot be archived because it is not completed.": "This plan cannot be archived because it is not completed.", "This plan cannot be duplicated because the inventory data for its associated providers is not ready.": "This plan cannot be duplicated because the inventory data for its associated providers is not ready.", "This plan cannot be edited because it has been archived.": "This plan cannot be edited because it has been archived.", @@ -122,14 +274,46 @@ "This plan cannot be restarted because it is running must gather service": "This plan cannot be restarted because it is running must gather service", "This provider cannot be deleted because it has running migrations": "This provider cannot be deleted because it has running migrations", "This provider cannot be edited because it has running migrations": "This provider cannot be edited because it has running migrations", + "This provider will be created in the default namespace, if you wish to choose another namespace please cancel, and choose a namespace from the top bar.": "This provider will be created in the default namespace, if you wish to choose another namespace please cancel, and choose a namespace from the top bar.", + "This resource is managed by <2> and any modifications may be overwritten. Edit the managing resource to preserve changes.": "This resource is managed by <2> and any modifications may be overwritten. Edit the managing resource to preserve changes.", "To": "To", "To make changes to the plan, select Duplicate and edit the duplicate plan.": "To make changes to the plan, select Duplicate and edit the duplicate plan.", + "To troubleshoot, view the provider status available in the provider details page\n and check the Forklift controller pod logs.": "To troubleshoot, view the provider status available in the provider details page\n and check the Forklift controller pod logs.", + "Token": "Token", + "True": "True", "Type": "Type", + "Type of authentication to use when connecting to Openstack REST API.": "Type of authentication to use when connecting to Openstack REST API.", "Unable to retrieve data": "Unable to retrieve data", "Undefined": "Undefined", + "Unique Kubernetes resource name identifier": "Unique Kubernetes resource name identifier", + "Update credentials": "Update credentials", + "Updated": "Updated", + "URL": "URL", + "URL must start with https:// or http:// and contain valid hostname and path": "URL must start with https:// or http:// and contain valid hostname and path", + "URL of the provider": "URL of the provider", + "URL of the provider, leave empty to use this providers URL": "URL of the provider, leave empty to use this providers URL", + "User Domain Name": "User Domain Name", + "User ID": "User ID", + "User or service account bearer token for service accounts or user authentication.": "User or service account bearer token for service accounts or user authentication.", + "Username": "Username", "Validation Failed": "Validation Failed", + "VDDK container image of the provider, when left empty some functionality will not be available": "VDDK container image of the provider, when left empty some functionality will not be available", + "vddk Init Image": "vddk Init Image", + "VDDK Init Image": "VDDK Init Image", + "VDDK Init Image must be a valid container image, for example quay.io/kubev2v/example:latest": "VDDK Init Image must be a valid container image, for example quay.io/kubev2v/example:latest", "View details": "View details", + "View provider details": "View provider details", + "Virtual Machined": "Virtual Machined", + "Virtual machines": "Virtual machines", + "Virtual Machines": "Virtual Machines", "VMs": "VMs", + "VMware only: Specify the VDDK image that you created.": "VMware only: Specify the VDDK image that you created.", + "VMware only: vSphere product name.": "VMware only: vSphere product name.", + "Volume Types": "Volume Types", + "Volumes": "Volumes", + "vSphere REST API password credentials.": "vSphere REST API password credentials.", + "vSphere REST API user name.": "vSphere REST API user name.", "When a plan is archived, its history, metadata, and logs are deleted. The plan cannot be edited or restarted but it can be viewed.": "When a plan is archived, its history, metadata, and logs are deleted. The plan cannot be edited or restarted but it can be viewed.", + "YAML": "YAML", "You will no longer be able to select mapping \"{{name}}\" when you create a migration plan.": "You will no longer be able to select mapping \"{{name}}\" when you create a migration plan." } diff --git a/packages/forklift-console-plugin/plugin-extensions.ts b/packages/forklift-console-plugin/plugin-extensions.ts index df5b15f77..e0299e392 100644 --- a/packages/forklift-console-plugin/plugin-extensions.ts +++ b/packages/forklift-console-plugin/plugin-extensions.ts @@ -4,7 +4,7 @@ import type { NavSection } from '@openshift-console/dynamic-plugin-sdk'; import { extensions as mockConsoleExtensions } from './src/__mock-console-extension/dynamic-plugin'; import { extensions as networkMapExtensions } from './src/modules/NetworkMaps/dynamic-plugin'; import { extensions as planExtensions } from './src/modules/Plans/dynamic-plugin'; -import { extensions as providerExtensions } from './src/modules/Providers/dynamic-plugin'; +import { extensions as providerExtensions } from './src/modules/ProvidersNG/dynamic-plugin'; import { extensions as storageMapExtensions } from './src/modules/StorageMaps/dynamic-plugin'; const extensions: EncodedExtension[] = [ diff --git a/packages/forklift-console-plugin/plugin-metadata.ts b/packages/forklift-console-plugin/plugin-metadata.ts index 4087e93de..c2035e844 100644 --- a/packages/forklift-console-plugin/plugin-metadata.ts +++ b/packages/forklift-console-plugin/plugin-metadata.ts @@ -3,7 +3,7 @@ import type { ConsolePluginMetadata } from '@openshift-console/dynamic-plugin-sd import { exposedModules as mockExtensionModules } from './src/__mock-console-extension/dynamic-plugin'; import { exposedModules as networkMapModules } from './src/modules/NetworkMaps/dynamic-plugin'; import { exposedModules as planModules } from './src/modules/Plans/dynamic-plugin'; -import { exposedModules as providerModules } from './src/modules/Providers/dynamic-plugin'; +import { exposedModules as providerModules } from './src/modules/ProvidersNG/dynamic-plugin'; import { exposedModules as storageMapModules } from './src/modules/StorageMaps/dynamic-plugin'; import pkg from './package.json'; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/actions/ProviderActionsDropdown.style.css b/packages/forklift-console-plugin/src/modules/ProvidersNG/actions/ProviderActionsDropdown.style.css new file mode 100644 index 000000000..3cced821c --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/actions/ProviderActionsDropdown.style.css @@ -0,0 +1,4 @@ +.forklift-dropdown { + margin: 0; + padding: 0; +} diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/actions/ProviderActionsDropdown.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/actions/ProviderActionsDropdown.tsx new file mode 100644 index 000000000..390dd6454 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/actions/ProviderActionsDropdown.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { Dropdown, DropdownPosition, DropdownToggle, KebabToggle } from '@patternfly/react-core'; + +import { useToggle } from '../hooks'; +import { ModalHOC } from '../modals'; +import { CellProps } from '../views'; + +import { ProviderActionsDropdownItems } from './ProviderActionsDropdownItems'; + +import './ProviderActionsDropdown.style.css'; + +/** + * ProviderActionsKebabDropdown_ is a helper component that displays a kebab dropdown menu. + * @param {CellProps} props - The properties passed to this component. + * @param {ProviderWithInventory} props.data - The data to be used in ProviderActionsDropdownItems. + * @returns {React.Element} The rendered dropdown menu component. + */ +const ProviderActionsKebabDropdown_: React.FC = ({ + data, + isKebab, +}) => { + const { t } = useForkliftTranslation(); + + // Hook for managing the open/close state of the dropdown + const [isDropdownOpen, toggle] = useToggle(); + + // Returning the Dropdown component from PatternFly library + return ( + + ) : ( + + {t('Actions')} + + ) + } + dropdownItems={ProviderActionsDropdownItems({ data })} + /> + ); +}; + +/** + * ProviderActionsDropdown is a component that provides a context for the dropdown menu. + * It uses a ModalProvider to manage modals that may be used in the dropdown menu. + * @param {CellProps} props - The properties passed to this component. + * @returns {React.Element} The rendered component with a ModalProvider context. + */ +export const ProviderActionsDropdown: React.FC = (props) => ( + + + +); + +export interface ProviderActionsDropdownProps extends CellProps { + isKebab?: boolean; +} diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/actions/ProviderActionsDropdownItems.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/actions/ProviderActionsDropdownItems.tsx new file mode 100644 index 000000000..297a585e6 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/actions/ProviderActionsDropdownItems.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { ProviderModel, ProviderModelRef } from '@kubev2v/types'; +import { DropdownItem } from '@patternfly/react-core'; + +import { DeleteModal, useModal } from '../modals'; +import { getResourceUrl, ProviderData } from '../utils'; + +export const ProviderActionsDropdownItems = ({ data }: ProviderActionsDropdownItemsProps) => { + const { t } = useForkliftTranslation(); + const { showModal } = useModal(); + + const { provider } = data; + + const providerURL = getResourceUrl({ + reference: ProviderModelRef, + name: provider?.metadata?.name, + namespace: provider?.metadata?.namespace, + }); + + return [ + {t('Edit Provider')}} + />, + showModal()} + > + {t('Delete Provider')} + , + ]; +}; + +interface ProviderActionsDropdownItemsProps { + data: ProviderData; +} diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/actions/index.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/actions/index.ts new file mode 100644 index 000000000..1644a9b0f --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/actions/index.ts @@ -0,0 +1,4 @@ +// @index('./*.tsx', f => `export * from '${f.path}';`) +export * from './ProviderActionsDropdown'; +export * from './ProviderActionsDropdownItems'; +// @endindex diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/dynamic-plugin.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/dynamic-plugin.ts new file mode 100644 index 000000000..c1a8f6376 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/dynamic-plugin.ts @@ -0,0 +1,74 @@ +import { ProviderModel, ProviderModelGroupVersionKind } from '@kubev2v/types'; +import { EncodedExtension } from '@openshift/dynamic-plugin-sdk'; +import { + CreateResource, + ModelMetadata, + ResourceDetailsPage, + ResourceListPage, + ResourceNSNavItem, +} from '@openshift-console/dynamic-plugin-sdk'; +import type { ConsolePluginMetadata } from '@openshift-console/dynamic-plugin-sdk-webpack/lib/schema/plugin-package'; + +export const exposedModules: ConsolePluginMetadata['exposedModules'] = { + ProvidersListPage: './modules/ProvidersNG/views/list/ProvidersListPage', + ProviderDetailsPage: './modules/ProvidersNG/views/details/ProviderDetailsPage', + ProvidersCreatePage: './modules/ProvidersNG/views/create/ProvidersCreatePage', +}; + +export const extensions: EncodedExtension[] = [ + { + type: 'console.navigation/resource-ns', + properties: { + id: 'providers-ng', + insertAfter: 'importSeparator', + perspective: 'admin', + section: 'migration', + // t('plugin__forklift-console-plugin~Providers for virtualization') + name: '%plugin__forklift-console-plugin~Providers for virtualization%', + model: ProviderModelGroupVersionKind, + dataAttributes: { + 'data-quickstart-id': 'qs-nav-providers', + 'data-testid': 'providers-nav-item', + }, + }, + } as EncodedExtension, + + { + type: 'console.page/resource/list', + properties: { + component: { + $codeRef: 'ProvidersListPage', + }, + model: ProviderModelGroupVersionKind, + }, + } as EncodedExtension, + + { + type: 'console.page/resource/details', + properties: { + component: { + $codeRef: 'ProviderDetailsPage', + }, + model: ProviderModelGroupVersionKind, + }, + } as EncodedExtension, + + { + type: 'console.model-metadata', + properties: { + model: ProviderModelGroupVersionKind, + ...ProviderModel, + }, + } as EncodedExtension, + + { + type: 'console.resource/create', + properties: { + component: { + $codeRef: 'ProvidersCreatePage', + }, + model: ProviderModelGroupVersionKind, + ...ProviderModel, + }, + } as EncodedExtension, +]; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/hooks/__tests__/useProviders.test.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/hooks/__tests__/useProviders.test.ts new file mode 100644 index 000000000..b780846d2 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/hooks/__tests__/useProviders.test.ts @@ -0,0 +1,92 @@ +import { V1beta1Provider } from '@kubev2v/types'; + +import { getIsManaged, getIsOnlySource, getIsTarget } from '../../utils'; + +describe('Provider Utils', () => { + describe('getIsManaged', () => { + it('should return true if the provider has owner references', () => { + const provider: V1beta1Provider = { + metadata: { + ownerReferences: [ + { + apiVersion: '', + kind: '', + name: '', + uid: '', + }, + ], + }, + apiVersion: '', + kind: '', + }; + + expect(getIsManaged(provider)).toBe(true); + }); + + it('should return false if the provider has no owner references', () => { + const provider: V1beta1Provider = { + metadata: {}, + apiVersion: '', + kind: '', + }; + + expect(getIsManaged(provider)).toBe(false); + }); + }); + + describe('getIsTarget', () => { + it('should return true if the provider type is included in TARGET_PROVIDER_TYPES', () => { + const provider: V1beta1Provider = { + metadata: {}, + apiVersion: '', + kind: '', + spec: { + type: 'openshift', + }, + }; + + expect(getIsTarget(provider)).toBe(true); + }); + + it('should return false if the provider type is not included in TARGET_PROVIDER_TYPES', () => { + const provider: V1beta1Provider = { + metadata: {}, + apiVersion: '', + kind: '', + spec: { + type: 'nonTargetType', + }, + }; + + expect(getIsTarget(provider)).toBe(false); + }); + }); + + describe('getIsOnlySource', () => { + it('should return true if the provider type is included in SOURCE_ONLY_PROVIDER_TYPES', () => { + const provider: V1beta1Provider = { + metadata: {}, + apiVersion: '', + kind: '', + spec: { + type: 'vsphere', + }, + }; + + expect(getIsOnlySource(provider)).toBe(true); + }); + + it('should return false if the provider type is not included in SOURCE_ONLY_PROVIDER_TYPES', () => { + const provider: V1beta1Provider = { + metadata: {}, + apiVersion: '', + kind: '', + spec: { + type: 'nonSourceType', + }, + }; + + expect(getIsOnlySource(provider)).toBe(false); + }); + }); +}); diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/hooks/index.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/hooks/index.ts new file mode 100644 index 000000000..93dad0390 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/hooks/index.ts @@ -0,0 +1,9 @@ +// @index(['./*', /__/g], f => `export * from '${f.path}';`) +export * from './useGetDeleteAndEditAccessReview'; +export * from './useK8sWatchProviderNames'; +export * from './useK8sWatchSecretData'; +export * from './useProviderInventory'; +export * from './useProvidersInventoryList'; +export * from './useToggle'; +export * from './utils'; +// @endindex diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/hooks/useGetDeleteAndEditAccessReview.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/hooks/useGetDeleteAndEditAccessReview.ts new file mode 100644 index 000000000..2010f3f3a --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/hooks/useGetDeleteAndEditAccessReview.ts @@ -0,0 +1,67 @@ +import { K8sModel, useAccessReview } from '@openshift-console/dynamic-plugin-sdk'; + +import { ProvidersPermissionStatus } from '../utils'; + +/** + * Type for the parameters of the useGetDeleteAndEditAccessReview custom hook. + * + * @typedef {Object} K8sModelAccessReviewParams + * @property {K8sModel} model - The Kubernetes model to check permissions on. + * @property {string} [name] - The name of the specific instance of the model, if any. + * @property {string} [namespace] - The namespace in which to review access permissions. + */ +interface K8sModelAccessReviewParams { + model: K8sModel; + name?: string; + namespace?: string; +} + +/** + * A React hook that checks permissions for different actions on a Kubernetes model within a specified namespace. + * @param {K8sModelAccessReviewParams} param0 - An object that contains model, name and namespace details. + * @returns {Object} An object containing permissions and a loading state. + */ +export const useGetDeleteAndEditAccessReview: UseAccessReviewFn = ({ model, name, namespace }) => { + const [canCreate, loadingCreate] = useAccessReview({ + group: model.apiGroup, + resource: model.plural, + verb: 'create', + namespace, + }); + + const [canPatch, loadingPatch] = useAccessReview({ + group: model.apiGroup, + resource: model.plural, + verb: 'patch', + name, + namespace, + }); + + const [canDelete, loadingDelete] = useAccessReview({ + group: model.apiGroup, + resource: model.plural, + verb: 'delete', + name, + namespace, + }); + + const [canGet, loadingGet] = useAccessReview({ + group: model.apiGroup, + resource: model.plural, + verb: 'get', + name, + namespace, + }); + + return { + canCreate, + canPatch, + canDelete, + canGet, + loading: loadingCreate || loadingPatch || loadingDelete || loadingGet, + }; +}; + +type UseAccessReviewFn = (props: K8sModelAccessReviewParams) => ProvidersPermissionStatus; + +export default useGetDeleteAndEditAccessReview; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/hooks/useK8sWatchProviderNames.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/hooks/useK8sWatchProviderNames.ts new file mode 100644 index 000000000..6eb1836c6 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/hooks/useK8sWatchProviderNames.ts @@ -0,0 +1,50 @@ +import { useEffect, useState } from 'react'; + +import { ProviderModelGroupVersionKind, V1beta1Provider } from '@kubev2v/types'; +import { useK8sWatchResource } from '@openshift-console/dynamic-plugin-sdk'; + +/** + * Type for the return value of the useK8sWatchProviderNames hook. + */ +type K8sProvidersWatchResult = [string[] | undefined, boolean, Error | null]; + +/** + * React hook to watch Provider resources and only trigger re-renders when the providers `metadata.name` changes. + * + * @param {string} namespace - namespace to watch. + * @returns {K8sProvidersWatchResult} - An array of names. + */ +export const useK8sWatchProviderNames = ({ namespace }): K8sProvidersWatchResult => { + const [names, setNames] = useState(undefined); + const [namesLoaded, setLoaded] = useState(false); + const [namesLoadError, setLoadError] = useState(null); + + const [providers, providersLoaded, providersLoadError] = useK8sWatchResource({ + groupVersionKind: ProviderModelGroupVersionKind, + namespaced: true, + isList: true, + namespace, + }); + + useEffect(() => { + if (providersLoaded && providersLoadError) { + handleLoadError(providersLoadError); + } else if (providersLoaded) { + handleLoadedProviders(providers); + } + }, [providers, providersLoaded, providersLoadError]); + + const handleLoadError = (error: Error | null) => { + setLoadError(error); + setLoaded(true); + }; + + const handleLoadedProviders = (providers: V1beta1Provider[] | null) => { + setLoaded(true); + + const names = (providers || []).map((p) => p.metadata.name); + setNames(names.filter((n) => n)); + }; + + return [names, namesLoaded, namesLoadError]; +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/hooks/useK8sWatchSecretData.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/hooks/useK8sWatchSecretData.ts new file mode 100644 index 000000000..887902679 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/hooks/useK8sWatchSecretData.ts @@ -0,0 +1,45 @@ +import { useEffect, useState } from 'react'; + +import { V1Secret } from '@kubev2v/types'; +import { useK8sWatchResource, WatchK8sResource } from '@openshift-console/dynamic-plugin-sdk'; + +/** + * Type for the return value of the useK8sWatchSecretData hook. + */ +type K8sSecretWatchResult = [Record | undefined, boolean, Error | null]; + +/** + * React hook to watch a specific Kubernetes Secret resource and only trigger re-renders when the Secret's `data` changes. + * + * @param {WatchK8sResource} resourceParams - Parameters specifying the Kubernetes Secret to watch. + * @returns {K8sSecretWatchResult} - An array containing the Secret's data (or `null` if not loaded), a boolean indicating if the data has been loaded, and any error that occurred while loading. + */ +export const useK8sWatchSecretData = (resourceParams: WatchK8sResource): K8sSecretWatchResult => { + const [secretData, setSecretData] = useState | undefined>(undefined); + const [secretLoaded, setLoaded] = useState(false); + const [secretLoadError, setLoadError] = useState(null); + + const [secret, loaded, error] = useK8sWatchResource(resourceParams); + + useEffect(() => { + if (loaded && error) { + handleLoadError(error); + } else if (loaded) { + handleLoadedSecret(secret); + } + }, [secret, loaded, error]); + + const handleLoadError = (error: Error | null) => { + setLoadError(error); + setLoaded(true); + }; + + const handleLoadedSecret = (secret: V1Secret | null) => { + setLoaded(true); + if (JSON.stringify(secret?.data) !== JSON.stringify(secretData)) { + setSecretData(secret?.data); + } + }; + + return [secretData, secretLoaded, secretLoadError]; +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/hooks/useProviderInventory.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/hooks/useProviderInventory.ts new file mode 100644 index 000000000..418a1fdc4 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/hooks/useProviderInventory.ts @@ -0,0 +1,185 @@ +import { useEffect, useRef, useState } from 'react'; + +import { V1beta1Provider } from '@kubev2v/types'; +import { consoleFetchJSON } from '@openshift-console/dynamic-plugin-sdk'; + +import { + getCachedData, + getInventoryApiUrl, + hasObjectChangedInGivenFields, + setCachedData, +} from '../utils/helpers'; + +import { DEFAULT_FIELDS_TO_COMPARE } from './utils'; + +/** + * @typedef {Object} UseProviderInventoryParams + * + * @property {V1beta1Provider} provider - The provider from which the inventory is fetched. + * @property {string} [subPath] - The sub path to be used in the inventory fetch API URL. + * @property {string[]} [fieldsToCompare] - The fields to compare to check if the inventory has changed. + * @property {number} [interval] - The polling interval in milliseconds. + * @property {number} [cacheExpiryDuration] - Duration in milliseconds till the cache remains valid. + */ +interface UseProviderInventoryParams { + provider: V1beta1Provider; + subPath?: string; + fieldsToCompare?: string[]; + interval?: number; + cacheExpiryDuration?: number; +} + +/** + * @typedef {Object} UseProviderInventoryResult + * + * @property {T | null} inventory - The fetched inventory. + * @property {boolean} loading - Whether the inventory fetch is in progress. + * @property {Error | null} error - The error occurred during inventory fetch. + */ +interface UseProviderInventoryResult { + inventory: T | null; + loading: boolean; + error: Error | null; +} + +/** + * A React hook to fetch and cache inventory data from a provider. + * It fetches new data on mount and then at the specified interval. + * If the new data is the same as the old data (compared using the specified fields), + * it does not update the state to prevent unnecessary re-renders. + * + * @param {Object} useProviderInventoryParams Configuration parameters for the hook + * @param {Object} useProviderInventoryParams.provider Provider object to get inventory data from + * @param {string} [useProviderInventoryParams.subPath=''] Sub-path to append to the provider API URL + * @param {Array} [useProviderInventoryParams.fieldsToCompare=DEFAULT_FIELDS_TO_COMPARE] Fields to use for comparing new data with old data + * @param {number} [useProviderInventoryParams.interval=10000] Interval (in milliseconds) to fetch new data at + * @param {number} [useProviderInventoryParams.cacheExpiryDuration=60000] Duration (in milliseconds) to keep data in cache, if zero, don't use cache + * + * @returns {Object} useProviderInventoryResult Contains the inventory data (or null if loading, not fetched yet, or error), + * the loading state, and the error state (or null if no errors) + * + * @template T Type of the inventory data + */ +export const useProviderInventory = ({ + provider, + subPath = '', + fieldsToCompare = DEFAULT_FIELDS_TO_COMPARE, + interval = 10000, + cacheExpiryDuration = 0, // default cache validity is 0 seconds (don't use cache) +}: UseProviderInventoryParams): UseProviderInventoryResult => { + const [inventory, setInventory] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const oldDataRef = useRef(null); + const oldErrorRef = useRef(null); + + const cacheKey = + provider?.metadata?.uid && + `forklift_inventory_${provider.spec.type}_${provider.metadata.uid}${ + subPath ? `_${subPath}` : '' + }`; + + // Fetch cached data + useEffect(() => { + if (cacheExpiryDuration > 0) { + const fetchCachedData = async () => { + if (!isValidProvider(provider)) { + const e = new Error('Invalid provider data'); + handleError(e); + + return; + } + + const cachedData = getCachedData(cacheKey, cacheExpiryDuration); + if (cachedData) { + updateInventoryIfChanged(cachedData, fieldsToCompare); + } + }; + + fetchCachedData(); + } + }, [provider, subPath, interval, cacheExpiryDuration]); + + // Fetch data from API + useEffect(() => { + const fetchData = async () => { + if (!isValidProvider(provider)) { + const e = new Error('Invalid provider data'); + handleError(e); + + return; + } + + try { + const newInventory = await consoleFetchJSON( + getInventoryApiUrl( + `providers/${provider.spec.type}/${provider.metadata.uid}${ + subPath ? `/${subPath}` : '' + }`, + ), + ); + + updateInventoryIfChanged(newInventory, fieldsToCompare); + setCachedData(cacheKey, newInventory); + } catch (e) { + handleError(e); + } + }; + + fetchData(); + const intervalId = setInterval(fetchData, interval); + + return () => clearInterval(intervalId); + }, [provider, subPath, interval, cacheExpiryDuration]); + + /** + * Handles any errors thrown when trying to fetch the inventory. + * If the error is new (compared to the last error), + * it sets the error state and stops the loading state. + * + * @param {Error} e The error object to handle + * @returns {void} + */ + function handleError(e: Error): void { + if (e?.toString() !== oldErrorRef.current?.error) { + setError(e); + setLoading(false); + + oldErrorRef.current = { error: e?.toString() }; + } + } + + /** + * Checks if provider object is valid. + * @param {V1beta1Provider} provider - The provider object to be validated. + * @returns {boolean} - True if the provider object is valid, false otherwise. + */ + function isValidProvider(provider: V1beta1Provider): boolean { + return Boolean(provider?.spec?.type && provider?.metadata?.uid); + } + + /** + * Checks if the inventory data has changed and updates the inventory state if it has. + * Also updates the loading state. + * @param {T} newInventory - The new inventory data. + * @param {string[]} fieldsToCompare - The fields to compare to check if the inventory data has changed. + */ + function updateInventoryIfChanged(newInventory: T, fieldsToCompare: string[]): void { + const needReRender = hasObjectChangedInGivenFields({ + oldObject: oldDataRef.current?.inventory, + newObject: newInventory, + fieldsToCompare: fieldsToCompare, + }); + + if (needReRender) { + setInventory(newInventory); + setLoading(false); + + oldDataRef.current = { inventory: newInventory }; + } + } + + return { inventory, loading, error }; +}; + +export default useProviderInventory; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/hooks/useProvidersInventoryList.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/hooks/useProvidersInventoryList.ts new file mode 100644 index 000000000..cac3cbcaa --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/hooks/useProvidersInventoryList.ts @@ -0,0 +1,139 @@ +import { useEffect, useRef, useState } from 'react'; + +import { ProviderInventory, ProvidersInventoryList } from '@kubev2v/types'; +import { consoleFetchJSON } from '@openshift-console/dynamic-plugin-sdk'; + +import { getInventoryApiUrl, hasObjectChangedInGivenFields } from '../utils'; + +import { DEFAULT_FIELDS_TO_COMPARE, INVENTORY_TYPES } from './utils'; + +/** + * Configuration parameters for useProvidersInventoryList hook. + * @interface + * @property {number} interval - Polling interval in milliseconds. + */ +interface UseInventoryParams { + interval?: number; // Polling interval in milliseconds +} + +/** + * The result object from useProvidersInventoryList hook. + * @interface + * @property {ProvidersInventoryList | null} inventory - The fetched inventory data, or null if loading, not fetched yet, or error. + * @property {boolean} loading - Indicates whether the inventory data is currently being fetched. + * @property {Error | null} error - Any error that occurred when fetching the inventory data, or null if no errors. + */ +interface UseInventoryResult { + inventory: ProvidersInventoryList | null; + loading: boolean; + error: Error | null; +} + +/** + * A React hook to fetch and maintain an up-to-date list of providers' inventory data. + * It fetches data on mount and then at the specified interval. + * + * @param {UseInventoryParams} params - Configuration parameters for the hook. + * @param {number} [params.interval=10000] - Interval (in milliseconds) to fetch new data at. + * + * @returns {UseInventoryResult} result - Contains the inventory data, the loading state, and the error state. + */ +export const useProvidersInventoryList = ({ + interval = 10000, +}: UseInventoryParams): UseInventoryResult => { + const [inventory, setInventory] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const oldDataRef = useRef(null); + const oldErrorRef = useRef(null); + + useEffect(() => { + const fetchData = async () => { + try { + const newInventory: ProvidersInventoryList = await consoleFetchJSON( + getInventoryApiUrl(`providers?detail=1`), + ); + + updateInventoryIfChanged(newInventory, DEFAULT_FIELDS_TO_COMPARE); + } catch (e) { + if (e?.toString() !== oldErrorRef.current?.error) { + oldErrorRef.current = { error: e?.toString() }; + setError(e as Error); + setLoading(false); + } + } + }; + + fetchData(); + + // Polling interval set by the passed parameter + const intervalId = setInterval(fetchData, interval); + return () => clearInterval(intervalId); + }, [interval]); + + /** + * Checks if there have been changes to any inventory items, and if so, + * updates the inventory list, sets the loading status to false, + * and updates the reference to the old data. + * + * @param newInventoryList - The new inventory list. + * @param fieldsToCompare - The fields to compare in order to determine + * if an inventory item has changed. + * + * @returns {void} + */ + function updateInventoryIfChanged( + newInventoryList: ProvidersInventoryList, + fieldsToCompare: string[], + ): void { + // Calculate total lengths of old and new inventories. + const oldTotalLength = INVENTORY_TYPES.reduce( + (total, type) => total + (oldDataRef.current?.inventoryList?.[type]?.length || 0), + 0, + ); + const newTotalLength = INVENTORY_TYPES.reduce( + (total, type) => total + (newInventoryList[type]?.length || 0), + 0, + ); + + const hasInventorySizeChanged = oldTotalLength !== newTotalLength; + let needReRender = hasInventorySizeChanged; + + // Test if inventory items changed + if (!hasInventorySizeChanged && oldTotalLength !== 0) { + const oldFlatInventory = INVENTORY_TYPES.flatMap( + (type) => oldDataRef.current?.inventoryList?.[type] || [], + ); + const newFlatInventory = INVENTORY_TYPES.flatMap( + (type) => newInventoryList[type] || [], + ); + + // Create maps of old and new inventories, using 'uid' as the key. + const oldInventoryMap = new Map(oldFlatInventory.map((item) => [item.uid, item])); + const newInventoryMap = new Map(newFlatInventory.map((item) => [item.uid, item])); + + for (const [uid, oldItem] of oldInventoryMap) { + const newItem = newInventoryMap.get(uid); + + // If a matching item is not found in the new list, or the item has changed, we need to re-render. + if ( + !newItem || + hasObjectChangedInGivenFields({ oldObject: oldItem, newObject: newItem, fieldsToCompare }) + ) { + needReRender = true; + break; + } + } + } + + if (needReRender) { + setInventory(newInventoryList); + setLoading(false); + oldDataRef.current = { inventoryList: newInventoryList }; + } + } + + return { inventory, loading, error }; +}; + +export default useProvidersInventoryList; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/hooks/useToggle.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/hooks/useToggle.ts new file mode 100644 index 000000000..78eb1729c --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/hooks/useToggle.ts @@ -0,0 +1,27 @@ +import { useState } from 'react'; + +/** + * `useToggle` is a hook that manages a single boolean state value. + * It initializes the state to the `initialValue` provided and returns + * the current state and a function to toggle it. + * + * @param {boolean} initialValue - The initial state. + * @returns {Array} An array where the first element is the current state + * and the second element is a function to toggle the state. + * + * @example + * const [isOpen, toggleIsOpen] = useToggle(false); + * // To toggle the isOpen state + * toggleIsOpen(); + */ +export const useToggle = (initialValue = false): [boolean, () => void] => { + const [value, setIsOpen] = useState(initialValue); + + const toggle = () => { + setIsOpen((v) => !v); + }; + + return [value, toggle]; +}; + +export default useToggle; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/hooks/utils/constants.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/hooks/utils/constants.ts new file mode 100644 index 000000000..4d57ee4b6 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/hooks/utils/constants.ts @@ -0,0 +1,19 @@ +import { ProviderType } from '@kubev2v/types'; + +export const DEFAULT_FIELDS_TO_COMPARE = [ + 'vmCount', + 'networkCount', + 'storageClassCount', + 'regionCount', + 'projectCount', + 'imageCount', + 'volumeCount', + 'volumeTypeCount', + 'datacenterCount', + 'clusterCount', + 'hostCount', + 'storageDomainCount', + 'datastoreCount', +]; + +export const INVENTORY_TYPES: ProviderType[] = ['openshift', 'openstack', 'ovirt', 'vsphere']; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/hooks/utils/index.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/hooks/utils/index.ts new file mode 100644 index 000000000..ae755f3bd --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/hooks/utils/index.ts @@ -0,0 +1,3 @@ +// @index('./*.ts', f => `export * from '${f.path}';`) +export * from './constants'; +// @endindex diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/DeleteModal/DeleteModal.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/DeleteModal/DeleteModal.tsx new file mode 100644 index 000000000..dd26bef28 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/DeleteModal/DeleteModal.tsx @@ -0,0 +1,110 @@ +import React, { ReactNode, useCallback, useState } from 'react'; +import { Trans } from 'react-i18next'; +import { useHistory } from 'react-router-dom'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { + k8sDelete, + K8sGroupVersionKind, + K8sModel, + K8sResourceCommon, +} from '@openshift-console/dynamic-plugin-sdk'; +import { Button, Modal, ModalVariant } from '@patternfly/react-core'; + +import { getResourceUrl } from '../../utils'; +import { AlertMessageForModals, ItemIsOwnedAlert } from '../components'; +import { useModal } from '../ModalHOC'; + +/** + * Props for the DeleteModal component + * @typedef DeleteModalProps + * @property {string} title - The title to display in the modal + * @property {K8sResourceCommon} resource - The resource object to delete + * @property {K8sModel} model - The model used for deletion + * @property {string} [redirectTo] - Optional redirect URL after deletion + */ +interface DeleteModalProps { + resource: K8sResourceCommon; + model: K8sModel; + title?: string; + redirectTo?: string; +} + +/** + * A generic delete modal component + * @component + * @param {DeleteModalProps} props - Props for DeleteModal + * @returns {React.Element} The DeleteModal component + */ +export const DeleteModal: React.FC = ({ title, resource, model, redirectTo }) => { + const { t } = useForkliftTranslation(); + const { toggleModal } = useModal(); + const history = useHistory(); + const [alertMessage, setAlertMessage] = useState(null); + + const title_ = title || t('Delete {{model.label}}', { model }); + const { name, namespace } = resource?.metadata || {}; + const owner = resource?.metadata?.ownerReferences?.[0]; + const groupVersionKind: K8sGroupVersionKind = { + group: model.apiGroup, + version: model.apiVersion, + kind: model.kind, + }; + + const onDelete = useCallback(async () => { + const isOnResourcePage = () => { + const re = new RegExp(`/${name}(/|$)`); + return re.test(window.location.pathname); + }; + + try { + await k8sDelete({ model, resource }); + if (redirectTo) { + history.push(redirectTo); + } else if (isOnResourcePage()) { + history.push(getResourceUrl({ groupVersionKind, namespace })); + } + + toggleModal(); + } catch (err) { + setAlertMessage(); + } + }, [resource]); + + const actions = [ + , + , + ]; + + return ( + + {namespace ? ( + + Are you sure you want to delete{' '} + {{ resourceName: name }} in namespace{' '} + {{ namespace: namespace }}? + + ) : ( + + Are you sure you want to delete{' '} + {{ resourceName: name }}? + + )} + {typeof owner === 'object' && } + {alertMessage} + + ); +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/DeleteModal/index.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/DeleteModal/index.ts new file mode 100644 index 000000000..17ef7fdc7 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/DeleteModal/index.ts @@ -0,0 +1,3 @@ +// @index('./*.tsx', f => `export * from '${f.path}';`) +export * from './DeleteModal'; +// @endindex diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/EditModal/EditModal.style.css b/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/EditModal/EditModal.style.css new file mode 100644 index 000000000..355fb3329 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/EditModal/EditModal.style.css @@ -0,0 +1,4 @@ +.forklift-edit-modal-body { + margin: 0; + padding-bottom: var(--pf-global--spacer--md); +} diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/EditModal/EditModal.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/EditModal/EditModal.tsx new file mode 100644 index 000000000..c7e0ea548 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/EditModal/EditModal.tsx @@ -0,0 +1,185 @@ +import React, { ReactNode, useCallback, useState } from 'react'; +import { useHistory } from 'react-router-dom'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { + Button, + Form, + FormGroup, + Modal, + ModalVariant, + Popover, + TextInput, +} from '@patternfly/react-core'; +import HelpIcon from '@patternfly/react-icons/dist/esm/icons/help-icon'; + +import { getValueByJsonPath } from '../../utils'; +import { AlertMessageForModals, ItemIsOwnedAlert } from '../components'; +import { useModal } from '../ModalHOC'; + +import { defaultOnConfirm } from './utils/defaultOnConfirm'; +import { EditModalProps, ValidationResults } from './types'; + +import './EditModal.style.css'; + +/** + * `EditModal` is a React Functional Component that allows editing a Kubernetes resource property inside a modal. + * + * @component + * @param {object} props - The properties that define the behavior and display of the `EditModal`. + * @param {K8sResourceCommon} props.resource - The Kubernetes resource that will be modified. + * @param {K8sModel} props.model - The model for the Kubernetes resource. + * @param {string | string[]} props.jsonPath - The JSON path to the property in the resource that will be modified. + * @param {string} props.title - The title of the modal. + * @param {string} props.label - The label of the field being edited. + * @param {ReactNode} [props.body] - The body content of the modal. + * @param {ReactNode} [props.headerContent] - The help popup header content of the input field. + * @param {ReactNode} [props.bodyContent] - The help popup content in the body of the input field. + * @param {'small' | 'default' | 'medium' | 'large'} [props.variant] - The size of the modal. + * @param {OnConfirmHookType} [props.onConfirmHook] - A hook that gets called when the user confirms the edit. + * @param {ModalInputComponentType} [props.InputComponent] - The component used for the input field. + * @param {string} [props.helperText] - Helper text that will be displayed under the input field. + * @param {string} [props.redirectTo] - The path to redirect to after the modal is closed. + * @param {ValidationHookType} [props.validationHook] - A hook that is used to validate the new value. + * + * @returns {ReactElement} Returns a `Modal` React Element that renders the modal. + */ +export const EditModal: React.FC = ({ + title, + body, + label, + headerContent, + bodyContent, + resource, + jsonPath, + model, + InputComponent, + helperText, + variant, + redirectTo, + onConfirmHook = defaultOnConfirm, + validationHook, +}) => { + const { t } = useForkliftTranslation(); + const { toggleModal } = useModal(); + const history = useHistory(); + const [alertMessage, setAlertMessage] = useState(null); + const [value, setValue] = useState(getValueByJsonPath(resource, jsonPath) as string); + const [validation, setValidation] = useState<{ + helperText: string; + validated: ValidationResults; + }>({ helperText: '', validated: undefined }); + + const { namespace } = resource?.metadata || {}; + const owner = resource?.metadata?.ownerReferences?.[0]; + + /** + * Handles value change. + */ + const handleValueChange = (newValue: string) => { + setValue(newValue); + + if (validationHook) { + const validationResult = validationHook(newValue); + setValidation({ + helperText: validationResult.validationHelpText, + validated: validationResult.validated, + }); + } + }; + + /** + * Handles save action. + */ + const handleSave = useCallback(async () => { + try { + await onConfirmHook({ resource, jsonPath, model, newValue: value }); + + if (redirectTo) { + history.push(redirectTo); + } + + toggleModal(); + } catch (err) { + setAlertMessage( + , + ); + } + }, [resource, value]); + + /** + * LabelIcon is a (?) icon that triggers a Popover component when clicked. + */ + const LabelIcon = headerContent && bodyContent && ( + + + + ); + + /** + * InputComponent_ is a higher-order component that renders either the passed-in InputComponent, or a default TextInput, + */ + const InputComponent_ = InputComponent ? ( + handleValueChange(value)} /> + ) : ( + handleValueChange(value)} + validated={validation.validated} + /> + ); + + const actions = [ + , + , + ]; + + return ( + +
{body}
+ + + + {typeof owner === 'object' && } + {alertMessage} +
+ ); +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/EditModal/index.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/EditModal/index.ts new file mode 100644 index 000000000..e8fe77e78 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/EditModal/index.ts @@ -0,0 +1,4 @@ +// @index('./*.tsx', f => `export * from '${f.path}';`) +export * from './EditModal'; +export * from './types'; +// @endindex diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/EditModal/types.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/EditModal/types.tsx new file mode 100644 index 000000000..2f1f0f6d1 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/EditModal/types.tsx @@ -0,0 +1,101 @@ +import React, { ReactNode } from 'react'; + +import { K8sModel, K8sResourceCommon } from '@openshift-console/dynamic-plugin-sdk'; + +import './EditModal.style.css'; + +export interface EditModalProps { + /** The Kubernetes resource being edited. This object contains all the information about the Kubernetes resource including its metadata, status, and spec. */ + resource: K8sResourceCommon; + + /** The model of the Kubernetes resource. This object contains the information about the Kubernetes kind, apiVersion, and other model specific details. */ + model: K8sModel; + + /** The JSON path of the value in the resource object that needs to be edited. This can either be a string or an array of strings. */ + jsonPath: string | string[]; + + /** The title of the modal that will be displayed at the top. */ + title: string; + + /** The label of the form input field. */ + label: string; + + /** Optional. The content to be displayed in the modal's body. */ + body?: ReactNode; + + /** Optional. The header of the field help popup. */ + headerContent?: ReactNode; + + /** Optional. The content of the field help popup. */ + bodyContent?: ReactNode; + + /** Optional. The size variant of the modal. Can be 'small', 'default', 'medium', or 'large'. */ + variant?: 'small' | 'default' | 'medium' | 'large'; + + /** Optional. The custom input component to be used in the form. If not provided, a default TextInput will be used. */ + InputComponent?: ModalInputComponentType; + + /** Optional. Helper text that provides additional hints to the user, printed in grayed text under the input field. */ + helperText?: string; + + /** Optional. The URL to which the user will be redirected after the confirmation action. */ + redirectTo?: string; + + /** Optional. The hook function to be called when the confirmation button is clicked. */ + onConfirmHook?: OnConfirmHookType; + + /** Optional. The validation hook function that checks the new input value and returns a helper text and validation status. */ + validationHook?: ValidationHookType; +} + +/** + * ValidationResults type defines the possible states of a validation result. + * 'success' means the validation was successful, + * 'error' means the validation failed, + * 'warning' indicates a potential issue but not a failure, + * undefined indicates no validation state is set. + */ +export type ValidationResults = 'success' | 'error' | 'warning' | undefined; + +/** + * ModalInputComponentType defines the functional component type for the input fields used in the modal. + * It accepts two props: + * 'value' which can be a string or a number, + * and 'onChange' a callback function which is triggered when the value of the input changes. + */ +export type ModalInputComponentType = React.FC<{ + value: string | number; + onChange: (value: string) => void; +}>; + +/** + * ValidationHookType defines the structure of a hook function that performs validation. + * It accepts a value, which can be a string or a number, and returns an object containing + * 'validationHelpText' which is a string giving details about the result of the validation, + * and 'validated' which indicates the status of the validation and is of type ValidationResults. + */ +export type ValidationHookType = (value: string | number) => { + validationHelpText: string; + validated: ValidationResults; +}; + +/** + * OnConfirmHookType defines the structure of a hook function that is called when the confirmation action takes place. + * It accepts an object as an argument with four fields: + * 'resource' which is the Kubernetes resource being modified, + * 'newValue' which is the updated value for the resource, + * 'jsonPath' is the path in the JSON representation of the resource where the new value is applied, + * 'model' represents the model of the Kubernetes resource. + * The function returns a promise that resolves to the updated Kubernetes resource. + */ +export type OnConfirmHookType = ({ + resource, + newValue, + jsonPath, + model, +}: { + resource: K8sResourceCommon; + newValue: unknown; + jsonPath?: string | string[]; + model?: K8sModel; +}) => Promise; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/EditModal/utils/defaultOnConfirm.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/EditModal/utils/defaultOnConfirm.ts new file mode 100644 index 000000000..7c9844f01 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/EditModal/utils/defaultOnConfirm.ts @@ -0,0 +1,19 @@ +import { getValueByJsonPath, jsonPathToPatch } from 'src/modules/ProvidersNG/utils'; + +import { k8sPatch } from '@openshift-console/dynamic-plugin-sdk'; + +export const defaultOnConfirm = async ({ resource, jsonPath, model, newValue: value }) => { + const op = getValueByJsonPath(resource, jsonPath) ? 'replace' : 'add'; + + await k8sPatch({ + model: model, + resource: resource, + data: [ + { + op, + path: jsonPathToPatch(jsonPath), + value: value, + }, + ], + }); +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/EditProviderDefaultTransferNetwork/EditProviderDefaultTransferNetwork.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/EditProviderDefaultTransferNetwork/EditProviderDefaultTransferNetwork.tsx new file mode 100644 index 000000000..f7f5d5c30 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/EditProviderDefaultTransferNetwork/EditProviderDefaultTransferNetwork.tsx @@ -0,0 +1,167 @@ +import React from 'react'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { + Modify, + OpenShiftNetworkAttachmentDefinition, + ProviderModel, + V1beta1Provider, +} from '@kubev2v/types'; +import { K8sModel, k8sPatch } from '@openshift-console/dynamic-plugin-sdk'; +import { Dropdown, DropdownItem, DropdownToggle } from '@patternfly/react-core'; + +import { useProviderInventory, useToggle } from '../../hooks'; +import { + EditModal, + EditModalProps, + ModalInputComponentType, + OnConfirmHookType, +} from '../EditModal'; + +/** + * Handles the confirmation action for editing a resource annotations. + * Adds or updates the 'forklift.konveyor.io/defaultTransferNetwork' annotation in the resource's metadata. + * + * @param {Object} options - Options for the confirmation action. + * @param {Object} options.resource - The resource to be modified. + * @param {Object} options.model - The model associated with the resource. + * @param {any} options.newValue - The new value for the 'forklift.konveyor.io/defaultTransferNetwork' annotation. + * @returns {Promise} - The modified resource. + */ +const onConfirm: OnConfirmHookType = async ({ resource, model, newValue: value }) => { + const currentAnnotations = resource?.metadata?.annotations; + const newAnnotations = { + ...currentAnnotations, + 'forklift.konveyor.io/defaultTransferNetwork': value || undefined, + }; + + const op = resource?.metadata?.annotations ? 'replace' : 'add'; + + const obj = await k8sPatch({ + model: model, + resource: resource, + data: [ + { + op, + path: '/metadata/annotations', + value: newAnnotations, + }, + ], + }); + + return obj; +}; + +interface DropdownRendererProps { + value: string | number; + onChange: (string) => void; +} + +const OpenshiftNetworksInputFactory: ({ resource }) => ModalInputComponentType = ({ + resource: provider, +}) => { + const DropdownRenderer: React.FC = ({ value, onChange }) => { + const [isOpen, onToggle] = useToggle(false); + const { inventory: networks } = useProviderInventory({ + provider, + // eslint-disable-next-line @cspell/spellchecker + subPath: 'networkattachmentdefinitions?detail=4', + }); + + const name = getNetworkName(value); + + const dropdownItems = [ + onChange('')} + > + {'Pod network'} + , + ...(networks || []).map((n) => ( + onChange(`${n.namespace}/${n.name}`)} + > + {n.name} + + )), + ]; + + return ( + + {name} + + } + isOpen={isOpen} + dropdownItems={dropdownItems} + menuAppendTo="parent" + /> + ); + }; + + return DropdownRenderer; +}; + +const EditProviderDefaultTransferNetwork_: React.FC = ( + props, +) => { + const { t } = useForkliftTranslation(); + + return ( + + ); +}; + +/** + * Extracts the network name from a string. The input string can be of the form 'name' or 'namespace/name'. + * + * @param {string} value - The input string from which the network name is to be extracted. + * @returns {string} The network name extracted from the input string. + */ +function getNetworkName(value: string | number): string { + if (!value || typeof value !== 'string') { + return 'Pod network'; + } + + const parts = value.split('/'); + return parts[parts.length - 1]; +} + +export type EditProviderDefaultTransferNetworkProps = Modify< + EditModalProps, + { + resource: V1beta1Provider; + title?: string; + label?: string; + model?: K8sModel; + jsonPath?: string | string[]; + } +>; + +export const EditProviderDefaultTransferNetwork: React.FC< + EditProviderDefaultTransferNetworkProps +> = (props) => { + if (props.resource?.spec?.type !== 'openshift') { + return <>; + } + + return ; +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/EditProviderDefaultTransferNetwork/index.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/EditProviderDefaultTransferNetwork/index.ts new file mode 100644 index 000000000..16d4c9dc6 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/EditProviderDefaultTransferNetwork/index.ts @@ -0,0 +1,3 @@ +// @index('./*.tsx', f => `export * from '${f.path}';`) +export * from './EditProviderDefaultTransferNetwork'; +// @endindex diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/EditProviderURL/EditProviderURLModal.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/EditProviderURL/EditProviderURLModal.tsx new file mode 100644 index 000000000..50dfa1987 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/EditProviderURL/EditProviderURLModal.tsx @@ -0,0 +1,37 @@ +import React from 'react'; + +import { Modify, V1beta1Provider } from '@kubev2v/types'; +import { K8sModel } from '@openshift-console/dynamic-plugin-sdk/lib/api/core-api'; + +import { EditModalProps } from '../EditModal'; + +import { OpenshiftEditURLModal } from './OpenshiftEditURLModal'; +import { OpenstackEditURLModal } from './OpenstackEditURLModal'; +import { OvirtEditURLModal } from './OvirtEditURLModal'; +import { VSphereEditURLModal } from './VSphereEditURLModal'; + +export type EditProviderURLModalProps = Modify< + EditModalProps, + { + resource: V1beta1Provider; + title?: string; + label?: string; + model?: K8sModel; + jsonPath?: string | string[]; + } +>; + +export const EditProviderURLModal: React.FC = (props) => { + switch (props.resource?.spec?.type) { + case 'ovirt': + return ; + case 'openshift': + return ; + case 'openstack': + return ; + case 'vsphere': + return ; + default: + return <>; + } +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/EditProviderURL/OpenshiftEditURLModal.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/EditProviderURL/OpenshiftEditURLModal.tsx new file mode 100644 index 000000000..42a691d09 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/EditProviderURL/OpenshiftEditURLModal.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { ProviderModel } from '@kubev2v/types'; +import { ModalVariant } from '@patternfly/react-core'; + +import { validateURL } from '../../utils'; +import { EditModal, ValidationHookType } from '../EditModal'; + +import { patchProviderURL } from './utils/patchProviderURL'; +import { EditProviderURLModalProps } from './EditProviderURLModal'; + +export const OpenshiftEditURLModal: React.FC = (props) => { + const { t } = useForkliftTranslation(); + + const urlValidationHook: ValidationHookType = (value) => { + const isValidURL = validateURL(value.toString().trim()); + + return isValidURL + ? { + validationHelpText: undefined, + validated: 'success', + } + : { + validationHelpText: t( + 'URL must start with https:// or http:// and contain valid hostname and path', + ), + validated: 'error', + }; + }; + + return ( + :6443 for OpenShift.', + )} + helperText={t( + 'Please enter URL for the kubernetes API server, if empty URL default to this cluster.', + )} + validationHook={urlValidationHook} + onConfirmHook={patchProviderURL} + /> + ); +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/EditProviderURL/OpenstackEditURLModal.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/EditProviderURL/OpenstackEditURLModal.tsx new file mode 100644 index 000000000..650b4503a --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/EditProviderURL/OpenstackEditURLModal.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { ProviderModel } from '@kubev2v/types'; +import { ModalVariant } from '@patternfly/react-core'; + +import { validateURL } from '../../utils'; +import { EditModal, ValidationHookType } from '../EditModal'; + +import { patchProviderURL } from './utils/patchProviderURL'; +import { EditProviderURLModalProps } from './EditProviderURLModal'; + +export const OpenstackEditURLModal: React.FC = (props) => { + const { t } = useForkliftTranslation(); + + const urlValidationHook: ValidationHookType = (value) => { + const isValidURL = validateURL(value.toString().trim()); + + return isValidURL + ? { + validationHelpText: undefined, + validated: 'success', + } + : { + validationHelpText: t( + 'URL must start with https:// or http:// and contain valid hostname and path', + ), + validated: 'error', + }; + }; + + return ( + /v3 for OpenStack.', + )} + helperText={t('Please enter URL for OpenStack services REST APIs.')} + onConfirmHook={patchProviderURL} + validationHook={urlValidationHook} + /> + ); +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/EditProviderURL/OvirtEditURLModal.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/EditProviderURL/OvirtEditURLModal.tsx new file mode 100644 index 000000000..8f28d2eb7 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/EditProviderURL/OvirtEditURLModal.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { ProviderModel } from '@kubev2v/types'; +import { ModalVariant } from '@patternfly/react-core'; + +import { validateURL } from '../../utils'; +import { EditModal, ValidationHookType } from '../EditModal'; + +import { patchProviderURL } from './utils/patchProviderURL'; +import { EditProviderURLModalProps } from './EditProviderURLModal'; + +export const OvirtEditURLModal: React.FC = (props) => { + const { t } = useForkliftTranslation(); + + const urlValidationHook: ValidationHookType = (value) => { + const isValidURL = validateURL(value.toString().trim()); + + return isValidURL + ? { + validationHelpText: undefined, + validated: 'success', + } + : { + validationHelpText: t( + 'URL must start with https:// or http:// and contain valid hostname and path', + ), + validated: 'error', + }; + }; + + return ( + /ovirt-engine/api/ for RHV.', + )} + helperText={t('Please enter the URL for oVirt engine server.')} + onConfirmHook={patchProviderURL} + validationHook={urlValidationHook} + /> + ); +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/EditProviderURL/VSphereEditURLModal.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/EditProviderURL/VSphereEditURLModal.tsx new file mode 100644 index 000000000..637a0eae2 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/EditProviderURL/VSphereEditURLModal.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { ProviderModel } from '@kubev2v/types'; +import { ModalVariant } from '@patternfly/react-core'; + +import { validateURL } from '../../utils'; +import { EditModal, ValidationHookType } from '../EditModal'; + +import { patchProviderURL } from './utils/patchProviderURL'; +import { EditProviderURLModalProps } from './EditProviderURLModal'; + +export const VSphereEditURLModal: React.FC = (props) => { + const { t } = useForkliftTranslation(); + + const urlValidationHook: ValidationHookType = (value) => { + const isValidURL = validateURL(value.toString().trim()); + + return isValidURL + ? { + validationHelpText: undefined, + validated: 'success', + } + : { + validationHelpText: t( + 'URL must start with https:// or http:// and contain valid hostname and path', + ), + validated: 'error', + }; + }; + + return ( + /sdk for vSphere.', + )} + helperText={t('Please enter URL for vSphere REST APIs server.')} + onConfirmHook={patchProviderURL} + validationHook={urlValidationHook} + /> + ); +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/EditProviderURL/index.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/EditProviderURL/index.ts new file mode 100644 index 000000000..4db42c955 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/EditProviderURL/index.ts @@ -0,0 +1,7 @@ +// @index('./*.tsx', f => `export * from '${f.path}';`) +export * from './EditProviderURLModal'; +export * from './OpenshiftEditURLModal'; +export * from './OpenstackEditURLModal'; +export * from './OvirtEditURLModal'; +export * from './VSphereEditURLModal'; +// @endindex diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/EditProviderURL/utils/patchProviderURL.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/EditProviderURL/utils/patchProviderURL.ts new file mode 100644 index 000000000..a105883f3 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/EditProviderURL/utils/patchProviderURL.ts @@ -0,0 +1,59 @@ +import { Base64 } from 'js-base64'; + +import { SecretModel, V1beta1Provider, V1Secret } from '@kubev2v/types'; +import { k8sPatch } from '@openshift-console/dynamic-plugin-sdk'; + +import { OnConfirmHookType } from '../../EditModal'; + +/** + * Handles the confirmation action for editing a resource annotations. + * Adds or updates the 'forklift.konveyor.io/defaultTransferNetwork' annotation in the resource's metadata. + * + * @param {Object} options - Options for the confirmation action. + * @param {Object} options.resource - The resource to be modified. + * @param {Object} options.model - The model associated with the resource. + * @param {any} options.newValue - The new value for the 'forklift.konveyor.io/defaultTransferNetwork' annotation. + * @returns {Promise} - The modified resource. + */ +export const patchProviderURL: OnConfirmHookType = async ({ resource, model, newValue: value }) => { + const provider: V1beta1Provider = resource as V1beta1Provider; + const providerOp = provider?.spec?.url ? 'replace' : 'add'; + + // Get providers secret stub + const secret: V1Secret = { + kind: 'Secret', + apiVersion: 'v1', + metadata: { + name: provider?.spec?.secret?.name, + namespace: provider?.spec?.secret?.namespace, + }, + }; + + // Patch provider secret + await k8sPatch({ + model: SecretModel, + resource: secret, + data: [ + { + op: providerOp, // assume secret and provider has the same url + path: '/data/url', + value: Base64.encode(value.toString().trim()), + }, + ], + }); + + // Patch provider URL + const obj = await k8sPatch({ + model: model, + resource: provider, + data: [ + { + op: providerOp, + path: '/spec/url', + value: value.toString().trim(), + }, + ], + }); + + return obj; +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/EditProviderVDDKImage/EditProviderVDDKImage.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/EditProviderVDDKImage/EditProviderVDDKImage.tsx new file mode 100644 index 000000000..6a989fb33 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/EditProviderVDDKImage/EditProviderVDDKImage.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { Modify, ProviderModel, V1beta1Provider } from '@kubev2v/types'; +import { K8sModel } from '@openshift-console/dynamic-plugin-sdk/lib/api/core-api'; + +import { validateContainerImage } from '../../utils'; +import { EditModal, EditModalProps, ValidationHookType } from '../EditModal'; + +export type EditProviderVDDKImageProps = Modify< + EditModalProps, + { + resource: V1beta1Provider; + title?: string; + label?: string; + model?: K8sModel; + jsonPath?: string | string[]; + } +>; + +const EditProviderVDDKImage_: React.FC = (props) => { + const { t } = useForkliftTranslation(); + + const imageValidationHook: ValidationHookType = (value) => { + const trimmedValue = value.toString().trim(); + const isValidImage = trimmedValue === '' || validateContainerImage(value.toString().trim()); + + return isValidImage + ? { + validationHelpText: undefined, + validated: 'success', + } + : { + validationHelpText: t( + 'VDDK Init Image must be a valid container image, for example quay.io/kubev2v/example:latest', + ), + validated: 'error', + }; + }; + + return ( + + ); +}; + +export const EditProviderVDDKImage: React.FC = (props) => { + if (props.resource?.spec?.type !== 'vsphere') { + return <>; + } + + return ; +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/EditProviderVDDKImage/index.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/EditProviderVDDKImage/index.ts new file mode 100644 index 000000000..2c210a581 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/EditProviderVDDKImage/index.ts @@ -0,0 +1,3 @@ +// @index('./*.tsx', f => `export * from '${f.path}';`) +export * from './EditProviderVDDKImage'; +// @endindex diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/ModalHOC/ModalHOC.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/ModalHOC/ModalHOC.tsx new file mode 100644 index 000000000..4a9ba0133 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/ModalHOC/ModalHOC.tsx @@ -0,0 +1,75 @@ +import React, { createContext, ReactNode, useCallback, useContext, useMemo, useState } from 'react'; + +import { useToggle } from '../../hooks'; + +/** + * A provider component that wraps its children with the modal context. + * + * @example + * // Usage: + * + * + * + * + * // In your child components, you can use the useModal hook to access showModal and toggleModal: + * const { showModal, toggleModal } = useModal(); + * showModal(); + * // To close the modal, call toggleModal(). + * + * @param {ModalHOCProps} props - The component props. + * @param {ReactNode} props.children - The children components to be wrapped. + * @returns {JSX.Element} The JSX element representing the ModalProvider. + */ +export const ModalHOC: React.FC = ({ children }) => { + const [modalComponent, setModalComponent] = useState(null); + const [isModalOpen, toggleModal] = useToggle(); + + const showModal = useCallback( + (modal) => { + setModalComponent(modal); + toggleModal(); + }, + [toggleModal], + ); + + /* + * { showModal, toggleModal } is a new object each time the ModalHOC component renders, + * even though showModal and toggleModal themselves don't change. + * useMemo will ensure that the object { showModal, toggleModal } is only recalculated when showModal or toggleModal changes. + * This will prevent unnecessary re-renders of components that consume the context. + */ + const value = useMemo(() => ({ showModal, toggleModal }), [showModal, toggleModal]); + + return ( + + {children} + {isModalOpen && modalComponent} + + ); +}; + +/** + * A custom hook that provides access to the Forklift modal context. + * + * @returns {ModalContextType} The modal context object. + * @throws {Error} If used outside of the ModalProvider. + */ +export const useModal = (): ModalContextType => { + const context = useContext(ModalContext); + if (!context) { + throw new Error('useModal must be used within a ModalProvider'); + } + return context; +}; + +export interface ModalContextType { + showModal: (modal: ReactNode) => void; + toggleModal: () => void; +} + +export interface ModalHOCProps { + children: ReactNode; +} + +// Creating the context. +const ModalContext = createContext(undefined); diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/ModalHOC/index.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/ModalHOC/index.ts new file mode 100644 index 000000000..be2ba218e --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/ModalHOC/index.ts @@ -0,0 +1,3 @@ +// @index('./*.tsx', f => `export * from '${f.path}';`) +export * from './ModalHOC'; +// @endindex diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/components/AlertMessageForModals.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/components/AlertMessageForModals.tsx new file mode 100644 index 000000000..059a8a019 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/components/AlertMessageForModals.tsx @@ -0,0 +1,12 @@ +import React from 'react'; + +import { Alert } from '@patternfly/react-core'; + +export const AlertMessageForModals: React.FC<{ title: string; message: string }> = ({ + title, + message, +}) => ( + + {message} + +); diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/components/ItemIsOwnedAlert.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/components/ItemIsOwnedAlert.tsx new file mode 100644 index 000000000..0ba2bd2be --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/components/ItemIsOwnedAlert.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { Trans } from 'react-i18next'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { + getGroupVersionKindForResource, + OwnerReference, + ResourceLink, +} from '@openshift-console/dynamic-plugin-sdk'; +import { Alert } from '@patternfly/react-core'; + +interface ItemIsOwnedAlertProps { + owner: OwnerReference; + namespace: string; +} + +export const ItemIsOwnedAlert: React.FC = ({ owner, namespace }) => { + const { t } = useForkliftTranslation(); + + return ( + + + This resource is managed by{' '} + {' '} + and any modifications may be overwritten. Edit the managing resource to preserve changes. + + + ); +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/components/index.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/components/index.ts new file mode 100644 index 000000000..7fcd1fe4a --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/components/index.ts @@ -0,0 +1,4 @@ +// @index('./*.tsx', f => `export * from '${f.path}';`) +export * from './AlertMessageForModals'; +export * from './ItemIsOwnedAlert'; +// @endindex diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/index.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/index.ts new file mode 100644 index 000000000..570d40d6d --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/modals/index.ts @@ -0,0 +1,9 @@ +// @index(['./*', /__/g], f => `export * from '${f.path}';`) +export * from './components'; +export * from './DeleteModal'; +export * from './EditModal'; +export * from './EditProviderDefaultTransferNetwork'; +export * from './EditProviderURL'; +export * from './EditProviderVDDKImage'; +export * from './ModalHOC'; +// @endindex diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/components/DetailsPage/DetailItem.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/components/DetailsPage/DetailItem.tsx new file mode 100644 index 000000000..dfcd0aaf6 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/components/DetailsPage/DetailItem.tsx @@ -0,0 +1,158 @@ +import React, { ReactNode } from 'react'; + +import { ExternalLink } from '@kubev2v/common'; +import { + Breadcrumb, + BreadcrumbItem, + Button, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, + DescriptionListTermHelpText, + DescriptionListTermHelpTextButton, + Flex, + FlexItem, + Popover, +} from '@patternfly/react-core'; +import Pencil from '@patternfly/react-icons/dist/esm/icons/pencil-alt-icon'; + +/** + * Component for displaying a details item. + * It can optionally include a help text popover, breadcrumbs, and an edit button. + * + * @component + * @param {DetailsItemProps} props - The props of the details item. + */ +export const DetailsItem: React.FC = ({ + title, + content, + helpContent, + moreInfoLabel, + moreInfoLink, + crumbs, + onEdit, +}) => { + return ( + + {helpContent ? ( + + ) : ( + + )} + {onEdit ? ( + + ) : ( + + )} + + ); +}; + +/** + * Component for displaying title with help text in a popover. + * + * @component + */ +export const DescriptionTitleWithHelp: React.FC<{ + title: string; + helpContent: ReactNode; + moreInfoLabel?: string; + moreInfoLink?: string; + crumbs?: string[]; +}> = ({ title, helpContent, crumbs, moreInfoLabel = 'More info:', moreInfoLink }) => ( + + {title}} + bodyContent={ + + {helpContent} + + {moreInfoLink && ( + + {moreInfoLabel}{' '} + + {moreInfoLink} + + . + + )} + + {crumbs && crumbs.length > 0 && ( + + + {crumbs.map((c) => ( + {c} + ))} + + + )} + + } + > + {title} + + +); + +/** + * Component for displaying title. + * + * @component + */ +export const DescriptionTitle: React.FC<{ title: string }> = ({ title }) => ( + {title} +); + +/** + * Component for displaying an inline link button with editable content. + * + * @component + * @param {ReactNode} content - The content of the button. + * @param {Function} onEdit - Function to be called when the button is clicked. + */ +export const EditableContentButton: React.FC<{ content: ReactNode; onEdit: () => void }> = ({ + content, + onEdit, +}) => ( + +); + +/** + * Component for displaying a non-editable content. + * + * @component + * @param {ReactNode} content - The content of the description. + */ +export const NonEditableContent: React.FC<{ content: ReactNode }> = ({ content }) => ( + {content} +); + +/** + * Type for the props of the DetailsItem component. + * + * @typedef {Object} DetailsItemProps + * @property {string} title - The title of the details item. + * @property {ReactNode} content - The content of the details item. + * @property {ReactNode} [helpContent] - The content to display in the help popover. + * @property {string[]} [crumbs] - Breadcrumbs for the details item. + * @property {Function} [onEdit] - Function to be called when the edit button is clicked. + */ +export type DetailsItemProps = { + title: string; + content: ReactNode; + helpContent?: ReactNode; + moreInfoLabel?: string; + moreInfoLink?: string; + crumbs?: string[]; + onEdit?: () => void; +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/components/DetailsPage/OwnerReferencesItem.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/components/DetailsPage/OwnerReferencesItem.tsx new file mode 100644 index 000000000..64a5f2bc9 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/components/DetailsPage/OwnerReferencesItem.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { + K8sResourceCommon, + OwnerReference, + ResourceLink, +} from '@openshift-console/dynamic-plugin-sdk'; + +/** + * React Component to display a list of owner references for a given Kubernetes resource. + * + * @component + * @param {OwnerReferencesProps} props - Props for the OwnerReferences component. + * @param {K8sResourceCommon} props.resource - The resource whose owner references will be displayed. + * @returns {ReactElement} A list of owner references or a 'No owner' message if there are no owner references. + */ +export const OwnerReferencesItem: React.FC = ({ resource }) => { + const { t } = useForkliftTranslation(); + const owners = (resource?.metadata?.ownerReferences || []).map((o: OwnerReference) => ( + + )); + return owners.length ? <>{owners} : {t('No owner')}; +}; + +/** + * Type for the props of the OwnerReferences component. + * + * @typedef {Object} OwnerReferencesProps + * @property {K8sResourceCommon} resource - The resource whose owner references will be displayed. + */ +export type OwnerReferencesProps = { + resource: K8sResourceCommon; +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/components/DetailsPage/PageHeadings.style.css b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/components/DetailsPage/PageHeadings.style.css new file mode 100644 index 000000000..b84b33907 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/components/DetailsPage/PageHeadings.style.css @@ -0,0 +1,3 @@ +.forklift-page-headings { + margin-top: 1rem; +} diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/components/DetailsPage/PageHeadings.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/components/DetailsPage/PageHeadings.tsx new file mode 100644 index 000000000..7d21e0a7c --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/components/DetailsPage/PageHeadings.tsx @@ -0,0 +1,117 @@ +import React, { ReactNode } from 'react'; +import { TFunction } from 'react-i18next'; +import { Link } from 'react-router-dom'; +import { getResourceUrl } from 'src/modules/ProvidersNG/utils'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { ProviderModelGroupVersionKind } from '@kubev2v/types'; +import { + K8sGroupVersionKind, + K8sModel, + K8sResourceCommon, + ResourceIcon, + ResourceStatus, +} from '@openshift-console/dynamic-plugin-sdk'; +import Status from '@openshift-console/dynamic-plugin-sdk/lib/app/components/status/Status'; +import { Breadcrumb, BreadcrumbItem, Split, SplitItem } from '@patternfly/react-core'; + +import './PageHeadings.style.css'; + +export const PageHeadings: React.FC = ({ + model, + namespace, + obj: data, + children, + actions, +}) => { + const status = data?.['status']?.phase; + + return ( +
+ + +

+ + + {' '} + {data?.metadata?.name} + {status && ( + + + + )} + + +

+ + {actions} + +
+ {children} +
+ ); +}; + +export interface PageHeadingsProps { + model: K8sModel; + namespace?: string; + obj?: K8sResourceCommon; + title?: ReactNode; + actions?: ReactNode; +} + +const BreadCrumbs: React.FC = ({ model, namespace }) => { + const { t } = useForkliftTranslation(); + + const breadcrumbs = breadcrumbsForModel(t, model, namespace); + + return ( + + {breadcrumbs.map((crumb, i, { length }) => { + const isLast = i === length - 1; + + return ( + + {isLast ? ( + crumb.name + ) : ( + + {crumb.name} + + )} + + ); + })} + + ); +}; + +type BreadCrumbsProps = { + model: K8sModel; + namespace?: string; +}; + +const breadcrumbsForModel = (t: TFunction, model: K8sModel, namespace: string) => { + const groupVersionKind: K8sGroupVersionKind = { + group: model.apiGroup, + version: model.apiVersion, + kind: model.kind, + }; + + return [ + { + name: `${model.labelPlural}`, + path: `${getResourceUrl({ groupVersionKind, namespace })}`, + }, + { + name: t('{{name}} Details', { name: model.label }), + }, + ]; +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/components/DetailsPage/index.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/components/DetailsPage/index.ts new file mode 100644 index 000000000..570b431c3 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/components/DetailsPage/index.ts @@ -0,0 +1,5 @@ +// @index(['./*', /style/g], f => `export * from '${f.path}';`) +export * from './DetailItem'; +export * from './OwnerReferencesItem'; +export * from './PageHeadings'; +// @endindex diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/components/Galerry/SelectableCard.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/components/Galerry/SelectableCard.tsx new file mode 100644 index 000000000..1b7162741 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/components/Galerry/SelectableCard.tsx @@ -0,0 +1,46 @@ +import React, { ReactNode } from 'react'; + +import { Card, CardBody, CardTitle } from '@patternfly/react-core'; + +interface SelectableCardProps { + /** The title of the card */ + title: ReactNode; + /** The content of the card */ + content: ReactNode; + /** Handler function to be called when the card is clicked */ + onChange: (isSelected: boolean) => void; + /** The selected state of the card */ + isSelected: boolean; +} + +/** + * SelectableCard component + * @param props The properties of the SelectableCard + */ +export const SelectableCard: React.FC = ({ + title, + content, + onChange, + isSelected, +}) => { + // Handler function to toggle selection and call onChange + const handleClick = () => { + // Flip the isSelected status and send the new status via the onChange handler + onChange(!isSelected); + }; + + return ( + + {title} + {content} + + ); +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/components/Galerry/SelectableGallery.style.css b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/components/Galerry/SelectableGallery.style.css new file mode 100644 index 000000000..ed6c96b0b --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/components/Galerry/SelectableGallery.style.css @@ -0,0 +1,7 @@ +.forklift-selectable-gallery { + padding-top: var(--pf-global--spacer--sm); +} + +.forklift-selectable-gallery-card { + height: 100%; +} \ No newline at end of file diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/components/Galerry/SelectableGallery.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/components/Galerry/SelectableGallery.tsx new file mode 100644 index 000000000..a21965817 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/components/Galerry/SelectableGallery.tsx @@ -0,0 +1,69 @@ +import React, { FC } from 'react'; + +import { Gallery, GalleryItem } from '@patternfly/react-core'; + +import { SelectableCard } from './SelectableCard'; + +import './SelectableGallery.style.css'; + +export interface SelectableGalleryItem { + /** The title of the item */ + title: string; + /** The content of the item */ + content: string; +} + +interface SelectableGalleryProps { + /** An object of items to be displayed in the gallery. Key is the item's id */ + items: Record; + /** Handler function to be called when a card is selected */ + onChange: (selectedCardId: string | null) => void; + /** A function to sort the items. Default is alphabetic sort on item titles. */ + sortFunction?: (a: [string, SelectableGalleryItem], b: [string, SelectableGalleryItem]) => number; + /** initial selected value */ + selectedID?: string; +} + +/** + * SelectableGallery component + * @param props The properties of the SelectableGallery + */ +export const SelectableGallery: FC = ({ + items, + onChange, + sortFunction = ([, a], [, b]) => a.title.localeCompare(b.title), + selectedID, +}) => { + // State to manage the selected card's id + const [selectedCardId, setSelectedCardId] = React.useState(selectedID); + + // Callback function for when a card is selected + const handleCardChange = (isSelected: boolean, id: string) => { + if (isSelected) { + setSelectedCardId(id); + onChange(id); + } else if (selectedCardId === id) { + // Unselect the card if it's currently selected + setSelectedCardId(null); + onChange(null); + } + }; + + // Convert the items object to an array and sort it + const sortedItems = Object.entries(items).sort(sortFunction); + + return ( + + {sortedItems.map(([id, item]) => ( + + handleCardChange(isSelected, id)} + /> + + ))} + + ); +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/components/TableCell/TableCell.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/components/TableCell/TableCell.tsx new file mode 100644 index 000000000..861c24a48 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/components/TableCell/TableCell.tsx @@ -0,0 +1,33 @@ +import React, { Children, ReactNode } from 'react'; + +import { Flex, FlexItem } from '@patternfly/react-core'; + +import './TableCells.style.css'; + +/** + * A component that displays a table cell. + * + * @param {TableCellProps} props - The props for the component. + * @returns {ReactElement} The rendered TableCell component. + */ +export const TableCell: React.FC = ({ children }) => { + const arrayChildren = Children.toArray(children); + + return ( + + + {Children.map(arrayChildren, (child) => ( + {child} + ))} + + + ); +}; + +export interface TableCellProps { + children?: ReactNode; +} diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/components/TableCell/TableCells.style.css b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/components/TableCell/TableCells.style.css new file mode 100644 index 000000000..9e5d4a469 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/components/TableCell/TableCells.style.css @@ -0,0 +1,8 @@ +.forklift-table__flex-cell { + display: flex; + flex-wrap: wrap; +} + +.forklift-table__flex-cell-label { + margin-left: var(--pf-global--spacer--sm); +} diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/components/TableCell/TableEmptyCell.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/components/TableCell/TableEmptyCell.tsx new file mode 100644 index 000000000..908a77fe1 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/components/TableCell/TableEmptyCell.tsx @@ -0,0 +1,11 @@ +import React from 'react'; + +import { Td } from '@patternfly/react-table'; + +/** + * A component that renders an empty cell with a dash symbol (-). + * @returns {JSX.Element} The JSX element representing the empty cell. + */ +export const TableEmptyCell: React.FC = () => { + return -; +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/components/TableCell/TableIconCell.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/components/TableCell/TableIconCell.tsx new file mode 100644 index 000000000..3a9e3b30a --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/components/TableCell/TableIconCell.tsx @@ -0,0 +1,28 @@ +import React, { ReactNode } from 'react'; + +import { TableLabelCell, TableLabelCellProps } from './TableLabelCell'; + +/** + * A component that displays a table cell, with an optional icon. + * + * @param {TableIconCellProps} props - The props for the component. + * @returns {ReactElement} The rendered TableLinkCell component. + */ +export const TableIconCell: React.FC = ({ + children, + icon, + hasLabel = false, + label, + labelColor = 'grey', +}) => { + return ( + + {icon} + {children} + + ); +}; + +export interface TableIconCellProps extends TableLabelCellProps { + icon?: ReactNode; +} diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/components/TableCell/TableLabelCell.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/components/TableCell/TableLabelCell.tsx new file mode 100644 index 000000000..5ea301d51 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/components/TableCell/TableLabelCell.tsx @@ -0,0 +1,35 @@ +import React, { ReactNode } from 'react'; + +import { Label } from '@patternfly/react-core'; + +import { TableCell, TableCellProps } from './TableCell'; + +/** + * A component that displays a table cell, with an optional label. + * + * @param {TableLabelCellProps} props - The props for the component. + * @returns {ReactElement} The rendered TableLabelCell component. + */ +export const TableLabelCell: React.FC = ({ + children, + hasLabel = false, + label, + labelColor = 'grey', +}) => { + return ( + + {children} + {hasLabel && ( + + )} + + ); +}; + +export interface TableLabelCellProps extends TableCellProps { + hasLabel?: boolean; + label?: ReactNode; + labelColor?: 'blue' | 'cyan' | 'green' | 'orange' | 'purple' | 'red' | 'grey'; +} diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/components/TableCell/TableLinkCell.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/components/TableCell/TableLinkCell.tsx new file mode 100644 index 000000000..00d0a181c --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/components/TableCell/TableLinkCell.tsx @@ -0,0 +1,32 @@ +import React from 'react'; + +import { K8sGroupVersionKind, ResourceLink } from '@openshift-console/dynamic-plugin-sdk'; + +import { TableLabelCell, TableLabelCellProps } from './TableLabelCell'; + +/** + * A component that displays a resource link, with an optional label. + * + * @param {TableLinkCellProps} props - The props for the component. + * @returns {ReactElement} The rendered TableLinkCell component. + */ +export const TableLinkCell: React.FC = ({ + groupVersionKind, + name, + namespace, + hasLabel = false, + label, + labelColor = 'grey', +}) => { + return ( + + + + ); +}; + +export interface TableLinkCellProps extends TableLabelCellProps { + groupVersionKind: K8sGroupVersionKind; + name: string; + namespace: string; +} diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/components/TableCell/index.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/components/TableCell/index.ts new file mode 100644 index 000000000..39a1e7400 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/components/TableCell/index.ts @@ -0,0 +1,7 @@ +// @index(['./*', /__/g, /style/g], f => `export * from '${f.path}';`) +export * from './TableCell'; +export * from './TableEmptyCell'; +export * from './TableIconCell'; +export * from './TableLabelCell'; +export * from './TableLinkCell'; +// @endindex diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/components/index.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/components/index.ts new file mode 100644 index 000000000..97e5ed955 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/components/index.ts @@ -0,0 +1,4 @@ +// @index(['./*', /__/g, /style/g], f => `export * from '${f.path}';`) +export * from './DetailsPage'; +export * from './TableCell'; +// @endindex diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/helpers/__tests__/validators.test.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/helpers/__tests__/validators.test.ts new file mode 100644 index 000000000..4cbf111b3 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/helpers/__tests__/validators.test.ts @@ -0,0 +1,150 @@ +/* eslint-disable @cspell/spellchecker */ +import { + validateContainerImage, + validateFingerprint, + validateK8sName, + validatePublicCert, + validateURL, +} from '../../validators/common'; + +describe('validator', () => { + // Tests for validateContainerImage + describe('validateContainerImage', () => { + it('should return true for valid container images', () => { + const images = [ + 'my-registry/my-repo/my-image:my-tag', + 'localhost:5000/my-repo/my-image:my-tag', + 'my-repo/my-image@sha256:389d6e4ec6277e14d3684195be4d0531ff666ff8a8ee9e6bb56837dec642283f', + 'my-registry/my-repo/my-image', + ]; + for (const image of images) { + expect(validateContainerImage(image)).toBe(true); + } + }); + + it('should return false for invalid container images', () => { + const images = [ + 'my-repo/my+image:my-tag', // invalid char + 'my-repo/my-image@sha256', // missing sha256 hash + ]; + for (const image of images) { + expect(validateContainerImage(image)).toBe(false); + } + }); + }); + + // Tests for validateURL + describe('validateURL', () => { + it('should return true for valid URLs', () => { + const urls = [ + 'https://example.com:8080/my/path?param=value', + 'http://192.168.1.1:8000', + 'https://www.example.co.uk', + ]; + for (const url of urls) { + expect(validateURL(url)).toBe(true); + } + }); + + it('should return false for invalid URLs', () => { + const urls = [ + 'http:/example.com', // missing slash + 'https://192.168.1.1.1', // invalid IP + 'http://example', // no TLD + ]; + for (const url of urls) { + expect(validateURL(url)).toBe(false); + } + }); + }); + + // Tests for validatePublicCert + describe('validatePublicCert', () => { + it('should return true for valid certificates', () => { + const certs = [ + ` +-----BEGIN CERTIFICATE----- +MIIDXTCCAkWgAwIBAgIJAJC1HiIAZAiIMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV +BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRtZSBXaWRn +-----END CERTIFICATE----- + `, + ]; + for (const ca of certs) { + expect(validatePublicCert(ca.trim())).toBe(true); + } + }); + + it('should return false for invalid certificates', () => { + const certs = [ + ` +-----BEGIN CERTIFICATE----- +MIIDXTCCAkWgAwIBAgIJAJC1HiIAZAiIMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV +BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRtZSBXaWRn + `, // missing end tag + ` +-----BEGIN CERTIFICATE----- +MIIDXTCCAkWgAwIBAgIJAJC1HiIAZAiIMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV +BAYTAkFVMRMwEQYDVQQIDApTb21lLVN 0YXRlMSEwHwYDVQQKDBhJ= +-----END CERTIFICATE----- + `, // invalid Base64 content + '-----BEGIN CERTIFICATE-----', // missing content and end tag + ]; + for (const ca of certs) { + expect(validatePublicCert(ca.trim())).toBe(false); + } + }); + }); + + describe('validateFingerprint', () => { + it('validates correct fingerprints', () => { + const validFingerprint = '52:6C:4E:88:1D:78:AE:12:1C:F3:BB:6C:5B:F4:E2:82:86:A7:08:AF'; + expect(validateFingerprint(validFingerprint)).toBe(true); + }); + + it('invalidates fingerprints with wrong length', () => { + const invalidFingerprint = '52:6C:4E:88:1D:78:AE:12:1C:F3:BB:6C:5B:F4:E2:82:86:A7:08'; + expect(validateFingerprint(invalidFingerprint)).toBe(false); + }); + + it('invalidates fingerprints with wrong characters', () => { + const invalidFingerprint = 'G2:6C:4E:88:1D:78:AE:12:1C:F3:BB:6C:5B:F4:E2:82:86:A7:08:AF'; + expect(validateFingerprint(invalidFingerprint)).toBe(false); + }); + + it('invalidates fingerprints with missing colons', () => { + const invalidFingerprint = '526C4E881D78AE121CF3BB6C5BF4E28286A708AF'; + expect(validateFingerprint(invalidFingerprint)).toBe(false); + }); + + it('validates lowercase fingerprints', () => { + const validFingerprint = '52:6C:4E:88:1D:78:AE:12:1C:F3:BB:6C:5B:F4:E2:82:86:A7:08:AF'; + expect(validateFingerprint(validFingerprint)).toBe(true); + }); + }); + + describe('validateK8sName', () => { + it('validates correct k8s names', () => { + expect(validateK8sName('k8s-name')).toBe(true); + expect(validateK8sName('k8sname')).toBe(true); + expect(validateK8sName('k8')).toBe(true); + }); + + it('invalidates k8s names with invalid characters', () => { + expect(validateK8sName('k8s_name')).toBe(false); + expect(validateK8sName('k8s.name')).toBe(false); + }); + + it('invalidates k8s names that are too long', () => { + const longName = 'k'.repeat(254); + expect(validateK8sName(longName)).toBe(false); + }); + + it('invalidates k8s names that start with a hyphen', () => { + expect(validateK8sName('-k8sname')).toBe(false); + }); + + it('invalidates k8s names that end with a hyphen', () => { + expect(validateK8sName('k8sname-')).toBe(false); + }); + }); +}); diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/helpers/findInventoryByID.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/helpers/findInventoryByID.ts new file mode 100644 index 000000000..4c184b435 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/helpers/findInventoryByID.ts @@ -0,0 +1,25 @@ +import { ProviderInventory, ProvidersInventoryList } from '@kubev2v/types'; + +/** + * Finds an inventory by its unique identifier. + * + * @param {ProvidersInventoryList} inventory - The list of provider inventories by type. + * @param {string} uid - The unique identifier of the inventory to be found. + * @returns {ProviderInventory} - The inventory if found, undefined otherwise. + */ +export function findInventoryByID( + inventory: ProvidersInventoryList, + uid: string, +): ProviderInventory { + if (!inventory || !uid) { + return undefined; + } + + const providers = [ + ...inventory.openshift, + ...inventory.openstack, + ...inventory.ovirt, + ...inventory.vsphere, + ]; + return providers.find((provider) => provider.uid === uid); +} diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/helpers/getCachedData.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/helpers/getCachedData.ts new file mode 100644 index 000000000..5d06a9837 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/helpers/getCachedData.ts @@ -0,0 +1,25 @@ +/** + * Fetches cached inventory data if it's still valid. + * @param {string} cacheKey - The key used to store inventory data in cache. + * @param {number} cacheExpiryDuration - The duration till cache is valid. + * @returns {T | null} - The cached inventory data if valid, null otherwise. + */ +export function getCachedData(cacheKey: string, cacheExpiryDuration: number): T | null { + if (cacheExpiryDuration < 1) { + return null; + } + + const cacheData = sessionStorage.getItem(cacheKey); + if (cacheData) { + const { data, timestamp } = JSON.parse(cacheData); + + // If cache is not expired, return data + if (Date.now() - timestamp < cacheExpiryDuration) { + return data; + } + } + + return null; +} + +export default getCachedData; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/helpers/getInventoryApiUrl.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/helpers/getInventoryApiUrl.ts new file mode 100644 index 000000000..f323e3523 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/helpers/getInventoryApiUrl.ts @@ -0,0 +1,12 @@ +/** + * Provides API url for getting inventory. + * + * @param {string} relativePath - An optional relative path to append to the URL + * @returns {string} - The API URL for getting inventory + */ +export const getInventoryApiUrl = (relativePath = ''): string => { + const pluginPath = `/api/proxy/plugin/${process.env.PLUGIN_NAME}`; + const inventoryPath = '/forklift-inventory'; + + return `${pluginPath}${inventoryPath}/${relativePath}`; +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/helpers/getIsManaged.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/helpers/getIsManaged.ts new file mode 100644 index 000000000..8ec51d1f0 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/helpers/getIsManaged.ts @@ -0,0 +1,12 @@ +import { V1beta1Provider } from '@kubev2v/types'; + +/** + * Checks if the provider is managed or not. + * + * @param {V1beta1Provider} provider - The provider to be checked. + * @returns {boolean} - Returns true if the provider is managed, false otherwise. + */ +export function getIsManaged(provider: V1beta1Provider): boolean { + const ownerReferences = provider?.metadata?.ownerReferences || []; + return ownerReferences.length > 0; +} diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/helpers/getIsTarget.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/helpers/getIsTarget.ts new file mode 100644 index 000000000..39481cd77 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/helpers/getIsTarget.ts @@ -0,0 +1,24 @@ +import { ProviderType, V1beta1Provider } from '@kubev2v/types'; + +/** + * Checks if the provider is a target provider or not. + * + * @param {V1beta1Provider} provider - The provider to be checked. + * @returns {boolean} - Returns true if the provider is a target provider, false otherwise. + */ +export function getIsTarget(provider: V1beta1Provider): boolean { + return TARGET_PROVIDER_TYPES.includes(provider?.spec.type as ProviderType); +} + +/** + * Checks if the provider is only a source provider or not. + * + * @param {V1beta1Provider} provider - The provider to be checked. + * @returns {boolean} - Returns true if the provider is a target provider, false otherwise. + */ +export function getIsOnlySource(provider: V1beta1Provider): boolean { + return SOURCE_ONLY_PROVIDER_TYPES.includes(provider?.spec.type as ProviderType); +} + +export const SOURCE_ONLY_PROVIDER_TYPES: ProviderType[] = ['vsphere', 'ovirt', 'openstack']; +export const TARGET_PROVIDER_TYPES: ProviderType[] = ['openshift']; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/helpers/getResourceUrl.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/helpers/getResourceUrl.ts new file mode 100644 index 000000000..f35e73933 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/helpers/getResourceUrl.ts @@ -0,0 +1,31 @@ +import { K8sGroupVersionKind } from '@openshift-console/dynamic-plugin-sdk'; + +/** + * Provides resource url. + * + * @param {GetResourceUrlProps} param0 - An object of GetResourceUrlProps + * @returns {string} - The resource URL + */ +export const getResourceUrl = ({ + reference, + groupVersionKind, + namespaced = true, + namespace, + name, +}: GetResourceUrlProps): string => { + const ns = namespace ? `ns/${namespace}` : 'all-namespaces'; + const resourcePath = namespaced ? ns : 'cluster'; + const reference_ = + reference || `${groupVersionKind.group}~${groupVersionKind.version}~${groupVersionKind.kind}`; + const name_ = name ? `/${encodeURIComponent(name)}` : ''; + + return `/k8s/${resourcePath}/${reference_}${name_}`; +}; + +interface GetResourceUrlProps { + reference?: string; + groupVersionKind?: K8sGroupVersionKind; + namespaced?: boolean; + namespace?: string; + name?: string; +} diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/helpers/getValueByJsonPath.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/helpers/getValueByJsonPath.ts new file mode 100644 index 000000000..9a32377d4 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/helpers/getValueByJsonPath.ts @@ -0,0 +1,32 @@ +/** + * Retrieves the deep value of an object given a JSON path. + * + * @param obj - The object to retrieve the value from. + * @param path - The JSON path (dot notation) to the property. + * @returns The value at the given path, or undefined if the path doesn't exist. + */ +export function getValueByJsonPath(obj: T, path: string | string[]): unknown { + let pathParts = []; + + if (typeof path === 'string') { + pathParts = path.split('.'); + } else { + pathParts = path; + } + + return pathParts.reduce((o, key) => o?.[key], obj); +} + +export function jsonPathToPatch(path: string | string[]) { + let pathParts = []; + + if (typeof path === 'string') { + pathParts = path.split('.'); + } else { + pathParts = path; + } + + pathParts = pathParts.map((o) => o.replaceAll('/', '~1')); + + return `/${pathParts.join('/')}`; +} diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/helpers/hasObjectChangedInGivenFields.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/helpers/hasObjectChangedInGivenFields.ts new file mode 100644 index 000000000..0e37cffb7 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/helpers/hasObjectChangedInGivenFields.ts @@ -0,0 +1,28 @@ +import { getValueByJsonPath } from './getValueByJsonPath'; + +type FieldsComparisonArgs = { + oldObject?: T; + newObject?: T; + fieldsToCompare: string[]; +}; + +/** + * Checks whether the specified fields have changed in two objects. + * + * @param params - An object containing the old object, new object, and fields to be compared. + * @returns A boolean indicating whether any of the specified fields have changed. + */ +export function hasObjectChangedInGivenFields(params: FieldsComparisonArgs): boolean { + if (!params?.oldObject && !params?.newObject) { + return false; + } + + if (!params?.oldObject || !params?.newObject) { + return true; + } + + return params.fieldsToCompare.some( + (field) => + getValueByJsonPath(params.oldObject, field) !== getValueByJsonPath(params.newObject, field), + ); +} diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/helpers/index.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/helpers/index.ts new file mode 100644 index 000000000..e4d03306e --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/helpers/index.ts @@ -0,0 +1,14 @@ +// @index(['./*.tsx', './*.ts', /__/g], f => `export * from '${f.path}';`) +export * from './findInventoryByID'; +export * from './getCachedData'; +export * from './getInventoryApiUrl'; +export * from './getIsManaged'; +export * from './getIsTarget'; +export * from './getResourceUrl'; +export * from './getValueByJsonPath'; +export * from './hasObjectChangedInGivenFields'; +export * from './isSecretDataChanged'; +export * from './missingKeysInSecretData'; +export * from './safeBase64Decode'; +export * from './setCachedData'; +// @endindex diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/helpers/isSecretDataChanged.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/helpers/isSecretDataChanged.ts new file mode 100644 index 000000000..4f18c726a --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/helpers/isSecretDataChanged.ts @@ -0,0 +1,35 @@ +import { V1Secret } from '@kubev2v/types'; + +/** + * Compares the data records between two versions of a secret. + * + * @param {V1Secret} secret1 - The first version of the secret. + * @param {V1Secret} secret2 - The second version of the secret. + * @returns {boolean} Returns true if the data records have changed, otherwise returns false. + */ +export function isSecretDataChanged(secret1: V1Secret, secret2: V1Secret): boolean { + // Both secrets don't have data records + if (!secret1.data && !secret2.data) { + return false; + } + + // One of the secrets doesn't have data records + if (!secret1.data || !secret2.data) { + return true; + } + + // Both secrets have data records, but the number of records is different + if (Object.keys(secret1.data).length !== Object.keys(secret2.data).length) { + return true; + } + + // Compare each data record + for (const key in secret1.data) { + if (secret1.data[key] !== secret2.data[key]) { + return true; + } + } + + // No differences found + return false; +} diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/helpers/missingKeysInSecretData.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/helpers/missingKeysInSecretData.ts new file mode 100644 index 000000000..de2da5ff6 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/helpers/missingKeysInSecretData.ts @@ -0,0 +1,31 @@ +import { Base64 } from 'js-base64'; + +import { V1Secret } from '@kubev2v/types'; + +/** + * Checks if a list of keys exist in a secret's data, and verifies they are not null or empty strings. + * + * @param {V1Secret} secret - The secret to be checked. + * @param {string[]} keys - The list of keys to check. + * @returns {string[]} Returns a list of missing keys in secret data. + */ +export function missingKeysInSecretData(secret: V1Secret, keys: string[]): string[] { + // If secret or secret's data is not defined, return false + if (!secret?.data) { + return keys; + } + + const missing: string[] = []; + + for (const key of keys) { + const secretValue = secret.data[key] && Base64.decode(secret.data[key]); + + // Check if the key exists and is not null or empty string + if (!secretValue || secretValue.trim() === '') { + missing.push(key); + } + } + + // All keys exist and are not null or empty string + return missing; +} diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/helpers/safeBase64Decode.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/helpers/safeBase64Decode.ts new file mode 100644 index 000000000..11224454f --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/helpers/safeBase64Decode.ts @@ -0,0 +1,9 @@ +import { Base64 } from 'js-base64'; + +export function safeBase64Decode(value: string) { + try { + return Base64.decode(value); + } catch { + return ''; + } +} diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/helpers/setCachedData.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/helpers/setCachedData.ts new file mode 100644 index 000000000..768265267 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/helpers/setCachedData.ts @@ -0,0 +1,15 @@ +/** + * Saves the inventory data to cache. + * @param {string} cacheKey - The key used to store inventory data in cache. + * @param {T} data - The inventory data to be cached. + */ +export function setCachedData(cacheKey: string, data: T): void { + const cacheData = { + data, + timestamp: Date.now(), + }; + + sessionStorage.setItem(cacheKey, JSON.stringify(cacheData)); +} + +export default setCachedData; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/index.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/index.ts new file mode 100644 index 000000000..84510f407 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/index.ts @@ -0,0 +1,6 @@ +// @index(['./*', /__/g], f => `export * from '${f.path}';`) +export * from './components'; +export * from './helpers'; +export * from './types'; +export * from './validators'; +// @endindex diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/types/ProviderData.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/types/ProviderData.ts new file mode 100644 index 000000000..6470b5b35 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/types/ProviderData.ts @@ -0,0 +1,9 @@ +import { ProviderInventory, V1beta1Provider } from '@kubev2v/types'; + +import { ProvidersPermissionStatus } from './ProvidersPermissionStatus'; + +export interface ProviderData { + provider?: V1beta1Provider; + inventory?: ProviderInventory; + permissions?: ProvidersPermissionStatus; +} diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/types/ProvidersPermissionStatus.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/types/ProvidersPermissionStatus.ts new file mode 100644 index 000000000..09b27865a --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/types/ProvidersPermissionStatus.ts @@ -0,0 +1,17 @@ +/** + * Type for the return value of useAccessReviewProviders hook. + * + * @typedef {Object} ProvidersPermissionStatus + * @property {boolean} canCreate - Permission to create a resource. + * @property {boolean} canPatch - Permission to patch a resource. + * @property {boolean} canDelete - Permission to delete a resource. + * @property {boolean} canGet - Permission to get a resource. + * @property {boolean} loading - Flag indicating if any access review is pending. + */ +export type ProvidersPermissionStatus = { + canCreate: boolean; + canPatch: boolean; + canDelete: boolean; + canGet: boolean; + loading: boolean; +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/types/Validation.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/types/Validation.ts new file mode 100644 index 000000000..7416895b7 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/types/Validation.ts @@ -0,0 +1 @@ +export type Validation = 'default' | 'success' | 'warning' | 'error'; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/types/index.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/types/index.ts new file mode 100644 index 000000000..db58b583c --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/types/index.ts @@ -0,0 +1,5 @@ +// @index('./*.ts', f => `export * from '${f.path}';`) +export * from './ProviderData'; +export * from './ProvidersPermissionStatus'; +export * from './Validation'; +// @endindex diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/validators/common.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/validators/common.ts new file mode 100644 index 000000000..b18885009 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/validators/common.ts @@ -0,0 +1,78 @@ +// regex + +// validate container images +// example: quay.io/image:latest +const REGISTRY = '(?:[a-z0-9]+([.:_-][a-z0-9]+)*\\/)?'; +const IMAGE_NAME = '[a-z0-9]+([._-][a-z0-9]+)*(\\/[a-z0-9]+([._-][a-z0-9]+)*)*'; +const TAG = '[a-zA-Z0-9]+([._-][a-zA-Z0-9]+)*'; +const SHA256 = 'sha256:[A-Fa-f0-9]{64}'; + +const IMAGE_REGEX = new RegExp(`^${REGISTRY}?${IMAGE_NAME}((@${SHA256}|:${TAG}))?$`); + +// validate URL +// example: https://example.com/index +const PROTOCOL = '(https?:\\/\\/)'; +const IPV4 = '((?:[0-9]{1,3}\\.){3}[0-9]{1,3})'; +const HOSTNAME = '([a-zA-Z-_]+[a-zA-Z0-9-_]+\\.[a-zA-Z0-9-_\\.]+)'; +const PORT = '(:[0-9]+)?'; +const PATH = '(\\/[^ ]*)*'; +const QUERY_PARAMS = '(\\?[a-zA-Z0-9=&_]*)?'; + +const URL_REGEX = new RegExp( + `^${PROTOCOL}((${IPV4})|(${HOSTNAME}))((${PORT})(${PATH})?(${QUERY_PARAMS})?)?$`, +); + +// validate CA certification. +const CERTIFICATE_HEADER = '-----BEGIN CERTIFICATE-----'; +const CERTIFICATE_FOOTER = '-----END CERTIFICATE-----'; +const BASE64_LINE = '([A-Za-z0-9+\\/]{64}\\r?\\n)'; +const LAST_BASE64_LINE = '([A-Za-z0-9+\\/=]{1,64}\\r?\\n)?'; +const BASE64_CONTENT = `(${BASE64_LINE}*${LAST_BASE64_LINE})`; + +const EMPTY_LINES = '((\\#[^\\r\\n]*)?\\s*\\r?\\n)*'; + +const CERTIFICATE_REGEX = new RegExp( + `^(${EMPTY_LINES}${CERTIFICATE_HEADER}\\r?\\n${BASE64_CONTENT}${CERTIFICATE_FOOTER}${EMPTY_LINES})+$`, +); + +// validate CA certification fingerprint. +const FINGERPRINT_REGEX = /^([a-fA-F0-9]{2}:){19}[a-fA-F0-9]{2}$/; + +// validate sub domain names, used in K8s +const DNS_SUBDOMAINS_NAME_REGEXP = /^[a-z][a-z0-9-]{0,251}[a-z0-9]$/; + +// validate bearer tokens, used in K8s +const JWT_TOKEN_REGEX = /^([a-zA-Z0-9_=]+)\.([a-zA-Z0-9_=]+)\.([a-zA-Z0-9_\-+/=]*)/gm; +const K8S_TOKEN_REGEX = /^[a-z0-9]{6}.[a-z0-9]{16}$/; + +// helper methods + +export function validateContainerImage(image: string) { + return IMAGE_REGEX.test(image); +} + +export function validateURL(url: string) { + return URL_REGEX.test(url); +} + +export function validatePublicCert(ca: string) { + return CERTIFICATE_REGEX.test(ca); +} + +export function validateFingerprint(fingerprint: string) { + return FINGERPRINT_REGEX.test(fingerprint); +} + +export function validateK8sName(k8sName: string) { + return DNS_SUBDOMAINS_NAME_REGEXP.test(k8sName); +} + +export function validateK8sToken(token: string) { + return JWT_TOKEN_REGEX.test(token) || K8S_TOKEN_REGEX.test(token); +} + +export function validateNoSpaces(value: string) { + // any string without spaces + // max length 128 chars + return /^[^\s]{1,128}$/.test(value); +} diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/validators/index.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/validators/index.ts new file mode 100644 index 000000000..4fcf50c41 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/validators/index.ts @@ -0,0 +1,6 @@ +// @index(['./*', /__/g], f => `export * from '${f.path}';`) +export * from './common'; +export * from './provider'; +export * from './secret'; +export * from './secret-fields'; +// @endindex diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/validators/provider/index.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/validators/provider/index.ts new file mode 100644 index 000000000..7a8a85642 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/validators/provider/index.ts @@ -0,0 +1,7 @@ +// @index(['./*.tsx', './*.ts', /__/g], f => `export * from '${f.path}';`) +export * from './openshiftProviderValidator'; +export * from './openstackProviderValidator'; +export * from './ovirtProviderValidator'; +export * from './providerValidator'; +export * from './vsphereProviderValidator'; +// @endindex diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/validators/provider/openshiftProviderValidator.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/validators/provider/openshiftProviderValidator.ts new file mode 100644 index 000000000..c00f5383c --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/validators/provider/openshiftProviderValidator.ts @@ -0,0 +1,18 @@ +import { V1beta1Provider } from '@kubev2v/types'; + +import { validateK8sName, validateURL } from '../common'; + +export function openshiftProviderValidator(provider: V1beta1Provider) { + const name = provider?.metadata?.name; + const url = provider?.spec?.url || ''; + + if (!validateK8sName(name)) { + return new Error('invalided kubernetes resource name'); + } + + if (url !== '' && !validateURL(url)) { + return new Error('invalided URL'); + } + + return null; +} diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/validators/provider/openstackProviderValidator.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/validators/provider/openstackProviderValidator.ts new file mode 100644 index 000000000..c3ff1e97f --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/validators/provider/openstackProviderValidator.ts @@ -0,0 +1,18 @@ +import { V1beta1Provider } from '@kubev2v/types'; + +import { validateK8sName, validateURL } from '../common'; + +export function openstackProviderValidator(provider: V1beta1Provider) { + const name = provider?.metadata?.name; + const url = provider?.spec?.url || ''; + + if (!validateK8sName(name)) { + return new Error('invalided kubernetes resource name'); + } + + if (!validateURL(url)) { + return new Error('invalided URL'); + } + + return null; +} diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/validators/provider/ovirtProviderValidator.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/validators/provider/ovirtProviderValidator.ts new file mode 100644 index 000000000..9fb96b4bc --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/validators/provider/ovirtProviderValidator.ts @@ -0,0 +1,18 @@ +import { V1beta1Provider } from '@kubev2v/types'; + +import { validateK8sName, validateURL } from '../common'; + +export function ovirtProviderValidator(provider: V1beta1Provider) { + const name = provider?.metadata?.name; + const url = provider?.spec?.url || ''; + + if (!validateK8sName(name)) { + return new Error('invalided kubernetes resource name'); + } + + if (!validateURL(url)) { + return new Error('invalided URL'); + } + + return null; +} diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/validators/provider/providerValidator.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/validators/provider/providerValidator.ts new file mode 100644 index 000000000..4f34f903b --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/validators/provider/providerValidator.ts @@ -0,0 +1,29 @@ +import { V1beta1Provider } from '@kubev2v/types'; + +import { openshiftProviderValidator } from './openshiftProviderValidator'; +import { openstackProviderValidator } from './openstackProviderValidator'; +import { ovirtProviderValidator } from './ovirtProviderValidator'; +import { vsphereProviderValidator } from './vsphereProviderValidator'; + +export function providerValidator(provider: V1beta1Provider) { + let validationError = null; + + switch (provider.spec.type) { + case 'openshift': + validationError = openshiftProviderValidator(provider); + break; + case 'openstack': + validationError = openstackProviderValidator(provider); + break; + case 'ovirt': + validationError = ovirtProviderValidator(provider); + break; + case 'vsphere': + validationError = vsphereProviderValidator(provider); + break; + default: + validationError = new Error('bad provider type'); + } + + return validationError; +} diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/validators/provider/vsphereProviderValidator.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/validators/provider/vsphereProviderValidator.ts new file mode 100644 index 000000000..dee75acc5 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/validators/provider/vsphereProviderValidator.ts @@ -0,0 +1,23 @@ +import { V1beta1Provider } from '@kubev2v/types'; + +import { validateContainerImage, validateK8sName, validateURL } from '../common'; + +export function vsphereProviderValidator(provider: V1beta1Provider) { + const name = provider?.metadata?.name; + const url = provider?.spec?.url || ''; + const vddkInitImage = provider?.spec?.settings?.['vddkInitImage'] || ''; + + if (!validateK8sName(name)) { + return new Error('invalided kubernetes resource name'); + } + + if (!validateURL(url)) { + return new Error('invalided URL'); + } + + if (vddkInitImage !== '' && !validateContainerImage(vddkInitImage)) { + return new Error('invalided VDDK Init Image'); + } + + return null; +} diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/validators/providerAndSecretValidator.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/validators/providerAndSecretValidator.ts new file mode 100644 index 000000000..4474e5440 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/validators/providerAndSecretValidator.ts @@ -0,0 +1,19 @@ +import { V1beta1Provider, V1Secret } from '@kubev2v/types'; + +import { providerValidator } from './provider/providerValidator'; +import { secretValidator } from './secret/secretValidator'; + +export function providerAndSecretValidator(provider: V1beta1Provider, secret: V1Secret) { + const providerValidation = providerValidator(provider); + if (providerValidation) { + return providerValidation; + } + + const type = provider?.spec?.type || ''; + const secretValidation = secretValidator(type, secret); + if (secretValidation) { + return secretValidation; + } + + return null; +} diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/validators/secret-fields/index.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/validators/secret-fields/index.ts new file mode 100644 index 000000000..dd4fb7808 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/validators/secret-fields/index.ts @@ -0,0 +1,6 @@ +// @index(['./*.tsx', './*.ts', /__/g], f => `export * from '${f.path}';`) +export * from './openshiftSecretFieldValidator'; +export * from './openstackSecretFieldValidator'; +export * from './ovirtSecretFieldValidator'; +export * from './vsphereSecretFieldValidator'; +// @endindex diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/validators/secret-fields/openshiftSecretFieldValidator.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/validators/secret-fields/openshiftSecretFieldValidator.ts new file mode 100644 index 000000000..371947bb6 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/validators/secret-fields/openshiftSecretFieldValidator.ts @@ -0,0 +1,30 @@ +import { Validation } from '../../types'; +import { validateK8sToken } from '../common'; + +/** + * Validates form input fields based on their id. + * + * @param {string} id - The ID of the form field. + * @param {string} value - The value of the form field. + * + * @return {Validation} - The validation state of the form field. Can be one of the following: + * 'default' - The default state of the form field, used when the field is empty or a value hasn't been entered yet. + * 'success' - The field's value has passed validation. + * 'error' - The field's value has failed validation. + */ +export const openshiftSecretFieldValidator = (id: string, value: string) => { + const trimmedValue = value.trim(); + + let validationState: Validation; + + switch (id) { + case 'token': + validationState = validateK8sToken(trimmedValue) ? 'success' : 'error'; + break; + default: + validationState = 'default'; + break; + } + + return validationState; +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/validators/secret-fields/openstackSecretFieldValidator.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/validators/secret-fields/openstackSecretFieldValidator.ts new file mode 100644 index 000000000..12f7fca4b --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/validators/secret-fields/openstackSecretFieldValidator.ts @@ -0,0 +1,125 @@ +import { Validation } from '../../types'; +import { validateNoSpaces, validatePublicCert } from '../common'; + +/** + * Validates form input fields based on their id. + * + * @param {string} id - The ID of the form field. + * @param {string} value - The value of the form field. + * + * @return {Validation} - The validation state of the form field. Can be one of the following: + * 'default' - The default state of the form field, used when the field is empty or a value hasn't been entered yet. + * 'success' - The field's value has passed validation. + * 'error' - The field's value has failed validation. + */ +export const openstackSecretFieldValidator = (id: string, value: string) => { + const trimmedValue = value.trim(); + + let validationState: Validation; + + switch (id) { + case 'username': + validationState = validateUsername(trimmedValue) ? 'success' : 'error'; + break; + case 'password': + validationState = validatePassword(trimmedValue) ? 'success' : 'error'; + break; + case 'regionName': + validationState = validateRegionName(trimmedValue) ? 'success' : 'error'; + break; + case 'projectName': + validationState = validateProjectName(trimmedValue) ? 'success' : 'error'; + break; + case 'domainName': + validationState = validateDomainName(trimmedValue) ? 'success' : 'error'; + break; + case 'token': + validationState = validateToken(trimmedValue) ? 'success' : 'error'; + break; + case 'userID': + validationState = validateUserID(trimmedValue) ? 'success' : 'error'; + break; + case 'projectID': + validationState = validateProjectID(trimmedValue) ? 'success' : 'error'; + break; + case 'userDomainName': + validationState = validateUserDomainName(trimmedValue) ? 'success' : 'error'; + break; + case 'applicationCredentialID': + validationState = validateApplicationCredentialID(trimmedValue) ? 'success' : 'error'; + break; + case 'applicationCredentialSecret': + validationState = validateApplicationCredentialSecret(trimmedValue) ? 'success' : 'error'; + break; + case 'applicationCredentialName': + validationState = validateApplicationCredentialName(trimmedValue) ? 'success' : 'error'; + break; + case 'insecureSkipVerify': + validationState = validateInsecureSkipVerify(trimmedValue) ? 'success' : 'error'; + break; + case 'cacert': + validationState = validateCacert(trimmedValue) ? 'success' : 'error'; + break; + default: + validationState = 'default'; + break; + } + + return validationState; +}; + +const validateUsername = (value: string) => { + return validateNoSpaces(value); +}; + +const validatePassword = (value: string) => { + return validateNoSpaces(value); +}; + +const validateRegionName = (value: string) => { + return validateNoSpaces(value); +}; + +const validateProjectName = (value: string) => { + return validateNoSpaces(value); +}; + +const validateDomainName = (value: string) => { + return validateNoSpaces(value); +}; + +const validateToken = (value: string) => { + return validateNoSpaces(value); +}; + +const validateUserID = (value: string) => { + return validateNoSpaces(value); +}; + +const validateProjectID = (value: string) => { + return validateNoSpaces(value); +}; + +const validateUserDomainName = (value: string) => { + return validateNoSpaces(value); +}; + +const validateApplicationCredentialID = (value: string) => { + return validateNoSpaces(value); +}; + +const validateApplicationCredentialSecret = (value: string) => { + return validateNoSpaces(value); +}; + +const validateApplicationCredentialName = (value: string) => { + return validateNoSpaces(value); +}; + +const validateInsecureSkipVerify = (value: string) => { + return ['true', 'false', ''].includes(value); +}; + +const validateCacert = (value: string) => { + return value === '' || validatePublicCert(value); +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/validators/secret-fields/ovirtSecretFieldValidator.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/validators/secret-fields/ovirtSecretFieldValidator.ts new file mode 100644 index 000000000..4ec5efba5 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/validators/secret-fields/ovirtSecretFieldValidator.ts @@ -0,0 +1,57 @@ +import { Validation } from '../../types'; +import { validateNoSpaces, validatePublicCert } from '../common'; + +/** + * Validates form input fields based on their id. + * + * @param {string} id - The ID of the form field. + * @param {string} value - The value of the form field. + * + * @return {Validation} - The validation state of the form field. Can be one of the following: + * 'default' - The default state of the form field, used when the field is empty or a value hasn't been entered yet. + * 'success' - The field's value has passed validation. + * 'error' - The field's value has failed validation. + */ +export const ovirtSecretFieldValidator = (id: string, value: string) => { + const trimmedValue = value.trim(); + + let validationState: Validation; + + switch (id) { + case 'user': + validationState = validateUser(trimmedValue) ? 'success' : 'error'; + break; + case 'password': + validationState = validatePassword(trimmedValue) ? 'success' : 'error'; + break; + case 'insecureSkipVerify': + validationState = 'default'; + break; + case 'cacert': + validationState = validateCacert(trimmedValue); + break; + default: + validationState = 'default'; + break; + } + + return validationState; +}; + +const validateUser = (value: string) => { + return validateNoSpaces(value); +}; + +const validatePassword = (value: string) => { + return validateNoSpaces(value); +}; + +const validateCacert = (value: string) => { + if (value === '') { + return 'default'; + } else if (validatePublicCert(value)) { + return 'success'; + } else { + return 'error'; + } +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/validators/secret-fields/vsphereSecretFieldValidator.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/validators/secret-fields/vsphereSecretFieldValidator.ts new file mode 100644 index 000000000..30894fda3 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/validators/secret-fields/vsphereSecretFieldValidator.ts @@ -0,0 +1,47 @@ +import { Validation } from '../../types'; +import { validateFingerprint, validateNoSpaces } from '../common'; + +/** + * Validates form input fields based on their id. + * + * @param {string} id - The ID of the form field. + * @param {string} value - The value of the form field. + * + * @return {Validation} - The validation state of the form field. Can be one of the following: + * 'default' - The default state of the form field, used when the field is empty or a value hasn't been entered yet. + * 'success' - The field's value has passed validation. + * 'error' - The field's value has failed validation. + */ +export const vsphereSecretFieldValidator = (id: string, value: string) => { + const trimmedValue = value.trim(); + + let validationState: Validation; + + switch (id) { + case 'user': + validationState = validateUser(trimmedValue) ? 'success' : 'error'; + break; + case 'password': + validationState = validatePassword(trimmedValue) ? 'success' : 'error'; + break; + case 'insecureSkipVerify': + validationState = 'default'; + break; + case 'thumbprint': + validationState = validateFingerprint(trimmedValue) ? 'success' : 'error'; + break; + default: + validationState = 'default'; + break; + } + + return validationState; +}; + +const validateUser = (value: string) => { + return validateNoSpaces(value); +}; + +const validatePassword = (value: string) => { + return validateNoSpaces(value); +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/validators/secret/index.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/validators/secret/index.ts new file mode 100644 index 000000000..cb6277b4e --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/validators/secret/index.ts @@ -0,0 +1,7 @@ +// @index(['./*.tsx', './*.ts', /__/g], f => `export * from '${f.path}';`) +export * from './openshiftSecretValidator'; +export * from './openstackSecretValidator'; +export * from './ovirtSecretValidator'; +export * from './secretValidator'; +export * from './vsphereSecretValidator'; +// @endindex diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/validators/secret/openshiftSecretValidator.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/validators/secret/openshiftSecretValidator.ts new file mode 100644 index 000000000..da5497626 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/validators/secret/openshiftSecretValidator.ts @@ -0,0 +1,26 @@ +import { Base64 } from 'js-base64'; + +import { V1Secret } from '@kubev2v/types'; + +import { missingKeysInSecretData } from '../../helpers'; +import { openshiftSecretFieldValidator } from '../secret-fields'; + +export function openshiftSecretValidator(secret: V1Secret) { + const requiredFields = ['token']; + const validateFields = ['token']; + + const missingRequiredFields = missingKeysInSecretData(secret, requiredFields); + if (missingRequiredFields.length > 0) { + return new Error(`missing required fields [${missingRequiredFields.join(', ')}]`); + } + + for (const id of validateFields) { + const value = Base64.decode(secret?.data?.[id] || ''); + + if (openshiftSecretFieldValidator(id, value) === 'error') { + return new Error(`invalid ${id}`); + } + } + + return null; +} diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/validators/secret/openstackSecretValidator.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/validators/secret/openstackSecretValidator.ts new file mode 100644 index 000000000..d32a7c0d8 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/validators/secret/openstackSecretValidator.ts @@ -0,0 +1,86 @@ +import { V1Secret } from '@kubev2v/types'; + +import { missingKeysInSecretData, safeBase64Decode } from '../../helpers'; +import { openstackSecretFieldValidator } from '../secret-fields'; + +export function openstackSecretValidator(secret: V1Secret) { + const authType = safeBase64Decode(secret?.data?.['authType']) || 'password'; + + let requiredFields = []; + let validateFields = []; + + // guess authenticationType based on authType and username + switch (authType) { + case 'password': + requiredFields = ['username', 'password', 'regionName', 'projectName', 'domainName']; + validateFields = [ + 'username', + 'password', + 'regionName', + 'projectName', + 'domainName', + 'cacert', + 'insecureSkipVerify', + ]; + break; + case 'token': + if (secret?.data?.['username']) { + requiredFields = ['token', 'username', 'projectName', 'userDomainName']; + validateFields = [ + 'token', + 'username', + 'projectName', + 'userDomainName', + 'cacert', + 'insecureSkipVerify', + ]; + } else { + requiredFields = ['token', 'userID', 'projectID']; + validateFields = ['token', 'userID', 'projectID', 'cacert', 'insecureSkipVerify']; + } + break; + case 'applicationcredential': + if (secret?.data?.['username']) { + requiredFields = [ + 'applicationCredentialName', + 'applicationCredentialSecret', + 'username', + 'domainName', + ]; + validateFields = [ + 'applicationCredentialName', + 'applicationCredentialSecret', + 'username', + 'domainName', + 'cacert', + 'insecureSkipVerify', + ]; + } else { + requiredFields = ['applicationCredentialID', 'applicationCredentialSecret']; + validateFields = [ + 'applicationCredentialID', + 'applicationCredentialSecret', + 'cacert', + 'insecureSkipVerify', + ]; + } + break; + default: + return new Error(`invalid authType`); + } + + const missingRequiredFields = missingKeysInSecretData(secret, requiredFields); + if (missingRequiredFields.length > 0) { + return new Error(`missing required fields [${missingRequiredFields.join(', ')}]`); + } + + for (const id of validateFields) { + const value = safeBase64Decode(secret?.data?.[id] || ''); + + if (openstackSecretFieldValidator(id, value) === 'error') { + return new Error(`invalid ${id}`); + } + } + + return null; +} diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/validators/secret/ovirtSecretValidator.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/validators/secret/ovirtSecretValidator.ts new file mode 100644 index 000000000..682cb77d5 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/validators/secret/ovirtSecretValidator.ts @@ -0,0 +1,26 @@ +import { Base64 } from 'js-base64'; + +import { V1Secret } from '@kubev2v/types'; + +import { missingKeysInSecretData } from '../../helpers'; +import { ovirtSecretFieldValidator } from '../secret-fields'; + +export function ovirtSecretValidator(secret: V1Secret) { + const requiredFields = ['user', 'password']; + const validateFields = ['user', 'password', 'cacert']; + + const missingRequiredFields = missingKeysInSecretData(secret, requiredFields); + if (missingRequiredFields.length > 0) { + return new Error(`missing required fields [${missingRequiredFields.join(', ')}]`); + } + + for (const id of validateFields) { + const value = Base64.decode(secret?.data?.[id] || ''); + + if (ovirtSecretFieldValidator(id, value) === 'error') { + return new Error(`invalid ${id}`); + } + } + + return null; +} diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/validators/secret/secretValidator.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/validators/secret/secretValidator.ts new file mode 100644 index 000000000..1c7eaef0e --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/validators/secret/secretValidator.ts @@ -0,0 +1,29 @@ +import { V1Secret } from '@kubev2v/types'; + +import { openshiftSecretValidator } from './openshiftSecretValidator'; +import { openstackSecretValidator } from './openstackSecretValidator'; +import { ovirtSecretValidator } from './ovirtSecretValidator'; +import { vsphereSecretValidator } from './vsphereSecretValidator'; + +export function secretValidator(type: string, secret: V1Secret) { + let validationError = null; + + switch (type) { + case 'openshift': + validationError = openshiftSecretValidator(secret); + break; + case 'openstack': + validationError = openstackSecretValidator(secret); + break; + case 'ovirt': + validationError = ovirtSecretValidator(secret); + break; + case 'vsphere': + validationError = vsphereSecretValidator(secret); + break; + default: + validationError = new Error('bad provider type'); + } + + return validationError; +} diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/validators/secret/vsphereSecretValidator.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/validators/secret/vsphereSecretValidator.ts new file mode 100644 index 000000000..a824d0285 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/utils/validators/secret/vsphereSecretValidator.ts @@ -0,0 +1,26 @@ +import { Base64 } from 'js-base64'; + +import { V1Secret } from '@kubev2v/types'; + +import { missingKeysInSecretData } from '../../helpers'; +import { vsphereSecretFieldValidator } from '../secret-fields'; + +export function vsphereSecretValidator(secret: V1Secret) { + const requiredFields = ['user', 'password', 'thumbprint']; + const validateFields = ['user', 'password', 'thumbprint']; + + const missingRequiredFields = missingKeysInSecretData(secret, requiredFields); + if (missingRequiredFields.length > 0) { + return new Error(`missing required fields [${missingRequiredFields.join(', ')}]`); + } + + for (const id of validateFields) { + const value = Base64.decode(secret?.data?.[id] || ''); + + if (vsphereSecretFieldValidator(id, value) === 'error') { + return new Error(`invalid ${id}`); + } + } + + return null; +} diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/create/ProvidersCreatePage.style.css b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/create/ProvidersCreatePage.style.css new file mode 100644 index 000000000..9366ffee6 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/create/ProvidersCreatePage.style.css @@ -0,0 +1,3 @@ +.forklift-create-provider-edit-section { + padding-top: var(--pf-global--spacer--md); +} \ No newline at end of file diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/create/ProvidersCreatePage.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/create/ProvidersCreatePage.tsx new file mode 100644 index 000000000..b59390447 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/create/ProvidersCreatePage.tsx @@ -0,0 +1,271 @@ +import React, { useReducer } from 'react'; +import { useHistory } from 'react-router'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { ProviderModelRef, V1beta1Provider, V1Secret } from '@kubev2v/types'; +import { + Alert, + Button, + Divider, + Flex, + FlexItem, + HelperText, + HelperTextItem, + PageSection, + Title, +} from '@patternfly/react-core'; + +import { useK8sWatchProviderNames } from '../../hooks'; +import { getResourceUrl, Validation } from '../../utils'; +import { providerAndSecretValidator } from '../../utils/validators/providerAndSecretValidator'; + +import { ProvidersCreateForm } from './components'; +import { providerTemplate, secretTemplate } from './templates'; +import { createProvider, createSecret, patchSecretOwner } from './utils'; + +import './ProvidersCreatePage.style.css'; + +interface ProvidersCreatePageState { + newSecret: V1Secret; + newProvider: V1beta1Provider; + validationError: Error | null; + apiError: Error | null; + validation: { + name: Validation; + }; +} + +export const ProvidersCreatePage: React.FC<{ + namespace: string; +}> = ({ namespace }) => { + const { t } = useForkliftTranslation(); + const history = useHistory(); + + const [providerNames] = useK8sWatchProviderNames({ namespace }); + + const initialState: ProvidersCreatePageState = { + newSecret: { + ...secretTemplate, + metadata: { ...secretTemplate.metadata, namespace: namespace || 'default' }, + }, + newProvider: { + ...providerTemplate, + metadata: { ...providerTemplate.metadata, namespace: namespace || 'default' }, + }, + validationError: new Error('Missing provider name'), + apiError: null, + validation: { + name: 'default', + }, + }; + + function reducer( + state: ProvidersCreatePageState, + action: { type: string; payload?: string | V1Secret | V1beta1Provider }, + ): ProvidersCreatePageState { + switch (action.type) { + case 'SET_NEW_SECRET': { + const value = action.payload as V1beta1Provider; + let validationError = providerAndSecretValidator(state.newProvider, value); + + if (providerNames.includes(state.newProvider?.metadata?.name)) { + validationError = new Error('new provider name is not unique'); + } + + if (!state.newProvider?.metadata?.name) { + validationError = new Error('Missing provider name'); + } + + return { + ...state, + validationError: validationError, + newSecret: value, + apiError: null, + }; + } + case 'SET_NEW_PROVIDER': { + const value = action.payload as V1beta1Provider; + let validationError = providerAndSecretValidator(value, state.newSecret); + + if (providerNames.includes(value?.metadata?.name)) { + validationError = new Error('new provider name is not unique'); + } + + if (!value?.metadata?.name) { + validationError = new Error('Missing provider name'); + } + + return { + ...state, + validationError: validationError, + newProvider: value, + apiError: null, + }; + } + case 'SET_API_ERROR': { + const value = action.payload as Error | null; + return { ...state, apiError: value }; + } + default: + return state; + } + } + + const [state, dispatch] = useReducer(reducer, initialState); + + if (!state.newSecret) { + return {t('No credentials found.')}; + } + + // Handle user edits + function onNewSecretChange(newValue: V1Secret) { + // update staged secret with new value + dispatch({ type: 'SET_NEW_SECRET', payload: newValue }); + } + + // Handle user edits + function onNewProviderChange(newValue: V1beta1Provider) { + // update staged provider with new value + dispatch({ type: 'SET_NEW_PROVIDER', payload: newValue }); + } + + // Handle user clicking "save" + async function onUpdate() { + let secret: V1Secret; + let provider: V1beta1Provider; + + // try to generate a secret with data + // + // add generateName using provider name as prefix + // add createdForProviderType label + // add url + try { + secret = await createSecret(state.newProvider, state.newSecret); + } catch (err) { + dispatch({ + type: 'SET_API_ERROR', + payload: err, + }); + + return; + } + + // try to create a provider with secret + // add spec.secret + try { + provider = await createProvider(state.newProvider, secret); + } catch (err) { + dispatch({ + type: 'SET_API_ERROR', + payload: err, + }); + + return; + } + + // set secret ownership using provider uid + try { + await patchSecretOwner(provider, secret); + } catch (err) { + dispatch({ + type: 'SET_API_ERROR', + payload: err, + }); + + return; + } + + // go to providers derails page + const providerURL = getResourceUrl({ + reference: ProviderModelRef, + namespace: namespace, + name: provider.metadata.name, + }); + + history.push(providerURL); + } + + const providersListURL = getResourceUrl({ + reference: ProviderModelRef, + namespace: namespace, + }); + + return ( +
+ + {t('Create Provider')} + + + + {t( + 'Create by using the form or manually entering YAML or JSON definitions, Provider CR stores attributes that enable MTV to connect to and interact with the source and target providers.', + )} + + + + + + + + + + + + + + {state.validationError ? ( + + {state.validationError.toString()} + + ) : ( + {t('Create new provider')} + )} + + + + + {state.apiError && ( + + {state.apiError.message || state.apiError.toString()} + + )} + + {!namespace && ( + + {t( + 'This provider will be created in the default namespace, if you wish to choose another namespace please cancel, and choose a namespace from the top bar.', + )} + + )} + + + +
+ ); +}; + +export default ProvidersCreatePage; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/create/components/EditProvider.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/create/components/EditProvider.tsx new file mode 100644 index 000000000..aca1167e1 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/create/components/EditProvider.tsx @@ -0,0 +1,54 @@ +import React from 'react'; + +import { + OpenshiftCredentialsEdit, + OpenstackCredentialsEdit, + OvirtCredentialsEdit, + VSphereCredentialsEdit, +} from '../../details'; + +import { OpenshiftProviderFormCreate } from './OpenshiftProviderCreateForm'; +import { OpenstackProviderCreateForm } from './OpenstackProviderCreateForm'; +import { OvirtProviderCreateForm } from './OvirtProviderCreateForm'; +import { ProvidersCreateFormProps } from './ProviderCreateForm'; +import { VSphereProviderCreateForm } from './VSphereProviderCreateForm'; + +export const EditProvider: React.FC = ({ + newProvider, + newSecret, + onNewProviderChange, + onNewSecretChange, +}) => { + switch (newProvider?.spec?.type) { + case 'openstack': + return ( + <> + + + + ); + case 'openshift': + return ( + <> + + + + ); + case 'ovirt': + return ( + <> + + + + ); + case 'vsphere': + return ( + <> + + + + ); + default: + return <>; + } +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/create/components/OpenshiftProviderCreateForm.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/create/components/OpenshiftProviderCreateForm.tsx new file mode 100644 index 000000000..9a21898a2 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/create/components/OpenshiftProviderCreateForm.tsx @@ -0,0 +1,79 @@ +import React, { useCallback, useReducer } from 'react'; +import { validateURL, Validation } from 'src/modules/ProvidersNG/utils'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { V1beta1Provider } from '@kubev2v/types'; +import { Form, FormGroup, TextInput } from '@patternfly/react-core'; + +export interface OpenshiftProviderCreateFormProps { + provider: V1beta1Provider; + onChange: (newValue: V1beta1Provider) => void; +} + +export const OpenshiftProviderFormCreate: React.FC = ({ + provider, + onChange, +}) => { + const { t } = useForkliftTranslation(); + + const url = provider?.spec?.url || ''; + + const initialState = { + validation: { + url: 'default' as Validation, + }, + }; + + const reducer = (state, action) => { + switch (action.type) { + case 'SET_FIELD_VALIDATED': + return { + ...state, + validation: { + ...state.validation, + [action.payload.field]: action.payload.validationState, + }, + }; + default: + return state; + } + }; + + const [state, dispatch] = useReducer(reducer, initialState); + + const handleChange = useCallback( + (id, value) => { + const trimmedValue = value.trim(); + + if (id === 'url') { + const validationState = + trimmedValue === '' || validateURL(trimmedValue) ? 'success' : 'error'; + dispatch({ type: 'SET_FIELD_VALIDATED', payload: { field: id, validationState } }); + + onChange({ ...provider, spec: { ...provider.spec, url: trimmedValue } }); + } + }, + [provider], + ); + + return ( +
+ + handleChange('url', value)} + /> + +
+ ); +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/create/components/OpenstackProviderCreateForm.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/create/components/OpenstackProviderCreateForm.tsx new file mode 100644 index 000000000..81ca779aa --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/create/components/OpenstackProviderCreateForm.tsx @@ -0,0 +1,80 @@ +import React, { useCallback, useReducer } from 'react'; +import { validateURL, Validation } from 'src/modules/ProvidersNG/utils'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { V1beta1Provider } from '@kubev2v/types'; +import { Form, FormGroup, TextInput } from '@patternfly/react-core'; + +export interface OpenstackProviderCreateFormProps { + provider: V1beta1Provider; + onChange: (newValue: V1beta1Provider) => void; +} + +export const OpenstackProviderCreateForm: React.FC = ({ + provider, + onChange, +}) => { + const { t } = useForkliftTranslation(); + + const url = provider?.spec?.url || ''; + + const initialState = { + validation: { + url: 'default' as Validation, + }, + }; + + const reducer = (state, action) => { + switch (action.type) { + case 'SET_FIELD_VALIDATED': + return { + ...state, + validation: { + ...state.validation, + [action.payload.field]: action.payload.validationState, + }, + }; + default: + return state; + } + }; + + const [state, dispatch] = useReducer(reducer, initialState); + + const handleChange = useCallback( + (id, value) => { + const trimmedValue = value.trim(); + + if (id === 'url') { + const validationState = validateURL(trimmedValue) ? 'success' : 'error'; + dispatch({ type: 'SET_FIELD_VALIDATED', payload: { field: id, validationState } }); + + onChange({ ...provider, spec: { ...provider.spec, url: trimmedValue } }); + } + }, + [provider], + ); + + return ( +
+ + handleChange('url', value)} + /> + +
+ ); +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/create/components/OvirtProviderCreateForm.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/create/components/OvirtProviderCreateForm.tsx new file mode 100644 index 000000000..1791a169d --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/create/components/OvirtProviderCreateForm.tsx @@ -0,0 +1,80 @@ +import React, { useCallback, useReducer } from 'react'; +import { validateURL, Validation } from 'src/modules/ProvidersNG/utils'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { V1beta1Provider } from '@kubev2v/types'; +import { Form, FormGroup, TextInput } from '@patternfly/react-core'; + +export interface OvirtProviderCreateFormProps { + provider: V1beta1Provider; + onChange: (newValue: V1beta1Provider) => void; +} + +export const OvirtProviderCreateForm: React.FC = ({ + provider, + onChange, +}) => { + const { t } = useForkliftTranslation(); + + const url = provider?.spec?.url || ''; + + const initialState = { + validation: { + url: 'default' as Validation, + }, + }; + + const reducer = (state, action) => { + switch (action.type) { + case 'SET_FIELD_VALIDATED': + return { + ...state, + validation: { + ...state.validation, + [action.payload.field]: action.payload.validationState, + }, + }; + default: + return state; + } + }; + + const [state, dispatch] = useReducer(reducer, initialState); + + const handleChange = useCallback( + (id, value) => { + const trimmedValue = value.trim(); + + if (id === 'url') { + const validationState = validateURL(trimmedValue) ? 'success' : 'error'; + dispatch({ type: 'SET_FIELD_VALIDATED', payload: { field: id, validationState } }); + + onChange({ ...provider, spec: { ...provider.spec, url: trimmedValue } }); + } + }, + [provider], + ); + + return ( +
+ + handleChange('url', value)} + /> + +
+ ); +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/create/components/ProviderCreateForm.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/create/components/ProviderCreateForm.tsx new file mode 100644 index 000000000..2aad3c1bb --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/create/components/ProviderCreateForm.tsx @@ -0,0 +1,136 @@ +import React, { useReducer } from 'react'; +import { Base64 } from 'js-base64'; +import { validateK8sName, Validation } from 'src/modules/ProvidersNG/utils'; +import { SelectableCard } from 'src/modules/ProvidersNG/utils/components/Galerry/SelectableCard'; +import { SelectableGallery } from 'src/modules/ProvidersNG/utils/components/Galerry/SelectableGallery'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { ProviderType, V1beta1Provider, V1Secret } from '@kubev2v/types'; +import { Flex, FlexItem, Form, FormGroup, TextInput } from '@patternfly/react-core'; + +import { EditProvider } from './EditProvider'; +import { providerCardItems } from './providerCardItems'; + +export interface ProvidersCreateFormProps { + newProvider: V1beta1Provider; + newSecret: V1Secret; + onNewProviderChange: (V1beta1Provider) => void; + onNewSecretChange: (V1Secret) => void; + providerNames?: string[]; +} + +export const ProvidersCreateForm: React.FC = ({ + newProvider, + newSecret, + onNewProviderChange, + onNewSecretChange, + providerNames = [], +}) => { + const { t } = useForkliftTranslation(); + + const initialState = { + validation: { + name: 'default', + }, + }; + + const [state, dispatch] = useReducer((state, action) => { + switch (action.type) { + case 'SET_VALIDATION': + return { ...state, validation: action.payload }; + default: + return state; + } + }, initialState); + + const handleNameChange = (name: string) => { + const trimmedValue = name.trim(); + const validation: Validation = + !providerNames.includes(trimmedValue) && validateK8sName(trimmedValue) ? 'success' : 'error'; + + dispatch({ + type: 'SET_VALIDATION', + payload: { name: validation }, + }); + + onNewProviderChange({ + ...newProvider, + metadata: { ...newProvider?.metadata, name: trimmedValue }, + }); + }; + + const handleTypeChange = (type: ProviderType) => { + // default auth type for openstack (if not defined) + if (type === 'openstack' && !newSecret?.data?.authType) { + onNewSecretChange({ + ...newSecret, + data: { ...newSecret.data, authType: Base64.encode('applicationcredential') }, + }); + } + + onNewProviderChange({ ...newProvider, spec: { ...newProvider?.spec, type: type } }); + }; + + return ( + <> +
+
+ + handleNameChange(value)} // Call the custom handler method + /> + +
+ +
+ + {newProvider?.spec?.type ? ( + + + handleTypeChange(null)} + isSelected={true} + /> + + + ) : ( + + )} + +
+
+ +
+ +
+ + ); +}; + +export default ProvidersCreateForm; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/create/components/VSphereProviderCreateForm.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/create/components/VSphereProviderCreateForm.tsx new file mode 100644 index 000000000..3a22bea66 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/create/components/VSphereProviderCreateForm.tsx @@ -0,0 +1,120 @@ +import React, { useCallback, useReducer } from 'react'; +import { validateContainerImage, validateURL, Validation } from 'src/modules/ProvidersNG/utils'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { V1beta1Provider } from '@kubev2v/types'; +import { Form, FormGroup, TextInput } from '@patternfly/react-core'; + +export interface VSphereProviderCreateFormProps { + provider: V1beta1Provider; + onChange: (newValue: V1beta1Provider) => void; +} + +export const VSphereProviderCreateForm: React.FC = ({ + provider, + onChange, +}) => { + const { t } = useForkliftTranslation(); + + const url = provider?.spec?.url || ''; + const vddkInitImage = provider?.spec?.settings?.['vddkInitImage'] || ''; + + const initialState = { + validation: { + url: 'default' as Validation, + vddkInitImage: 'default' as Validation, + }, + }; + + const reducer = (state, action) => { + switch (action.type) { + case 'SET_FIELD_VALIDATED': + return { + ...state, + validation: { + ...state.validation, + [action.payload.field]: action.payload.validationState, + }, + }; + default: + return state; + } + }; + + const [state, dispatch] = useReducer(reducer, initialState); + + const handleChange = useCallback( + (id, value) => { + const trimmedValue = value.trim(); + + if (id == 'vddkInitImage') { + const validationState = + trimmedValue == '' || validateContainerImage(trimmedValue) ? 'success' : 'error'; + dispatch({ type: 'SET_FIELD_VALIDATED', payload: { field: id, validationState } }); + + onChange({ + ...provider, + spec: { + type: provider.spec.type, + url: provider.spec.url, + ...provider?.spec, + settings: { + ...(provider?.spec?.settings as object), + vddkInitImage: value.trim(), + }, + }, + }); + } + + if (id === 'url') { + const validationState = validateURL(trimmedValue) ? 'success' : 'error'; + dispatch({ type: 'SET_FIELD_VALIDATED', payload: { field: id, validationState } }); + + onChange({ ...provider, spec: { ...provider.spec, url: trimmedValue } }); + } + }, + [provider], + ); + + return ( +
+ + handleChange('url', value)} + /> + + + + handleChange('vddkInitImage', value)} + /> + +
+ ); +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/create/components/index.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/create/components/index.ts new file mode 100644 index 000000000..a421326e6 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/create/components/index.ts @@ -0,0 +1,8 @@ +// @index(['./*', /style/g], f => `export * from '${f.path}';`) +export * from './OpenshiftProviderCreateForm'; +export * from './OpenstackProviderCreateForm'; +export * from './OvirtProviderCreateForm'; +export * from './providerCardItems'; +export * from './ProviderCreateForm'; +export * from './VSphereProviderCreateForm'; +// @endindex diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/create/components/providerCardItems.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/create/components/providerCardItems.ts new file mode 100644 index 000000000..69d249e95 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/create/components/providerCardItems.ts @@ -0,0 +1,20 @@ +import { SelectableGalleryItem } from 'src/modules/ProvidersNG/utils/components/Galerry/SelectableGallery'; + +export const providerCardItems: Record = { + openshift: { + title: 'OpenShift Virtualization', + content: 'OpenShift Virtualization run and manage virtual machine in Openshift.', + }, + openstack: { + title: 'OpenStack', + content: 'OpenStack is a cloud computing platform that controls large pools of resources.', + }, + ovirt: { + title: 'Red Hat Virtualization', + content: 'Red Hat Virtualization (RHV) is a virtualization platform from Red Hat.', + }, + vsphere: { + title: 'vSphere', + content: "vSphere is VMware's cloud computing virtualization platform.", + }, +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/create/index.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/create/index.ts new file mode 100644 index 000000000..11b1670bc --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/create/index.ts @@ -0,0 +1,6 @@ +// @index(['./*', /style/g], f => `export * from '${f.path}';`) +export * from './components'; +export * from './ProvidersCreatePage'; +export * from './templates'; +export * from './utils'; +// @endindex diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/create/templates/index.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/create/templates/index.ts new file mode 100644 index 000000000..9408052f7 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/create/templates/index.ts @@ -0,0 +1,4 @@ +// @index(['./*', /style/g], f => `export * from '${f.path}';`) +export * from './providerTemplate'; +export * from './secretTemplate'; +// @endindex diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/create/templates/providerTemplate.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/create/templates/providerTemplate.ts new file mode 100644 index 000000000..1810ac939 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/create/templates/providerTemplate.ts @@ -0,0 +1,18 @@ +import { V1beta1Provider } from '@kubev2v/types'; + +export const providerTemplate: V1beta1Provider = { + apiVersion: 'forklift.konveyor.io/v1beta1', + kind: 'Provider', + metadata: { + name: undefined, + namespace: undefined, + }, + spec: { + secret: { + name: undefined, + namespace: undefined, + }, + type: undefined, + url: undefined, + }, +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/create/templates/secretTemplate.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/create/templates/secretTemplate.ts new file mode 100644 index 000000000..df3173227 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/create/templates/secretTemplate.ts @@ -0,0 +1,12 @@ +import { V1Secret } from '@kubev2v/types'; + +export const secretTemplate: V1Secret = { + kind: 'Secret', + apiVersion: 'v1', + metadata: { + name: undefined, + namespace: undefined, + }, + data: undefined, + type: 'Opaque', +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/create/utils/createProvider.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/create/utils/createProvider.ts new file mode 100644 index 000000000..8658a2ff0 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/create/utils/createProvider.ts @@ -0,0 +1,38 @@ +import { ProviderModel, V1beta1Provider, V1Secret } from '@kubev2v/types'; +import { k8sCreate } from '@openshift-console/dynamic-plugin-sdk'; + +/** + * Creates a new provider with the specified secret information. + * + * @param {V1beta1Provider} provider - The provider object to be cloned and modified. + * @param {V1Secret} secret - The secret object used to update the provider's secret information. + * @returns {Promise} A Promise that resolves to the created provider object. + * + * @async + * @throws Will throw an error if the k8sCreate operation fails. + * + * @example + * + * const provider = { metadata: { name: 'my-provider' }, spec: {}}; + * const secret = { metadata: { name: 'my-secret', namespace: 'my-namespace' }}; + * + * createProvider(provider, secret) + * .then(newProvider => console.log(newProvider)) + * .catch(err => console.error(err)); + */ +export async function createProvider(provider: V1beta1Provider, secret: V1Secret) { + const newProvider: V1beta1Provider = { + ...provider, + spec: { + ...provider?.spec, + secret: { name: secret?.metadata?.name, namespace: secret?.metadata?.namespace }, + }, + }; + + const obj = await k8sCreate({ + model: ProviderModel, + data: newProvider, + }); + + return obj; +} diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/create/utils/createSecret.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/create/utils/createSecret.ts new file mode 100644 index 000000000..e0d97235a --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/create/utils/createSecret.ts @@ -0,0 +1,49 @@ +import { Base64 } from 'js-base64'; + +import { SecretModel, V1beta1Provider, V1Secret } from '@kubev2v/types'; +import { k8sCreate } from '@openshift-console/dynamic-plugin-sdk'; + +/** + * Creates a new Kubernetes secret using the provided provider and secret data. + * + * @param {V1beta1Provider} provider - The provider object which includes metadata and spec information. + * @param {V1Secret} secret - The base secret object to be cloned and modified. + * @returns {Promise} A Promise that resolves to the created Kubernetes secret object. + * + * @async + * @throws Will throw an error if the k8sCreate operation fails. + * + * @example + * + * const provider = { metadata: { name: 'my-provider', namespace: 'my-namespace' }, spec: { type: 'my-type', url: 'http://example.com' }}; + * const secret = { metadata: { namespace: 'my-namespace' }, data: {}}; + * + * createSecret(provider, secret) + * .then(newSecret => console.log(newSecret)) + * .catch(err => console.error(err)); + */ +export async function createSecret(provider: V1beta1Provider, secret: V1Secret) { + const url = provider?.spec?.url; + const encodedURL = url ? Base64.encode(url) : undefined; + const generateName = `${provider.metadata.name}-`; + + const newSecret: V1Secret = { + ...secret, + metadata: { + ...secret?.metadata, + generateName: generateName, + labels: { + ...secret?.metadata?.labels, + createdForProviderType: provider?.spec?.type, + }, + }, + data: { ...secret?.data, url: encodedURL }, + }; + + const obj = await k8sCreate({ + model: SecretModel, + data: newSecret, + }); + + return obj; +} diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/create/utils/index.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/create/utils/index.ts new file mode 100644 index 000000000..fd3702eda --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/create/utils/index.ts @@ -0,0 +1,5 @@ +// @index(['./*', /style/g], f => `export * from '${f.path}';`) +export * from './createProvider'; +export * from './createSecret'; +export * from './patchSecretOwner'; +// @endindex diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/create/utils/patchSecretOwner.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/create/utils/patchSecretOwner.ts new file mode 100644 index 000000000..d1b66637f --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/create/utils/patchSecretOwner.ts @@ -0,0 +1,43 @@ +import { SecretModel, V1beta1Provider, V1Secret } from '@kubev2v/types'; +import { k8sPatch } from '@openshift-console/dynamic-plugin-sdk'; + +/** + * Updates the owner reference of the specified secret to point to the provided provider. + * + * @param {V1beta1Provider} provider - The provider object to be set as the owner of the secret. + * @param {V1Secret} secret - The secret object to be updated with the provider's owner reference. + * + * @async + * @throws Will throw an error if the k8sPatch operation fails. + * + * @example + * + * const provider = { metadata: { name: 'my-provider', uid: 'uid-123' }}; + * const secret = { metadata: {}, data: {}}; + * + * patchSecretOwner(provider, secret) + * .then(() => console.log('Secret owner patched successfully')) + * .catch(err => console.error(err)); + */ +export async function patchSecretOwner(provider: V1beta1Provider, secret: V1Secret) { + const op = secret?.metadata?.ownerReferences ? 'replace' : 'add'; + + await k8sPatch({ + model: SecretModel, + resource: secret, + data: [ + { + op, + path: '/metadata/ownerReferences', + value: [ + { + apiVersion: 'forklift.konveyor.io/v1beta1', + kind: 'Provider', + name: provider.metadata.name, + uid: provider.metadata.uid, + }, + ], + }, + ], + }); +} diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/ProviderDetailsPage.style.css b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/ProviderDetailsPage.style.css new file mode 100644 index 000000000..ccda3b670 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/ProviderDetailsPage.style.css @@ -0,0 +1,7 @@ +.forklift-page-headings-alerts { + padding-left: 0; +} + +.forklift-page-section { + border-top: 1px solid var(--pf-global--BorderColor--100); +} diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/ProviderDetailsPage.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/ProviderDetailsPage.tsx new file mode 100644 index 000000000..342210136 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/ProviderDetailsPage.tsx @@ -0,0 +1,155 @@ +import React from 'react'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { + ProviderInventory, + ProviderModel, + ProviderModelGroupVersionKind, + V1beta1Provider, +} from '@kubev2v/types'; +import { + HorizontalNav, + K8sModel, + useK8sWatchResource, +} from '@openshift-console/dynamic-plugin-sdk'; +import { PageSection } from '@patternfly/react-core'; + +import { ProviderActionsDropdown } from '../../actions'; +import { useGetDeleteAndEditAccessReview, useProviderInventory } from '../../hooks'; +import { PageHeadings } from '../../utils'; + +import { + ProviderCredentials, + ProviderDetails, + ProviderHosts, + ProviderNetworks, + ProviderVirtualMachines, + ProviderYAMLPage, +} from './tabs'; + +import './ProviderDetailsPage.style.css'; + +export const ProviderDetailsPage: React.FC = ({ name, namespace }) => { + const { t } = useForkliftTranslation(); + + const [provider, providerLoaded, providerLoadError] = useK8sWatchResource({ + groupVersionKind: ProviderModelGroupVersionKind, + namespaced: true, + name, + namespace, + }); + + const { inventory } = useProviderInventory({ + provider, + }); + + const permissions = useGetDeleteAndEditAccessReview({ + model: ProviderModel, + namespace, + }); + + const data = { provider, inventory, permissions }; + const alerts = []; + const loaded = providerLoaded; + const loadError = providerLoadError; + const type = provider?.spec?.type; + + const providerHasSecret = provider?.spec?.secret?.name; + const providerHasHosts = ['vsphere'].includes(type); + const ProviderHasVirtualMachines = ['openshift', 'openstack', 'ovirt', 'vsphere'].includes(type); + const providerHasNetworks = ['openshift'].includes(type); + + const pages = [ + { + href: '', + name: t('Details'), + component: () => { + return ; + }, + }, + { + href: 'yaml', + name: t('YAML'), + component: () => { + return ( + + ); + }, + }, + providerHasSecret && { + href: 'credentials', + name: t('Credentials'), + component: () => { + return ( + + ); + }, + }, + + ProviderHasVirtualMachines && { + href: 'vms', + name: t('Virtual Machines'), + component: () => { + return ( + + ); + }, + }, + + providerHasHosts && { + href: 'hosts', + name: t('Hosts'), + component: () => { + return ; + }, + }, + + providerHasNetworks && { + href: 'networks', + name: t('Networks'), + component: () => { + return ( + + ); + }, + }, + ]; + + return ( + <> + } + > + {alerts && alerts.length > 0 && ( + {alerts} + )} + + p)} /> + + ); +}; +ProviderDetailsPage.displayName = 'ProviderDetails'; + +type ProviderDetailsPageProps = { + kind: string; + kindObj: K8sModel; + match: { path: string; url: string; isExact: boolean; params: unknown }; + name: string; + namespace?: string; +}; + +export default ProviderDetailsPage; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/ConditionsSection/ConditionsSection.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/ConditionsSection/ConditionsSection.tsx new file mode 100644 index 000000000..0a4da97ee --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/ConditionsSection/ConditionsSection.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { K8sResourceCondition } from '@kubev2v/types'; +import { Timestamp } from '@openshift-console/dynamic-plugin-sdk'; +import { TableComposable, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; + +/** + * React Component to display a table of conditions. + * + * @component + * @param {ConditionsProps} props - Props for the Conditions component. + * @param {K8sResourceCondition[]} props.conditions - Array of conditions to be displayed. + * @returns {ReactElement} A table displaying the provided conditions. + */ +export const ConditionsSection: React.FC = ({ conditions }) => { + const { t } = useForkliftTranslation(); + + if (!conditions) { + return <>; + } + + const getStatusLabel = (status: string) => { + switch (status) { + case 'True': + return t('True'); + case 'False': + return t('False'); + default: + return status; + } + }; + + return ( + <> + + + + {t('Type')} + {t('Status')} + {t('Updated')} + {t('Reason')} + {t('Message')} + + + + {conditions.map((condition) => ( + + {condition.type} + {getStatusLabel(condition.status)} + + + + {condition.reason} + {condition?.message || '-'} + + ))} + + + + ); +}; + +/** + * Type for the props of the Conditions component. + * + * @typedef {Object} ConditionsProps + * @property {K8sResourceCondition[]} conditions - The conditions to be displayed. + */ +export type ConditionsProps = { + conditions: K8sResourceCondition[]; +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/ConditionsSection/index.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/ConditionsSection/index.ts new file mode 100644 index 000000000..75876a3f6 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/ConditionsSection/index.ts @@ -0,0 +1,3 @@ +// @index(['./*', /style/g], f => `export * from '${f.path}';`) +export * from './ConditionsSection'; +// @endindex diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/CredentialsSection.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/CredentialsSection.tsx new file mode 100644 index 000000000..dcb920434 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/CredentialsSection.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { ProviderData } from 'src/modules/ProvidersNG/utils'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { V1Secret } from '@kubev2v/types'; +import { useK8sWatchResource } from '@openshift-console/dynamic-plugin-sdk'; + +import { OpenshiftCredentialsSection } from './OpenshiftCredentialsSection'; +import { OpenstackCredentialsSection } from './OpenstackCredentialsSection'; +import { OvirtCredentialsSection } from './OvirtCredentialsSection'; +import { VSphereCredentialsSection } from './VSphereCredentialsSection'; + +export const CredentialsSection: React.FC = (props) => { + const { t } = useForkliftTranslation(); + const { data, loaded: providerLoaded, loadError: providerError } = props; + const { provider } = data; + + const [secret, loaded, loadError] = useK8sWatchResource({ + groupVersionKind: { version: 'v1', kind: 'Secret' }, + namespaced: true, + namespace: provider?.spec?.secret?.namespace, + name: provider?.spec?.secret?.name, + }); + + // Checking if all necessary data is available + const isDataLoaded = secret && loaded && !loadError && providerLoaded && !providerError; + const isProviderDataAvailable = provider?.spec?.secret?.name && provider?.spec?.secret?.namespace; + const isSecretDataAvailable = secret?.metadata?.name && secret?.metadata?.namespace; + + // Checking if provider data matches secret data + const doesProviderDataMatchSecret = + secret?.metadata?.name === provider?.spec?.secret?.name && + secret?.metadata?.namespace === provider?.spec?.secret?.namespace; + + if ( + !isDataLoaded || + !isProviderDataAvailable || + !isSecretDataAvailable || + !doesProviderDataMatchSecret + ) { + return ( +
+ {t('No secret found.')} +
+ ); + } + + switch (provider?.spec?.type) { + case 'ovirt': + return ; + case 'openshift': + return ; + case 'openstack': + return ; + case 'vsphere': + return ; + default: + return <>; + } +}; + +export type CredentialsProps = { + data: ProviderData; + loaded: boolean; + loadError: unknown; +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/MaskedData.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/MaskedData.tsx new file mode 100644 index 000000000..9057d9589 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/MaskedData.tsx @@ -0,0 +1,7 @@ +import React from 'react'; + +import { TextInput } from '@patternfly/react-core'; + +export const MaskedData: React.FC = () => { + return ; +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/OpenshiftCredentialsSection.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/OpenshiftCredentialsSection.tsx new file mode 100644 index 000000000..b133d0476 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/OpenshiftCredentialsSection.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { openshiftSecretValidator } from 'src/modules/ProvidersNG/utils'; + +import { + BaseCredentialsSection, + BaseCredentialsSectionProps, +} from './components/BaseCredentialsSection'; +import { OpenshiftCredentialsEdit } from './components/edit/OpenshiftCredentialsEdit'; +import { OpenshiftCredentialsList } from './components/list/OpenshiftCredentialsList'; + +export type OpenshiftCredentialsSectionProps = Omit< + BaseCredentialsSectionProps, + 'ListComponent' | 'EditComponent' | 'validator' +>; + +export const OpenshiftCredentialsSection: React.FC = (props) => { + return ( + + ); +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/OpenstackCredentialsSection.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/OpenstackCredentialsSection.tsx new file mode 100644 index 000000000..c61eac65d --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/OpenstackCredentialsSection.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { openstackSecretValidator } from 'src/modules/ProvidersNG/utils'; + +import { + BaseCredentialsSection, + BaseCredentialsSectionProps, +} from './components/BaseCredentialsSection'; +import { OpenstackCredentialsEdit } from './components/edit/OpenstackCredentialsEdit'; +import { OpenstackCredentialsList } from './components/list/OpenstackCredentialsList'; + +export type OpenstackCredentialsSectionProps = Omit< + BaseCredentialsSectionProps, + 'ListComponent' | 'EditComponent' | 'validator' +>; + +export const OpenstackCredentialsSection: React.FC = (props) => ( + +); diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/OvirtCredentialsSection.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/OvirtCredentialsSection.tsx new file mode 100644 index 000000000..54670dfa9 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/OvirtCredentialsSection.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { ovirtSecretValidator } from 'src/modules/ProvidersNG/utils'; + +import { + BaseCredentialsSection, + BaseCredentialsSectionProps, +} from './components/BaseCredentialsSection'; +import { OvirtCredentialsEdit } from './components/edit/OvirtCredentialsEdit'; +import { OvirtCredentialsList } from './components/list/OvirtCredentialsList'; + +export type OvirtCredentialsSectionProps = Omit< + BaseCredentialsSectionProps, + 'ListComponent' | 'EditComponent' | 'validator' +>; + +export const OvirtCredentialsSection: React.FC = (props) => ( + +); diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/VSphereCredentialsSection.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/VSphereCredentialsSection.tsx new file mode 100644 index 000000000..aa842918d --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/VSphereCredentialsSection.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { vsphereSecretValidator } from 'src/modules/ProvidersNG/utils'; + +import { + BaseCredentialsSection, + BaseCredentialsSectionProps, +} from './components/BaseCredentialsSection'; +import { VSphereCredentialsEdit } from './components/edit/VSphereCredentialsEdit'; +import { VSphereCredentialsList } from './components/list/VSphereCredentialsList'; + +export type VSphereCredentialsSectionProps = Omit< + BaseCredentialsSectionProps, + 'ListComponent' | 'EditComponent' | 'validator' +>; + +export const VSphereCredentialsSection: React.FC = (props) => ( + +); diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/components/BaseCredentialsSection.style.css b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/components/BaseCredentialsSection.style.css new file mode 100644 index 000000000..b94dfceb3 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/components/BaseCredentialsSection.style.css @@ -0,0 +1,45 @@ +.forklift-page-secret-title-div { + padding-top: var(--pf-global--spacer--sm); + padding-bottom: var(--pf-global--spacer--xm); +} + +.forklift-page-secret-content-div{ + padding-top: var(--pf-global--spacer--sm); + padding-bottom: var(--pf-global--spacer--xm); +} + +.forklift-page-secret-title { + margin-top: var(--pf-global--spacer--sm); + margin-bottom: 0; +} + +.forklift-page-secret-subtitle { + margin-top: var(--pf-global--spacer--xs); + margin-bottom: var(--pf-global--spacer--md); + color: var(--pf-global--secondary-color--100); +} + +.forklift-page-details-edit-pencil{ + color: var(--pf-c-button--m-plain--Color); +} + +.forklift-section-secret-content-div{ + padding-top: var(--pf-global--spacer--md); +} + +.forklift-section-secret-edit{ + padding-top: var(--pf-global--spacer--md); +} + +.forklift-form-section-heading { + border-top: 1px solid var(--pf-global--BorderColor--100); +} + +.forklift-create-subtitle { + padding-bottom: var(--pf-global--spacer--md); +} + +.forklift-create-subtitle-errors { + padding-top: var(--pf-global--spacer--xs); + padding-bottom: var(--pf-global--spacer--sm); +} \ No newline at end of file diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/components/BaseCredentialsSection.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/components/BaseCredentialsSection.tsx new file mode 100644 index 000000000..c3efd2cad --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/components/BaseCredentialsSection.tsx @@ -0,0 +1,211 @@ +import React, { ReactNode, useReducer } from 'react'; +import { AlertMessageForModals } from 'src/modules/ProvidersNG/modals'; +import { isSecretDataChanged, ProviderData } from 'src/modules/ProvidersNG/utils'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { V1Secret } from '@kubev2v/types'; +import { + Button, + Divider, + Flex, + FlexItem, + HelperText, + HelperTextItem, +} from '@patternfly/react-core'; +import EyeIcon from '@patternfly/react-icons/dist/esm/icons/eye-icon'; +import EyeSlashIcon from '@patternfly/react-icons/dist/esm/icons/eye-slash-icon'; +import Pencil from '@patternfly/react-icons/dist/esm/icons/pencil-alt-icon'; + +import { patchSecretData } from './edit'; + +import './BaseCredentialsSection.style.css'; + +export interface ListComponentProps { + secret: V1Secret; + reveal: boolean; +} + +export interface EditComponentProps { + secret: V1Secret; + onChange: (newValue: V1Secret) => void; +} + +/** + * Represents the state of the secret edit form. + * + * @typedef {Object} BaseCredentialsSecretState + * @property {boolean} reveal - Determines whether the secret's values are visible. + * @property {boolean} edit - Determines whether the secret is currently being edited. + * @property {V1Secret} newSecret - The new version of the secret being edited. + * @property {boolean} dataChanged - Determines whether the secret's data has changed. + * @property {boolean} dataIsValid - Determines whether the new secret's data is valid. + * @property {ReactNode} alertMessage - The message to display when a validation error occurs. + */ +export interface BaseCredentialsSecretState { + reveal: boolean; + edit: boolean; + newSecret: V1Secret; + dataChanged: boolean; + dataError: Error | null; + alertMessage: ReactNode; +} + +export type BaseCredentialsSectionProps = { + data: ProviderData; + loaded: boolean; + loadError: unknown; + secret: V1Secret; + validator: (secret: V1Secret) => Error | null; + ListComponent: React.FC; + EditComponent: React.FC; +}; + +export const BaseCredentialsSection: React.FC = ({ + secret, + validator, + ListComponent, + EditComponent, +}) => { + const { t } = useForkliftTranslation(); + const initialState: BaseCredentialsSecretState = { + reveal: false, + edit: false, + newSecret: secret, + dataChanged: false, + dataError: null, + alertMessage: null, + }; + + function reducer( + state: BaseCredentialsSecretState, + action: { type: string; payload?: V1Secret }, + ): BaseCredentialsSecretState { + switch (action.type) { + case 'TOGGLE_REVEAL': + return { ...state, reveal: !state.reveal }; + case 'TOGGLE_EDIT': + return { ...state, edit: !state.edit }; + case 'SET_NEW_SECRET': { + const dataChanged = isSecretDataChanged(secret, action.payload); + const validationError = validator(action.payload); + + return { + ...state, + dataChanged, + dataError: validationError, + newSecret: action.payload, + alertMessage: null, + }; + } + case 'SET_ALERT_MESSAGE': + return { ...state, alertMessage: action.payload }; + default: + return state; + } + } + const [state, dispatch] = useReducer(reducer, initialState); + + if (!secret?.data) { + return {t('No credentials found.')}; + } + + // toggle between view and edit mode + function toggleEdit() { + dispatch({ type: 'TOGGLE_EDIT' }); + } + + // toggle secrets visible and hidden in view mode + function toggleReveal() { + dispatch({ type: 'TOGGLE_REVEAL' }); + } + + // Handle user edits + function onNewSecretChange(newValue: V1Secret) { + // update staged secret with new value + dispatch({ type: 'SET_NEW_SECRET', payload: newValue }); + } + + // Handle user clicking "cancel" + function onCancel() { + // clear changes and return to view mode + dispatch({ type: 'SET_NEW_SECRET', payload: secret }); + toggleEdit(); + } + + // Handle user clicking "save" + async function onUpdate() { + try { + // Patch provider secret, set clean to `true` + // to remove old values from the secret + await patchSecretData(state.newSecret, true); + + toggleEdit(); + } catch (err) { + dispatch({ + type: 'SET_ALERT_MESSAGE', + payload: ( + + ), + }); + } + } + + return state.edit ? ( + <> + + + + + + + + + + + {state.dataError ? ( + {state.dataError.toString()} + ) : ( + + {t( + 'Click the update credentials button to save your changes, button is disabled until a change is detected.', + )} + + )} + + + + + {state.alertMessage} + + + ) : ( + <> + + + + + + + + + + + + ); +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/components/edit/OpenshiftCredentialsEdit.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/components/edit/OpenshiftCredentialsEdit.tsx new file mode 100644 index 000000000..d85ecdeee --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/components/edit/OpenshiftCredentialsEdit.tsx @@ -0,0 +1,101 @@ +import React, { useCallback, useReducer } from 'react'; +import { Base64 } from 'js-base64'; +import { + openshiftSecretFieldValidator, + safeBase64Decode, + Validation, +} from 'src/modules/ProvidersNG/utils'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { Button, Form, FormGroup, TextInput } from '@patternfly/react-core'; +import EyeIcon from '@patternfly/react-icons/dist/esm/icons/eye-icon'; +import EyeSlashIcon from '@patternfly/react-icons/dist/esm/icons/eye-slash-icon'; + +import { EditComponentProps } from '../BaseCredentialsSection'; + +export const OpenshiftCredentialsEdit: React.FC = ({ secret, onChange }) => { + const { t } = useForkliftTranslation(); + + const token = safeBase64Decode(secret?.data?.token || ''); + + const initialState = { + passwordHidden: true, + validation: { + token: 'default' as Validation, + }, + }; + + const reducer = (state, action) => { + switch (action.type) { + case 'SET_FIELD_VALIDATED': + return { + ...state, + validation: { + ...state.validation, + [action.payload.field]: action.payload.validationState, + }, + }; + case 'TOGGLE_PASSWORD_HIDDEN': + return { ...state, passwordHidden: !state.passwordHidden }; + default: + return state; + } + }; + + const [state, dispatch] = useReducer(reducer, initialState); + + // Define handleChange and validation functions + const handleChange = useCallback( + (id, value) => { + const validationState = openshiftSecretFieldValidator(id, value); + dispatch({ type: 'SET_FIELD_VALIDATED', payload: { field: id, validationState } }); + + // don't trim fields that allow spaces + const encodedValue = ['cacert'].includes(id) + ? Base64.encode(value) + : Base64.encode(value.trim()); + + onChange({ ...secret, data: { ...secret.data, [id]: encodedValue } }); + }, + [secret], + ); + + // Handle password hide/reveal click + function togglePasswordHidden() { + dispatch({ type: 'TOGGLE_PASSWORD_HIDDEN' }); + } + + return ( +
+ + handleChange('token', value)} + value={token} + validated={state.validation.token} + /> + + +
+ ); +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/components/edit/OpenstackCredentialsEdit.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/components/edit/OpenstackCredentialsEdit.tsx new file mode 100644 index 000000000..2cd66c2dc --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/components/edit/OpenstackCredentialsEdit.tsx @@ -0,0 +1,255 @@ +import React, { useCallback, useReducer } from 'react'; +import { Base64 } from 'js-base64'; +import { + openstackSecretFieldValidator, + safeBase64Decode, + Validation, +} from 'src/modules/ProvidersNG/utils'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { Checkbox, Divider, FileUpload, Form, FormGroup, Radio } from '@patternfly/react-core'; + +import { EditComponentProps } from '../BaseCredentialsSection'; + +import { + ApplicationCredentialNameSecretFieldsFormGroup, + ApplicationWithCredentialsIDFormGroup, + PasswordSecretFieldsFormGroup, + TokenWithUserIDSecretFieldsFormGroup, + TokenWithUsernameSecretFieldsFormGroup, +} from './OpenstackCredentialsEditFormGroups'; + +export const OpenstackCredentialsEdit: React.FC = ({ secret, onChange }) => { + const { t } = useForkliftTranslation(); + + const authType = safeBase64Decode(secret?.data?.authType || ''); + const username = safeBase64Decode(secret?.data?.username || ''); + const insecureSkipVerify = safeBase64Decode(secret?.data?.insecureSkipVerify || '') === 'true'; + const cacert = safeBase64Decode(secret?.data?.cacert || ''); + + let authenticationType: + | 'passwordSecretFields' + | 'tokenWithUserIDSecretFields' + | 'tokenWithUsernameSecretFields' + | 'applicationCredentialIdSecretFields' + | 'applicationCredentialNameSecretFields'; + + // guess initial authenticationType based on authType and username + switch (authType) { + case 'password': + authenticationType = 'passwordSecretFields'; + break; + case 'token': + if (username) { + authenticationType = 'tokenWithUsernameSecretFields'; + } else { + authenticationType = 'tokenWithUserIDSecretFields'; + } + break; + case 'applicationcredential': + if (username) { + authenticationType = 'applicationCredentialNameSecretFields'; + } else { + authenticationType = 'applicationCredentialIdSecretFields'; + } + break; + default: + authenticationType = 'passwordSecretFields'; + break; + } + + const initialState = { + authenticationType: authenticationType, + validation: { + cacert: 'default' as Validation, + }, + }; + + const reducer = (state, action) => { + switch (action.type) { + case 'SET_FIELD_VALIDATED': + return { + ...state, + validation: { + ...state.validation, + [action.payload.field]: action.payload.validationState, + }, + }; + case 'SET_AUTHENTICATION_TYPE': + return { + ...state, + authenticationType: action.payload, + }; + default: + return state; + } + }; + + const [state, dispatch] = useReducer(reducer, initialState); + + // Define handleChange and validation functions + const handleChange = useCallback( + (id, value) => { + const validationState = openstackSecretFieldValidator(id, value); + dispatch({ type: 'SET_FIELD_VALIDATED', payload: { field: id, validationState } }); + + // don't trim fields that allow spaces + const encodedValue = ['cacert'].includes(id) + ? Base64.encode(value) + : Base64.encode(value.trim()); + + onChange({ ...secret, data: { ...secret.data, [id]: encodedValue } }); + }, + [secret], + ); + + const handleAuthTypeChange = useCallback( + (type: string) => { + dispatch({ type: 'SET_AUTHENTICATION_TYPE', payload: type }); + + switch (type) { + case 'passwordSecretFields': + onChange({ + ...secret, + data: { ...secret.data, ['authType']: Base64.encode('password') }, + }); + break; + case 'tokenWithUserIDSecretFields': + case 'tokenWithUsernameSecretFields': + // on change also clean userID and username + onChange({ + ...secret, + data: { + ...secret.data, + ['authType']: Base64.encode('token'), + userID: '', + username: '', + }, + }); + break; + case 'applicationCredentialIdSecretFields': + case 'applicationCredentialNameSecretFields': + // on change also clean userID and username + onChange({ + ...secret, + data: { + ...secret.data, + ['authType']: Base64.encode('applicationcredential'), + applicationCredentialID: '', + username: '', + }, + }); + break; + } + }, + [secret], + ); + + return ( +
+ + handleAuthTypeChange('passwordSecretFields')} + /> + handleAuthTypeChange('tokenWithUserIDSecretFields')} + /> + handleAuthTypeChange('tokenWithUsernameSecretFields')} + /> + handleAuthTypeChange('applicationCredentialIdSecretFields')} + /> + handleAuthTypeChange('applicationCredentialNameSecretFields')} + /> + + + + + {state.authenticationType === 'passwordSecretFields' && ( + + )} + {state.authenticationType === 'tokenWithUserIDSecretFields' && ( + + )} + {state.authenticationType === 'tokenWithUsernameSecretFields' && ( + + )} + {state.authenticationType === 'applicationCredentialIdSecretFields' && ( + + )} + {state.authenticationType === 'applicationCredentialNameSecretFields' && ( + + )} + + + + + handleChange('insecureSkipVerify', value ? 'true' : 'false')} + /> + + + handleChange('cacert', value)} + onTextChange={(value) => handleChange('cacert', value)} + onClearClick={() => handleChange('cacert', '')} + browseButtonText="Upload" + isDisabled={insecureSkipVerify} + /> + + + ); +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/components/edit/OpenstackCredentialsEditFormGroups/ApplicationCredentialNameSecretFieldsFormGroup.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/components/edit/OpenstackCredentialsEditFormGroups/ApplicationCredentialNameSecretFieldsFormGroup.tsx new file mode 100644 index 000000000..e1eca0fd7 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/components/edit/OpenstackCredentialsEditFormGroups/ApplicationCredentialNameSecretFieldsFormGroup.tsx @@ -0,0 +1,161 @@ +import React, { useCallback, useReducer } from 'react'; +import { Base64 } from 'js-base64'; +import { + openstackSecretFieldValidator, + safeBase64Decode, + Validation, +} from 'src/modules/ProvidersNG/utils'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { Button, FormGroup, TextInput } from '@patternfly/react-core'; +import EyeIcon from '@patternfly/react-icons/dist/esm/icons/eye-icon'; +import EyeSlashIcon from '@patternfly/react-icons/dist/esm/icons/eye-slash-icon'; + +import { EditComponentProps } from '../../BaseCredentialsSection'; + +export const ApplicationCredentialNameSecretFieldsFormGroup: React.FC = ({ + secret, + onChange, +}) => { + const { t } = useForkliftTranslation(); + + const applicationCredentialName = safeBase64Decode(secret?.data?.applicationCredentialName || ''); + const applicationCredentialSecret = safeBase64Decode( + secret?.data?.applicationCredentialSecret || '', + ); + const username = safeBase64Decode(secret?.data?.username || ''); + const domainName = safeBase64Decode(secret?.data?.domainName || ''); + + const initialState = { + passwordHidden: true, + validation: { + applicationCredentialName: 'default' as Validation, + applicationCredentialSecret: 'default' as Validation, + username: 'default' as Validation, + domainName: 'default' as Validation, + }, + }; + + const reducer = (state, action) => { + switch (action.type) { + case 'SET_FIELD_VALIDATED': + return { + ...state, + validation: { + ...state.validation, + [action.payload.field]: action.payload.validationState, + }, + }; + case 'TOGGLE_PASSWORD_HIDDEN': + return { ...state, passwordHidden: !state.passwordHidden }; + default: + return state; + } + }; + + const [state, dispatch] = useReducer(reducer, initialState); + + // Define handleChange and validation functions + const handleChange = useCallback( + (id, value) => { + const validationState = openstackSecretFieldValidator(id, value); + dispatch({ type: 'SET_FIELD_VALIDATED', payload: { field: id, validationState } }); + + const encodedValue = Base64.encode(value.trim()); + onChange({ ...secret, data: { ...secret.data, [id]: encodedValue } }); + }, + [secret, onChange], + ); + + const togglePasswordHidden = () => { + dispatch({ type: 'TOGGLE_PASSWORD_HIDDEN' }); + }; + + return ( + <> + + handleChange('applicationCredentialName', value)} + validated={state.validation.applicationCredentialName} + /> + + + + handleChange('applicationCredentialSecret', value)} + validated={state.validation.applicationCredentialSecret} + /> + + + + + handleChange('username', value)} + validated={state.validation.username} + /> + + + + handleChange('domainName', value)} + validated={state.validation.domainName} + /> + + + ); +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/components/edit/OpenstackCredentialsEditFormGroups/ApplicationWithCredentialsIDFormGroup.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/components/edit/OpenstackCredentialsEditFormGroups/ApplicationWithCredentialsIDFormGroup.tsx new file mode 100644 index 000000000..2350c235c --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/components/edit/OpenstackCredentialsEditFormGroups/ApplicationWithCredentialsIDFormGroup.tsx @@ -0,0 +1,119 @@ +import React, { useCallback, useReducer } from 'react'; +import { Base64 } from 'js-base64'; +import { + openstackSecretFieldValidator, + safeBase64Decode, + Validation, +} from 'src/modules/ProvidersNG/utils'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { Button, FormGroup, TextInput } from '@patternfly/react-core'; +import EyeIcon from '@patternfly/react-icons/dist/esm/icons/eye-icon'; +import EyeSlashIcon from '@patternfly/react-icons/dist/esm/icons/eye-slash-icon'; + +import { EditComponentProps } from '../../BaseCredentialsSection'; + +export const ApplicationWithCredentialsIDFormGroup: React.FC = ({ + secret, + onChange, +}) => { + const { t } = useForkliftTranslation(); + + const applicationCredentialID = safeBase64Decode(secret?.data?.applicationCredentialID || ''); + const applicationCredentialSecret = safeBase64Decode( + secret?.data?.applicationCredentialSecret || '', + ); + + const initialState = { + passwordHidden: true, + validation: { + applicationCredentialID: 'default' as Validation, + applicationCredentialSecret: 'default' as Validation, + }, + }; + + const reducer = (state, action) => { + switch (action.type) { + case 'SET_FIELD_VALIDATED': + return { + ...state, + validation: { + ...state.validation, + [action.payload.field]: action.payload.validationState, + }, + }; + case 'TOGGLE_PASSWORD_HIDDEN': + return { ...state, passwordHidden: !state.passwordHidden }; + default: + return state; + } + }; + + const [state, dispatch] = useReducer(reducer, initialState); + + // Define handleChange and validation functions + const handleChange = useCallback( + (id, value) => { + const validationState = openstackSecretFieldValidator(id, value); + dispatch({ type: 'SET_FIELD_VALIDATED', payload: { field: id, validationState } }); + + const encodedValue = Base64.encode(value.trim()); + onChange({ ...secret, data: { ...secret.data, [id]: encodedValue } }); + }, + [secret, onChange], + ); + + const togglePasswordHidden = () => { + dispatch({ type: 'TOGGLE_PASSWORD_HIDDEN' }); + }; + + return ( + <> + + handleChange('applicationCredentialID', value)} + validated={state.validation.applicationCredentialID} + /> + + + + handleChange('applicationCredentialSecret', value)} + validated={state.validation.applicationCredentialSecret} + /> + + + + ); +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/components/edit/OpenstackCredentialsEditFormGroups/PasswordSecretFieldsFormGroup.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/components/edit/OpenstackCredentialsEditFormGroups/PasswordSecretFieldsFormGroup.tsx new file mode 100644 index 000000000..0514947ff --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/components/edit/OpenstackCredentialsEditFormGroups/PasswordSecretFieldsFormGroup.tsx @@ -0,0 +1,179 @@ +import React, { useCallback, useReducer } from 'react'; +import { Base64 } from 'js-base64'; +import { + openstackSecretFieldValidator, + safeBase64Decode, + Validation, +} from 'src/modules/ProvidersNG/utils'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { Button, FormGroup, TextInput } from '@patternfly/react-core'; +import EyeIcon from '@patternfly/react-icons/dist/esm/icons/eye-icon'; +import EyeSlashIcon from '@patternfly/react-icons/dist/esm/icons/eye-slash-icon'; + +import { EditComponentProps } from '../../BaseCredentialsSection'; + +export const PasswordSecretFieldsFormGroup: React.FC = ({ + secret, + onChange, +}) => { + const { t } = useForkliftTranslation(); + + const username = safeBase64Decode(secret?.data?.username || ''); + const password = safeBase64Decode(secret?.data?.password || ''); + const regionName = safeBase64Decode(secret?.data?.regionName || ''); + const projectName = safeBase64Decode(secret?.data?.projectName || ''); + const domainName = safeBase64Decode(secret?.data?.domainName || ''); + + const initialState = { + passwordHidden: true, + validation: { + username: 'default' as Validation, + password: 'default' as Validation, + regionName: 'default' as Validation, + projectName: 'default' as Validation, + domainName: 'default' as Validation, + }, + }; + + const reducer = (state, action) => { + switch (action.type) { + case 'SET_FIELD_VALIDATED': + return { + ...state, + validation: { + ...state.validation, + [action.payload.field]: action.payload.validationState, + }, + }; + case 'TOGGLE_PASSWORD_HIDDEN': + return { ...state, passwordHidden: !state.passwordHidden }; + default: + return state; + } + }; + + const [state, dispatch] = useReducer(reducer, initialState); + + // Define handleChange and validation functions + const handleChange = useCallback( + (id, value) => { + const validationState = openstackSecretFieldValidator(id, value); + dispatch({ type: 'SET_FIELD_VALIDATED', payload: { field: id, validationState } }); + + const encodedValue = Base64.encode(value.trim()); + onChange({ ...secret, data: { ...secret.data, [id]: encodedValue } }); + }, + [secret, onChange], + ); + + const togglePasswordHidden = () => { + dispatch({ type: 'TOGGLE_PASSWORD_HIDDEN' }); + }; + + return ( + <> + + handleChange('username', value)} + validated={state.validation.username} + /> + + + + handleChange('password', value)} + validated={state.validation.password} + /> + + + + + handleChange('regionName', value)} + validated={state.validation.regionName} + /> + + + + handleChange('projectName', value)} + validated={state.validation.projectName} + /> + + + + handleChange('domainName', value)} + validated={state.validation.domainName} + /> + + + ); +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/components/edit/OpenstackCredentialsEditFormGroups/TokenWithUserIDSecretFieldsFormGroup.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/components/edit/OpenstackCredentialsEditFormGroups/TokenWithUserIDSecretFieldsFormGroup.tsx new file mode 100644 index 000000000..02db6ec63 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/components/edit/OpenstackCredentialsEditFormGroups/TokenWithUserIDSecretFieldsFormGroup.tsx @@ -0,0 +1,135 @@ +import React, { useCallback, useReducer } from 'react'; +import { Base64 } from 'js-base64'; +import { + openstackSecretFieldValidator, + safeBase64Decode, + Validation, +} from 'src/modules/ProvidersNG/utils'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { Button, FormGroup, TextInput } from '@patternfly/react-core'; +import EyeIcon from '@patternfly/react-icons/dist/esm/icons/eye-icon'; +import EyeSlashIcon from '@patternfly/react-icons/dist/esm/icons/eye-slash-icon'; + +import { EditComponentProps } from '../../BaseCredentialsSection'; + +export const TokenWithUserIDSecretFieldsFormGroup: React.FC = ({ + secret, + onChange, +}) => { + const { t } = useForkliftTranslation(); + + const token = safeBase64Decode(secret?.data?.token || ''); + const userID = safeBase64Decode(secret?.data?.userID || ''); + const projectID = safeBase64Decode(secret?.data?.projectID || ''); + + const initialState = { + passwordHidden: true, + validation: { + token: 'default' as Validation, + userID: 'default' as Validation, + projectID: 'default' as Validation, + }, + }; + + const reducer = (state, action) => { + switch (action.type) { + case 'SET_FIELD_VALIDATED': + return { + ...state, + validation: { + ...state.validation, + [action.payload.field]: action.payload.validationState, + }, + }; + case 'TOGGLE_PASSWORD_HIDDEN': + return { ...state, passwordHidden: !state.passwordHidden }; + default: + return state; + } + }; + + const [state, dispatch] = useReducer(reducer, initialState); + + const handleChange = useCallback( + (id, value) => { + const validationState = openstackSecretFieldValidator(id, value); + dispatch({ type: 'SET_FIELD_VALIDATED', payload: { field: id, validationState } }); + + const encodedValue = Base64.encode(value.trim()); + onChange({ ...secret, data: { ...secret.data, [id]: encodedValue } }); + }, + [secret, onChange], + ); + + const togglePasswordHidden = () => { + dispatch({ type: 'TOGGLE_PASSWORD_HIDDEN' }); + }; + + return ( + <> + + handleChange('token', value)} + validated={state.validation.token} + /> + + + + handleChange('userID', value)} + validated={state.validation.userID} + /> + + + handleChange('projectID', value)} + validated={state.validation.projectID} + /> + + + ); +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/components/edit/OpenstackCredentialsEditFormGroups/TokenWithUsernameSecretFieldsFormGroup.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/components/edit/OpenstackCredentialsEditFormGroups/TokenWithUsernameSecretFieldsFormGroup.tsx new file mode 100644 index 000000000..6619aef70 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/components/edit/OpenstackCredentialsEditFormGroups/TokenWithUsernameSecretFieldsFormGroup.tsx @@ -0,0 +1,155 @@ +import React, { useCallback, useReducer } from 'react'; +import { Base64 } from 'js-base64'; +import { + openstackSecretFieldValidator, + safeBase64Decode, + Validation, +} from 'src/modules/ProvidersNG/utils'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { Button, FormGroup, TextInput } from '@patternfly/react-core'; +import EyeIcon from '@patternfly/react-icons/dist/esm/icons/eye-icon'; +import EyeSlashIcon from '@patternfly/react-icons/dist/esm/icons/eye-slash-icon'; + +import { EditComponentProps } from '../../BaseCredentialsSection'; + +export const TokenWithUsernameSecretFieldsFormGroup: React.FC = ({ + secret, + onChange, +}) => { + const { t } = useForkliftTranslation(); + + const token = safeBase64Decode(secret?.data?.token || ''); + const username = safeBase64Decode(secret?.data?.username || ''); + const projectName = safeBase64Decode(secret?.data?.projectName || ''); + const userDomainName = safeBase64Decode(secret?.data?.userDomainName || ''); + + const initialState = { + passwordHidden: true, + validation: { + token: 'default' as Validation, + username: 'default' as Validation, + projectName: 'default' as Validation, + userDomainName: 'default' as Validation, + }, + }; + + const reducer = (state, action) => { + switch (action.type) { + case 'SET_FIELD_VALIDATED': + return { + ...state, + validation: { + ...state.validation, + [action.payload.field]: action.payload.validationState, + }, + }; + case 'TOGGLE_PASSWORD_HIDDEN': + return { ...state, passwordHidden: !state.passwordHidden }; + default: + return state; + } + }; + + const [state, dispatch] = useReducer(reducer, initialState); + + const handleChange = useCallback( + (id, value) => { + const validationState = openstackSecretFieldValidator(id, value); + dispatch({ type: 'SET_FIELD_VALIDATED', payload: { field: id, validationState } }); + + const encodedValue = Base64.encode(value.trim()); + onChange({ ...secret, data: { ...secret.data, [id]: encodedValue } }); + }, + [secret, onChange], + ); + + const togglePasswordHidden = () => { + dispatch({ type: 'TOGGLE_PASSWORD_HIDDEN' }); + }; + + return ( + <> + + handleChange('token', value)} + validated={state.validation.token} + /> + + + + handleChange('username', value)} + validated={state.validation.username} + /> + + + handleChange('projectName', value)} + validated={state.validation.projectName} + /> + + + handleChange('userDomainName', value)} + validated={state.validation.userDomainName} + /> + + + ); +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/components/edit/OpenstackCredentialsEditFormGroups/index.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/components/edit/OpenstackCredentialsEditFormGroups/index.ts new file mode 100644 index 000000000..72c94c1a3 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/components/edit/OpenstackCredentialsEditFormGroups/index.ts @@ -0,0 +1,7 @@ +// @index('./*', f => `export * from '${f.path}';`) +export * from './ApplicationCredentialNameSecretFieldsFormGroup'; +export * from './ApplicationWithCredentialsIDFormGroup'; +export * from './PasswordSecretFieldsFormGroup'; +export * from './TokenWithUserIDSecretFieldsFormGroup'; +export * from './TokenWithUsernameSecretFieldsFormGroup'; +// @endindex diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/components/edit/OvirtCredentialsEdit.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/components/edit/OvirtCredentialsEdit.tsx new file mode 100644 index 000000000..f74cb52ad --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/components/edit/OvirtCredentialsEdit.tsx @@ -0,0 +1,170 @@ +import React, { useCallback, useReducer } from 'react'; +import { Base64 } from 'js-base64'; +import { + ovirtSecretFieldValidator, + safeBase64Decode, + Validation, +} from 'src/modules/ProvidersNG/utils'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { + Button, + Checkbox, + Divider, + FileUpload, + Form, + FormGroup, + TextInput, +} from '@patternfly/react-core'; +import EyeIcon from '@patternfly/react-icons/dist/esm/icons/eye-icon'; +import EyeSlashIcon from '@patternfly/react-icons/dist/esm/icons/eye-slash-icon'; + +import { EditComponentProps } from '../BaseCredentialsSection'; + +export const OvirtCredentialsEdit: React.FC = ({ secret, onChange }) => { + const { t } = useForkliftTranslation(); + + const user = safeBase64Decode(secret?.data?.user || ''); + const password = safeBase64Decode(secret?.data?.password || ''); + const insecureSkipVerify = safeBase64Decode(secret?.data?.insecureSkipVerify || '') === 'true'; + const cacert = safeBase64Decode(secret?.data?.cacert || ''); + + const initialState = { + passwordHidden: true, + validation: { + user: 'default' as Validation, + password: 'default' as Validation, + cacert: 'default' as Validation, + }, + }; + + const reducer = (state, action) => { + switch (action.type) { + case 'SET_FIELD_VALIDATED': + return { + ...state, + validation: { + ...state.validation, + [action.payload.field]: action.payload.validationState, + }, + }; + case 'TOGGLE_PASSWORD_HIDDEN': + return { ...state, passwordHidden: !state.passwordHidden }; + default: + return state; + } + }; + + const [state, dispatch] = useReducer(reducer, initialState); + + // Define handleChange and validation functions + const handleChange = useCallback( + (id, value) => { + const validationState = ovirtSecretFieldValidator(id, value); + dispatch({ type: 'SET_FIELD_VALIDATED', payload: { field: id, validationState } }); + + // don't trim fields that allow spaces + const encodedValue = ['cacert'].includes(id) + ? Base64.encode(value) + : Base64.encode(value.trim()); + + onChange({ ...secret, data: { ...secret.data, [id]: encodedValue } }); + }, + [secret], + ); + + const togglePasswordHidden = () => { + dispatch({ type: 'TOGGLE_PASSWORD_HIDDEN' }); + }; + + return ( +
+ + handleChange('user', value)} + /> + + + handleChange('password', value)} + /> + + + + + + + handleChange('insecureSkipVerify', value ? 'true' : 'false')} + /> + + + handleChange('cacert', value)} + onTextChange={(value) => handleChange('cacert', value)} + onClearClick={() => handleChange('cacert', '')} + browseButtonText="Upload" + isDisabled={insecureSkipVerify} + /> + + + ); +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/components/edit/VSphereCredentialsEdit.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/components/edit/VSphereCredentialsEdit.tsx new file mode 100644 index 000000000..3ba6121a9 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/components/edit/VSphereCredentialsEdit.tsx @@ -0,0 +1,156 @@ +import React, { useCallback, useReducer } from 'react'; +import { Base64 } from 'js-base64'; +import { + safeBase64Decode, + Validation, + vsphereSecretFieldValidator, +} from 'src/modules/ProvidersNG/utils'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { Button, Checkbox, Divider, Form, FormGroup, TextInput } from '@patternfly/react-core'; +import EyeIcon from '@patternfly/react-icons/dist/esm/icons/eye-icon'; +import EyeSlashIcon from '@patternfly/react-icons/dist/esm/icons/eye-slash-icon'; + +import { EditComponentProps } from '../BaseCredentialsSection'; + +export const VSphereCredentialsEdit: React.FC = ({ secret, onChange }) => { + const { t } = useForkliftTranslation(); + + const user = safeBase64Decode(secret?.data?.user || ''); + const password = safeBase64Decode(secret?.data?.password || ''); + const thumbprint = safeBase64Decode(secret?.data?.thumbprint || ''); + const insecureSkipVerify = safeBase64Decode(secret?.data?.insecureSkipVerify || '') === 'true'; + + const initialState = { + passwordHidden: true, + validation: { + user: 'default' as Validation, + password: 'default' as Validation, + thumbprint: 'default' as Validation, + }, + }; + + const reducer = (state, action) => { + switch (action.type) { + case 'SET_FIELD_VALIDATED': + return { + ...state, + validation: { + ...state.validation, + [action.payload.field]: action.payload.validationState, + }, + }; + case 'TOGGLE_PASSWORD_HIDDEN': + return { ...state, passwordHidden: !state.passwordHidden }; + default: + return state; + } + }; + + const [state, dispatch] = useReducer(reducer, initialState); + + const handleChange = useCallback( + (id, value) => { + const validationState = vsphereSecretFieldValidator(id, value); + dispatch({ type: 'SET_FIELD_VALIDATED', payload: { field: id, validationState } }); + + // don't trim fields that allow spaces + const encodedValue = ['cacert'].includes(id) + ? Base64.encode(value) + : Base64.encode(value.trim()); + + onChange({ ...secret, data: { ...secret.data, [id]: encodedValue } }); + }, + [secret, onChange], + ); + + const togglePasswordHidden = () => { + dispatch({ type: 'TOGGLE_PASSWORD_HIDDEN' }); + }; + + return ( +
+ + handleChange('user', value)} + value={user} + validated={state.validation.user} + /> + + + handleChange('password', value)} + value={password} + validated={state.validation.password} + /> + + + + + + + handleChange('thumbprint', value)} + value={thumbprint} + validated={state.validation.thumbprint} + /> + + + + handleChange('insecureSkipVerify', value ? 'true' : 'false')} + /> + + + ); +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/components/edit/index.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/components/edit/index.ts new file mode 100644 index 000000000..c75ffa21b --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/components/edit/index.ts @@ -0,0 +1,8 @@ +// @index('./*', f => `export * from '${f.path}';`) +export * from './OpenshiftCredentialsEdit'; +export * from './OpenstackCredentialsEdit'; +export * from './OpenstackCredentialsEditFormGroups'; +export * from './OvirtCredentialsEdit'; +export * from './patchSecretData'; +export * from './VSphereCredentialsEdit'; +// @endindex diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/components/edit/patchSecretData.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/components/edit/patchSecretData.ts new file mode 100644 index 000000000..e39cc33d3 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/components/edit/patchSecretData.ts @@ -0,0 +1,42 @@ +import { SecretModel, V1Secret } from '@kubev2v/types'; +import { k8sPatch } from '@openshift-console/dynamic-plugin-sdk'; + +/** + * Updates the data of a Kubernetes secret resource. + * + * @param {V1Secret} secret - The secret object containing the updated data. + * @param {boolean} clean - Clean old values from the secret before patching. + * @returns {Promise} A promise that resolves when the patch operation is complete. + */ +export async function patchSecretData(secret: V1Secret, clean?: boolean) { + const op = secret?.data ? 'replace' : 'add'; + + await k8sPatch({ + model: SecretModel, + resource: secret, + data: [ + { + op, + path: '/data', + value: clean ? { ...EmptyOpenstackCredentials, ...secret.data } : secret.data, + }, + ], + }); +} + +// when patching a secret with new data, first remove all other fields +const EmptyOpenstackCredentials = { + authType: undefined, + username: undefined, + password: undefined, + regionName: undefined, + projectName: undefined, + domainName: undefined, + token: undefined, + userID: undefined, + projectID: undefined, + userDomainName: undefined, + applicationCredentialID: undefined, + applicationCredentialSecret: undefined, + applicationCredentialName: undefined, +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/components/index.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/components/index.ts new file mode 100644 index 000000000..face51176 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/components/index.ts @@ -0,0 +1,5 @@ +// @index(['./*', /style/g], f => `export * from '${f.path}';`) +export * from './BaseCredentialsSection'; +export * from './edit'; +export * from './list'; +// @endindex diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/components/list/OpenshiftCredentialsList.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/components/list/OpenshiftCredentialsList.tsx new file mode 100644 index 000000000..469049ea4 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/components/list/OpenshiftCredentialsList.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { Base64 } from 'js-base64'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { ClipboardCopy, ClipboardCopyVariant, Text, TextVariants } from '@patternfly/react-core'; + +import { MaskedData } from '../../MaskedData'; +import { ListComponentProps } from '../BaseCredentialsSection'; + +export const OpenshiftCredentialsList: React.FC = ({ secret, reveal }) => { + const { t } = useForkliftTranslation(); + + const items = []; + + const fields = { + token: { + label: t('Service account token'), + description: t( + 'User or service account bearer token for service accounts or user authentication.', + ), + }, + }; + + for (const key in fields) { + const field = fields[key]; + const base64Value = secret.data?.[key]; + const value = base64Value ? Base64.decode(secret.data[key]) : undefined; + + items.push( + <> +
+ + {field.label} + + + {field.description} + +
+
+ {reveal ? ( + 128 ? ClipboardCopyVariant.expansion : undefined} + > + {value} + + ) : ( + + )} +
+ , + ); + } + + return <>{items}; +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/components/list/OpenstackCredentialsList.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/components/list/OpenstackCredentialsList.tsx new file mode 100644 index 000000000..9ef8f2fc3 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/components/list/OpenstackCredentialsList.tsx @@ -0,0 +1,236 @@ +import React from 'react'; +import { Base64 } from 'js-base64'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { ClipboardCopy, ClipboardCopyVariant, Text, TextVariants } from '@patternfly/react-core'; + +import { MaskedData } from '../../MaskedData'; +import { ListComponentProps } from '../BaseCredentialsSection'; + +export const OpenstackCredentialsList: React.FC = ({ secret, reveal }) => { + const { t } = useForkliftTranslation(); + + const items = []; + + const fields = { + passwordSecretFields: { + authType: { + label: t('Authentication type'), + description: t('Type of authentication to use when connecting to Openstack REST API.'), + }, + username: { label: t('Username'), description: t('Openstack REST API user name.') }, + password: { + label: t('Password'), + description: t('Openstack REST API password credentials.'), + }, + regionName: { + label: t('Region'), + description: t('Openstack region for password credentials.'), + }, + projectName: { + label: t('Project'), + description: t('Openstack project for password credentials.'), + }, + domainName: { + label: t('Domain'), + description: t('Openstack domain for password credentials.'), + }, + insecureSkipVerify: { + label: t('Skip certificate validation'), + description: t("If true, the provider's REST API TLS certificate won't be validated."), + }, + cacert: { + label: t('CA certificate'), + description: t( + 'Custom certification used to verify the Openstack REST API server, when empty use system certificate.', + ), + }, + }, + + tokenWithUserIDSecretFields: { + authType: { + label: t('Authentication type'), + description: t('Type of authentication to use when connecting to Openstack REST API.'), + }, + token: { + label: t('Token'), + description: t('Openstack REST API token credentials.'), + }, + userID: { + label: t('User ID'), + description: t('Openstack REST API user ID.'), + }, + projectID: { + label: t('Project ID'), + description: t('Openstack project ID for token credentials.'), + }, + insecureSkipVerify: { + label: t('Skip certificate validation'), + description: t("If true, the provider's REST API TLS certificate won't be validated."), + }, + cacert: { + label: t('CA certificate'), + description: t( + 'Custom certification used to verify the Openstack REST API server, when empty use system certificate.', + ), + }, + }, + + tokenWithUsernameSecretFields: { + authType: { + label: t('Authentication type'), + description: t('Type of authentication to use when connecting to Openstack REST API.'), + }, + token: { + label: t('Token'), + description: t('Openstack REST API token credentials.'), + }, + username: { + label: t('Username'), + description: t('Openstack REST API user name.'), + }, + projectName: { + label: t('Project'), + description: t('Openstack project for token credentials.'), + }, + userDomainName: { + label: t('User Domain Name'), + description: t('Openstack user domain name for token credentials.'), + }, + insecureSkipVerify: { + label: t('Skip certificate validation'), + description: t("If true, the provider's REST API TLS certificate won't be validated."), + }, + cacert: { + label: t('CA certificate'), + description: t( + 'Custom certification used to verify the Openstack REST API server, when empty use system certificate.', + ), + }, + }, + + applicationCredentialIdSecretFields: { + authType: { + label: t('Authentication type'), + description: t('Type of authentication to use when connecting to Openstack REST API.'), + }, + applicationCredentialID: { + label: t('Application Credential ID'), + description: t('Openstack REST API Application Credential ID.'), + }, + applicationCredentialSecret: { + label: t('Application Credential Secret'), + description: t('Openstack REST API Application Credential Secret.'), + }, + insecureSkipVerify: { + label: t('Skip certificate validation'), + description: t("If true, the provider's REST API TLS certificate won't be validated."), + }, + cacert: { + label: t('CA certificate'), + description: t( + 'Custom certification used to verify the Openstack REST API server, when empty use system certificate.', + ), + }, + }, + + applicationCredentialNameSecretFields: { + authType: { + label: t('Authentication type'), + description: t('Type of authentication to use when connecting to Openstack REST API.'), + }, + applicationCredentialName: { + label: t('Application Credential Name'), + description: t('Openstack REST API Application Credential Name.'), + }, + applicationCredentialSecret: { + label: t('Application Credential Secret'), + description: t('Openstack REST API Application Credential Secret.'), + }, + username: { + label: t('Username'), + description: t('Openstack REST API user name.'), + }, + domainName: { + label: t('Domain'), + description: t('Openstack domain for application credential credentials.'), + }, + insecureSkipVerify: { + label: t('Skip certificate validation'), + description: t("If true, the provider's REST API TLS certificate won't be validated."), + }, + cacert: { + label: t('CA certificate'), + description: t( + 'Custom certification used to verify the Openstack REST API server, when empty use system certificate.', + ), + }, + }, + }; + + let openstackSecretFields = {}; + + const decodedAuthType = secret?.data?.authType ? Base64.decode(secret.data.authType) : ''; + + switch (decodedAuthType) { + case '': + case 'password': + openstackSecretFields = fields.passwordSecretFields; + break; + + case 'token': + if (!secret?.data?.userID) { + openstackSecretFields = fields.tokenWithUserIDSecretFields; + } else if (!secret?.data?.username) { + openstackSecretFields = fields.tokenWithUsernameSecretFields; + } + break; + + case 'applicationcredential': + if (!secret?.data?.applicationCredentialID) { + openstackSecretFields = fields.applicationCredentialIdSecretFields; + } else if (!secret?.data?.applicationCredentialName) { + openstackSecretFields = fields.applicationCredentialNameSecretFields; + } + break; + + default: + // Handle case when none of the conditions are met + } + + for (const key in openstackSecretFields) { + const field = openstackSecretFields[key]; + const base64Value = secret.data?.[key]; + const value = base64Value ? Base64.decode(secret.data[key]) : undefined; + + items.push( + <> +
+ + {field.label} + + + {field.description} + +
+
+ {reveal ? ( + 128 ? ClipboardCopyVariant.expansion : undefined} + > + {value} + + ) : ( + + )} +
+ , + ); + } + + return <>{items}; +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/components/list/OvirtCredentialsList.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/components/list/OvirtCredentialsList.tsx new file mode 100644 index 000000000..99aa6e057 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/components/list/OvirtCredentialsList.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { Base64 } from 'js-base64'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { ClipboardCopy, ClipboardCopyVariant, Text, TextVariants } from '@patternfly/react-core'; + +import { MaskedData } from '../../MaskedData'; +import { ListComponentProps } from '../BaseCredentialsSection'; + +export const OvirtCredentialsList: React.FC = ({ secret, reveal }) => { + const { t } = useForkliftTranslation(); + + const items = []; + + const fields = { + user: { label: t('Username'), description: t('RH Virtualization engine REST API user name.') }, + password: { + label: t('Password'), + description: t('RH Virtualization engine REST API password credentials.'), + }, + insecureSkipVerify: { + label: t('Skip certificate validation'), + description: t("If true, the provider's REST API TLS certificate won't be validated."), + }, + cacert: { + label: t('CA certificate'), + description: t( + 'The CA certificate is the /etc/pki/ovirt-engine/apache-ca.pem file on the Manager machine.', + ), + }, + }; + + for (const key in fields) { + const field = fields[key]; + const base64Value = secret.data?.[key]; + const value = base64Value ? Base64.decode(secret.data[key]) : undefined; + + items.push( + <> +
+ + {field.label} + + + {field.description} + +
+
+ {reveal ? ( + 128 ? ClipboardCopyVariant.expansion : undefined} + > + {value} + + ) : ( + + )} +
+ , + ); + } + + return <>{items}; +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/components/list/VSphereCredentialsList.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/components/list/VSphereCredentialsList.tsx new file mode 100644 index 000000000..b36ba870f --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/components/list/VSphereCredentialsList.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { Base64 } from 'js-base64'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { ClipboardCopy, ClipboardCopyVariant, Text, TextVariants } from '@patternfly/react-core'; + +import { MaskedData } from '../../MaskedData'; +import { ListComponentProps } from '../BaseCredentialsSection'; + +export const VSphereCredentialsList: React.FC = ({ secret, reveal }) => { + const { t } = useForkliftTranslation(); + + const items = []; + + const fields = { + user: { label: t('Username'), description: t('vSphere REST API user name.') }, + password: { label: t('Password'), description: t('vSphere REST API password credentials.') }, + thumbprint: { + label: t('SSHA-1 fingerprint'), + description: t( + "The provider currently requires the SHA-1 fingerprint of the vCenter Server's TLS certificate in all circumstances. vSphere calls this the server's thumbprint.", + ), + }, + insecureSkipVerify: { + label: t('Skip certificate validation'), + description: t("If true, the provider's TLS certificate won't be validated."), + }, + }; + + for (const key in fields) { + const field = fields[key]; + const base64Value = secret.data?.[key]; + const value = base64Value ? Base64.decode(secret.data[key]) : undefined; + + items.push( + <> +
+ + {field.label} + + + {field.description} + +
+
+ {reveal ? ( + 128 ? ClipboardCopyVariant.expansion : undefined} + > + {value} + + ) : ( + + )} +
+ , + ); + } + + return <>{items}; +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/components/list/index.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/components/list/index.ts new file mode 100644 index 000000000..c73006769 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/components/list/index.ts @@ -0,0 +1,6 @@ +// @index('./*.tsx', f => `export * from '${f.path}';`) +export * from './OpenshiftCredentialsList'; +export * from './OpenstackCredentialsList'; +export * from './OvirtCredentialsList'; +export * from './VSphereCredentialsList'; +// @endindex diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/index.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/index.ts new file mode 100644 index 000000000..8387f033f --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/CredentialsSection/index.ts @@ -0,0 +1,9 @@ +// @index(['./*', /style/g], f => `export * from '${f.path}';`) +export * from './components'; +export * from './CredentialsSection'; +export * from './MaskedData'; +export * from './OpenshiftCredentialsSection'; +export * from './OpenstackCredentialsSection'; +export * from './OvirtCredentialsSection'; +export * from './VSphereCredentialsSection'; +// @endindex diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/DetailsSection/DetailsSection.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/DetailsSection/DetailsSection.tsx new file mode 100644 index 000000000..c66186033 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/DetailsSection/DetailsSection.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { ModalHOC } from 'src/modules/ProvidersNG/modals'; +import { ProviderData } from 'src/modules/ProvidersNG/utils'; + +import { OpenshiftDetailsSection } from './OpenshiftDetailsSection'; +import { OpenstackDetailsSection } from './OpenstackDetailsSection'; +import { OvirtDetailsSection } from './OvirtDetailsSection'; +import { VSphereDetailsSection } from './VSphereDetailsSection'; + +const DetailsSection_: React.FC = (props) => { + const { provider } = props.data; + + switch (provider?.spec?.type) { + case 'ovirt': + return ; + case 'openshift': + return ; + case 'openstack': + return ; + case 'vsphere': + return ; + default: + return <>; + } +}; + +export const DetailsSection: React.FC = (props) => ( + + + +); + +export type DetailsSectionProps = { + data: ProviderData; +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/DetailsSection/OpenshiftDetailsSection.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/DetailsSection/OpenshiftDetailsSection.tsx new file mode 100644 index 000000000..911f4e4a9 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/DetailsSection/OpenshiftDetailsSection.tsx @@ -0,0 +1,171 @@ +import React from 'react'; +import { + EditProviderDefaultTransferNetwork, + EditProviderURLModal, + useModal, +} from 'src/modules/ProvidersNG/modals'; +import { HELP_LINK_HREF } from 'src/utils/constants'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { ResourceLink, Timestamp } from '@openshift-console/dynamic-plugin-sdk'; +import { DescriptionList, Label, Text } from '@patternfly/react-core'; + +import { DetailsItem, OwnerReferencesItem } from '../../../../utils'; + +import { DetailsSectionProps } from './DetailsSection'; + +export const OpenshiftDetailsSection: React.FC = ({ data }) => { + const { t } = useForkliftTranslation(); + const { showModal } = useModal(); + + const { provider } = data; + + return ( + + + {provider?.spec?.type}{' '} + {!provider?.spec?.url && ( + + )} + + } + moreInfoLink={HELP_LINK_HREF} + helpContent={ + {t(`Allowed values are openshift, ovirt, vsphere, and openstack.`)} + } + crumbs={['Provider', 'spec', 'type']} + /> + + + + + {t( + 'Name is primarily intended for creation idempotence and configuration definition. Cannot be updated.', + )} + + } + crumbs={['Provider', 'metadata', 'name']} + /> + + + } + moreInfoLink={ + 'https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces' + } + helpContent={t( + `Namespace defines the space within which each name must be unique. + An empty namespace is equivalent to the "default" namespace, but "default" is the canonical representation. + Not all objects are required to be scoped to a namespace - the value of this field for those objects will be empty.`, + )} + crumbs={['Provider', 'metadata', 'namespace']} + /> + + {t('Empty')}} + moreInfoLink={ + 'https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces' + } + helpContent={{t(`The provider URL. Empty may be used for the host provider.`)}} + crumbs={['Provider', 'spec', 'url']} + onEdit={ + provider?.spec?.url && (() => showModal()) + } + /> + + + ) : ( + {t('No secret')} + ) + } + helpContent={t( + `References a secret containing credentials and other confidential information. Empty may be used for the host provider.`, + )} + crumbs={['Provider', 'spec', 'secret']} + /> + + } + helpContent={ + + {t( + `CreationTimestamp is a timestamp representing the server time when this object was created. + It is not guaranteed to be set in happens-before order across separate operations. + Clients may not set this value. It is represented in RFC3339 form and is in UTC.`, + )} + + } + crumbs={['Provider', 'metadata', 'creationTimestamp']} + /> + + {t('Empty')} + ) + } + helpContent={ + + {t( + `The default network attachment definition that should be used for disk transfer. + If not available in the target namespace or empty, Pod network will be used`, + )} + + } + crumbs={[ + 'Provider', + 'metadata', + 'annotations', + 'forklift.konveyor.io/defaultTransferNetwork', + ]} + onEdit={() => showModal()} + /> + + } + helpContent={ + + {t( + `List of objects depended by this object. If ALL objects in the list have been deleted, this object will be garbage collected. + If this object is managed by a controller, then an entry in this list will point to this controller, + with the controller field set to true. There cannot be more than one managing controller.`, + )} + + } + crumbs={['Provider', 'metadata', 'ownerReferences']} + /> + + ); +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/DetailsSection/OpenstackDetailsSection.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/DetailsSection/OpenstackDetailsSection.tsx new file mode 100644 index 000000000..bda41f907 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/DetailsSection/OpenstackDetailsSection.tsx @@ -0,0 +1,134 @@ +import React from 'react'; +import { EditProviderURLModal, useModal } from 'src/modules/ProvidersNG/modals'; +import { HELP_LINK_HREF } from 'src/utils/constants'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { ResourceLink, Timestamp } from '@openshift-console/dynamic-plugin-sdk'; +import { DescriptionList, Text } from '@patternfly/react-core'; + +import { DetailsItem, OwnerReferencesItem } from '../../../../utils'; + +import { DetailsSectionProps } from './DetailsSection'; + +export const OpenstackDetailsSection: React.FC = ({ data }) => { + const { t } = useForkliftTranslation(); + const { showModal } = useModal(); + + const { provider } = data; + + return ( + + {t(`Allowed values are openshift, ovirt, vsphere, and openstack.`)} + } + crumbs={['Provider', 'spec', 'type']} + /> + + + + + {t( + 'Name is primarily intended for creation idempotence and configuration definition. Cannot be updated.', + )} + + } + crumbs={['Provider', 'metadata', 'name']} + /> + + + } + moreInfoLink={ + 'https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces' + } + helpContent={t( + `Namespace defines the space within which each name must be unique. + An empty namespace is equivalent to the "default" namespace, but "default" is the canonical representation. + Not all objects are required to be scoped to a namespace - the value of this field for those objects will be empty.`, + )} + crumbs={['Provider', 'metadata', 'namespace']} + /> + + {t('Empty')}} + moreInfoLink={ + 'https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces' + } + helpContent={{t(`The provider URL. Empty may be used for the host provider.`)}} + crumbs={['Provider', 'spec', 'url']} + onEdit={() => showModal()} + /> + + + ) : ( + {t('No secret')} + ) + } + helpContent={t( + `References a secret containing credentials and other confidential information. Empty may be used for the host provider.`, + )} + crumbs={['Provider', 'spec', 'secret']} + /> + + } + helpContent={ + + {t( + `CreationTimestamp is a timestamp representing the server time when this object was created. + It is not guaranteed to be set in happens-before order across separate operations. + Clients may not set this value. It is represented in RFC3339 form and is in UTC.`, + )} + + } + crumbs={['Provider', 'metadata', 'creationTimestamp']} + /> + + + + } + helpContent={ + + {t( + `List of objects depended by this object. If ALL objects in the list have been deleted, this object will be garbage collected. + If this object is managed by a controller, then an entry in this list will point to this controller, + with the controller field set to true. There cannot be more than one managing controller.`, + )} + + } + crumbs={['Provider', 'metadata', 'ownerReferences']} + /> + + ); +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/DetailsSection/OvirtDetailsSection.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/DetailsSection/OvirtDetailsSection.tsx new file mode 100644 index 000000000..2b32be983 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/DetailsSection/OvirtDetailsSection.tsx @@ -0,0 +1,134 @@ +import React from 'react'; +import { EditProviderURLModal, useModal } from 'src/modules/ProvidersNG/modals'; +import { HELP_LINK_HREF } from 'src/utils/constants'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { ResourceLink, Timestamp } from '@openshift-console/dynamic-plugin-sdk'; +import { DescriptionList, Text } from '@patternfly/react-core'; + +import { DetailsItem, OwnerReferencesItem } from '../../../../utils'; + +import { DetailsSectionProps } from './DetailsSection'; + +export const OvirtDetailsSection: React.FC = ({ data }) => { + const { t } = useForkliftTranslation(); + const { showModal } = useModal(); + + const { provider } = data; + + return ( + + {t(`Allowed values are openshift, ovirt, vsphere, and openstack.`)} + } + crumbs={['Provider', 'spec', 'type']} + /> + + + + + {t( + 'Name is primarily intended for creation idempotence and configuration definition. Cannot be updated.', + )} + + } + crumbs={['Provider', 'metadata', 'name']} + /> + + + } + moreInfoLink={ + 'https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces' + } + helpContent={t( + `Namespace defines the space within which each name must be unique. + An empty namespace is equivalent to the "default" namespace, but "default" is the canonical representation. + Not all objects are required to be scoped to a namespace - the value of this field for those objects will be empty.`, + )} + crumbs={['Provider', 'metadata', 'namespace']} + /> + + {t('Empty')}} + moreInfoLink={ + 'https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces' + } + helpContent={{t(`The provider URL. Empty may be used for the host provider.`)}} + crumbs={['Provider', 'spec', 'url']} + onEdit={() => showModal()} + /> + + + ) : ( + {t('No secret')} + ) + } + helpContent={t( + `References a secret containing credentials and other confidential information. Empty may be used for the host provider.`, + )} + crumbs={['Provider', 'spec', 'secret']} + /> + + } + helpContent={ + + {t( + `CreationTimestamp is a timestamp representing the server time when this object was created. + It is not guaranteed to be set in happens-before order across separate operations. + Clients may not set this value. It is represented in RFC3339 form and is in UTC.`, + )} + + } + crumbs={['Provider', 'metadata', 'creationTimestamp']} + /> + + + + } + helpContent={ + + {t( + `List of objects depended by this object. If ALL objects in the list have been deleted, this object will be garbage collected. + If this object is managed by a controller, then an entry in this list will point to this controller, + with the controller field set to true. There cannot be more than one managing controller.`, + )} + + } + crumbs={['Provider', 'metadata', 'ownerReferences']} + /> + + ); +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/DetailsSection/VSphereDetailsSection.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/DetailsSection/VSphereDetailsSection.tsx new file mode 100644 index 000000000..b78a7e84a --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/DetailsSection/VSphereDetailsSection.tsx @@ -0,0 +1,153 @@ +import React from 'react'; +import { + EditProviderURLModal, + EditProviderVDDKImage, + useModal, +} from 'src/modules/ProvidersNG/modals'; +import { HELP_LINK_HREF } from 'src/utils/constants'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { ResourceLink, Timestamp } from '@openshift-console/dynamic-plugin-sdk'; +import { DescriptionList, Text } from '@patternfly/react-core'; + +import { DetailsItem, OwnerReferencesItem } from '../../../../utils'; + +import { DetailsSectionProps } from './DetailsSection'; + +export const VSphereDetailsSection: React.FC = ({ data }) => { + const { t } = useForkliftTranslation(); + const { showModal } = useModal(); + + const { provider, inventory } = data; + + return ( + + {t(`Allowed values are openshift, ovirt, vsphere, and openstack.`)} + } + crumbs={['Provider', 'spec', 'type']} + /> + + {t('Empty')}} + helpContent={{t(`VMware only: vSphere product name.`)}} + crumbs={['Inventory', 'providers', `${provider.spec.type}`, '[UID]']} + /> + + + {t( + 'Name is primarily intended for creation idempotence and configuration definition. Cannot be updated.', + )} + + } + crumbs={['Provider', 'metadata', 'name']} + /> + + + } + moreInfoLink={ + 'https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces' + } + helpContent={t( + `Namespace defines the space within which each name must be unique. + An empty namespace is equivalent to the "default" namespace, but "default" is the canonical representation. + Not all objects are required to be scoped to a namespace - the value of this field for those objects will be empty.`, + )} + crumbs={['Provider', 'metadata', 'namespace']} + /> + + {t('Empty')}} + moreInfoLink={ + 'https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces' + } + helpContent={{t(`The provider URL. Empty may be used for the host provider.`)}} + crumbs={['Provider', 'spec', 'url']} + onEdit={() => showModal()} + /> + + + ) : ( + {t('No secret')} + ) + } + helpContent={t( + `References a secret containing credentials and other confidential information. Empty may be used for the host provider.`, + )} + crumbs={['Provider', 'spec', 'secret']} + /> + + } + helpContent={ + + {t( + `CreationTimestamp is a timestamp representing the server time when this object was created. + It is not guaranteed to be set in happens-before order across separate operations. + Clients may not set this value. It is represented in RFC3339 form and is in UTC.`, + )} + + } + crumbs={['Provider', 'metadata', 'creationTimestamp']} + /> + + {t('Empty')} + ) + } + helpContent={{t(`VMware only: Specify the VDDK image that you created.`)}} + crumbs={['Provider', 'spec', 'settings', 'vddkInitImage']} + onEdit={() => showModal()} + /> + + } + helpContent={ + + {t( + `List of objects depended by this object. If ALL objects in the list have been deleted, this object will be garbage collected. + If this object is managed by a controller, then an entry in this list will point to this controller, + with the controller field set to true. There cannot be more than one managing controller.`, + )} + + } + crumbs={['Provider', 'metadata', 'ownerReferences']} + /> + + ); +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/DetailsSection/index.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/DetailsSection/index.ts new file mode 100644 index 000000000..ec4ca3979 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/DetailsSection/index.ts @@ -0,0 +1,7 @@ +// @index(['./*', /style/g], f => `export * from '${f.path}';`) +export * from './DetailsSection'; +export * from './OpenshiftDetailsSection'; +export * from './OpenstackDetailsSection'; +export * from './OvirtDetailsSection'; +export * from './VSphereDetailsSection'; +// @endindex diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/InventorySection/InventorySection.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/InventorySection/InventorySection.tsx new file mode 100644 index 000000000..6cfd0e299 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/InventorySection/InventorySection.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { ProviderData } from 'src/modules/ProvidersNG/utils'; + +import { OpenshiftInventorySection } from './OpenshiftInventorySection'; +import { OpenstackInventorySection } from './OpenstackInventorySection'; +import { OvirtInventorySection } from './OvirtInventorySection'; +import { VSphereInventorySection } from './VSphereInventorySection'; + +export const InventorySection: React.FC = (props) => { + const { provider } = props.data; + + switch (provider?.spec?.type) { + case 'ovirt': + return ; + case 'openshift': + return ; + case 'openstack': + return ; + case 'vsphere': + return ; + default: + return <>; + } +}; + +export type InventoryProps = { + data: ProviderData; +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/InventorySection/OpenshiftInventorySection.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/InventorySection/OpenshiftInventorySection.tsx new file mode 100644 index 000000000..f573c963b --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/InventorySection/OpenshiftInventorySection.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { DescriptionList } from '@patternfly/react-core'; + +import { DetailsItem } from '../../../../utils'; + +import { InventoryProps } from './InventorySection'; + +export const OpenshiftInventorySection: React.FC = ({ data }) => { + const { t } = useForkliftTranslation(); + const { provider, inventory } = data; + + if (!provider || !inventory) { + return {t('No credentials found.')}; + } + + const inventoryItems = { + vmCount: { + title: t('Virtual machines'), + helpContent: t('Number of virtual machines in cluster'), + }, + networkCount: { + title: t('Network interfaces'), + helpContent: t('Number of network interfaces in provider cluster'), + }, + storageClassCount: { + title: t('Storage classes'), + helpContent: t('Number of storage classes in provider cluster'), + }, + }; + + const items = []; + + for (const key in inventoryItems) { + const item = inventoryItems?.[key]; + + if (item) { + const value = inventory[key] || '-'; + items.push( + , + ); + } + } + + return ( + + {items} + + ); +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/InventorySection/OpenstackInventorySection.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/InventorySection/OpenstackInventorySection.tsx new file mode 100644 index 000000000..a863c05e9 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/InventorySection/OpenstackInventorySection.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { DescriptionList } from '@patternfly/react-core'; + +import { DetailsItem } from '../../../../utils'; + +import { InventoryProps } from './InventorySection'; + +export const OpenstackInventorySection: React.FC = ({ data }) => { + const { t } = useForkliftTranslation(); + const { provider, inventory } = data; + + if (!provider || !inventory) { + return {t('No credentials found.')}; + } + + const inventoryItems = { + vmCount: { + title: t('Virtual machines'), + helpContent: t('Number of virtual machines in cluster'), + }, + networkCount: { + title: t('Network interfaces'), + helpContent: t('Number of network interfaces in provider cluster'), + }, + regionCount: { + title: t('Regions'), + helpContent: t('Number of regions in Openstack cluster'), + }, + projectCount: { + title: t('Projects'), + helpContent: t('Number of projects in Openstack cluster'), + }, + volumeCount: { + title: t('Volumes'), + helpContent: t('Number of storage volumes in cluster'), + }, + volumeTypeCount: { + title: t('Volume Types'), + helpContent: t('Number of storage types in cluster'), + }, + }; + + const items = []; + + for (const key in inventoryItems) { + const item = inventoryItems?.[key]; + + if (item) { + const value = inventory[key] || '-'; + items.push( + , + ); + } + } + + return ( + + {items} + + ); +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/InventorySection/OvirtInventorySection.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/InventorySection/OvirtInventorySection.tsx new file mode 100644 index 000000000..f83599dcb --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/InventorySection/OvirtInventorySection.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { DescriptionList } from '@patternfly/react-core'; + +import { DetailsItem } from '../../../../utils'; + +import { InventoryProps } from './InventorySection'; + +export const OvirtInventorySection: React.FC = ({ data }) => { + const { t } = useForkliftTranslation(); + const { provider, inventory } = data; + + if (!provider || !inventory) { + return {t('No credentials found.')}; + } + + const inventoryItems = { + vmCount: { + title: t('Virtual machines'), + helpContent: t('Number of virtual machines in cluster'), + }, + networkCount: { + title: t('Network interfaces'), + helpContent: t('Number of network interfaces in provider cluster'), + }, + datacenterCount: { + title: t('Data centers'), + helpContent: t('Number of data centers in provider'), + }, + storageDomainCount: { + title: t('Storage domains'), + helpContent: t('Number of storage domains in provider'), + }, + clusterCount: { + title: t('Clusters'), + helpContent: t('Number of cluster in provider'), + }, + hostCount: { + title: t('Hosts'), + helpContent: t('Number of hosts in provider clusters'), + }, + }; + + const items = []; + + for (const key in inventoryItems) { + const item = inventoryItems?.[key]; + + if (item) { + const value = inventory[key] || '-'; + items.push( + , + ); + } + } + + return ( + + {items} + + ); +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/InventorySection/VSphereInventorySection.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/InventorySection/VSphereInventorySection.tsx new file mode 100644 index 000000000..3d52c74df --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/InventorySection/VSphereInventorySection.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { DescriptionList } from '@patternfly/react-core'; + +import { DetailsItem } from '../../../../utils'; + +import { InventoryProps } from './InventorySection'; + +export const VSphereInventorySection: React.FC = ({ data }) => { + const { t } = useForkliftTranslation(); + const { provider, inventory } = data; + + if (!provider || !inventory) { + return {t('No credentials found.')}; + } + + const inventoryItems = { + vmCount: { + title: t('Virtual machines'), + helpContent: t('Number of virtual machines in cluster'), + }, + networkCount: { + title: t('Network interfaces'), + helpContent: t('Number of network interfaces in provider cluster'), + }, + datacenterCount: { + title: t('Data centers'), + helpContent: t('Number of data centers in provider'), + }, + datastoreCount: { + title: t('Data stores'), + helpContent: t('Number of data stores in provider'), + }, + clusterCount: { + title: t('Clusters'), + helpContent: t('Number of cluster in provider'), + }, + hostCount: { + title: t('Hosts'), + helpContent: t('Number of hosts in provider clusters'), + }, + }; + + const items = []; + + for (const key in inventoryItems) { + const item = inventoryItems?.[key]; + + if (item) { + const value = inventory[key] || '-'; + items.push( + , + ); + } + } + + return ( + + {items} + + ); +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/InventorySection/index.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/InventorySection/index.ts new file mode 100644 index 000000000..42833fa93 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/InventorySection/index.ts @@ -0,0 +1,7 @@ +// @index(['./*', /style/g], f => `export * from '${f.path}';`) +export * from './InventorySection'; +export * from './OpenshiftInventorySection'; +export * from './OpenstackInventorySection'; +export * from './OvirtInventorySection'; +export * from './VSphereInventorySection'; +// @endindex diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/index.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/index.ts new file mode 100644 index 000000000..534645655 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/components/index.ts @@ -0,0 +1,6 @@ +// @index(['./*', /style/g], f => `export * from '${f.path}';`) +export * from './ConditionsSection'; +export * from './CredentialsSection'; +export * from './DetailsSection'; +export * from './InventorySection'; +// @endindex diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/index.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/index.ts new file mode 100644 index 000000000..20ce21249 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/index.ts @@ -0,0 +1,5 @@ +// @index(['./*', /style/g], f => `export * from '${f.path}';`) +export * from './components'; +export * from './ProviderDetailsPage'; +export * from './tabs'; +// @endindex diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/tabs/Credentials/ProviderCredentials.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/tabs/Credentials/ProviderCredentials.tsx new file mode 100644 index 000000000..7c4456045 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/tabs/Credentials/ProviderCredentials.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { RouteComponentProps } from 'react-router-dom'; +import { ProviderData } from 'src/modules/ProvidersNG/utils'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { PageSection, Title } from '@patternfly/react-core'; + +import { CredentialsSection } from '../../components'; + +interface ProviderCredentialsProps extends RouteComponentProps { + obj: ProviderData; + ns?: string; + name?: string; + loaded?: boolean; + loadError?: unknown; +} + +export const ProviderCredentials: React.FC = ({ + obj, + loaded, + loadError, +}) => { + const { t } = useForkliftTranslation(); + + return ( +
+ + + {t('Credentials')} + + + +
+ ); +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/tabs/Credentials/index.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/tabs/Credentials/index.ts new file mode 100644 index 000000000..781dc3223 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/tabs/Credentials/index.ts @@ -0,0 +1,3 @@ +// @index(['./*', /style/g], f => `export * from '${f.path}';`) +export * from './ProviderCredentials'; +// @endindex diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/tabs/Details/ProviderDetails.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/tabs/Details/ProviderDetails.tsx new file mode 100644 index 000000000..ac30a8f1c --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/tabs/Details/ProviderDetails.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { RouteComponentProps } from 'react-router-dom'; +import { ProviderData } from 'src/modules/ProvidersNG/utils'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { PageSection, Title } from '@patternfly/react-core'; + +import { ConditionsSection, DetailsSection, InventorySection } from '../../components'; + +interface ProviderDetailsProps extends RouteComponentProps { + obj: ProviderData; + ns?: string; + name?: string; + loaded?: boolean; + loadError?: unknown; +} + +export const ProviderDetails: React.FC = ({ obj, loaded, loadError }) => { + const { t } = useForkliftTranslation(); + const { provider } = obj; + + if (!loaded || loadError || !provider?.metadata?.name) { + return <>; + } + + return ( +
+ + + {t('Provider details')} + + + + + + + {t('Provider inventory')} + + + + + + + {t('Conditions')} + + + +
+ ); +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/tabs/Details/index.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/tabs/Details/index.ts new file mode 100644 index 000000000..66f1baed5 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/tabs/Details/index.ts @@ -0,0 +1,3 @@ +// @index(['./*', /style/g], f => `export * from '${f.path}';`) +export * from './ProviderDetails'; +// @endindex diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/tabs/Hosts/ProviderHosts.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/tabs/Hosts/ProviderHosts.tsx new file mode 100644 index 000000000..280c0378b --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/tabs/Hosts/ProviderHosts.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { RouteComponentProps } from 'react-router-dom'; +import { useProviderInventory } from 'src/modules/ProvidersNG/hooks'; +import { ProviderData } from 'src/modules/ProvidersNG/utils'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { ProviderHost } from '@kubev2v/types'; +import { PageSection, Title } from '@patternfly/react-core'; +import { TableComposable, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; + +interface ProviderHostsProps extends RouteComponentProps { + obj: ProviderData; + ns?: string; + name?: string; + loaded?: boolean; + loadError?: unknown; +} + +export const ProviderHosts: React.FC = ({ obj }) => { + const { t } = useForkliftTranslation(); + const { provider } = obj; + + const { inventory: hosts } = useProviderInventory({ + provider, + subPath: 'hosts?detail=4', + }); + + if (!hosts || hosts.length === 0) { + return ( + + {t('No hosts found.')} + + ); + } + + return ( +
+ + + {t('Hosts')} + + + + + + + {t('Name')} + {t('ID')} + + + + {hosts && + hosts.length > 0 && + hosts.map((host) => ( + + {host.name} + {host.id || '-'} + + ))} + + + +
+ ); +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/tabs/Hosts/index.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/tabs/Hosts/index.ts new file mode 100644 index 000000000..3305f9fb6 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/tabs/Hosts/index.ts @@ -0,0 +1,3 @@ +// @index(['./*', /style/g], f => `export * from '${f.path}';`) +export * from './ProviderHosts'; +// @endindex diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/tabs/Networks/ProviderNetworks.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/tabs/Networks/ProviderNetworks.tsx new file mode 100644 index 000000000..77a32a711 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/tabs/Networks/ProviderNetworks.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import { RouteComponentProps } from 'react-router-dom'; +import { useProviderInventory } from 'src/modules/ProvidersNG/hooks'; +import { ProviderData } from 'src/modules/ProvidersNG/utils'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { CnoConfig, OpenShiftNetworkAttachmentDefinition } from '@kubev2v/types'; +import { Label, PageSection, Title } from '@patternfly/react-core'; +import { TableComposable, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; + +interface ProviderNetworksProps extends RouteComponentProps { + obj: ProviderData; + ns?: string; + name?: string; + loaded?: boolean; + loadError?: unknown; +} + +export const ProviderNetworks: React.FC = ({ obj }) => { + const { t } = useForkliftTranslation(); + const { provider } = obj; + + const { inventory: networks } = useProviderInventory({ + provider, + // eslint-disable-next-line @cspell/spellchecker + subPath: 'networkattachmentdefinitions?detail=4', + }); + + if (!networks || networks.length === 0) { + return ( + + {t('No networks found.')} + + ); + } + + const defaultNetwork = + provider?.metadata?.annotations?.['forklift.konveyor.io/defaultTransferNetwork']; + const networkData = networks.map((net) => ({ + name: net.name, + namespace: net.namespace, + isDefault: `${net.namespace}/${net.name}` === defaultNetwork, + config: JSON.parse(net?.object?.spec?.config || '{}') as CnoConfig, + })); + + return ( +
+ + + {t('NetworkAttachmentDefinitions')} + + + + + + + {t('Name')} + {t('Namespace')} + {t('Type')} + + + + + + {'Pod network'}{' '} + {!defaultNetwork && ( + + )} + + {'-'} + {'pod-network'} + + {networkData.map((data) => ( + + + {data.name}{' '} + {data.isDefault && ( + + )} + + {data?.namespace || '-'} + {data?.config?.type || '-'} + + ))} + + + +
+ ); +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/tabs/Networks/index.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/tabs/Networks/index.ts new file mode 100644 index 000000000..485802c5d --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/tabs/Networks/index.ts @@ -0,0 +1,3 @@ +// @index(['./*', /style/g], f => `export * from '${f.path}';`) +export * from './ProviderNetworks'; +// @endindex diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/tabs/VirtualMachines/ProviderVirtualMachines.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/tabs/VirtualMachines/ProviderVirtualMachines.tsx new file mode 100644 index 000000000..c8fe176a0 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/tabs/VirtualMachines/ProviderVirtualMachines.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { RouteComponentProps } from 'react-router-dom'; +import { useProviderInventory } from 'src/modules/ProvidersNG/hooks'; +import { ProviderData } from 'src/modules/ProvidersNG/utils'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { ProviderVirtualMachine } from '@kubev2v/types'; +import { List, ListItem, PageSection, Title } from '@patternfly/react-core'; +import { TableComposable, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; + +interface ProviderVirtualMachinesProps extends RouteComponentProps { + obj: ProviderData; + ns?: string; + name?: string; + loaded?: boolean; + loadError?: unknown; +} + +export const ProviderVirtualMachines: React.FC = ({ obj }) => { + const { t } = useForkliftTranslation(); + const { provider } = obj; + + const { inventory: vms } = useProviderInventory({ + provider, + subPath: 'vms?detail=4', + }); + + if (!vms || vms.length === 0) { + return ( + + {t('No virtual machines found.')} + + ); + } + + return ( +
+ + + {t('Virtual Machined')} + + + + + + + {t('Name')} + {t('Concerns')} + + + + {vms && + vms.length > 0 && + vms.map((vm) => ( + + {vm.name} + + + {vm?.concerns?.map((c) => ( + {c.label} + ))} + + + + ))} + + + +
+ ); +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/tabs/VirtualMachines/index.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/tabs/VirtualMachines/index.ts new file mode 100644 index 000000000..e2ef18d6a --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/tabs/VirtualMachines/index.ts @@ -0,0 +1,3 @@ +// @index(['./*', /style/g], f => `export * from '${f.path}';`) +export * from './ProviderVirtualMachines'; +// @endindex diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/tabs/YAML/ProviderYAML.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/tabs/YAML/ProviderYAML.tsx new file mode 100644 index 000000000..c82ba641a --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/tabs/YAML/ProviderYAML.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { RouteComponentProps } from 'react-router-dom'; +import { ProviderData } from 'src/modules/ProvidersNG/utils'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { ResourceYAMLEditor } from '@openshift-console/dynamic-plugin-sdk'; +import { Bullseye } from '@patternfly/react-core'; + +interface ProviderYAMLPageProps extends RouteComponentProps { + obj: ProviderData; + ns?: string; + name?: string; + loaded?: boolean; + loadError?: unknown; +} + +export const ProviderYAMLPage: React.FC = ({ obj, loaded, loadError }) => { + const { t } = useForkliftTranslation(); + const { provider } = obj; + + return ( + + + + } + > + {provider && loaded && !loadError && ( + + )} + + ); +}; + +const Loading: React.FC = () => ( +
+
+
+
+
+); + +export default ProviderYAMLPage; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/tabs/YAML/index.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/tabs/YAML/index.ts new file mode 100644 index 000000000..e5c12df2e --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/tabs/YAML/index.ts @@ -0,0 +1,3 @@ +// @index(['./*', /style/g], f => `export * from '${f.path}';`) +export * from './ProviderYAML'; +// @endindex diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/tabs/index.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/tabs/index.ts new file mode 100644 index 000000000..572c94d4d --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/details/tabs/index.ts @@ -0,0 +1,8 @@ +// @index(['./*', /style/g], f => `export * from '${f.path}';`) +export * from './Credentials'; +export * from './Details'; +export * from './Hosts'; +export * from './Networks'; +export * from './VirtualMachines'; +export * from './YAML'; +// @endindex diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/index.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/index.ts new file mode 100644 index 000000000..4eb11bef6 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/index.ts @@ -0,0 +1,5 @@ +// @index(['./*', /style/g], f => `export * from '${f.path}';`) +export * from './create'; +export * from './details'; +export * from './list'; +// @endindex diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/list/ProviderRow.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/list/ProviderRow.tsx new file mode 100644 index 000000000..c38188821 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/list/ProviderRow.tsx @@ -0,0 +1,96 @@ +import React from 'react'; +import { ProviderData } from 'src/modules/ProvidersNG/utils'; + +import { ResourceField, RowProps } from '@kubev2v/common'; +import { + DatabaseIcon, + NetworkIcon, + OutlinedHddIcon, + VirtualMachineIcon, +} from '@patternfly/react-icons'; +import { Td, Tr } from '@patternfly/react-table'; + +import { ProviderActionsDropdown } from '../../actions'; +import { TableEmptyCell } from '../../utils'; + +import { + CellProps, + InventoryCellFactory, + NamespaceCell, + ProviderLinkCell, + StatusCell, + TypeCell, + URLCell, +} from './components'; + +/** + * Function component to render a table row (Tr) for a provider with inventory. + * Each cell (Td) in the row is rendered by the `renderTd` function. + * + * @param {RowProps} props - The properties passed to the component. + * @param {ResourceField[]} props.resourceFields - The fields to be displayed in the table row. + * @param {ProviderData} props.resourceData - The data for the provider, including its inventory. + * + * @returns {ReactNode - A React table row (Tr) component. + */ +export const ProviderRow: React.FC> = ({ resourceFields, resourceData }) => { + return ( + + {resourceFields.map(({ resourceFieldId }) => + renderTd({ resourceData, resourceFieldId, resourceFields }), + )} + + ); +}; + +/** + * Function to render a table cell (Td). + * If the cell is an inventory cell (NETWORK_COUNT, STORAGE_COUNT, VM_COUNT, or HOST_COUNT) + * and there's no inventory data, it won't render the cell. + * + * @param {RenderTdProps} props - An object holding all the parameters. + * @param {ProviderData} props.resourceData - The data for the resource. + * @param {string} props.resourceFieldId - The field ID for the resource. + * @param {ResourceField[]} props.resourceFields - Array of resource fields + * + * @returns {ReactNode | undefined} - A React table cell (Td) component or undefined. + */ +const renderTd = ({ resourceData, resourceFieldId, resourceFields }: RenderTdProps) => { + const fieldId = resourceFieldId; + const hasInventory = resourceData?.inventory !== undefined; + const inventoryCells = ['networkCount', 'storageCount', 'vmCount', 'hostCount']; + + // If the current cell is an inventory cell and there's no inventory data, + // don't render the cell + if (inventoryCells.includes(fieldId) && !hasInventory) { + return ; + } + + const CellRenderer = cellRenderers?.[fieldId] ?? (() => <>); + return ( + + + + ); +}; + +const cellRenderers: Record> = { + ['name']: ProviderLinkCell, + ['phase']: StatusCell, + ['url']: URLCell, + ['type']: TypeCell, + ['namespace']: NamespaceCell, + ['networkCount']: InventoryCellFactory({ icon: }), + ['storageCount']: InventoryCellFactory({ icon: }), + ['vmCount']: InventoryCellFactory({ icon: }), + ['hostCount']: InventoryCellFactory({ icon: }), + ['actions']: (props) => ProviderActionsDropdown({ isKebab: true, ...props }), +}; + +interface RenderTdProps { + resourceData: ProviderData; + resourceFieldId: string; + resourceFields: ResourceField[]; +} + +export default ProviderRow; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/list/ProvidersListPage.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/list/ProvidersListPage.tsx new file mode 100644 index 000000000..304894041 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/list/ProvidersListPage.tsx @@ -0,0 +1,221 @@ +import React, { useState } from 'react'; +import { useHistory } from 'react-router'; +import StandardPage from 'src/components/page/StandardPage'; +import { + getResourceUrl, + ProviderData, + SOURCE_ONLY_PROVIDER_TYPES, +} from 'src/modules/ProvidersNG/utils'; +import { PROVIDER_STATUS, PROVIDERS } from 'src/utils/enums'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { EnumToTuple, loadUserSettings, ResourceFieldFactory } from '@kubev2v/common'; +import { + ProviderModel, + ProviderModelGroupVersionKind, + ProviderModelRef, + ProviderType, + V1beta1Provider, +} from '@kubev2v/types'; +import { useK8sWatchResource } from '@openshift-console/dynamic-plugin-sdk'; +import { Alert, Button, Text, TextContent, TextVariants } from '@patternfly/react-core'; + +import { useGetDeleteAndEditAccessReview, useProvidersInventoryList } from '../../hooks'; +import { findInventoryByID } from '../../utils'; + +import ProviderRow from './ProviderRow'; + +export const fieldsMetadataFactory: ResourceFieldFactory = (t) => [ + { + resourceFieldId: 'name', + jsonPath: '$.provider.metadata.name', + label: t('Name'), + isVisible: true, + isIdentity: true, // Name is sufficient ID when Namespace is pre-selected + filter: { + type: 'freetext', + placeholderLabel: t('Filter by name'), + }, + sortable: true, + }, + { + resourceFieldId: 'namespace', + jsonPath: '$.provider.metadata.namespace', + label: t('Namespace'), + isVisible: true, + isIdentity: true, + filter: { + type: 'freetext', + placeholderLabel: t('Filter by namespace'), + }, + sortable: true, + }, + { + resourceFieldId: 'phase', + jsonPath: '$.provider.status.phase', + label: t('Status'), + isVisible: true, + filter: { + type: 'enum', + primary: true, + placeholderLabel: t('Status'), + values: EnumToTuple(PROVIDER_STATUS), + }, + sortable: true, + }, + { + resourceFieldId: 'url', + jsonPath: '$.provider.spec.url', + label: t('Endpoint'), + isVisible: true, + filter: { + type: 'freetext', + placeholderLabel: t('Filter by endpoint'), + }, + sortable: true, + }, + { + resourceFieldId: 'type', + jsonPath: '$.provider.spec.type', + label: t('Type'), + isVisible: true, + filter: { + type: 'groupedEnum', + primary: true, + placeholderLabel: t('Type'), + values: EnumToTuple(PROVIDERS).map(({ id, ...rest }) => ({ + id, + groupId: SOURCE_ONLY_PROVIDER_TYPES.includes(id as ProviderType) ? 'source' : 'target', + ...rest, + })), + groups: [ + { groupId: 'target', label: t('Target and Source') }, + { groupId: 'source', label: t('Source Only') }, + ], + }, + sortable: true, + }, + { + resourceFieldId: 'vmCount', + jsonPath: '$.inventory.vmCount', + label: t('VMs'), + isVisible: true, + sortable: true, + }, + { + resourceFieldId: 'networkCount', + jsonPath: '$.inventory.networkCount', + label: t('Networks'), + isVisible: true, + sortable: true, + }, + { + resourceFieldId: 'clusterCount', + jsonPath: '$.inventory.clusterCount', + label: t('Clusters'), + isVisible: false, + sortable: true, + }, + { + resourceFieldId: 'hostCount', + jsonPath: '$.inventory.hostCount', + label: t('Hosts'), + isVisible: true, + sortable: true, + }, + { + resourceFieldId: 'storageCount', + jsonPath: '$.inventory.storageCount', + label: t('Storage'), + isVisible: false, + sortable: true, + }, + { + resourceFieldId: 'actions', + label: '', + isAction: true, + isVisible: true, + sortable: false, + }, +]; + +const ProvidersListPage: React.FC<{ + namespace: string; +}> = ({ namespace }) => { + const { t } = useForkliftTranslation(); + const history = useHistory(); + const [userSettings] = useState(() => loadUserSettings({ pageId: 'Providers' })); + + const [providers, providersLoaded, providersLoadError] = useK8sWatchResource({ + groupVersionKind: ProviderModelGroupVersionKind, + namespaced: true, + isList: true, + namespace, + }); + + const { + inventory, + loading: inventoryLoading, + error: inventoryError, + } = useProvidersInventoryList({}); + + const permissions = useGetDeleteAndEditAccessReview({ + model: ProviderModel, + namespace, + }); + + const data: ProviderData[] = providers.map((provider) => ({ + provider, + inventory: findInventoryByID(inventory, provider.metadata?.uid), + permissions, + })); + + const providersListURL = getResourceUrl({ + reference: ProviderModelRef, + namespace: namespace, + }); + + const AddButton = ( + + ); + + const inventoryNotReachable = ( + + + + {t( + 'Inventory server is not reachable. To troubleshoot, check the Forklift controller pod logs.', + )} + + + + ); + + return ( + + data-testid="providers-list" + addButton={AddButton} + dataSource={[data || [], providersLoaded, providersLoadError]} + RowMapper={ProviderRow} + fieldsMetadata={fieldsMetadataFactory(t)} + namespace={namespace} + title={t('Providers')} + userSettings={userSettings} + alerts={!inventoryLoading && inventoryError ? [inventoryNotReachable] : undefined} + /> + ); +}; + +export default ProvidersListPage; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/list/components/CellProps.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/list/components/CellProps.tsx new file mode 100644 index 000000000..87f9d050b --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/list/components/CellProps.tsx @@ -0,0 +1,9 @@ +import { ProviderData } from 'src/modules/ProvidersNG/utils'; + +import { ResourceField } from '@kubev2v/common'; + +export type CellProps = { + data: ProviderData; + fieldId: string; + fields: ResourceField[]; +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/list/components/InventoryCellFactory.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/list/components/InventoryCellFactory.tsx new file mode 100644 index 000000000..9d671cb6f --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/list/components/InventoryCellFactory.tsx @@ -0,0 +1,31 @@ +import React, { ReactNode } from 'react'; +import { TableEmptyCell, TableIconCell } from 'src/modules/ProvidersNG/utils'; + +import { getResourceFieldValue } from '@kubev2v/common'; + +import { CellProps } from './CellProps'; + +/** + * Factory function for creating InventoryCell components. + * @param {Object} param0 - The icon for the component. + * @returns {Function} - A function that returns a TableIconCell component. + */ +export const InventoryCellFactory: CellFactory = ({ icon }) => { + /** + * Inner function that returns a TableIconCell component. + * @param {CellProps} param1 - The props for the component. + * @returns {JSX.Element} - The rendered component. + */ + // eslint-disable-next-line react/display-name + return ({ data, fieldId, fields }: CellProps) => { + const { provider, inventory } = data; + const value = getResourceFieldValue({ ...provider, inventory }, fieldId, fields); + + if (value === undefined) { + return ; + } + return {value}; + }; +}; + +type CellFactory = (props: { icon: ReactNode }) => React.FC; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/list/components/NamespaceCell.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/list/components/NamespaceCell.tsx new file mode 100644 index 000000000..e38129fb8 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/list/components/NamespaceCell.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { TableLinkCell } from 'src/modules/ProvidersNG/utils'; + +import { CellProps } from './CellProps'; + +/** + * NamespaceCell component, used for displaying a link cell with information about the namespace. + * @param {CellProps} props - The props for the component. + * @returns {JSX.Element} - The rendered component. + */ +export const NamespaceCell: React.FC = ({ data }) => { + const { provider } = data; + const { namespace } = provider?.metadata || {}; + + return ( + + ); +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/list/components/ProviderLinkCell.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/list/components/ProviderLinkCell.tsx new file mode 100644 index 000000000..bcbf80e3f --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/list/components/ProviderLinkCell.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { TableLinkCell } from 'src/modules/ProvidersNG/utils'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { ProviderModelGroupVersionKind } from '@kubev2v/types'; + +import { CellProps } from './CellProps'; + +/** + * ProviderLinkCell component, used for displaying a link cell with information about the provider. + * @param {CellProps} props - The props for the component. + * @returns {JSX.Element} - The rendered component. + */ +export const ProviderLinkCell: React.FC = ({ data }) => { + const { t } = useForkliftTranslation(); + + const { provider } = data; + const { name, namespace } = provider?.metadata || {}; + const localCluster = + provider?.spec?.type === 'openshift' && (!provider?.spec?.url || provider?.spec?.url === ''); + + return ( + + ); +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/list/components/StatusCell.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/list/components/StatusCell.tsx new file mode 100644 index 000000000..4c40820c8 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/list/components/StatusCell.tsx @@ -0,0 +1,110 @@ +import React from 'react'; +import { TFunction } from 'react-i18next'; +import Linkify from 'react-linkify'; +import { Link } from 'react-router-dom'; +import { getResourceUrl, TableIconCell } from 'src/modules/ProvidersNG/utils'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { getResourceFieldValue } from '@kubev2v/common'; +import { ProviderModelRef } from '@kubev2v/types'; +import { + GreenCheckCircleIcon, + RedExclamationCircleIcon, +} from '@openshift-console/dynamic-plugin-sdk'; +import { Button, Popover, Spinner, Text, TextContent, TextVariants } from '@patternfly/react-core'; + +import { CellProps } from './CellProps'; + +/** + * StatusCell component, used for displaying the status of a resource. + * @param {CellProps} props - The props for the component. + * @returns {JSX.Element} - The rendered component. + */ +export const StatusCell: React.FC = ({ data, fields, fieldId }) => { + const { t } = useForkliftTranslation(); + + const phase = getResourceFieldValue(data, 'phase', fields); + const phaseLabel = phaseLabels[phase] ? t(phaseLabels[phase]) : t('Undefined'); + + switch (phase) { + case 'ConnectionFailed': + case 'ValidationFailed': + return ErrorStatusCell({ + t, + data, + fields, + fieldId, + }); + default: + return {phaseLabel}; + } +}; + +/** + * A component that displays an error status cell with popover content. + * @param {Object} props - The component props. + * @param {Object} props.data - The data object for the cell. + * @param {Object} props.fields - The fields object for the cell. + * @returns {JSX.Element} The JSX element representing the error status cell. + */ +export const ErrorStatusCell: React.FC = ({ t, data, fields }) => { + const { provider } = data; + const phase = getResourceFieldValue(data, 'phase', fields); + const phaseLabel = phaseLabels[phase] ? t(phaseLabels[phase]) : t('Undefined'); + const providerURL = getResourceUrl({ + reference: ProviderModelRef, + name: provider?.metadata?.name, + namespace: provider?.metadata?.namespace, + }); + + // Find the error message from the status conditions + const bodyContent = provider?.status?.conditions.find( + (condition) => condition?.category === 'Critical', + )?.message; + + // Set the footer content + const footerContent = ( + + {t(`The provider is not ready.`)} + + {t( + `To troubleshoot, view the provider status available in the provider details page + and check the Forklift controller pod logs.`, + )} + + + {t('View provider details')} + + + ); + + return ( + {bodyContent}} + footerContent={footerContent} + > + + + ); +}; + +const statusIcons = { + ValidationFailed: , + ConnectionFailed: , + Ready: , + Staging: , +}; + +const phaseLabels = { + // t('Ready') + Ready: 'Ready', + // t('Connection Failed') + ConnectionFailed: 'Connection Failed', + // t('Validation Failed') + ValidationFailed: 'Validation Failed', + // t('Staging') + Staging: 'Staging', +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/list/components/TypeCell.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/list/components/TypeCell.tsx new file mode 100644 index 000000000..f001082d5 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/list/components/TypeCell.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { getIsOnlySource, TableLabelCell } from 'src/modules/ProvidersNG/utils'; +import { PROVIDERS } from 'src/utils/enums'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { getResourceFieldValue } from '@kubev2v/common'; + +import { CellProps } from './CellProps'; + +/** + * TypeCell component, used for displaying the type of a resource. + * @param {CellProps} props - The props for the component. + * @returns {JSX.Element} - The rendered component. + */ +export const TypeCell: React.FC = ({ data, fields }) => { + const { t } = useForkliftTranslation(); + + const { provider } = data; + const type = getResourceFieldValue(data, 'type', fields); + const isSource = getIsOnlySource(provider); + + return ( + + {PROVIDERS?.[type] || ''} + + ); +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/list/components/URLCell.tsx b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/list/components/URLCell.tsx new file mode 100644 index 000000000..06ae26f26 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/list/components/URLCell.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { TableCell } from 'src/modules/ProvidersNG/utils'; + +import { getResourceFieldValue } from '@kubev2v/common'; +import { Truncate } from '@patternfly/react-core'; + +import { CellProps } from './CellProps'; + +/** + * URLCell component, used for displaying a TableCell with a URL string. + * @param {CellProps} props - The props for the component. + * @returns {JSX.Element} - The rendered component. + */ +export const URLCell: React.FC = ({ data, fieldId, fields }) => { + const url = (getResourceFieldValue(data, fieldId, fields) ?? '').toString(); + return ( + + + + ); +}; diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/list/components/index.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/list/components/index.ts new file mode 100644 index 000000000..e009778c6 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/list/components/index.ts @@ -0,0 +1,9 @@ +// @index('./*.tsx', f => `export * from '${f.path}';`) +export * from './CellProps'; +export * from './InventoryCellFactory'; +export * from './NamespaceCell'; +export * from './ProviderLinkCell'; +export * from './StatusCell'; +export * from './TypeCell'; +export * from './URLCell'; +// @endindex diff --git a/packages/forklift-console-plugin/src/modules/ProvidersNG/views/list/index.ts b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/list/index.ts new file mode 100644 index 000000000..1b15d7d81 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/ProvidersNG/views/list/index.ts @@ -0,0 +1,5 @@ +// @index(['./*', /style/g], f => `export * from '${f.path}';`) +export * from './components'; +export * from './ProviderRow'; +export * from './ProvidersListPage'; +// @endindex