Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add connections section to project details #3183

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions frontend/src/__mocks__/mockConnection.ts
Original file line number Diff line number Diff line change
@@ -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,
});
4 changes: 3 additions & 1 deletion frontend/src/__mocks__/mockSecretK8sResource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ type MockResourceConfigType = {
name?: string;
namespace?: string;
displayName?: string;
connectionType?: string;
s3Bucket?: string;
endPoint?: string;
region?: string;
Expand All @@ -15,6 +16,7 @@ export const mockSecretK8sResource = ({
name = 'test-secret',
namespace = 'test-project',
displayName = 'Test Secret',
connectionType = 's3',
s3Bucket = 'dGVzdC1idWNrZXQ=',
endPoint = 'aHR0cHM6Ly9zMy5hbWF6b25hd3MuY29tLw==',
region = 'dXMtZWFzdC0x',
Expand All @@ -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,
},
},
Expand Down
Original file line number Diff line number Diff line change
@@ -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');
});
});
6 changes: 4 additions & 2 deletions frontend/src/components/table/TableRowTitleDescription.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import TruncatedText from '~/components/TruncatedText';

type TableRowTitleDescriptionProps = {
title: React.ReactNode;
boldTitle?: boolean;
resource?: K8sResourceCommon;
subtitle?: React.ReactNode;
description?: string;
Expand All @@ -17,6 +18,7 @@ type TableRowTitleDescriptionProps = {

const TableRowTitleDescription: React.FC<TableRowTitleDescriptionProps> = ({
title,
boldTitle = true,
description,
resource,
subtitle,
Expand Down Expand Up @@ -44,9 +46,9 @@ const TableRowTitleDescription: React.FC<TableRowTitleDescriptionProps> = ({

return (
<>
<b data-testid="table-row-title">
<div data-testid="table-row-title" className={boldTitle ? 'pf-v5-u-font-weight-bold' : ''}>
{resource ? <ResourceNameTooltip resource={resource}>{title}</ResourceNameTooltip> : title}
</b>
</div>
{subtitle}
{descriptionNode}
{label}
Expand Down
22 changes: 21 additions & 1 deletion frontend/src/concepts/connectionTypes/types.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -135,3 +135,23 @@ export type ConnectionTypeConfigMapObj = Omit<ConnectionTypeConfigMap, 'data'> &
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';
4 changes: 4 additions & 0 deletions frontend/src/concepts/design/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export enum ProjectObjectType {
deployedModels = 'deployed-models',
deployingModels = 'deploying-models',
dataConnection = 'data-connection',
connections = 'connections',
user = 'user',
group = 'group',
storageClasses = 'storageClasses',
Expand All @@ -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)';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 '';
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/pages/projects/ProjectDetailsContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,15 @@ 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';
import useDataConnections from './screens/detail/data-connections/useDataConnections';
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;
Expand All @@ -40,6 +42,7 @@ type ProjectDetailsContextType = {
notebooks: ContextResourceData<NotebookState>;
pvcs: ContextResourceData<PersistentVolumeClaimKind>;
dataConnections: ContextResourceData<DataConnection>;
connections: ContextResourceData<Connection>;
servingRuntimes: ContextResourceData<ServingRuntimeKind>;
servingRuntimeTemplates: CustomWatchK8sResult<TemplateKind[]>;
servingRuntimeTemplateOrder: ContextResourceData<string>;
Expand All @@ -59,6 +62,7 @@ export const ProjectDetailsContext = React.createContext<ProjectDetailsContextTy
notebooks: DEFAULT_CONTEXT_DATA,
pvcs: DEFAULT_CONTEXT_DATA,
dataConnections: DEFAULT_CONTEXT_DATA,
connections: DEFAULT_CONTEXT_DATA,
servingRuntimes: DEFAULT_CONTEXT_DATA,
servingRuntimeTemplates: DEFAULT_LIST_WATCH_RESULT,
servingRuntimeTemplateOrder: DEFAULT_CONTEXT_DATA,
Expand All @@ -78,6 +82,7 @@ const ProjectDetailsContextProvider: React.FC = () => {
const notebooks = useContextResourceData<NotebookState>(useProjectNotebookStates(namespace));
const pvcs = useContextResourceData<PersistentVolumeClaimKind>(useProjectPvcs(namespace));
const dataConnections = useContextResourceData<DataConnection>(useDataConnections(namespace));
const connections = useContextResourceData<Connection>(useConnections(namespace));
const servingRuntimes = useContextResourceData<ServingRuntimeKind>(useServingRuntimes(namespace));
const servingRuntimeTemplates = useTemplates(dashboardNamespace);

Expand Down Expand Up @@ -153,6 +158,7 @@ const ProjectDetailsContextProvider: React.FC = () => {
notebooks,
pvcs,
dataConnections,
connections,
servingRuntimes,
servingRuntimeTemplates,
servingRuntimeTemplateOrder,
Expand All @@ -170,6 +176,7 @@ const ProjectDetailsContextProvider: React.FC = () => {
notebooks,
pvcs,
dataConnections,
connections,
servingRuntimes,
servingRuntimeTemplates,
servingRuntimeTemplateOrder,
Expand Down
12 changes: 12 additions & 0 deletions frontend/src/pages/projects/screens/detail/ProjectDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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({
Expand Down Expand Up @@ -76,6 +79,15 @@ const ProjectDetails: React.FC = () => {
title: 'Cluster storage',
component: <StorageList />,
},
...(connectionTypesEnabled
? [
{
id: ProjectSectionID.CONNECTIONS,
title: 'Connections',
component: <ConnectionsList />,
},
]
: []),
{
id: ProjectSectionID.DATA_CONNECTIONS,
title: 'Data connections',
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<DetailsSection
objectType={ProjectObjectType.connections}
id={ProjectSectionID.CONNECTIONS}
title={ProjectSectionTitles[ProjectSectionID.CONNECTIONS]}
popover={
<Popover headerContent="About connections" bodyContent={ConnectionsDescription}>
<DashboardPopupIconButton icon={<OutlinedQuestionCircleIcon />} aria-label="More info" />
</Popover>
}
actions={[
<Button
key={`action-${ProjectSectionID.CONNECTIONS}`}
data-testid="add-connection-button"
variant="primary"
>
Add connection
</Button>,
]}
isLoading={!loaded || !connectionTypesLoaded}
isEmpty={connections.length === 0}
loadError={error || connectionTypesError}
emptyState={
<EmptyDetailsView
title="No connections"
description={ConnectionsDescription}
iconImage={typedEmptyImage(ProjectObjectType.connections)}
imageAlt="create a connection"
createButton={
<Button
key={`action-${ProjectSectionID.CONNECTIONS}`}
variant="primary"
data-testid="create-connection-button"
>
Create connection
</Button>
}
/>
}
>
<ConnectionsTable connections={connections} connectionTypes={connectionTypes} />
</DetailsSection>
);
};

export default ConnectionsList;
Loading
Loading