diff --git a/backend/src/routes/api/images/imageUtils.ts b/backend/src/routes/api/images/imageUtils.ts index 29c0337b0a..486a63f644 100644 --- a/backend/src/routes/api/images/imageUtils.ts +++ b/backend/src/routes/api/images/imageUtils.ts @@ -242,6 +242,7 @@ const mapImageStreamToBYONImage = (is: ImageStream): BYONImage => ({ imported_time: is.metadata.creationTimestamp, url: is.metadata.annotations['opendatahub.io/notebook-image-url'], provider: is.metadata.annotations['opendatahub.io/notebook-image-creator'], + recommendedAccelerators: jsonParseRecommendedAccelerators(is.metadata.annotations['opendatahub.io/recommended-accelerators']), }); export const postImage = async ( @@ -273,6 +274,8 @@ export const postImage = async ( }; } + const validRecommendedAccelerators = body.recommendedAccelerators + const payload: ImageStream = { kind: 'ImageStream', apiVersion: 'image.openshift.io/v1', @@ -282,6 +285,7 @@ export const postImage = async ( 'opendatahub.io/notebook-image-name': body.display_name, 'opendatahub.io/notebook-image-url': fullURL, 'opendatahub.io/notebook-image-creator': body.provider, + 'opendatahub.io/recommended-accelerators': JSON.stringify(body.recommendedAccelerators ?? []), }, name: `custom-${translateDisplayNameForK8s(body.display_name)}`, namespace: namespace, @@ -419,6 +423,10 @@ export const updateImage = async ( imageStream.metadata.annotations['opendatahub.io/notebook-image-desc'] = body.description; } + if(body.recommendedAccelerators !== undefined) { + imageStream.metadata.annotations['opendatahub.io/recommended-accelerators'] = JSON.stringify(body.recommendedAccelerators); + } + await customObjectsApi .patchNamespacedCustomObject( 'image.openshift.io', @@ -452,3 +460,11 @@ const jsonParsePackage = (unparsedPackage: string): BYONImagePackage[] => { return []; } }; + +const jsonParseRecommendedAccelerators = (unparsedRecommendations: string): string[] => { + try { + return JSON.parse(unparsedRecommendations) || []; + } catch { + return []; + } +}; diff --git a/backend/src/types.ts b/backend/src/types.ts index 47c22e2f3b..9099eb5a67 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -485,6 +485,7 @@ export type BYONImage = { visible: boolean; software: BYONImagePackage[]; packages: BYONImagePackage[]; + recommendedAccelerators: string[]; }; export type ImageTag = { diff --git a/frontend/src/__mocks__/mockByon.ts b/frontend/src/__mocks__/mockByon.ts index 83b8fe076f..d390944664 100644 --- a/frontend/src/__mocks__/mockByon.ts +++ b/frontend/src/__mocks__/mockByon.ts @@ -12,6 +12,7 @@ export const mockByon = (opts?: RecursivePartial): BYONImage[] => name: 'byon-123', display_name: 'Testing Custom Image', description: 'A custom notebook image', + recommendedAccelerators: [], visible: true, packages: [ { diff --git a/frontend/src/__tests__/integration/pages/notebookImageSettings/NotebookImageSettings.spec.ts b/frontend/src/__tests__/integration/pages/notebookImageSettings/NotebookImageSettings.spec.ts index 3608e296ce..a73ef930e3 100644 --- a/frontend/src/__tests__/integration/pages/notebookImageSettings/NotebookImageSettings.spec.ts +++ b/frontend/src/__tests__/integration/pages/notebookImageSettings/NotebookImageSettings.spec.ts @@ -18,6 +18,12 @@ test('Table filtering, sorting, searching', async ({ page }) => { await page.getByRole('button', { name: 'Provider' }).click(); expect(page.getByText('image-0')); + // by accelerator + await page.getByRole('button', { name: 'Recommended accelerators' }).click(); + expect(page.getByText('test-accelerator')).toHaveCount(0); + await page.getByRole('button', { name: 'Recommended accelerators' }).click(); + expect(page.getByText('test-accelerator')); + // by enabled await page.getByRole('button', { name: 'Enable', exact: true }).click(); expect(page.getByText('image-14')); @@ -114,6 +120,30 @@ test('Import form fields', async ({ page }) => { await page.getByLabel('Name *').fill('image'); await expect(page.getByRole('button', { name: 'Import' })).toBeEnabled(); + // test accelerator select field + // select accelerator from api call + await page.getByPlaceholder('Example, nvidia.com/gpu').click(); + await page.getByRole('option', { name: 'nvidia.com/gpu' }).click(); + + // create new and select + await page.getByPlaceholder('Example, nvidia.com/gpu').click(); + await page.getByPlaceholder('Example, nvidia.com/gpu').fill('test.com/gpu'); + await page.getByRole('option', { name: 'Create "test.com/gpu"' }).click(); + await page.getByRole('button', { name: 'Options menu' }).click(); + expect(page.getByText('test.com/gpu')); + + // remove custom + await page.getByRole('button', { name: 'Remove test.com/gpu' }).click(); + await page.getByRole('button', { name: 'Options menu' }).click(); + await expect(page.getByText('test.com/gpu')).toHaveCount(0); + + // reselect custom + await page + .getByRole('dialog', { name: 'Import notebook image' }) + .getByRole('button', { name: 'Options menu' }) + .click(); + await page.getByRole('option', { name: 'test.com/gpu' }).click(); + // test form is disabled after entering software add form await page.getByTestId('add-software-button').click(); await expect(page.getByRole('button', { name: 'Import' })).toBeDisabled(); @@ -204,7 +234,7 @@ test('Edit form fields match', async ({ page }) => { expect(await page.getByLabel('Image Location *').inputValue()).toBe('test-image:latest'); expect(await page.getByLabel('Name *').inputValue()).toBe('Testing Custom Image'); expect(await page.getByLabel('Description').inputValue()).toBe('A custom notebook image'); - + expect(page.getByText('nvidia.com/gpu')); // test software and packages have correct values expect(page.getByRole('gridcell', { name: 'test-software' })); expect(page.getByRole('gridcell', { name: '2.0' })); diff --git a/frontend/src/__tests__/integration/pages/notebookImageSettings/NotebookImageSettings.stories.tsx b/frontend/src/__tests__/integration/pages/notebookImageSettings/NotebookImageSettings.stories.tsx index 2d7aaac271..8aee17d357 100644 --- a/frontend/src/__tests__/integration/pages/notebookImageSettings/NotebookImageSettings.stories.tsx +++ b/frontend/src/__tests__/integration/pages/notebookImageSettings/NotebookImageSettings.stories.tsx @@ -5,24 +5,46 @@ import { rest } from 'msw'; import { userEvent, within } from '@storybook/testing-library'; import BYONImages from '~/pages/BYONImages/BYONImages'; import { mockByon } from '~/__mocks__/mockByon'; +import { mockAcceleratorProfile } from '~/__mocks__/mockAcceleratorProfile'; +import { mockK8sResourceList } from '~/__mocks__/mockK8sResourceList'; +import { mockProjectK8sResource } from '~/__mocks__/mockProjectK8sResource'; +import { mockStatus } from '~/__mocks__/mockStatus'; +import useDetectUser from '~/utilities/useDetectUser'; export default { component: BYONImages, -} as Meta; - -const Template: StoryFn = (args) => ; - -export const Default: StoryObj = { - render: Template, parameters: { msw: { - handlers: [ - rest.get('/api/images/byon', (req, res, ctx) => - res(ctx.json(mockByon([{ url: 'test-image:latest' }]))), + handlers: { + status: [ + rest.get('/api/k8s/apis/project.openshift.io/v1/projects', (req, res, ctx) => + res(ctx.json(mockK8sResourceList([mockProjectK8sResource({})]))), + ), + rest.get('/api/status', (req, res, ctx) => res(ctx.json(mockStatus()))), + ], + accelerators: rest.get( + '/api/k8s/apis/dashboard.opendatahub.io/v1/namespaces/opendatahub/acceleratorprofiles', + (req, res, ctx) => res(ctx.json(mockK8sResourceList([mockAcceleratorProfile()]))), ), - ], + images: rest.get('/api/images/byon', (req, res, ctx) => + res( + ctx.json( + mockByon([{ url: 'test-image:latest', recommendedAccelerators: ['nvidia.com/gpu'] }]), + ), + ), + ), + }, }, }, +} as Meta; + +const Template: StoryFn = (args) => { + useDetectUser(); + return ; +}; + +export const Default: StoryObj = { + render: Template, play: async ({ canvasElement }) => { // load page and wait until settled const canvas = within(canvasElement); @@ -34,7 +56,7 @@ export const Empty: StoryObj = { render: Template, parameters: { msw: { - handlers: [rest.get('/api/images/byon', (req, res, ctx) => res(ctx.json([])))], + handlers: { images: rest.get('/api/images/byon', (req, res, ctx) => res(ctx.json([]))) }, }, }, play: async ({ canvasElement }) => { @@ -48,7 +70,7 @@ export const LoadingError: StoryObj = { render: Template, parameters: { msw: { - handlers: [rest.get('/api/images/byon', (req, res, ctx) => res(ctx.status(404)))], + handlers: { images: rest.get('/api/images/byon', (req, res, ctx) => res(ctx.status(404))) }, }, }, play: async ({ canvasElement }) => { @@ -62,8 +84,8 @@ export const LargeList: StoryObj = { render: Template, parameters: { msw: { - handlers: [ - rest.get('/api/images/byon', (req, res, ctx) => + handlers: { + images: rest.get('/api/images/byon', (req, res, ctx) => res( ctx.json( Array.from( @@ -77,13 +99,14 @@ export const LargeList: StoryObj = { description: `description-${i}`, provider: `provider-${i}`, visible: i % 3 === 0, + recommendedAccelerators: i % 3 ? ['nvidia.com/gpu'] : [], }, ])[0], ), ), ), ), - ], + }, }, }, play: async ({ canvasElement }) => { @@ -97,39 +120,41 @@ export const ImageError: StoryObj = { render: Template, parameters: { msw: { - handlers: [ - rest.post('/api/images', (req, res, ctx) => - res( - ctx.json({ - success: false, - error: 'Testing create error message', - }), + handlers: { + images: [ + rest.post('/api/images', (req, res, ctx) => + res( + ctx.json({ + success: false, + error: 'Testing create error message', + }), + ), ), - ), - rest.put('/api/images/byon-1', (req, res, ctx) => - res( - ctx.json({ - success: false, - error: 'Testing edit error message', - }), + rest.put('/api/images/byon-1', (req, res, ctx) => + res( + ctx.json({ + success: false, + error: 'Testing edit error message', + }), + ), ), - ), - rest.delete('/api/images/byon-1', (req, res, ctx) => - res(ctx.status(404, 'Testing delete error message')), - ), - rest.get('/api/images/byon', (req, res, ctx) => - res( - ctx.json( - mockByon([ - { - name: 'byon-1', - error: 'Testing error message', - }, - ]), + rest.delete('/api/images/byon-1', (req, res, ctx) => + res(ctx.status(404, 'Testing delete error message')), + ), + rest.get('/api/images/byon', (req, res, ctx) => + res( + ctx.json( + mockByon([ + { + name: 'byon-1', + error: 'Testing error message', + }, + ]), + ), ), ), - ), - ], + ], + }, }, }, play: async ({ canvasElement }) => { @@ -148,7 +173,9 @@ export const EditModal: StoryObj = { element: '.pf-c-backdrop', }, msw: { - handlers: [rest.get('/api/images/byon', (req, res, ctx) => res(ctx.json(mockByon())))], + handlers: { + images: rest.get('/api/images/byon', (req, res, ctx) => res(ctx.json(mockByon()))), + }, }, }, play: async ({ canvasElement }) => { @@ -169,7 +196,9 @@ export const DeleteModal: StoryObj = { element: '.pf-c-backdrop', }, msw: { - handlers: [rest.get('/api/images/byon', (req, res, ctx) => res(ctx.json(mockByon())))], + handlers: { + images: rest.get('/api/images/byon', (req, res, ctx) => res(ctx.json(mockByon()))), + }, }, }, play: async ({ canvasElement }) => { @@ -190,7 +219,9 @@ export const ImportModal: StoryObj = { element: '.pf-c-backdrop', }, msw: { - handlers: [rest.get('/api/images/byon', (req, res, ctx) => res(ctx.json(mockByon())))], + handlers: { + images: rest.get('/api/images/byon', (req, res, ctx) => res(ctx.json(mockByon()))), + }, }, }, play: async ({ canvasElement }) => { diff --git a/frontend/src/pages/BYONImages/BYONImageAccelerators.tsx b/frontend/src/pages/BYONImages/BYONImageAccelerators.tsx new file mode 100644 index 0000000000..f6f97e8b79 --- /dev/null +++ b/frontend/src/pages/BYONImages/BYONImageAccelerators.tsx @@ -0,0 +1,78 @@ +import { Spinner, LabelGroup, Label, StackItem, Stack, Tooltip } from '@patternfly/react-core'; +import { PlusIcon } from '@patternfly/react-icons'; +import React from 'react'; +import { useNavigate } from 'react-router-dom'; +import { BYONImage } from '~/types'; +import { AcceleratorKind } from '~/k8sTypes'; +import { FetchState } from '~/utilities/useFetchState'; + +type BYONImageAcceleratorsProps = { + image: BYONImage; + acceleratorProfiles: FetchState; +}; + +export const BYONImageAccelerators: React.FC = ({ + image, + acceleratorProfiles, +}) => { + const navigate = useNavigate(); + + const [data, loaded, loadError] = acceleratorProfiles; + + const getRecommendedAccelerators = (image: BYONImage): string[] => + data + .filter((accelerator) => image.recommendedAccelerators?.includes(accelerator.spec.identifier)) + .map((accelerator) => accelerator.spec.displayName); + + if (loadError) { + return <>{'-'}; + } + + if (!loaded) { + return ; + } + + return ( + + + + {getRecommendedAccelerators(image).map((acceleratorName, i) => ( + + ))} + + + + {image.recommendedAccelerators?.length > 0 ? ( + + + + ) : ( + '-' + )} + + + ); +}; diff --git a/frontend/src/pages/BYONImages/BYONImageModal/AcceleratorIdentifierMultiselect.tsx b/frontend/src/pages/BYONImages/BYONImageModal/AcceleratorIdentifierMultiselect.tsx new file mode 100644 index 0000000000..137e4bec29 --- /dev/null +++ b/frontend/src/pages/BYONImages/BYONImageModal/AcceleratorIdentifierMultiselect.tsx @@ -0,0 +1,72 @@ +import React, { useEffect, useState } from 'react'; +import { Select, SelectOption, SelectVariant } from '@patternfly/react-core'; +import useAccelerators from '~/pages/notebookController/screens/server/useAccelerators'; +import { useDashboardNamespace } from '~/redux/selectors'; + +type AcceleratorIdentifierMultiselectProps = { + data: string[]; + setData: (data: string[]) => void; +}; + +export const AcceleratorIdentifierMultiselect: React.FC = ({ + data, + setData, +}) => { + const { dashboardNamespace } = useDashboardNamespace(); + const [accelerators, loaded, loadError] = useAccelerators(dashboardNamespace); + + const [isOpen, setIsOpen] = useState(false); + const [options, setOptions] = useState([]); + + useEffect(() => { + if (loaded && !loadError) { + const uniqueIdentifiers = new Set(); + accelerators.forEach((accelerator) => { + uniqueIdentifiers.add(accelerator.spec.identifier); + }); + + data.forEach((identifier) => { + uniqueIdentifiers.add(identifier); + }); + + setOptions((options) => { + options.forEach((option) => { + uniqueIdentifiers.add(option); + }); + return Array.from(uniqueIdentifiers); + }); + } + }, [accelerators, loaded, loadError, data]); + + const clearSelection = () => { + setData([]); + setIsOpen(false); + }; + + return ( + + ); +}; diff --git a/frontend/src/pages/BYONImages/BYONImageModal/ManageBYONImageModal.tsx b/frontend/src/pages/BYONImages/BYONImageModal/ManageBYONImageModal.tsx index b43d077bfe..9108ae60fa 100644 --- a/frontend/src/pages/BYONImages/BYONImageModal/ManageBYONImageModal.tsx +++ b/frontend/src/pages/BYONImages/BYONImageModal/ManageBYONImageModal.tsx @@ -8,12 +8,15 @@ import { Tabs, Tab, TabTitleText, + Popover, } from '@patternfly/react-core'; +import { HelpIcon } from '@patternfly/react-icons'; import { importBYONImage, updateBYONImage } from '~/services/imagesService'; import { ResponseStatus, BYONImagePackage, BYONImage } from '~/types'; import { useAppSelector } from '~/redux/hooks'; import DashboardModalFooter from '~/concepts/dashboard/DashboardModalFooter'; import { filterBlankPackages } from '~/pages/BYONImages/utils'; +import { AcceleratorIdentifierMultiselect } from '~/pages/BYONImages/BYONImageModal/AcceleratorIdentifierMultiselect'; import ImageLocationField from './ImageLocationField'; import DisplayedContentTabContent from './DisplayedContentTabContent'; @@ -40,6 +43,7 @@ export const ManageBYONImageModal: React.FC = ({ const [repository, setRepository] = React.useState(''); const [displayName, setDisplayName] = React.useState(''); const [description, setDescription] = React.useState(''); + const [recommendedAccelerators, setRecommendedAccelerators] = React.useState([]); const [software, setSoftware] = React.useState([]); const [packages, setPackages] = React.useState([]); const [tempSoftware, setTempSoftware] = React.useState([]); @@ -59,6 +63,7 @@ export const ManageBYONImageModal: React.FC = ({ setSoftware(existingImage.software); setTempPackages(existingImage.packages); setTempSoftware(existingImage.software); + setRecommendedAccelerators(existingImage.recommendedAccelerators); } }, [existingImage]); @@ -69,6 +74,7 @@ export const ManageBYONImageModal: React.FC = ({ setRepository(''); setDisplayName(''); setDescription(''); + setRecommendedAccelerators([]); setSoftware([]); setPackages([]); setTempSoftware([]); @@ -91,6 +97,7 @@ export const ManageBYONImageModal: React.FC = ({ // eslint-disable-next-line camelcase display_name: displayName, description: description, + recommendedAccelerators: recommendedAccelerators, packages: filterBlankPackages(packages), software: filterBlankPackages(software), }).then(handleResponse); @@ -100,6 +107,7 @@ export const ManageBYONImageModal: React.FC = ({ display_name: displayName, url: repository, description: description, + recommendedAccelerators: recommendedAccelerators, provider: userName, packages: filterBlankPackages(packages), software: filterBlankPackages(software), @@ -167,6 +175,26 @@ export const ManageBYONImageModal: React.FC = ({ }} /> + + + + } + > + setRecommendedAccelerators(identifiers)} + data={recommendedAccelerators} + /> + = ({ images, refres const [editImage, setEditImage] = React.useState(); const [deleteImage, setDeleteImage] = React.useState(); + const { dashboardNamespace } = useDashboardNamespace(); + const acceleratorProfiles = useAccelerators(dashboardNamespace); + return ( <> = ({ images, refres obj={image} onEditImage={(i) => setEditImage(i)} onDeleteImage={(i) => setDeleteImage(i)} + acceleratorProfiles={acceleratorProfiles} /> )} toolbarContent={ diff --git a/frontend/src/pages/BYONImages/BYONImagesTableRow.tsx b/frontend/src/pages/BYONImages/BYONImagesTableRow.tsx index 0544f234ea..0c903a28b3 100644 --- a/frontend/src/pages/BYONImages/BYONImagesTableRow.tsx +++ b/frontend/src/pages/BYONImages/BYONImagesTableRow.tsx @@ -10,14 +10,18 @@ import { import { BYONImage } from '~/types'; import { relativeTime } from '~/utilities/time'; import ResourceNameTooltip from '~/components/ResourceNameTooltip'; +import { AcceleratorKind } from '~/k8sTypes'; +import { FetchState } from '~/utilities/useFetchState'; import ImageErrorStatus from './ImageErrorStatus'; import BYONImageStatusToggle from './BYONImageStatusToggle'; import { convertBYONImageToK8sResource } from './utils'; import BYONImageDependenciesList from './BYONImageDependenciesList'; +import { BYONImageAccelerators } from './BYONImageAccelerators'; type BYONImagesTableRowProps = { obj: BYONImage; rowIndex: number; + acceleratorProfiles: FetchState; onEditImage: (obj: BYONImage) => void; onDeleteImage: (obj: BYONImage) => void; }; @@ -25,6 +29,7 @@ type BYONImagesTableRowProps = { const BYONImagesTableRow: React.FC = ({ obj, rowIndex, + acceleratorProfiles, onEditImage, onDeleteImage, }) => { @@ -68,6 +73,9 @@ const BYONImagesTableRow: React.FC = ({ +
+ + {obj.provider} diff --git a/frontend/src/pages/BYONImages/tableData.tsx b/frontend/src/pages/BYONImages/tableData.tsx index 9766ba576a..8ab71e8dfe 100644 --- a/frontend/src/pages/BYONImages/tableData.tsx +++ b/frontend/src/pages/BYONImages/tableData.tsx @@ -23,7 +23,16 @@ export const columns: SortableData[] = [ label: 'Enable', sortable: (a, b) => getEnabledStatus(a) - getEnabledStatus(b), info: { - tooltip: 'Enabled images are selectable when creating workbenches.', + popover: 'Enabled images are selectable when creating workbenches.', + }, + }, + { + field: 'recommendedAccelerators', + label: 'Recommended accelerators', + sortable: (a, b) => + a.recommendedAccelerators.toString().localeCompare(b.recommendedAccelerators.toString()), + info: { + popover: 'Accelerators are used to speed up the execution of workbenches.', }, }, { diff --git a/frontend/src/types.ts b/frontend/src/types.ts index b85a3dfa62..9438f125c1 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -453,6 +453,7 @@ export type BYONImage = { visible: boolean; software: BYONImagePackage[]; packages: BYONImagePackage[]; + recommendedAccelerators: string[]; }; export type BYONImagePackage = {