From 3242b8c5777e222705f911d675e2bd6dee38ddbf Mon Sep 17 00:00:00 2001 From: Gage Krumbach Date: Thu, 2 Nov 2023 18:37:28 -0500 Subject: [PATCH] Added accelerator column and field added tests tooltip to popover naming fix, and new ux added new disbaled state make label compact added navigate to create rerender fix --- backend/src/routes/api/images/imageUtils.ts | 20 +++ backend/src/types.ts | 1 + frontend/src/__mocks__/mockByon.ts | 1 + .../ManageAcceleratorProfile.spec.ts | 27 ++++ .../ManageAcceleratorProfile.stories.tsx | 51 ++++++ .../NotebookImageSettings.spec.ts | 32 +++- .../NotebookImageSettings.stories.tsx | 127 +++++++++------ .../BYONImages/BYONImageAccelerators.tsx | 98 +++++++++++ .../AcceleratorIdentifierMultiselect.tsx | 71 ++++++++ .../BYONImageModal/ManageBYONImageModal.tsx | 27 ++++ .../src/pages/BYONImages/BYONImagesTable.tsx | 6 + .../pages/BYONImages/BYONImagesTableRow.tsx | 8 + frontend/src/pages/BYONImages/tableData.tsx | 11 +- .../screens/manage/IdentifierSelectField.tsx | 67 ++++++++ ...ManageAcceleratorProfileDetailsSection.tsx | 152 +++++++++--------- frontend/src/types.ts | 1 + 16 files changed, 578 insertions(+), 122 deletions(-) create mode 100644 frontend/src/pages/BYONImages/BYONImageAccelerators.tsx create mode 100644 frontend/src/pages/BYONImages/BYONImageModal/AcceleratorIdentifierMultiselect.tsx create mode 100644 frontend/src/pages/acceleratorProfiles/screens/manage/IdentifierSelectField.tsx diff --git a/backend/src/routes/api/images/imageUtils.ts b/backend/src/routes/api/images/imageUtils.ts index cbab6aaee8..241bc7fc14 100644 --- a/backend/src/routes/api/images/imageUtils.ts +++ b/backend/src/routes/api/images/imageUtils.ts @@ -236,6 +236,9 @@ 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'], + recommendedAcceleratorIdentifiers: jsonParseRecommendedAcceleratorIdentifiers( + is.metadata.annotations['opendatahub.io/recommended-accelerators'], + ), }); export const postImage = async ( @@ -276,6 +279,9 @@ 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.recommendedAcceleratorIdentifiers ?? [], + ), }, name: `custom-${translateDisplayNameForK8s(body.display_name)}`, namespace: namespace, @@ -413,6 +419,12 @@ export const updateImage = async ( imageStream.metadata.annotations['opendatahub.io/notebook-image-desc'] = body.description; } + if (body.recommendedAcceleratorIdentifiers !== undefined) { + imageStream.metadata.annotations['opendatahub.io/recommended-accelerators'] = JSON.stringify( + body.recommendedAcceleratorIdentifiers, + ); + } + await customObjectsApi .patchNamespacedCustomObject( 'image.openshift.io', @@ -446,3 +458,11 @@ const jsonParsePackage = (unparsedPackage: string): BYONImagePackage[] => { return []; } }; + +const jsonParseRecommendedAcceleratorIdentifiers = (unparsedRecommendations: string): string[] => { + try { + return JSON.parse(unparsedRecommendations) || []; + } catch { + return []; + } +}; diff --git a/backend/src/types.ts b/backend/src/types.ts index 47c22e2f3b..ef328272e6 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -485,6 +485,7 @@ export type BYONImage = { visible: boolean; software: BYONImagePackage[]; packages: BYONImagePackage[]; + recommendedAcceleratorIdentifiers: string[]; }; export type ImageTag = { diff --git a/frontend/src/__mocks__/mockByon.ts b/frontend/src/__mocks__/mockByon.ts index 83b8fe076f..2086467278 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', + recommendedAcceleratorIdentifiers: [], visible: true, packages: [ { diff --git a/frontend/src/__tests__/integration/pages/acceleratorProfiles/ManageAcceleratorProfile.spec.ts b/frontend/src/__tests__/integration/pages/acceleratorProfiles/ManageAcceleratorProfile.spec.ts index 3d928b17fc..14af4837df 100644 --- a/frontend/src/__tests__/integration/pages/acceleratorProfiles/ManageAcceleratorProfile.spec.ts +++ b/frontend/src/__tests__/integration/pages/acceleratorProfiles/ManageAcceleratorProfile.spec.ts @@ -155,3 +155,30 @@ test('Invalid id in edit page', async ({ page }) => { page.getByText('acceleratorprofiles.dashboard.opendatahub.io "test-accelerator" not found'), ).toHaveCount(1); }); + +test('One preset identifier is auto filled and disabled', async ({ page }) => { + await page.goto( + navigateToStory( + 'pages-acceleratorprofiles-manageacceleratorprofile', + 'create-accelerator-with-one-set-identifier', + ), + ); + + expect(await page.getByTestId('accelerator-identifier-input').inputValue()).toBe( + 'test-identifier', + ); + + await expect(page.getByTestId('accelerator-identifier-input')).toBeDisabled(); +}); + +test('Multiple preset identifiers show dropdown', async ({ page }) => { + await page.goto( + navigateToStory( + 'pages-acceleratorprofiles-manageacceleratorprofile', + 'create-accelerator-with-multiple-set-identifiers', + ), + ); + + await page.getByRole('button', { name: 'Options menu' }).click(); + await expect(page.getByRole('option', { name: 'test-identifier-3' })).toHaveCount(1); +}); diff --git a/frontend/src/__tests__/integration/pages/acceleratorProfiles/ManageAcceleratorProfile.stories.tsx b/frontend/src/__tests__/integration/pages/acceleratorProfiles/ManageAcceleratorProfile.stories.tsx index 578609abe6..73c3e12e73 100644 --- a/frontend/src/__tests__/integration/pages/acceleratorProfiles/ManageAcceleratorProfile.stories.tsx +++ b/frontend/src/__tests__/integration/pages/acceleratorProfiles/ManageAcceleratorProfile.stories.tsx @@ -144,3 +144,54 @@ export const TolerationsModal: StoryObj = { await userEvent.click(canvas.getByText('Add toleration', { selector: 'button' })); }, }; + +export const CreateAcceleratorWithOneSetIdentifier: StoryObj = { + render: () => ( + + + + ), + play: async ({ canvasElement }) => { + // load page and wait until settled + const canvas = within(canvasElement); + await canvas.findByText('Identifier', undefined, { + timeout: 5000, + }); + }, + parameters: { + reactRouter: { + routePath: '/create', + searchParams: { + identifiers: ['test-identifier'], + }, + }, + }, +}; + +export const CreateAcceleratorWithMultipleSetIdentifiers: StoryObj = { + render: () => ( + + + + ), + play: async ({ canvasElement }) => { + // load page and wait until settled + const canvas = within(canvasElement); + await canvas.findByText('Identifier', undefined, { + timeout: 5000, + }); + }, + parameters: { + reactRouter: { + routePath: '/create', + searchParams: { + identifiers: [ + 'test-identifier-1', + 'test-identifier-2', + 'test-identifier-3', + 'test-identifier-3', + ], + }, + }, + }, +}; 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..9231a8626f 100644 --- a/frontend/src/__tests__/integration/pages/notebookImageSettings/NotebookImageSettings.stories.tsx +++ b/frontend/src/__tests__/integration/pages/notebookImageSettings/NotebookImageSettings.stories.tsx @@ -5,24 +5,48 @@ 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', recommendedAcceleratorIdentifiers: ['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 +58,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 +72,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 +86,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 +101,14 @@ export const LargeList: StoryObj = { description: `description-${i}`, provider: `provider-${i}`, visible: i % 3 === 0, + recommendedAcceleratorIdentifiers: i % 3 ? ['nvidia.com/gpu'] : [], }, ])[0], ), ), ), ), - ], + }, }, }, play: async ({ canvasElement }) => { @@ -97,39 +122,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 +175,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 +198,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 +221,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..2cc2b71d31 --- /dev/null +++ b/frontend/src/pages/BYONImages/BYONImageAccelerators.tsx @@ -0,0 +1,98 @@ +import { + Spinner, + LabelGroup, + Label, + StackItem, + Stack, + Tooltip, + Button, +} from '@patternfly/react-core'; +import { PlusIcon } from '@patternfly/react-icons'; +import React from 'react'; +import { Link } 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 [data, loaded, loadError] = acceleratorProfiles; + + const recommendedAcceleratorProfiles = data.filter((accelerator) => + image.recommendedAcceleratorIdentifiers?.includes(accelerator.spec.identifier), + ); + if (loadError) { + return <>{'-'}; + } + + if (!loaded) { + return ; + } + + return ( + + {recommendedAcceleratorProfiles.length > 0 && ( + + + {recommendedAcceleratorProfiles.map((cr) => ( + + ))} + + + )} + + {image.recommendedAcceleratorIdentifiers?.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..a50c15901c --- /dev/null +++ b/frontend/src/pages/BYONImages/BYONImageModal/AcceleratorIdentifierMultiselect.tsx @@ -0,0 +1,71 @@ +import React, { 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 [newOptions, setNewOptions] = useState([]); + + const options = React.useMemo(() => { + if (loaded && !loadError) { + const uniqueIdentifiers = new Set(); + accelerators.forEach((accelerator) => { + uniqueIdentifiers.add(accelerator.spec.identifier); + }); + + data.forEach((identifier) => { + uniqueIdentifiers.add(identifier); + }); + + newOptions.forEach((option) => { + uniqueIdentifiers.add(option); + }); + return Array.from(uniqueIdentifiers); + } + return []; + }, [accelerators, loaded, loadError, data, newOptions]); + + 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..bfa54cf9f5 100644 --- a/frontend/src/pages/BYONImages/BYONImageModal/ManageBYONImageModal.tsx +++ b/frontend/src/pages/BYONImages/BYONImageModal/ManageBYONImageModal.tsx @@ -8,12 +8,16 @@ import { Tabs, Tab, TabTitleText, + Popover, } from '@patternfly/react-core'; +import { QuestionCircleIcon } 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 DashboardPopupIconButton from '~/concepts/dashboard/DashboardPopupIconButton'; import ImageLocationField from './ImageLocationField'; import DisplayedContentTabContent from './DisplayedContentTabContent'; @@ -40,6 +44,9 @@ export const ManageBYONImageModal: React.FC = ({ const [repository, setRepository] = React.useState(''); const [displayName, setDisplayName] = React.useState(''); const [description, setDescription] = React.useState(''); + const [recommendedAcceleratorIdentifiers, setRecommendedAcceleratorIdentifiers] = React.useState< + string[] + >([]); const [software, setSoftware] = React.useState([]); const [packages, setPackages] = React.useState([]); const [tempSoftware, setTempSoftware] = React.useState([]); @@ -59,6 +66,7 @@ export const ManageBYONImageModal: React.FC = ({ setSoftware(existingImage.software); setTempPackages(existingImage.packages); setTempSoftware(existingImage.software); + setRecommendedAcceleratorIdentifiers(existingImage.recommendedAcceleratorIdentifiers); } }, [existingImage]); @@ -69,6 +77,7 @@ export const ManageBYONImageModal: React.FC = ({ setRepository(''); setDisplayName(''); setDescription(''); + setRecommendedAcceleratorIdentifiers([]); setSoftware([]); setPackages([]); setTempSoftware([]); @@ -91,6 +100,7 @@ export const ManageBYONImageModal: React.FC = ({ // eslint-disable-next-line camelcase display_name: displayName, description: description, + recommendedAcceleratorIdentifiers: recommendedAcceleratorIdentifiers, packages: filterBlankPackages(packages), software: filterBlankPackages(software), }).then(handleResponse); @@ -100,6 +110,7 @@ export const ManageBYONImageModal: React.FC = ({ display_name: displayName, url: repository, description: description, + recommendedAcceleratorIdentifiers: recommendedAcceleratorIdentifiers, provider: userName, packages: filterBlankPackages(packages), software: filterBlankPackages(software), @@ -167,6 +178,22 @@ export const ManageBYONImageModal: React.FC = ({ }} /> + + } + aria-label="More info for identifier field" + /> + + } + > + setRecommendedAcceleratorIdentifiers(identifiers)} + data={recommendedAcceleratorIdentifiers} + /> + = ({ 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..e45e7c0a17 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.recommendedAcceleratorIdentifiers.length - b.recommendedAcceleratorIdentifiers.length, + info: { + popover: 'Accelerators are used to speed up the execution of workbenches.', }, }, { diff --git a/frontend/src/pages/acceleratorProfiles/screens/manage/IdentifierSelectField.tsx b/frontend/src/pages/acceleratorProfiles/screens/manage/IdentifierSelectField.tsx new file mode 100644 index 0000000000..8a1e65e8de --- /dev/null +++ b/frontend/src/pages/acceleratorProfiles/screens/manage/IdentifierSelectField.tsx @@ -0,0 +1,67 @@ +import { Select, SelectOption, TextInput } from '@patternfly/react-core'; +import React, { useEffect, useMemo } from 'react'; + +type IdentifierSelectFieldProps = { + value: string; + onChange: (identifier: string) => void; + identifierOptions: string[]; +}; + +export const IdentifierSelectField = ({ + value, + onChange, + identifierOptions = [], +}: IdentifierSelectFieldProps) => { + const [isOpen, setIsOpen] = React.useState(false); + + // remove possible duplicates + const options = useMemo(() => Array.from(new Set(identifierOptions)), [identifierOptions]); + + // auto-select if there is only one option + useEffect(() => { + if (options.length === 1) { + onChange(options[0]); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [options]); + + if (options.length > 1) { + return ( + + ); + } + + return ( + onChange(identifier)} + placeholder="Example, nvidia.com/gpu" + aria-label="Identifier" + data-testid="accelerator-identifier-input" + /> + ); +}; diff --git a/frontend/src/pages/acceleratorProfiles/screens/manage/ManageAcceleratorProfileDetailsSection.tsx b/frontend/src/pages/acceleratorProfiles/screens/manage/ManageAcceleratorProfileDetailsSection.tsx index 1c0dff0850..3073e5bd94 100644 --- a/frontend/src/pages/acceleratorProfiles/screens/manage/ManageAcceleratorProfileDetailsSection.tsx +++ b/frontend/src/pages/acceleratorProfiles/screens/manage/ManageAcceleratorProfileDetailsSection.tsx @@ -10,11 +10,13 @@ import { } from '@patternfly/react-core'; import React from 'react'; import { HelpIcon } from '@patternfly/react-icons'; +import { useSearchParams } from 'react-router-dom'; import { UpdateObjectAtPropAndValue } from '~/pages/projects/types'; import { AcceleratorKind } from '~/k8sTypes'; import DashboardPopupIconButton from '~/concepts/dashboard/DashboardPopupIconButton'; import { ManageAcceleratorSectionTitles } from './const'; import { ManageAcceleratorSectionID } from './types'; +import { IdentifierSelectField } from './IdentifierSelectField'; type ManageAcceleratorProfileDetailsSectionProps = { state: AcceleratorKind['spec']; @@ -24,77 +26,81 @@ type ManageAcceleratorProfileDetailsSectionProps = { export const ManageAcceleratorProfileDetailsSection = ({ state, setState, -}: ManageAcceleratorProfileDetailsSectionProps) => ( - - - - - setState('displayName', name)} - aria-label="accelerator-name" - data-testid="accelerator-name-input" - /> - - - - - } - aria-label="More info for identifier field" - /> - - } - > - { + const [searchParams] = useSearchParams(); + + const acceleratorIdentifiers = React.useMemo( + () => searchParams.get('identifiers')?.split(',') ?? [], + [searchParams], + ); + + return ( + + + + + setState('displayName', name)} + aria-label="Name" + data-testid="accelerator-name-input" + /> + + + + setState('identifier', identifier)} - placeholder="Example, nvidia.com/gpu" - aria-label="accelerator-identifier" - data-testid="accelerator-identifier-input" - /> - - - - -