diff --git a/frontend/src/__mocks__/mockConnection.ts b/frontend/src/__mocks__/mockConnection.ts new file mode 100644 index 0000000000..7195f8ad8a --- /dev/null +++ b/frontend/src/__mocks__/mockConnection.ts @@ -0,0 +1,33 @@ +import { Connection } from '~/concepts/connectionTypes/types'; + +type MockConnection = { + name?: string; + namespace?: string; + connectionType?: string; + displayName?: string; + description?: string; + data?: { [key: string]: string }; +}; + +export const mockConnection = ({ + name = 's3-connection', + namespace = 'ds-project-1', + connectionType = 's3', + displayName, + description, + data = {}, +}: MockConnection): Connection => ({ + kind: 'Secret', + apiVersion: 'v1', + metadata: { + name, + namespace, + labels: { 'opendatahub.io/dashboard': 'true', 'opendatahub.io/managed': 'true' }, + annotations: { + 'opendatahub.io/connection-type': connectionType, + ...(displayName && { 'openshift.io/display-name': displayName }), + ...(description && { 'openshift.io/description': description }), + }, + }, + data, +}); diff --git a/frontend/src/__mocks__/mockSecretK8sResource.ts b/frontend/src/__mocks__/mockSecretK8sResource.ts index 90baaae2a5..8d8fd3962c 100644 --- a/frontend/src/__mocks__/mockSecretK8sResource.ts +++ b/frontend/src/__mocks__/mockSecretK8sResource.ts @@ -5,6 +5,7 @@ type MockResourceConfigType = { name?: string; namespace?: string; displayName?: string; + connectionType?: string; s3Bucket?: string; endPoint?: string; region?: string; @@ -15,6 +16,7 @@ export const mockSecretK8sResource = ({ name = 'test-secret', namespace = 'test-project', displayName = 'Test Secret', + connectionType = 's3', s3Bucket = 'dGVzdC1idWNrZXQ=', endPoint = 'aHR0cHM6Ly9zMy5hbWF6b25hd3MuY29tLw==', region = 'dXMtZWFzdC0x', @@ -33,7 +35,7 @@ export const mockSecretK8sResource = ({ [KnownLabels.DATA_CONNECTION_AWS]: 'true', }, annotations: { - 'opendatahub.io/connection-type': 's3', + 'opendatahub.io/connection-type': connectionType, 'openshift.io/display-name': displayName, }, }, diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/projects/connections.cy.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/projects/connections.cy.ts new file mode 100644 index 0000000000..4d1ef4212b --- /dev/null +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/projects/connections.cy.ts @@ -0,0 +1,59 @@ +import { + mockDashboardConfig, + mockK8sResourceList, + mockProjectK8sResource, + mockSecretK8sResource, +} from '~/__mocks__'; +import { mockConnectionTypeConfigMap } from '~/__mocks__/mockConnectionType'; +import { projectDetails } from '~/__tests__/cypress/cypress/pages/projects'; +import { ProjectModel, SecretModel } from '~/__tests__/cypress/cypress/utils/models'; + +const initIntercepts = (isEmpty = false) => { + cy.interceptK8sList( + ProjectModel, + mockK8sResourceList([mockProjectK8sResource({ k8sName: 'test-project' })]), + ); + cy.interceptK8sList( + { + model: SecretModel, + ns: 'test-project', + }, + mockK8sResourceList( + isEmpty + ? [] + : [ + mockSecretK8sResource({ name: 'test1', displayName: 'test1' }), + mockSecretK8sResource({ + name: 'test2', + displayName: 'test2', + connectionType: 'postgres', + }), + ], + ), + ); + cy.interceptOdh( + 'GET /api/config', + mockDashboardConfig({ + disableConnectionTypes: false, + }), + ); + cy.interceptOdh('GET /api/connection-types', [mockConnectionTypeConfigMap({})]); +}; + +describe('Connections', () => { + it('Empty state when no data connections are available', () => { + initIntercepts(true); + projectDetails.visitSection('test-project', 'connections'); + projectDetails.shouldBeEmptyState('Connections', 'connections', true); + }); + + it('List connections', () => { + initIntercepts(); + projectDetails.visitSection('test-project', 'connections'); + projectDetails.shouldBeEmptyState('Connections', 'connections', false); + cy.findByTestId('connection-table').findByText('test1').should('exist'); + cy.findByTestId('connection-table').findByText('s3').should('exist'); + cy.findByTestId('connection-table').findByText('test2').should('exist'); + cy.findByTestId('connection-table').findByText('postgres').should('exist'); + }); +}); diff --git a/frontend/src/components/table/TableRowTitleDescription.tsx b/frontend/src/components/table/TableRowTitleDescription.tsx index 86c4f17af1..276cbda647 100644 --- a/frontend/src/components/table/TableRowTitleDescription.tsx +++ b/frontend/src/components/table/TableRowTitleDescription.tsx @@ -7,6 +7,7 @@ import TruncatedText from '~/components/TruncatedText'; type TableRowTitleDescriptionProps = { title: React.ReactNode; + boldTitle?: boolean; resource?: K8sResourceCommon; subtitle?: React.ReactNode; description?: string; @@ -17,6 +18,7 @@ type TableRowTitleDescriptionProps = { const TableRowTitleDescription: React.FC = ({ title, + boldTitle = true, description, resource, subtitle, @@ -44,9 +46,9 @@ const TableRowTitleDescription: React.FC = ({ return ( <> - +
{resource ? {title} : title} - +
{subtitle} {descriptionNode} {label} diff --git a/frontend/src/concepts/connectionTypes/types.ts b/frontend/src/concepts/connectionTypes/types.ts index 5104715333..19524fb31d 100644 --- a/frontend/src/concepts/connectionTypes/types.ts +++ b/frontend/src/concepts/connectionTypes/types.ts @@ -1,5 +1,5 @@ import { K8sResourceCommon } from '@openshift/dynamic-plugin-sdk-utils'; -import { DashboardLabels, DisplayNameAnnotations } from '~/k8sTypes'; +import { DashboardLabels, DisplayNameAnnotations, SecretKind } from '~/k8sTypes'; export enum ConnectionTypeFieldType { Boolean = 'boolean', @@ -135,3 +135,23 @@ export type ConnectionTypeConfigMapObj = Omit & fields?: ConnectionTypeField[]; }; }; + +export type Connection = SecretKind & { + metadata: { + labels: DashboardLabels & { + 'opendatahub.io/managed': 'true'; + }; + annotations: DisplayNameAnnotations & { + 'opendatahub.io/connection-type': string; + }; + }; + data: { + [key: string]: string; + }; +}; + +export const isConnection = (secret: SecretKind): secret is Connection => + !!secret.metadata.annotations && + 'opendatahub.io/connection-type' in secret.metadata.annotations && + !!secret.metadata.labels && + secret.metadata.labels['opendatahub.io/managed'] === 'true'; diff --git a/frontend/src/concepts/design/utils.ts b/frontend/src/concepts/design/utils.ts index eb53428ee6..2feda91fec 100644 --- a/frontend/src/concepts/design/utils.ts +++ b/frontend/src/concepts/design/utils.ts @@ -42,6 +42,7 @@ export enum ProjectObjectType { deployedModels = 'deployed-models', deployingModels = 'deploying-models', dataConnection = 'data-connection', + connections = 'connections', user = 'user', group = 'group', storageClasses = 'storageClasses', @@ -66,6 +67,7 @@ export const typedBackgroundColor = (objectType: ProjectObjectType): string => { case ProjectObjectType.deployingModels: return 'var(--ai-model-server--BackgroundColor)'; case ProjectObjectType.dataConnection: + case ProjectObjectType.connections: return 'var(--ai-data-connection--BackgroundColor)'; case ProjectObjectType.user: return 'var(--ai-user--BackgroundColor)'; @@ -98,6 +100,7 @@ export const typedObjectImage = (objectType: ProjectObjectType): string => { case ProjectObjectType.deployingModels: return deployingModelsImg; case ProjectObjectType.dataConnection: + case ProjectObjectType.connections: return dataConnectionImg; case ProjectObjectType.user: return userImg; @@ -136,6 +139,7 @@ export const typedEmptyImage = (objectType: ProjectObjectType, option?: string): case ProjectObjectType.storageClasses: return storageClassesEmptyStateImg; case ProjectObjectType.dataConnection: + case ProjectObjectType.connections: return dataConnectionEmptyStateImg; default: return ''; diff --git a/frontend/src/pages/projects/ProjectDetailsContext.tsx b/frontend/src/pages/projects/ProjectDetailsContext.tsx index 2e7ea07e1d..ef58a5d2bb 100644 --- a/frontend/src/pages/projects/ProjectDetailsContext.tsx +++ b/frontend/src/pages/projects/ProjectDetailsContext.tsx @@ -25,6 +25,7 @@ import useTemplateDisablement from '~/pages/modelServing/customServingRuntimes/u import { useDashboardNamespace } from '~/redux/selectors'; import { getTokenNames } from '~/pages/modelServing/utils'; import { SupportedArea, useIsAreaAvailable } from '~/concepts/areas'; +import { Connection } from '~/concepts/connectionTypes/types'; import { useGroups, useTemplates } from '~/api'; import { NotebookState } from './notebook/types'; import { DataConnection } from './types'; @@ -32,6 +33,7 @@ import useDataConnections from './screens/detail/data-connections/useDataConnect import useProjectNotebookStates from './notebook/useProjectNotebookStates'; import useProjectPvcs from './screens/detail/storage/useProjectPvcs'; import useProjectSharing from './projectSharing/useProjectSharing'; +import useConnections from './screens/detail/connections/useConnections'; type ProjectDetailsContextType = { currentProject: ProjectKind; @@ -40,6 +42,7 @@ type ProjectDetailsContextType = { notebooks: ContextResourceData; pvcs: ContextResourceData; dataConnections: ContextResourceData; + connections: ContextResourceData; servingRuntimes: ContextResourceData; servingRuntimeTemplates: CustomWatchK8sResult; servingRuntimeTemplateOrder: ContextResourceData; @@ -59,6 +62,7 @@ export const ProjectDetailsContext = React.createContext { const notebooks = useContextResourceData(useProjectNotebookStates(namespace)); const pvcs = useContextResourceData(useProjectPvcs(namespace)); const dataConnections = useContextResourceData(useDataConnections(namespace)); + const connections = useContextResourceData(useConnections(namespace)); const servingRuntimes = useContextResourceData(useServingRuntimes(namespace)); const servingRuntimeTemplates = useTemplates(dashboardNamespace); @@ -153,6 +158,7 @@ const ProjectDetailsContextProvider: React.FC = () => { notebooks, pvcs, dataConnections, + connections, servingRuntimes, servingRuntimeTemplates, servingRuntimeTemplateOrder, @@ -170,6 +176,7 @@ const ProjectDetailsContextProvider: React.FC = () => { notebooks, pvcs, dataConnections, + connections, servingRuntimes, servingRuntimeTemplates, servingRuntimeTemplateOrder, diff --git a/frontend/src/pages/projects/screens/detail/ProjectDetails.tsx b/frontend/src/pages/projects/screens/detail/ProjectDetails.tsx index 223eafcdc2..1f2c4ff95b 100644 --- a/frontend/src/pages/projects/screens/detail/ProjectDetails.tsx +++ b/frontend/src/pages/projects/screens/detail/ProjectDetails.tsx @@ -15,11 +15,13 @@ import { ProjectSectionID } from '~/pages/projects/screens/detail/types'; import { AccessReviewResourceAttributes } from '~/k8sTypes'; import { useAccessReview } from '~/api'; import { getDescriptionFromK8sResource, getDisplayNameFromK8sResource } from '~/concepts/k8s/utils'; +import useConnectionTypesEnabled from '~/concepts/connectionTypes/useConnectionTypesEnabled'; import useCheckLogoutParams from './useCheckLogoutParams'; import ProjectOverview from './overview/ProjectOverview'; import NotebookList from './notebooks/NotebookList'; import StorageList from './storage/StorageList'; import DataConnectionsList from './data-connections/DataConnectionsList'; +import ConnectionsList from './connections/ConnectionsList'; import PipelinesSection from './pipelines/PipelinesSection'; import './ProjectDetails.scss'; @@ -38,6 +40,7 @@ const ProjectDetails: React.FC = () => { const projectSharingEnabled = useIsAreaAvailable(SupportedArea.DS_PROJECTS_PERMISSIONS).status; const pipelinesEnabled = useIsAreaAvailable(SupportedArea.DS_PIPELINES).status; const modelServingEnabled = useModelServingEnabled(); + const connectionTypesEnabled = useConnectionTypesEnabled(); const queryParams = useQueryParams(); const state = queryParams.get('section'); const [allowCreate, rbacLoaded] = useAccessReview({ @@ -76,6 +79,15 @@ const ProjectDetails: React.FC = () => { title: 'Cluster storage', component: , }, + ...(connectionTypesEnabled + ? [ + { + id: ProjectSectionID.CONNECTIONS, + title: 'Connections', + component: , + }, + ] + : []), { id: ProjectSectionID.DATA_CONNECTIONS, title: 'Data connections', diff --git a/frontend/src/pages/projects/screens/detail/connections/ConnectionsList.tsx b/frontend/src/pages/projects/screens/detail/connections/ConnectionsList.tsx new file mode 100644 index 0000000000..b7dfb3d2f2 --- /dev/null +++ b/frontend/src/pages/projects/screens/detail/connections/ConnectionsList.tsx @@ -0,0 +1,68 @@ +import * as React from 'react'; +import { Button, Popover } from '@patternfly/react-core'; +import { OutlinedQuestionCircleIcon } from '@patternfly/react-icons'; +import { ProjectSectionID } from '~/pages/projects/screens/detail/types'; +import { ProjectSectionTitles } from '~/pages/projects/screens/detail/const'; +import { ProjectDetailsContext } from '~/pages/projects/ProjectDetailsContext'; +import DetailsSection from '~/pages/projects/screens/detail/DetailsSection'; +import EmptyDetailsView from '~/components/EmptyDetailsView'; +import DashboardPopupIconButton from '~/concepts/dashboard/DashboardPopupIconButton'; +import { ProjectObjectType, typedEmptyImage } from '~/concepts/design/utils'; +import { useWatchConnectionTypes } from '~/utilities/useWatchConnectionTypes'; +import ConnectionsTable from './ConnectionsTable'; + +const ConnectionsDescription = + 'Connections enable you to store and retrieve information that typically should not be stored in code. For example, you can store details (including credentials) for object storage, databases, and more. You can then attach the connections to artifacts in your project, such as workbenches and model servers.'; + +const ConnectionsList: React.FC = () => { + const { + connections: { data: connections, loaded, error }, + } = React.useContext(ProjectDetailsContext); + const [connectionTypes, connectionTypesLoaded, connectionTypesError] = useWatchConnectionTypes(); + + return ( + + } aria-label="More info" /> + + } + actions={[ + , + ]} + isLoading={!loaded || !connectionTypesLoaded} + isEmpty={connections.length === 0} + loadError={error || connectionTypesError} + emptyState={ + + Create connection + + } + /> + } + > + + + ); +}; + +export default ConnectionsList; diff --git a/frontend/src/pages/projects/screens/detail/connections/ConnectionsTable.tsx b/frontend/src/pages/projects/screens/detail/connections/ConnectionsTable.tsx new file mode 100644 index 0000000000..b1fd940535 --- /dev/null +++ b/frontend/src/pages/projects/screens/detail/connections/ConnectionsTable.tsx @@ -0,0 +1,29 @@ +import * as React from 'react'; +import { Connection, ConnectionTypeConfigMapObj } from '~/concepts/connectionTypes/types'; +import { Table } from '~/components/table'; +import ConnectionsTableRow from './ConnectionsTableRow'; +import { columns } from './connectionsTableColumns'; + +type ConnectionsTableProps = { + connections: Connection[]; + connectionTypes?: ConnectionTypeConfigMapObj[]; +}; + +const ConnectionsTable: React.FC = ({ connections, connectionTypes }) => ( + ( + undefined} + onDeleteConnection={() => undefined} + /> + )} + isStriped + /> +); +export default ConnectionsTable; diff --git a/frontend/src/pages/projects/screens/detail/connections/ConnectionsTableRow.tsx b/frontend/src/pages/projects/screens/detail/connections/ConnectionsTableRow.tsx new file mode 100644 index 0000000000..df063dd513 --- /dev/null +++ b/frontend/src/pages/projects/screens/detail/connections/ConnectionsTableRow.tsx @@ -0,0 +1,63 @@ +import * as React from 'react'; +import { ActionsColumn, Td, Tr } from '@patternfly/react-table'; +import { Connection, ConnectionTypeConfigMapObj } from '~/concepts/connectionTypes/types'; +import { TableRowTitleDescription } from '~/components/table'; + +type ConnectionsTableRowProps = { + obj: Connection; + connectionTypes?: ConnectionTypeConfigMapObj[]; + onEditConnection: (pvc: Connection) => void; + onDeleteConnection: (dataConnection: Connection) => void; +}; + +const ConnectionsTableRow: React.FC = ({ + obj, + connectionTypes, + onEditConnection, + onDeleteConnection, +}) => { + const connectionTypeDisplayName = React.useMemo(() => { + const matchingType = connectionTypes?.find( + (type) => type.metadata.name === obj.metadata.annotations['opendatahub.io/connection-type'], + ); + return ( + matchingType?.metadata.annotations?.['openshift.io/display-name'] || + obj.metadata.annotations['opendatahub.io/connection-type'] + ); + }, [obj, connectionTypes]); + + return ( + + + + + + + ); +}; + +export default ConnectionsTableRow; diff --git a/frontend/src/pages/projects/screens/detail/connections/__tests__/ConnectionsTable.spec.tsx b/frontend/src/pages/projects/screens/detail/connections/__tests__/ConnectionsTable.spec.tsx new file mode 100644 index 0000000000..76778fcffe --- /dev/null +++ b/frontend/src/pages/projects/screens/detail/connections/__tests__/ConnectionsTable.spec.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import ConnectionsTable from '~/pages/projects/screens/detail/connections/ConnectionsTable'; +import { mockConnectionTypeConfigMapObj } from '~/__mocks__/mockConnectionType'; +import { mockConnection } from '~/__mocks__/mockConnection'; + +describe('ConnectionsTable', () => { + it('should render table', () => { + render( + , + ); + + expect(screen.getByTestId('connection-table')).toBeTruthy(); + expect(screen.getByText('connection1')).toBeTruthy(); + expect(screen.getByText('desc1')).toBeTruthy(); + expect(screen.getByText('s3')).toBeTruthy(); + }); + + it('should show display name of connection type if available', () => { + render( + , + ); + + expect(screen.getByTestId('connection-table')).toBeTruthy(); + expect(screen.getByText('connection1')).toBeTruthy(); + expect(screen.getByText('desc1')).toBeTruthy(); + expect(screen.queryByText('s3')).toBeFalsy(); + expect(screen.getByText('S3 Buckets')).toBeTruthy(); + }); +}); diff --git a/frontend/src/pages/projects/screens/detail/connections/connectionsTableColumns.ts b/frontend/src/pages/projects/screens/detail/connections/connectionsTableColumns.ts new file mode 100644 index 0000000000..1056cdb012 --- /dev/null +++ b/frontend/src/pages/projects/screens/detail/connections/connectionsTableColumns.ts @@ -0,0 +1,34 @@ +import { Connection } from '~/concepts/connectionTypes/types'; +import { SortableData } from '~/components/table'; + +export const columns: SortableData[] = [ + { + field: 'name', + label: 'Name', + width: 35, + sortable: (a, b) => + (a.metadata.annotations['openshift.io/display-name'] ?? '').localeCompare( + b.metadata.annotations['openshift.io/display-name'] ?? '', + ), + }, + { + field: 'type', + label: 'Type', + width: 25, + sortable: (a, b) => + a.metadata.annotations['opendatahub.io/connection-type'].localeCompare( + b.metadata.annotations['opendatahub.io/connection-type'], + ), + }, + { + field: 'connections', + label: 'Connected resources', + width: 35, + sortable: false, + }, + { + field: 'kebab', + label: '', + sortable: false, + }, +]; diff --git a/frontend/src/pages/projects/screens/detail/connections/useConnections.ts b/frontend/src/pages/projects/screens/detail/connections/useConnections.ts new file mode 100644 index 0000000000..1674b64318 --- /dev/null +++ b/frontend/src/pages/projects/screens/detail/connections/useConnections.ts @@ -0,0 +1,30 @@ +import * as React from 'react'; +import { getSecretsByLabel } from '~/api'; +import useFetchState, { + FetchState, + FetchStateCallbackPromise, + NotReadyError, +} from '~/utilities/useFetchState'; +import { Connection, isConnection } from '~/concepts/connectionTypes/types'; +import { LABEL_SELECTOR_DASHBOARD_RESOURCE, LABEL_SELECTOR_DATA_CONNECTION_AWS } from '~/const'; + +const useConnections = (namespace?: string): FetchState => { + const callback = React.useCallback>( + (opts) => { + if (!namespace) { + return Promise.reject(new NotReadyError('No namespace')); + } + + return getSecretsByLabel( + `${LABEL_SELECTOR_DASHBOARD_RESOURCE},${LABEL_SELECTOR_DATA_CONNECTION_AWS}`, + namespace, + opts, + ).then((secrets) => secrets.filter((secret) => isConnection(secret))); + }, + [namespace], + ); + + return useFetchState(callback, []); +}; + +export default useConnections; diff --git a/frontend/src/pages/projects/screens/detail/const.ts b/frontend/src/pages/projects/screens/detail/const.ts index 4f9c9cf862..be659349bd 100644 --- a/frontend/src/pages/projects/screens/detail/const.ts +++ b/frontend/src/pages/projects/screens/detail/const.ts @@ -5,6 +5,7 @@ export const ProjectSectionTitles: ProjectSectionTitlesType = { [ProjectSectionID.WORKBENCHES]: 'Workbenches', [ProjectSectionID.CLUSTER_STORAGES]: 'Cluster storage', [ProjectSectionID.DATA_CONNECTIONS]: 'Data connections', + [ProjectSectionID.CONNECTIONS]: 'Connections', [ProjectSectionID.MODEL_SERVER]: 'Models and model servers', [ProjectSectionID.PIPELINES]: 'Pipelines', [ProjectSectionID.PERMISSIONS]: 'Permissions', diff --git a/frontend/src/pages/projects/screens/detail/types.ts b/frontend/src/pages/projects/screens/detail/types.ts index fe54d146f5..7c95a49661 100644 --- a/frontend/src/pages/projects/screens/detail/types.ts +++ b/frontend/src/pages/projects/screens/detail/types.ts @@ -3,6 +3,7 @@ export enum ProjectSectionID { WORKBENCHES = 'workbenches', CLUSTER_STORAGES = 'cluster-storages', DATA_CONNECTIONS = 'data-connections', + CONNECTIONS = 'connections', MODEL_SERVER = 'model-server', PIPELINES = 'pipelines-projects', PERMISSIONS = 'permissions',
+ + {connectionTypeDisplayName}- + { + onEditConnection(obj); + }, + }, + { + title: 'Delete', + onClick: () => { + onDeleteConnection(obj); + }, + }, + ]} + /> +