diff --git a/console-extensions.ts b/console-extensions.ts index ade00f353..654e5195b 100644 --- a/console-extensions.ts +++ b/console-extensions.ts @@ -1,7 +1,13 @@ import type { EncodedExtension } from '@openshift/dynamic-plugin-sdk'; import type { + ActionProvider, + HorizontalNavTab, HrefNavItem, NavSection, + ResourceActionProvider, + ResourceDetailsPage, + ResourceListPage, + ResourceNSNavItem, RoutePage, Separator, } from '@openshift-console/dynamic-plugin-sdk'; @@ -38,16 +44,46 @@ 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.page/resource/list', + properties: { + component: { + $codeRef: 'ProvidersPage', + }, + model: { + group: 'forklift.konveyor.io', + kind: 'Provider', + version: 'v1beta1', + }, + }, + } as EncodedExtension, + + { + type: 'console.action/provider', + properties: { + contextId: 'mergedProvider', + provider: { + $codeRef: 'useMergedProviders', + }, + }, + } as EncodedExtension, { type: 'console.navigation/href', diff --git a/locales/en/plugin__forklift-console-plugin.json b/locales/en/plugin__forklift-console-plugin.json index e0815e3ad..c2e0b1856 100644 --- a/locales/en/plugin__forklift-console-plugin.json +++ b/locales/en/plugin__forklift-console-plugin.json @@ -1,23 +1,50 @@ { + "{{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 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", + "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/plugin.json b/plugin.json index e87cc8b4d..b3b5f62df 100644 --- a/plugin.json +++ b/plugin.json @@ -9,9 +9,10 @@ "MappingsPage": "./extensions/MappingsWrapper", "HostsPage": "./extensions/HostsPageWrapper", "PlanWizard": "./extensions/PlanWizardWrapper", - "VMMigrationDetails": "./extensions/VMMigrationDetailsWrapper" + "VMMigrationDetails": "./extensions/VMMigrationDetailsWrapper", + "useMergedProviders": "./extensions/UseMergedProviders" }, "dependencies": { "@console/pluginAPI": "*" } -} +} \ No newline at end of file diff --git a/src/Providers/ProviderRow.tsx b/src/Providers/ProviderRow.tsx new file mode 100644 index 000000000..2ed7170a6 --- /dev/null +++ b/src/Providers/ProviderRow.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import { RowProps } from 'src/components/TableView'; +import { useTranslation } from 'src/internal/i18n'; +import { NAME, NAMESPACE, READY, TYPE, URL } from 'src/utils/constants'; + +import { StatusIcon } from '@migtools/lib-ui'; +import { ResourceLink } from '@openshift-console/dynamic-plugin-sdk'; +import { Button, Popover } from '@patternfly/react-core'; +import { Td, Tr } from '@patternfly/react-table'; + +import { MergedProvider } from './data'; +import { ProviderActions } from './providerActions'; + +interface CellProps { + value: string; + entity: MergedProvider; +} +const StatusCell = ({ value, entity: { conditions } }: CellProps) => { + const { t } = useTranslation(); + 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 = ((value) => { + switch (value) { + case 'True': + return t('True'); + case 'False': + return t('False'); + default: + return t('Unknown'); + } + })(value); + return ( + + {existingConditions.length > 0 + ? existingConditions.map(({ message, status }) => { + return ; + }) + : t('No information')} + + } + > + + + ); +}; + +const TextCell = ({ value }: { value: string }) => <>{value}; + +const ProviderLink = ({ value, entity }: CellProps) => ( + +); + +const cellCreator = { + [NAME]: ProviderLink, + [READY]: StatusCell, + [URL]: TextCell, + [TYPE]: TextCell, + [NAMESPACE]: ({ value }: CellProps) => , +}; + +const ProviderRow = ({ columns, entity }: RowProps) => { + const { t } = useTranslation(); + return ( + + {columns.map(({ id, toLabel }) => ( + + {cellCreator?.[id]?.({ + value: entity[id], + entity, + }) ?? } + + ))} + + + + + ); +}; + +export default ProviderRow; diff --git a/src/Providers/ProvidersPage.tsx b/src/Providers/ProvidersPage.tsx new file mode 100644 index 000000000..a89b79fad --- /dev/null +++ b/src/Providers/ProvidersPage.tsx @@ -0,0 +1,159 @@ +import React from 'react'; +import { StandardPage } from 'src/components/StandardPage'; +import { Field } from 'src/components/types'; +import { useTranslation } from 'src/internal/i18n'; +import { ResourceConsolePageProps } from 'src/internal/k8s'; +import { + CLUSTER_COUNT, + HOST_COUNT, + NAME, + NAMESPACE, + NETWORK_COUNT, + READY, + STORAGE_COUNT, + TYPE, + URL, + VM_COUNT, +} from 'src/utils/constants'; + +import { Button } from '@patternfly/react-core'; + +import { MergedProvider, useProvidersWithInventory } from './data'; +import ProviderRow from './ProviderRow'; + +const fieldsMetadata: Field[] = [ + { + id: 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: NAMESPACE, + toLabel: (t) => t('Namespace'), + isVisible: true, + isIdentity: true, + filter: { + type: 'freetext', + toPlaceholderLabel: (t) => t('Filter by namespace'), + }, + sortable: true, + }, + { + id: READY, + toLabel: (t) => t('Ready'), + isVisible: true, + filter: { + type: 'enum', + primary: true, + toPlaceholderLabel: (t) => t('Ready'), + values: [ + { + id: 'True', + toLabel: (t) => { + return t('True'); + }, + }, + { + id: 'False', + toLabel: (t) => { + return t('False'); + }, + }, + { + id: 'Unknown', + toLabel: (t) => { + return t('Unknown'); + }, + }, + ], + }, + sortable: true, + }, + { + id: URL, + toLabel: (t) => t('Endpoint'), + isVisible: true, + filter: { + type: 'freetext', + toPlaceholderLabel: (t) => t('Filter by endpoint'), + }, + sortable: true, + }, + { + id: TYPE, + toLabel: (t) => t('Type'), + isVisible: true, + filter: { + type: 'enum', + primary: true, + toPlaceholderLabel: (t) => t('Type'), + values: [ + { id: 'vsphere', toLabel: (t) => t('VMware') }, + { id: 'ovirt', toLabel: (t) => t('oVirt') }, + { id: 'openshift', toLabel: (t) => t('KubeVirt') }, + ], + }, + sortable: true, + }, + { + id: VM_COUNT, + toLabel: (t) => t('VMs'), + isVisible: true, + sortable: true, + }, + { + id: NETWORK_COUNT, + toLabel: (t) => t('Networks'), + isVisible: true, + sortable: true, + }, + { + id: CLUSTER_COUNT, + toLabel: (t) => t('Clusters'), + isVisible: true, + sortable: true, + }, + { + id: HOST_COUNT, + toLabel: (t) => t('Hosts'), + isVisible: false, + sortable: true, + }, + { + id: STORAGE_COUNT, + toLabel: (t) => t('Storage'), + isVisible: false, + sortable: true, + }, +]; + +export const ProvidersPage = ({ namespace, kind }: ResourceConsolePageProps) => { + const { t } = useTranslation(); + const dataSource = useProvidersWithInventory({ + kind, + namespace, + }); + + return ( + + addButton={ + + } + dataSource={dataSource} + RowMapper={ProviderRow} + fieldsMetadata={fieldsMetadata} + namespace={namespace} + title={t('Providers')} + /> + ); +}; + +export default ProvidersPage; diff --git a/src/Providers/data.ts b/src/Providers/data.ts new file mode 100644 index 000000000..211101744 --- /dev/null +++ b/src/Providers/data.ts @@ -0,0 +1,133 @@ +import { useMemo } from 'react'; +import { ProviderResource } from 'src/internal/k8s'; +import { + CONNECTED, + INVENTORY, + KIND, + NAME, + NAMESPACE, + READY, + 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; +} + +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 } = {}, + status: { conditions = [] } = {}, + spec: { url, type } = {}, + kind, + }): [ + FlattenedProvider, + IVMwareProvider & IRHVProvider & IOpenShiftProvider, + SupportedConditions, + ] => [ + { + name, + namespace, + url, + type, + uid, + kind, + }, + 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], + ); + + return [providersWithInventory, loaded && isSuccess, error || isError]; +}; diff --git a/src/Providers/providerActions.tsx b/src/Providers/providerActions.tsx new file mode 100644 index 000000000..c5dbcf472 --- /dev/null +++ b/src/Providers/providerActions.tsx @@ -0,0 +1,109 @@ +import React, { useMemo, useState } from 'react'; +import { createActions } from 'src/extensions/actions'; +import withQueryClient from 'src/extensions/QueryClientHoc'; +import { useTranslation } from 'src/internal/i18n'; + +import { ConfirmModal } from '@app/common/components/ConfirmModal'; +import { ProviderType } from '@app/common/constants'; +import { useDeleteProviderMutation } from '@app/queries'; +import { ActionServiceProvider, useModal } from '@openshift-console/dynamic-plugin-sdk'; + +import { MergedProvider } from './data'; + +export const useMergedProviderActions = (entity: MergedProvider) => { + const { t } = useTranslation(); + const launchModal = useModal(); + const actions = useMemo( + () => [ + { + id: 'edit', + cta: () => console.warn('edit provider!'), + label: t('Edit Provider'), + }, + { + id: 'delete', + cta: () => launchModal(withQueryClient(DeleteModal), { entity }), + label: t('Delete Provider'), + }, + { + id: 'selectNetwork', + cta: () => console.warn('select network!'), + label: t('Select migration network'), + }, + ], + [t], + ); + + return [actions, true, undefined]; +}; + +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({ + metadata: { + name: entity.name, + namespace: entity.namespace, + }, + spec: { type: entity.type as ProviderType }, + kind: '', + apiVersion: '', + }) + } + 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} + + ); +}; 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/extensions/ProvidersWrapper.tsx b/src/extensions/ProvidersWrapper.tsx index 4e0a8c5d2..4efe44393 100644 --- a/src/extensions/ProvidersWrapper.tsx +++ b/src/extensions/ProvidersWrapper.tsx @@ -1,4 +1,4 @@ -import { ProvidersPage } from '@app/Providers/ProvidersPage'; +import ProvidersPage from 'src/Providers/ProvidersPage'; import withQueryClient from './QueryClientHoc'; diff --git a/src/extensions/UseMergedProviders.ts b/src/extensions/UseMergedProviders.ts new file mode 100644 index 000000000..a98c2cfe8 --- /dev/null +++ b/src/extensions/UseMergedProviders.ts @@ -0,0 +1,3 @@ +import { useMergedProviderActions } from 'src/Providers/providerActions'; + +export default useMergedProviderActions; diff --git a/src/extensions/actions.tsx b/src/extensions/actions.tsx new file mode 100644 index 000000000..18aec4af2 --- /dev/null +++ b/src/extensions/actions.tsx @@ -0,0 +1,34 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'src/internal/i18n'; + +import { ActionService } from '@openshift-console/dynamic-plugin-sdk'; +import { Dropdown, DropdownItem, DropdownToggle, KebabToggle } from '@patternfly/react-core'; + +export const createActions = (variant: 'kebab' | 'dropdown') => + 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 }) => ( + undefined}> + {label} + + ))} + /> + + ); + }; 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/utils/constants.ts b/src/utils/constants.ts index 9125e9645..badd8041e 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -1,3 +1,15 @@ 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'; diff --git a/src/utils/fetch.ts b/src/utils/fetch.ts new file mode 100644 index 000000000..65533b0dd --- /dev/null +++ b/src/utils/fetch.ts @@ -0,0 +1,29 @@ +import { ProviderResource } from 'src/internal/k8s'; + +import { MOCK_CLUSTER_PROVIDERS } from '@app/queries/mocks/providers.mock'; +import { useK8sWatchResource, WatchK8sResult } from '@openshift-console/dynamic-plugin-sdk'; + +const isMock = process.env.DATA_SOURCE === 'mock'; + +export function useMockableK8sWatchResource( + { kind, namespace, name }, + mockData: T[] = [], +): WatchK8sResult { + return isMock + ? [mockData, true, false] + : useK8sWatchResource({ + kind, + isList: true, + namespaced: true, + namespace, + name, + }); +} + +export const useProviders = ({ kind, namespace, name }) => + useMockableK8sWatchResource( + { kind, namespace, name }, + MOCK_CLUSTER_PROVIDERS?.filter( + (provider) => !name || provider?.metadata?.name === name, + ) as ProviderResource[], + );