From c1b409d84c27628bb5988e8f402b5b65be2347fe Mon Sep 17 00:00:00 2001 From: Radoslaw Szwajkowski Date: Wed, 16 Nov 2022 18:52:09 +0100 Subject: [PATCH] Convert Providers screen to Console page Code changes: 1. use StandardPage list component 2. entity list is produced by merging results from k8s API and exsting Forklift inventory REST API. 3. actions in the table (kebab actions) are provided via extension point 'console.action/provider' 4. use existing actions for Add/Edit/Delete/Select Network Functional changes: 1. display all providers in one table 2. use only 'Ready' condition to describe the state of the provider 3. disable rich content in Network/Storage columns until the design for the details page is known Signed-off-by: Radoslaw Szwajkowski --- console-extensions.ts | 24 +- .../en/plugin__forklift-console-plugin.json | 32 ++- pkg/web/src/app/Providers/HostsPage.tsx | 6 +- .../ProviderActionsDropdown.tsx | 57 +++-- src/components/ActionServiceDropdown.tsx | 39 ++++ src/components/Filter/helpers.ts | 3 + src/components/types.ts | 3 + src/internal/i18n/enums.ts | 15 ++ src/internal/i18n/index.ts | 1 + src/internal/k8s/types.ts | 5 + src/modules/Providers/ProviderRow.tsx | 124 ++++++++++ src/modules/Providers/ProvidersPage.tsx | 166 ++++++++++++++ src/modules/Providers/ProvidersWrapper.tsx | 2 +- src/modules/Providers/UseMergedProviders.ts | 3 + src/modules/Providers/data.ts | 152 +++++++++++++ src/modules/Providers/dynamic-plugin.ts | 24 +- src/modules/Providers/index.ts | 2 + src/modules/Providers/providerActions.tsx | 212 ++++++++++++++++++ src/utils/constants.ts | 19 +- src/utils/fetch.ts | 40 ++++ 20 files changed, 886 insertions(+), 43 deletions(-) create mode 100644 src/components/ActionServiceDropdown.tsx create mode 100644 src/internal/i18n/enums.ts create mode 100644 src/modules/Providers/ProviderRow.tsx create mode 100644 src/modules/Providers/ProvidersPage.tsx create mode 100644 src/modules/Providers/UseMergedProviders.ts create mode 100644 src/modules/Providers/data.ts create mode 100644 src/modules/Providers/index.ts create mode 100644 src/modules/Providers/providerActions.tsx create mode 100644 src/utils/fetch.ts diff --git a/console-extensions.ts b/console-extensions.ts index d233ba257..498eecdd6 100644 --- a/console-extensions.ts +++ b/console-extensions.ts @@ -1,5 +1,10 @@ import type { EncodedExtension } from '@openshift/dynamic-plugin-sdk'; -import type { HrefNavItem, NavSection, Separator } from '@openshift-console/dynamic-plugin-sdk'; +import type { + HrefNavItem, + NavSection, + ResourceNSNavItem, + Separator, +} from '@openshift-console/dynamic-plugin-sdk'; import { extensions as hostExtensions } from './src/modules/Hosts/dynamic-plugin'; import { extensions as mappingExtensions } from './src/modules/Mappings/dynamic-plugin'; @@ -39,17 +44,22 @@ const extensions: EncodedExtension[] = [ } as EncodedExtension, { - type: 'console.navigation/href', + type: 'console.navigation/resource-ns', properties: { id: 'providers', - insertAfter: 'importSeparator', - perspective: 'admin', section: 'virtualization', name: '%plugin__forklift-console-plugin~Providers for VM Import%', - href: '/mtv/providers', + model: { + group: 'forklift.konveyor.io', + kind: 'Provider', + version: 'v1beta1', + }, + dataAttributes: { + 'data-quickstart-id': 'qs-nav-providers', + 'data-test-id': 'providers-nav-item', + }, }, - } as EncodedExtension, - + } as EncodedExtension, { type: 'console.navigation/href', properties: { diff --git a/locales/en/plugin__forklift-console-plugin.json b/locales/en/plugin__forklift-console-plugin.json index e0815e3ad..bb7c2b670 100644 --- a/locales/en/plugin__forklift-console-plugin.json +++ b/locales/en/plugin__forklift-console-plugin.json @@ -1,23 +1,53 @@ { + "{{type}} provider {{name}} will no longer be selectable as a migration source.": "{{type}} provider {{name}} will no longer be selectable as a migration source.", + "{{type}} provider {{name}} will no longer be selectable as a migration target.": "{{type}} provider {{name}} will no longer be selectable as a migration target.", + "Actions": "Actions", + "Add Provider": "Add Provider", "Cancel": "Cancel", + "Cannot remove provider": "Cannot remove provider", "Clear all filters": "Clear all filters", + "Clusters": "Clusters", + "Delete": "Delete", + "Delete Provider": "Delete Provider", + "Edit Provider": "Edit Provider", + "Endpoint": "Endpoint", + "False": "False", + "Filter by endpoint": "Filter by endpoint", "Filter by name": "Filter by name", "Filter by namespace": "Filter by namespace", + "Hosts": "Hosts", + "KubeVirt": "KubeVirt", "Loading": "Loading", "Manage Columns": "Manage Columns", "Mappings for VM Import": "Mappings for VM Import", "Name": "Name", "Namespace": "Namespace", + "Networks": "Networks", + "No information": "No information", "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.", + "oVirt": "oVirt", + "Permanently delete provider?": "Permanently delete provider?", "Plans for VM Import": "Plans for VM Import", + "Providers": "Providers", "Providers for VM Import": "Providers for VM Import", + "Ready": "Ready", "Reorder": "Reorder", "Restore default colums": "Restore default colums", "Save": "Save", + "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 Filter": "Select Filter", + "Select migration network": "Select migration network", "Selected columns will be displayed in the table.": "Selected columns will be displayed in the table.", + "Storage": "Storage", "Table column management": "Table column management", + "The host provider cannot be edited": "The host provider cannot be edited", + "This provider cannot be edited because it has running migrations": "This provider cannot be edited because it has running migrations", + "True": "True", + "Type": "Type", "Unable to retrieve data": "Unable to retrieve data", - "Virtualization": "Virtualization" + "Unknown": "Unknown", + "Virtualization": "Virtualization", + "VMs": "VMs", + "VMware": "VMware" } diff --git a/pkg/web/src/app/Providers/HostsPage.tsx b/pkg/web/src/app/Providers/HostsPage.tsx index 233560bda..7e560308a 100644 --- a/pkg/web/src/app/Providers/HostsPage.tsx +++ b/pkg/web/src/app/Providers/HostsPage.tsx @@ -11,7 +11,7 @@ import { } from '@patternfly/react-core'; import spacing from '@patternfly/react-styles/css/utilities/Spacing/spacing'; import { Breadcrumb, BreadcrumbItem } from '@patternfly/react-core'; -import { Link, useRouteMatch } from 'react-router-dom'; +import { useRouteMatch } from 'react-router-dom'; import { VMwareProviderHostsTable } from './components/VMwareProviderHostsTable'; import PlusCircleIcon from '@patternfly/react-icons/dist/esm/icons/plus-circle-icon'; import { useHostsQuery, useInventoryProvidersQuery } from '@app/queries'; @@ -45,9 +45,7 @@ export const HostsPage: React.FunctionComponent = () => { Providers - - {PROVIDER_TYPE_NAMES.vsphere} - + {PROVIDER_TYPE_NAMES.vsphere} {match?.params.providerName} Hosts diff --git a/pkg/web/src/app/Providers/components/ProvidersTable/ProviderActionsDropdown.tsx b/pkg/web/src/app/Providers/components/ProvidersTable/ProviderActionsDropdown.tsx index 5fc79c1d9..a9bb2090f 100644 --- a/pkg/web/src/app/Providers/components/ProvidersTable/ProviderActionsDropdown.tsx +++ b/pkg/web/src/app/Providers/components/ProvidersTable/ProviderActionsDropdown.tsx @@ -1,7 +1,12 @@ import * as React from 'react'; import { Dropdown, KebabToggle, DropdownItem, DropdownPosition } from '@patternfly/react-core'; import { useDeleteProviderMutation } from '@app/queries'; -import { ICorrelatedProvider, InventoryProvider } from '@app/queries/types'; +import { + ICorrelatedProvider, + INameNamespaceRef, + InventoryProvider, + IPlan, +} from '@app/queries/types'; import { PATH_PREFIX, ProviderType, PROVIDER_TYPE_NAMES } from '@app/common/constants'; import { ConfirmModal } from '@app/common/components/ConfirmModal'; import { EditProviderContext } from '@app/Providers/ProvidersPage'; @@ -11,6 +16,22 @@ import { isSameResource } from '@app/queries/helpers'; import { useHistory } from 'react-router-dom'; import { useClusterProvidersQuery } from '@app/queries'; +export const hasRunningMigration = ({ + plans = [], + providerMetadata, +}: { + plans: IPlan[]; + providerMetadata: INameNamespaceRef; +}): boolean => + !!plans + .filter((plan) => hasCondition(plan.status?.conditions || [], 'Executing')) + .find((runningPlan) => { + const { source, destination } = runningPlan.spec.provider; + return ( + isSameResource(providerMetadata, source) || isSameResource(providerMetadata, destination) + ); + }); + interface IProviderActionsDropdownProps { provider: ICorrelatedProvider; providerType: ProviderType; @@ -37,15 +58,15 @@ export const ProviderActionsDropdown: React.FunctionComponent hasCondition(plan.status?.conditions || [], 'Executing')) - .find((runningPlan) => { - const { source, destination } = runningPlan.spec.provider; - return ( - isSameResource(provider.metadata, source) || isSameResource(provider.metadata, destination) - ); - }); - const isEditDeleteDisabled = !provider.spec.url || hasRunningMigration; + const isEditDeleteDisabled = + !provider.spec.url || hasRunningMigration({ plans, providerMetadata: provider.metadata }); + + const disabledEditTooltip = !provider.spec.url + ? 'The host provider cannot be edited' + : 'This provider cannot be edited because it has running migrations'; + const disabledDeleteTooltip = !provider.spec.url + ? 'The host provider cannot be removed' + : 'This provider cannot be removed because it has running migrations'; return ( <> @@ -58,13 +79,7 @@ export const ProviderActionsDropdown: React.FunctionComponent + function GenericActions({ actions }: ActionService) { + const { t } = useTranslation(); + const [isActionMenuOpen, setIsActionMenuOpen] = useState(false); + const isPlain = variant === 'kebab'; + const toggle = + variant === 'kebab' ? ( + + ) : ( + {t('Actions')} + ); + return ( + <> + setIsActionMenuOpen(!isActionMenuOpen)} + toggle={toggle} + isOpen={isActionMenuOpen} + isPlain={isPlain} + dropdownItems={actions.map(({ id, label, cta, disabled, disabledTooltip }) => ( + undefined} + isAriaDisabled={disabled} + tooltip={disabledTooltip} + > + {label} + + ))} + /> + + ); + }; diff --git a/src/components/Filter/helpers.ts b/src/components/Filter/helpers.ts index 5a15b3b21..44f7ac1d5 100644 --- a/src/components/Filter/helpers.ts +++ b/src/components/Filter/helpers.ts @@ -7,3 +7,6 @@ export const toFieldFilter = ({ toLabel: toFieldLabel, filter: filterDef, }: Field): FieldFilter => ({ fieldId, toFieldLabel, filterDef }); + +export const fromI18nEnum = (i18nEnum: { [k: string]: (t: (k: string) => string) => string }) => + Object.entries(i18nEnum).map(([type, toLabel]) => ({ id: type, toLabel })); diff --git a/src/components/types.ts b/src/components/types.ts index bc14e90cf..406100e03 100644 --- a/src/components/types.ts +++ b/src/components/types.ts @@ -16,3 +16,6 @@ export interface Field { // eslint-disable-next-line @typescript-eslint/no-explicit-any comparator?: (a: any, b: any, locale: string) => number; } + +export const K8sConditionStatusValues = ['True', 'False', 'Unknown'] as const; +export type K8sConditionStatus = typeof K8sConditionStatusValues[number]; diff --git a/src/internal/i18n/enums.ts b/src/internal/i18n/enums.ts new file mode 100644 index 000000000..722730342 --- /dev/null +++ b/src/internal/i18n/enums.ts @@ -0,0 +1,15 @@ +import { K8sConditionStatus } from '_/components/types'; + +import { ProviderType } from '@app/common/constants'; + +export const PROVIDERS: Record string) => string> = { + vsphere: (t) => t('VMware'), + ovirt: (t) => t('oVirt'), + openshift: (t) => t('KubeVirt'), +}; + +export const CONDITIONS: Record string) => string> = { + True: (t) => t('True'), + False: (t) => t('False'), + Unknown: (t) => t('Unknown'), +}; diff --git a/src/internal/i18n/index.ts b/src/internal/i18n/index.ts index e82230f1b..d92880912 100644 --- a/src/internal/i18n/index.ts +++ b/src/internal/i18n/index.ts @@ -1 +1,2 @@ +export * from './enums'; export * from './i18n'; diff --git a/src/internal/k8s/types.ts b/src/internal/k8s/types.ts index 89ccece69..63a7d1937 100644 --- a/src/internal/k8s/types.ts +++ b/src/internal/k8s/types.ts @@ -30,3 +30,8 @@ export type ProviderResource = { conditions?: Condition[]; }; } & K8sResourceCommon; + +export type ResourceConsolePageProps = { + kind: string; + namespace: string; +}; diff --git a/src/modules/Providers/ProviderRow.tsx b/src/modules/Providers/ProviderRow.tsx new file mode 100644 index 000000000..d8cb65f98 --- /dev/null +++ b/src/modules/Providers/ProviderRow.tsx @@ -0,0 +1,124 @@ +import React, { JSXElementConstructor } from 'react'; +import { Link } from 'react-router-dom'; +import { RowProps } from 'src/components/TableView'; +import { CONDITIONS, PROVIDERS, useTranslation } from 'src/internal/i18n'; +import { + ACTIONS, + HOST_COUNT, + NAME, + NAMESPACE, + NETWORK_COUNT, + READY, + STORAGE_COUNT, + TYPE, + URL, +} from 'src/utils/constants'; + +import { PATH_PREFIX } from '@app/common/constants'; +import { StatusIcon } from '@migtools/lib-ui'; +import { ResourceLink } from '@openshift-console/dynamic-plugin-sdk'; +import { Button, Popover } from '@patternfly/react-core'; +import { DatabaseIcon, NetworkIcon, OutlinedHddIcon } from '@patternfly/react-icons'; +import { Td, Tr } from '@patternfly/react-table'; + +import { MergedProvider } from './data'; +import { ProviderActions } from './providerActions'; + +interface CellProps { + value: string; + entity: MergedProvider; + t?: (k: string) => string; +} + +const StatusCell = ({ value, entity: { conditions }, t }: CellProps) => { + const existingConditions = Object.values(conditions).filter(Boolean); + const toState = (value) => { + switch (value) { + case 'True': + return 'Ok'; + case 'False': + return 'Error'; + default: + return 'Unknown'; + } + }; + + const label = CONDITIONS[value]?.(t) ?? t('Unknown'); + return ( + + {existingConditions.length > 0 + ? existingConditions.map(({ message, status }) => { + return ; + }) + : t('No information')} + + } + > + + + ); +}; + +const TextCell = ({ value }: { value: string }) => <>{value ?? ''}; + +const TextWithIcon = ({ value, Icon }: { value: string; Icon: JSXElementConstructor }) => ( + <> + {value && ( + <> + + + )} + +); + +const ProviderLink = ({ value, entity }: CellProps) => ( + +); + +const HostCell = ({ value, entity: { ready, name, type } }: CellProps) => ( + <> + {ready === 'True' && value && type === 'vsphere' ? ( + + + + ) : ( + + )} + +); + +const cellCreator: Record JSX.Element> = { + [NAME]: ProviderLink, + [READY]: StatusCell, + [URL]: TextCell, + [TYPE]: ({ value, t }: CellProps) => , + [NAMESPACE]: ({ value }: CellProps) => , + [ACTIONS]: ProviderActions, + [NETWORK_COUNT]: ({ value }: CellProps) => , + [STORAGE_COUNT]: ({ value }: CellProps) => , + [HOST_COUNT]: HostCell, +}; + +const ProviderRow = ({ columns, entity }: RowProps) => { + const { t } = useTranslation(); + return ( + + {columns.map(({ id, toLabel }) => ( + + {cellCreator?.[id]?.({ + value: entity[id], + entity, + t, + }) ?? } + + ))} + + ); +}; + +export default ProviderRow; diff --git a/src/modules/Providers/ProvidersPage.tsx b/src/modules/Providers/ProvidersPage.tsx new file mode 100644 index 000000000..a6b9acd5f --- /dev/null +++ b/src/modules/Providers/ProvidersPage.tsx @@ -0,0 +1,166 @@ +import React from 'react'; +import { fromI18nEnum } from '_/components/Filter/helpers'; +import withQueryClient from '_/components/QueryClientHoc'; +import { StandardPage } from 'src/components/StandardPage'; +import { Field } from 'src/components/types'; +import { CONDITIONS, PROVIDERS, useTranslation } from 'src/internal/i18n'; +import { ResourceConsolePageProps } from 'src/internal/k8s'; +import * as C from 'src/utils/constants'; + +import { AddEditProviderModal } from '@app/Providers/components/AddEditProviderModal'; +import { EditProviderContext } from '@app/Providers/ProvidersPage'; +import { useModal } from '@openshift-console/dynamic-plugin-sdk'; +import { Button } from '@patternfly/react-core'; + +import { MergedProvider, useProvidersWithInventory } from './data'; +import ProviderRow from './ProviderRow'; + +const fieldsMetadata: Field[] = [ + { + id: C.NAME, + toLabel: (t) => t('Name'), + isVisible: true, + isIdentity: true, // Name is sufficient ID when Namespace is pre-selected + filter: { + type: 'freetext', + toPlaceholderLabel: (t) => t('Filter by name'), + }, + sortable: true, + }, + { + id: C.NAMESPACE, + toLabel: (t) => t('Namespace'), + isVisible: true, + isIdentity: true, + filter: { + type: 'freetext', + toPlaceholderLabel: (t) => t('Filter by namespace'), + }, + sortable: true, + }, + { + id: C.READY, + toLabel: (t) => t('Ready'), + isVisible: true, + filter: { + type: 'enum', + primary: true, + toPlaceholderLabel: (t) => t('Ready'), + values: fromI18nEnum(CONDITIONS), + }, + sortable: true, + }, + { + id: C.URL, + toLabel: (t) => t('Endpoint'), + isVisible: true, + filter: { + type: 'freetext', + toPlaceholderLabel: (t) => t('Filter by endpoint'), + }, + sortable: true, + }, + { + id: C.TYPE, + toLabel: (t) => t('Type'), + isVisible: true, + filter: { + type: 'enum', + primary: true, + toPlaceholderLabel: (t) => t('Type'), + values: fromI18nEnum(PROVIDERS), + }, + sortable: true, + }, + { + id: C.VM_COUNT, + toLabel: (t) => t('VMs'), + isVisible: true, + sortable: true, + }, + { + id: C.NETWORK_COUNT, + toLabel: (t) => t('Networks'), + isVisible: true, + sortable: true, + }, + { + id: C.CLUSTER_COUNT, + toLabel: (t) => t('Clusters'), + isVisible: false, + sortable: true, + }, + { + id: C.HOST_COUNT, + toLabel: (t) => t('Hosts'), + isVisible: true, + sortable: true, + }, + { + id: C.STORAGE_COUNT, + toLabel: (t) => t('Storage'), + isVisible: false, + sortable: true, + }, + { + id: C.ACTIONS, + toLabel: () => '', + isVisible: true, + sortable: false, + }, +]; + +export const ProvidersPage = ({ namespace, kind }: ResourceConsolePageProps) => { + const { t } = useTranslation(); + + const dataSource = useProvidersWithInventory({ + kind, + namespace, + }); + + // data hook triggers frequent re-renders although data remains the same: + // both the content content and object reference + return ; +}; + +const Page = ({ + dataSource, + namespace, + title, +}: { + dataSource: [MergedProvider[], boolean, boolean]; + namespace: string; + title: string; +}) => ( + + addButton={} + dataSource={dataSource} + RowMapper={ProviderRow} + fieldsMetadata={fieldsMetadata} + namespace={namespace} + title={title} + /> +); + +const PageMemo = React.memo(Page); + +const AddProviderButton = () => { + const { t } = useTranslation(); + const launchModal = useModal(); + + return ( + + ); +}; + +const AddProviderModal = ({ closeModal }: { closeModal: () => void }) => { + return ( + undefined, plans: [] }}> + + + ); +}; + +export default ProvidersPage; diff --git a/src/modules/Providers/ProvidersWrapper.tsx b/src/modules/Providers/ProvidersWrapper.tsx index 28cbed6f9..4db6b9f46 100644 --- a/src/modules/Providers/ProvidersWrapper.tsx +++ b/src/modules/Providers/ProvidersWrapper.tsx @@ -1,6 +1,6 @@ import withQueryClient from 'src/components/QueryClientHoc'; -import { ProvidersPage } from '@app/Providers/ProvidersPage'; +import ProvidersPage from './ProvidersPage'; const Page = withQueryClient(ProvidersPage); diff --git a/src/modules/Providers/UseMergedProviders.ts b/src/modules/Providers/UseMergedProviders.ts new file mode 100644 index 000000000..36ae14381 --- /dev/null +++ b/src/modules/Providers/UseMergedProviders.ts @@ -0,0 +1,3 @@ +import { useMergedProviderActions } from './providerActions'; + +export default useMergedProviderActions; diff --git a/src/modules/Providers/data.ts b/src/modules/Providers/data.ts new file mode 100644 index 000000000..d00f44e23 --- /dev/null +++ b/src/modules/Providers/data.ts @@ -0,0 +1,152 @@ +import { useMemo } from 'react'; +import { ProviderResource } from 'src/internal/k8s'; +import { + API_VERSION, + CONNECTED, + DEFAULT_TRANSFER_NETWORK, + DEFAULT_TRANSFER_NETWORK_ANNOTATION, + INVENTORY, + KIND, + NAME, + NAMESPACE, + READY, + SECRET_NAME, + TYPE, + UID, + URL, + VALIDATED, +} from 'src/utils/constants'; +import { useProviders } from 'src/utils/fetch'; + +import { useInventoryProvidersQuery } from '@app/queries'; +import { + IOpenShiftProvider, + IProvidersByType, + IRHVProvider, + IVMwareProvider, +} from '@app/queries/types'; + +const conditionState = (state: string) => + state === 'True' || state === 'False' ? state : 'Unknown'; + +interface Condition { + status: string; + message: string; +} +interface SupportedConditions { + Ready?: Condition; + Validated?: Condition; + ConnectionTested?: Condition; + InventoryCreated?: Condition; +} + +interface FlattenedProvider { + [KIND]: string; + [NAME]: string; + [NAMESPACE]: string; + [URL]: string; + [TYPE]: string; + [UID]: string; + [SECRET_NAME]: string; + [API_VERSION]: string; + [DEFAULT_TRANSFER_NETWORK]: string; +} + +interface FlattenedConditions { + [READY]: string; + conditions: { + [READY]: Condition; + [CONNECTED]: Condition; + [VALIDATED]: Condition; + [INVENTORY]: Condition; + }; +} + +interface MergedInventory { + clusterCount: number; + hostCount: number; + vmCount: number; + networkCount: number; + storageCount: number; +} + +export type MergedProvider = FlattenedProvider & MergedInventory & FlattenedConditions; + +const mergeData = (resources: ProviderResource[], inventory: IProvidersByType) => + resources + .map( + ({ + metadata: { name, namespace, uid, annotations } = {}, + status: { conditions = [] } = {}, + spec: { url, type, secret: { name: secretName } = {} } = {}, + kind, + apiVersion, + }): [ + FlattenedProvider, + IVMwareProvider & IRHVProvider & IOpenShiftProvider, + SupportedConditions, + ] => [ + { + name, + namespace, + url, + type, + uid, + kind, + secretName, + apiVersion, + defaultTransferNetwork: annotations?.[DEFAULT_TRANSFER_NETWORK_ANNOTATION], + }, + inventory?.[type].find(({ uid: otherUid }) => otherUid === uid) ?? {}, + Object.fromEntries( + conditions.map(({ type, status, message }) => [ + type, + { status: conditionState(status), message }, + ]), + ), + ], + ) + .map( + ([ + provider, + { clusterCount, hostCount, vmCount, networkCount, datastoreCount, storageDomainCount }, + { Ready, Validated, ConnectionTested, InventoryCreated }, + ]): MergedProvider => ({ + ...provider, + clusterCount, + hostCount, + vmCount, + networkCount, + storageCount: storageDomainCount ?? datastoreCount, + ready: Ready?.status ?? 'Unknown', + conditions: { + ready: Ready, + inventory: InventoryCreated, + validated: Validated, + connected: ConnectionTested, + }, + }), + ); + +export const useProvidersWithInventory = ({ + kind, + namespace, + name = undefined, +}): [MergedProvider[], boolean, boolean] => { + const [resources, loaded, error] = useProviders({ kind, namespace, name }); + const { data, isSuccess, isError } = useInventoryProvidersQuery(); + const providersWithInventory = useMemo( + () => (resources && data ? mergeData(resources, data) : []), + [resources, data], + ); + + const totalSuccess = loaded && isSuccess; + const totalError = error || isError; + // extra memo to keep the tuple reference stable + // the tuple is used as data source and passed as prop + // which triggres unnecessary re-renders + return useMemo( + () => [providersWithInventory, totalSuccess, totalError], + [providersWithInventory, totalSuccess, totalError], + ); +}; diff --git a/src/modules/Providers/dynamic-plugin.ts b/src/modules/Providers/dynamic-plugin.ts index 9672b7f83..03773743a 100644 --- a/src/modules/Providers/dynamic-plugin.ts +++ b/src/modules/Providers/dynamic-plugin.ts @@ -1,20 +1,34 @@ import { EncodedExtension } from '@openshift/dynamic-plugin-sdk'; -import { RoutePage } from '@openshift-console/dynamic-plugin-sdk'; +import { ActionProvider, ResourceListPage } 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'] = { ProvidersPage: './modules/Providers/ProvidersWrapper', + useMergedProviders: './modules/Providers/UseMergedProviders', }; export const extensions: EncodedExtension[] = [ { - type: 'console.page/route', + type: 'console.page/resource/list', properties: { component: { $codeRef: 'ProvidersPage', }, - path: ['/mtv/providers', '/mtv/providers/:providerType'], - exact: true, + model: { + group: 'forklift.konveyor.io', + kind: 'Provider', + version: 'v1beta1', + }, + }, + } as EncodedExtension, + + { + type: 'console.action/provider', + properties: { + contextId: 'forklift-merged-provider', + provider: { + $codeRef: 'useMergedProviders', + }, }, - } as EncodedExtension, + } as EncodedExtension, ]; diff --git a/src/modules/Providers/index.ts b/src/modules/Providers/index.ts new file mode 100644 index 000000000..9ecea0580 --- /dev/null +++ b/src/modules/Providers/index.ts @@ -0,0 +1,2 @@ +export * from './providerActions'; +export * from './ProvidersPage'; diff --git a/src/modules/Providers/providerActions.tsx b/src/modules/Providers/providerActions.tsx new file mode 100644 index 000000000..34027b9b9 --- /dev/null +++ b/src/modules/Providers/providerActions.tsx @@ -0,0 +1,212 @@ +import React, { useMemo, useState } from 'react'; +import { createActions } from 'src/components/ActionServiceDropdown'; +import withQueryClient from 'src/components/QueryClientHoc'; +import { useTranslation } from 'src/internal/i18n'; + +import { ConfirmModal } from '@app/common/components/ConfirmModal'; +import { SelectOpenShiftNetworkModal } from '@app/common/components/SelectOpenShiftNetworkModal'; +import { ProviderType } from '@app/common/constants'; +import { AddEditProviderModal } from '@app/Providers/components/AddEditProviderModal'; +import { hasRunningMigration } from '@app/Providers/components/ProvidersTable/ProviderActionsDropdown'; +import { EditProviderContext } from '@app/Providers/ProvidersPage'; +import { + useDeleteProviderMutation, + useOCPMigrationNetworkMutation, + usePlansQuery, +} from '@app/queries'; +import { IOpenShiftProvider, IPlan, IProviderObject } from '@app/queries/types'; +import { ActionServiceProvider, useModal } from '@openshift-console/dynamic-plugin-sdk'; + +import { type MergedProvider } from './data'; + +export const useMergedProviderActions = (entity: MergedProvider) => { + const { t } = useTranslation(); + const launchModal = useModal(); + const plansQuery = usePlansQuery(); + const editingDisabled = + !entity.url || + hasRunningMigration({ + plans: plansQuery?.data?.items, + providerMetadata: { + name: entity.name, + namespace: entity.namespace, + }, + }); + const disabledTooltip = !entity.url + ? t('The host provider cannot be edited') + : t('This provider cannot be edited because it has running migrations'); + + const actions = useMemo( + () => + [ + { + id: 'edit', + cta: () => + launchModal(withQueryClient(EditModal), { + entity, + plans: plansQuery?.data?.items, + }), + label: t('Edit Provider'), + disabled: editingDisabled, + disabledTooltip: editingDisabled ? disabledTooltip : '', + }, + { + id: 'delete', + cta: () => launchModal(withQueryClient(DeleteModal), { entity }), + label: t('Delete Provider'), + disabled: editingDisabled, + disabledTooltip: editingDisabled ? disabledTooltip : '', + }, + entity.type === 'openshift' && { + id: 'selectNetwork', + cta: () => launchModal(withQueryClient(SelectNetworkForOpenshift), { entity }), + label: t('Select migration network'), + }, + ].filter(Boolean), + [t, editingDisabled, disabledTooltip], + ); + + return [actions, true, undefined]; +}; + +const EditModal = ({ + entity, + closeModal, + plans = [], +}: { + closeModal: () => void; + entity: MergedProvider; + plans: IPlan[]; +}) => { + return ( + undefined, plans }}> + + + ); +}; + +const SelectNetworkForOpenshift = ({ + entity, + closeModal, +}: { + closeModal: () => void; + entity: MergedProvider; +}) => { + const { t } = useTranslation(); + const migrationNetworkMutation = useOCPMigrationNetworkMutation(closeModal); + const inventory = toIOpenShiftProvider(entity, toIProviderObject(entity)); + return ( + { + migrationNetworkMutation.reset(); + closeModal(); + }} + onSubmit={(network) => + migrationNetworkMutation.mutate({ + provider: inventory, + network, + }) + } + mutationResult={migrationNetworkMutation} + /> + ); +}; + +const DeleteModal = ({ + entity, + closeModal, +}: { + closeModal: () => void; + entity: MergedProvider; +}) => { + const { t } = useTranslation(); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(true); + + const toggleDeleteModal = () => setIsDeleteModalOpen(!isDeleteModalOpen); + const deleteProviderMutation = useDeleteProviderMutation( + entity.type as ProviderType, + toggleDeleteModal, + ); + const isTarget = (type: ProviderType) => type !== 'openshift'; + + return ( + { + toggleDeleteModal(); + closeModal(); + }} + mutateFn={() => deleteProviderMutation.mutate(toIProviderObject(entity))} + mutateResult={deleteProviderMutation} + title={t('Permanently delete provider?')} + body={ + isTarget(entity.type as ProviderType) + ? t('{{type}} provider {{name}} will no longer be selectable as a migration target.', { + type: entity.type, + name: entity.name, + }) + : t('{{type}} provider {{name}} will no longer be selectable as a migration source.', { + type: entity.type, + name: entity.name, + }) + } + confirmButtonText={t('Delete')} + errorText={t('Cannot remove provider')} + cancelButtonText={t('Cancel')} + /> + ); +}; +export interface ProviderActionsProps { + entity: MergedProvider; + variant?: 'kebab' | 'dropdown'; +} + +export const ProviderActions = ({ entity, variant = 'kebab' }: ProviderActionsProps) => { + const ActionsComponent = useMemo(() => createActions(variant), [variant]); + return ( + + {ActionsComponent} + + ); +}; + +const toIProviderObject = ({ + name, + namespace, + type, + url, + secretName, + kind, + apiVersion, +}: MergedProvider): IProviderObject => ({ + metadata: { + name, + namespace, + }, + spec: { type: type as ProviderType, url, secret: { name: secretName, namespace } }, + kind, + apiVersion, +}); + +const toIOpenShiftProvider = ( + { name, namespace, networkCount, selfLink = 'foo', type, uid, vmCount }, + object, +): IOpenShiftProvider => ({ + object, + name, + namespace, + networkCount, + selfLink, + type, + uid, + vmCount, +}); diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 9125e9645..9d8c2c445 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -1,3 +1,20 @@ +export const ACTIONS = 'actions'; export const NAME = 'name'; -export const UID = 'uid'; +export const READY = 'ready'; +export const CONNECTED = 'connected'; +export const INVENTORY = 'inventory'; +export const VALIDATED = 'validated'; +export const TYPE = 'type'; +export const URL = 'url'; export const NAMESPACE = 'namespace'; +export const KIND = 'kind'; +export const UID = 'uid'; +export const CLUSTER_COUNT = 'clusterCount'; +export const HOST_COUNT = 'hostCount'; +export const VM_COUNT = 'vmCount'; +export const NETWORK_COUNT = 'networkCount'; +export const STORAGE_COUNT = 'storageCount'; +export const SECRET_NAME = 'secretName'; +export const API_VERSION = 'apiVersion'; +export const DEFAULT_TRANSFER_NETWORK = 'defaultTransferNetwork'; +export const DEFAULT_TRANSFER_NETWORK_ANNOTATION = 'forklift.konveyor.io/defaultTransferNetwork'; diff --git a/src/utils/fetch.ts b/src/utils/fetch.ts new file mode 100644 index 000000000..6de5f18c9 --- /dev/null +++ b/src/utils/fetch.ts @@ -0,0 +1,40 @@ +import { useMemo } from 'react'; +import { ProviderResource } from 'src/internal/k8s'; + +import { MOCK_CLUSTER_PROVIDERS } from '@app/queries/mocks/providers.mock'; +import { + useK8sWatchResource, + WatchK8sResource, + WatchK8sResult, +} from '@openshift-console/dynamic-plugin-sdk'; + +const IS_MOCK = process.env.DATA_SOURCE === 'mock'; + +function useRealK8sWatchResource({ + kind, + namespace, + name, +}: WatchK8sResource): WatchK8sResult { + return useK8sWatchResource({ + kind, + isList: true, + namespaced: true, + namespace, + name, + }); +} + +const useMockProviders = ({ name }: WatchK8sResource): WatchK8sResult => { + const mockData: ProviderResource[] = useMemo( + () => + !name + ? (MOCK_CLUSTER_PROVIDERS as ProviderResource[]) + : (MOCK_CLUSTER_PROVIDERS?.filter( + (provider) => provider?.metadata?.name === name, + ) as ProviderResource[]), + [name], + ); + return [mockData, true, false]; +}; + +export const useProviders = IS_MOCK ? useMockProviders : useRealK8sWatchResource;