diff --git a/backend/src/routes/api/accelerator-profiles/acceleratorProfilesUtils.ts b/backend/src/routes/api/accelerator-profiles/acceleratorProfilesUtils.ts new file mode 100644 index 0000000000..3a67bef923 --- /dev/null +++ b/backend/src/routes/api/accelerator-profiles/acceleratorProfilesUtils.ts @@ -0,0 +1,150 @@ +import { KubeFastifyInstance, AcceleratorKind } from '../../../types'; +import { FastifyRequest } from 'fastify'; +import createError from 'http-errors'; +import { translateDisplayNameForK8s } from '../../../utils/resourceUtils'; + +export const postAcceleratorProfile = async ( + fastify: KubeFastifyInstance, + request: FastifyRequest, +): Promise<{ success: boolean; error: string }> => { + const customObjectsApi = fastify.kube.customObjectsApi; + const namespace = fastify.kube.namespace; + const body = request.body as AcceleratorKind['spec']; + + const payload: AcceleratorKind = { + apiVersion: 'dashboard.opendatahub.io/v1', + kind: 'AcceleratorProfile', + metadata: { + name: translateDisplayNameForK8s(body.displayName), + namespace: namespace, + annotations: { + 'opendatahub.io/modified-date': new Date().toISOString(), + }, + }, + spec: body, + }; + + try { + await customObjectsApi + .createNamespacedCustomObject( + 'dashboard.opendatahub.io', + 'v1', + namespace, + 'acceleratorprofiles', + payload, + ) + .catch((e) => { + throw createError(e.statusCode, e?.body?.message); + }); + return { success: true, error: null }; + } catch (e) { + if (e.response?.statusCode !== 404) { + fastify.log.error(e, 'Unable to add accelerator profile.'); + return { success: false, error: 'Unable to add accelerator profile: ' + e.message }; + } + throw e; + } +}; + +export const deleteAcceleratorProfile = async ( + fastify: KubeFastifyInstance, + request: FastifyRequest, +): Promise<{ success: boolean; error: string }> => { + const customObjectsApi = fastify.kube.customObjectsApi; + const namespace = fastify.kube.namespace; + const params = request.params as { acceleratorProfileName: string }; + + try { + await customObjectsApi + .deleteNamespacedCustomObject( + 'dashboard.opendatahub.io', + 'v1', + namespace, + 'acceleratorprofiles', + params.acceleratorProfileName, + ) + .catch((e) => { + throw createError(e.statusCode, e?.body?.message); + }); + return { success: true, error: null }; + } catch (e) { + if (e.response?.statusCode === 404) { + fastify.log.error(e, 'Unable to delete accelerator profile.'); + return { success: false, error: 'Unable to delete accelerator profile: ' + e.message }; + } + throw e; + } +}; + +export const updateAcceleratorProfile = async ( + fastify: KubeFastifyInstance, + request: FastifyRequest, +): Promise<{ success: boolean; error: string }> => { + const customObjectsApi = fastify.kube.customObjectsApi; + const namespace = fastify.kube.namespace; + const params = request.params as { acceleratorProfileName: string }; + const body = request.body as Partial; + + try { + const currentProfile = await customObjectsApi + .getNamespacedCustomObject( + 'dashboard.opendatahub.io', + 'v1', + namespace, + 'acceleratorprofiles', + params.acceleratorProfileName, + ) + .then((r) => r.body as AcceleratorKind) + .catch((e) => { + throw createError(e.statusCode, e?.body?.message); + }); + + if (body.displayName !== undefined) { + currentProfile.spec.displayName = body.displayName; + } + if (body.enabled !== undefined) { + currentProfile.spec.enabled = body.enabled; + } + if (body.identifier !== undefined) { + currentProfile.spec.identifier = body.identifier; + } + if (body.description !== undefined) { + currentProfile.spec.description = body.description; + } + if (body.tolerations !== undefined) { + currentProfile.spec.tolerations = body.tolerations; + } + + // Update the modified date annotation + if (!currentProfile.metadata.annotations) { + currentProfile.metadata.annotations = {}; + } + currentProfile.metadata.annotations['opendatahub.io/modified-date'] = new Date().toISOString(); + + await customObjectsApi + .patchNamespacedCustomObject( + 'dashboard.opendatahub.io', + 'v1', + namespace, + 'acceleratorprofiles', + params.acceleratorProfileName, + currentProfile, + undefined, + undefined, + undefined, + { + headers: { 'Content-Type': 'application/merge-patch+json' }, + }, + ) + .catch((e) => { + throw createError(e.statusCode, e?.body?.message); + }); + return { success: true, error: null }; + } catch (e) { + if (e.response?.statusCode !== 404) { + fastify.log.error(e, 'Unable to update accelerator profile.'); + return { success: false, error: 'Unable to update accelerator profile: ' + e.message }; + } + throw e; + } +}; diff --git a/backend/src/routes/api/accelerator-profiles/index.ts b/backend/src/routes/api/accelerator-profiles/index.ts new file mode 100644 index 0000000000..984b6b527a --- /dev/null +++ b/backend/src/routes/api/accelerator-profiles/index.ts @@ -0,0 +1,48 @@ +import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import { secureAdminRoute } from '../../../utils/route-security'; +import { + deleteAcceleratorProfile, + postAcceleratorProfile, + updateAcceleratorProfile, +} from './acceleratorProfilesUtils'; + +export default async (fastify: FastifyInstance): Promise => { + fastify.delete( + '/:acceleratorProfileName', + secureAdminRoute(fastify)(async (request: FastifyRequest, reply: FastifyReply) => { + return deleteAcceleratorProfile(fastify, request) + .then((res) => { + return res; + }) + .catch((res) => { + reply.send(res); + }); + }), + ); + + fastify.put( + '/:acceleratorProfileName', + secureAdminRoute(fastify)(async (request: FastifyRequest, reply: FastifyReply) => { + return updateAcceleratorProfile(fastify, request) + .then((res) => { + return res; + }) + .catch((res) => { + reply.send(res); + }); + }), + ); + + fastify.post( + '/', + secureAdminRoute(fastify)(async (request: FastifyRequest, reply: FastifyReply) => { + return postAcceleratorProfile(fastify, request) + .then((res) => { + return res; + }) + .catch((res) => { + reply.send(res); + }); + }), + ); +}; diff --git a/backend/src/routes/api/images/imageUtils.ts b/backend/src/routes/api/images/imageUtils.ts index 29c0337b0a..cbab6aaee8 100644 --- a/backend/src/routes/api/images/imageUtils.ts +++ b/backend/src/routes/api/images/imageUtils.ts @@ -1,5 +1,6 @@ import { IMAGE_ANNOTATIONS } from '../../../utils/constants'; import { convertLabelsToString } from '../../../utils/componentUtils'; +import { translateDisplayNameForK8s } from '../../../utils/resourceUtils'; import { ImageStreamTag, ImageTagInfo, @@ -13,13 +14,6 @@ import { import { FastifyRequest } from 'fastify'; import createError from 'http-errors'; -const translateDisplayNameForK8s = (name: string): string => - name - .trim() - .toLowerCase() - .replace(/\s/g, '-') - .replace(/[^A-Za-z0-9-]/g, ''); - /** * This function uses a regex to match the image location string * The match result will return an array of 4 elements: diff --git a/backend/src/utils/resourceUtils.ts b/backend/src/utils/resourceUtils.ts index 3fe6353169..3ca54ccb88 100644 --- a/backend/src/utils/resourceUtils.ts +++ b/backend/src/utils/resourceUtils.ts @@ -952,3 +952,10 @@ export const migrateTemplateDisablement = async ( export const getServingRuntimeNameFromTemplate = (template: Template): string => template.objects[0].metadata.name; + +export const translateDisplayNameForK8s = (name: string): string => + name + .trim() + .toLowerCase() + .replace(/\s/g, '-') + .replace(/[^A-Za-z0-9-]/g, ''); diff --git a/frontend/src/__mocks__/mockAcceleratorProfile.ts b/frontend/src/__mocks__/mockAcceleratorProfile.ts index 4679cbe811..6f043f51dc 100644 --- a/frontend/src/__mocks__/mockAcceleratorProfile.ts +++ b/frontend/src/__mocks__/mockAcceleratorProfile.ts @@ -1,22 +1,34 @@ +import _ from 'lodash'; import { AcceleratorKind } from '~/k8sTypes'; +import { RecursivePartial } from '~/typeHelpers'; +import { TolerationEffect, TolerationOperator } from '~/types'; -export const mockAcceleratorProfile = (): AcceleratorKind => ({ - apiVersion: 'dashboard.opendatahub.io/v1', - kind: 'AcceleratorProfile', - metadata: { - name: 'test-accelerator', - }, - spec: { - displayName: 'test-accelerator', - enabled: true, - identifier: 'nvidia.com/gpu', - description: 'Test description', - tolerations: [ - { - key: 'nvidia.com/gpu', - operator: 'Exists', - effect: 'NoSchedule', +export const mockAcceleratorProfile = ( + data: RecursivePartial = {}, +): AcceleratorKind => + _.merge( + { + apiVersion: 'dashboard.opendatahub.io/v1', + kind: 'AcceleratorProfile', + metadata: { + name: 'test-accelerator', + annotations: { + 'opendatahub.io/modified-date': '2023-10-31T21:16:11.721Z', + }, }, - ], - }, -}); + spec: { + displayName: 'Test Accelerator', + enabled: true, + identifier: 'nvidia.com/gpu', + description: 'Test description', + tolerations: [ + { + key: 'nvidia.com/gpu', + operator: TolerationOperator.EXISTS, + effect: TolerationEffect.NO_SCHEDULE, + }, + ], + }, + } as AcceleratorKind, + data, + ); diff --git a/frontend/src/__mocks__/mockNotebookK8sResource.ts b/frontend/src/__mocks__/mockNotebookK8sResource.ts index 37a6497e72..bf47c47fff 100644 --- a/frontend/src/__mocks__/mockNotebookK8sResource.ts +++ b/frontend/src/__mocks__/mockNotebookK8sResource.ts @@ -1,6 +1,6 @@ import { KnownLabels, NotebookKind } from '~/k8sTypes'; import { DEFAULT_NOTEBOOK_SIZES } from '~/pages/projects/screens/spawner/const'; -import { ContainerResources } from '~/types'; +import { ContainerResources, TolerationEffect, TolerationOperator } from '~/types'; import { genUID } from '~/__mocks__/mockUtils'; type MockResourceConfigType = { @@ -135,23 +135,6 @@ export const mockNotebookK8sResource = ({ workingDir: '/opt/app-root/src', }, { - args: [ - '--provider=openshift', - '--https-address=:8443', - '--http-address=', - '--openshift-service-account=workbench', - '--cookie-secret-file=/etc/oauth/config/cookie_secret', - '--cookie-expire=24h0m0s', - '--tls-cert=/etc/tls/private/tls.crt', - '--tls-key=/etc/tls/private/tls.key', - '--upstream=http://localhost:8888', - '--upstream-ca=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt', - '--skip-auth-regex=^(?:/notebook/$(NAMESPACE)/workbench)?/api$', - '--email-domain=*', - '--skip-provider-button', - '--openshift-sar={"verb":"get","resource":"notebooks","resourceAPIGroup":"kubeflow.org","resourceName":"workbench","namespace":"$(NAMESPACE)"}', - '--logout-url=http://localhost:4010/projects/project?notebookLogout=workbench', - ], env: [ { name: 'NAMESPACE', @@ -220,12 +203,11 @@ export const mockNotebookK8sResource = ({ }, ], enableServiceLinks: false, - serviceAccountName: name, tolerations: [ { - effect: 'NoSchedule', + effect: TolerationEffect.NO_SCHEDULE, key: 'NotebooksOnlyChange', - operator: 'Exists', + operator: TolerationOperator.EXISTS, }, ], volumes: [ @@ -238,14 +220,12 @@ export const mockNotebookK8sResource = ({ { name: 'oauth-config', secret: { - defaultMode: 420, secretName: 'workbench-oauth-config', }, }, { name: 'tls-certificates', secret: { - defaultMode: 420, secretName: 'workbench-tls', }, }, @@ -284,11 +264,7 @@ export const mockNotebookK8sResource = ({ type: 'Waiting', }, ], - containerState: { - running: { - startedAt: '2023-02-14T22:06:52Z', - }, - }, + containerState: {}, readyReplicas: 1, }, }); diff --git a/frontend/src/__tests__/integration/pages/acceleratorProfiles/AcceleratorProfiles.stories.tsx b/frontend/src/__tests__/integration/pages/acceleratorProfiles/AcceleratorProfiles.stories.tsx index 27001e575b..fec2232a74 100644 --- a/frontend/src/__tests__/integration/pages/acceleratorProfiles/AcceleratorProfiles.stories.tsx +++ b/frontend/src/__tests__/integration/pages/acceleratorProfiles/AcceleratorProfiles.stories.tsx @@ -7,7 +7,7 @@ import { mockK8sResourceList } from '~/__mocks__/mockK8sResourceList'; import useDetectUser from '~/utilities/useDetectUser'; import { mockStatus } from '~/__mocks__/mockStatus'; -import AcceleratorProfiles from '~/pages/acceleratorProfiles/AcceleratorProfiles'; +import AcceleratorProfiles from '~/pages/acceleratorProfiles/screens/list/AcceleratorProfiles'; export default { component: AcceleratorProfiles, diff --git a/frontend/src/__tests__/integration/pages/acceleratorProfiles/ManageAcceleratorProfile.spec.ts b/frontend/src/__tests__/integration/pages/acceleratorProfiles/ManageAcceleratorProfile.spec.ts new file mode 100644 index 0000000000..3d928b17fc --- /dev/null +++ b/frontend/src/__tests__/integration/pages/acceleratorProfiles/ManageAcceleratorProfile.spec.ts @@ -0,0 +1,157 @@ +import { test, expect } from '@playwright/test'; +import { navigateToStory } from '~/__tests__/integration/utils'; + +test('Create accelerator profile', async ({ page }) => { + await page.goto( + navigateToStory( + 'pages-acceleratorprofiles-manageacceleratorprofile', + 'new-accelerator-profile', + ), + ); + + // wait for page to load + await page.waitForSelector('text=Details'); + + // test required fields + await expect(page.getByTestId('accelerator-profile-create-button')).toBeDisabled(); + await page.getByTestId('accelerator-name-input').fill('Test Name'); + await expect(page.getByTestId('accelerator-profile-create-button')).toBeDisabled(); + await page.getByTestId('accelerator-identifier-input').fill('test.com/gpu'); + await expect(page.getByTestId('accelerator-profile-create-button')).toBeEnabled(); + + // test tolerations + await expect(page.getByTestId('tolerations-modal-empty-state')).toBeTruthy(); + + // open modal + await page.getByTestId('add-toleration-button').click(); + + // fill in form required fields + await expect(page.getByTestId('modal-submit-button')).toBeDisabled(); + await page.getByTestId('toleration-key-input').fill('test-key'); + await expect(page.getByTestId('modal-submit-button')).toBeEnabled(); + + // test value info warning when operator is Exists + await page.getByTestId('toleration-value-input').fill('test-value'); + await expect(page.getByTestId('toleration-value-alert')).toBeTruthy(); + await page.getByRole('button', { name: 'Equal' }).click(); + await page + .getByRole('menuitem', { + name: 'Equal A toleration "matches" a taint if the keys are the same, the effects are the same, and the values are equal.', + }) + .click(); + await expect(page.getByTestId('toleration-value-alert')).toHaveCount(0); + + // test toleration seconds warning when effect is not NoExecute + await page.getByTestId('toleration-seconds-radio-custom').check(); + await expect(page.getByTestId('toleration-seconds-alert')).toHaveCount(1); + await page.getByRole('button', { name: 'None' }).click(); + await page + .getByRole('menuitem', { + name: 'NoExecute Pods will be evicted from the node if they do not tolerate the taint.', + }) + .click(); + await expect(page.getByTestId('toleration-seconds-alert')).toHaveCount(0); + await page.getByRole('button', { name: 'Plus' }).click(); + + // test adding toleration + await page.getByTestId('modal-submit-button').click(); + + // test that values were added correctly + await expect(page.getByRole('gridcell', { name: 'Equal' })).toBeTruthy(); + await expect(page.getByRole('gridcell', { name: 'test-key' })).toBeTruthy(); + await expect(page.getByRole('gridcell', { name: 'test-value' })).toBeTruthy(); + await expect(page.getByRole('gridcell', { name: 'NoExecute' })).toBeTruthy(); + await expect(page.getByRole('gridcell', { name: '1 seconds(s)' })).toBeTruthy(); + + // test bare minimum fields + await page.getByTestId('add-toleration-button').click(); + await page.getByTestId('toleration-key-input').click(); + await page.getByTestId('toleration-key-input').fill('new-key'); + await page.getByTestId('modal-submit-button').click(); + await expect(page.getByRole('gridcell', { name: 'Exists' })).toBeTruthy(); + await expect(page.getByRole('gridcell', { name: 'new-key' })).toBeTruthy(); + await expect(page.getByRole('gridcell', { name: '-', exact: true }).first()).toBeTruthy(); + await expect(page.getByRole('gridcell', { name: '-', exact: true }).nth(1)).toBeTruthy(); + await expect(page.getByRole('gridcell', { name: '-', exact: true }).nth(2)).toBeTruthy(); + + // test edit toleration + await page + .getByRole('row', { name: 'Equal new-key - - - Actions' }) + .getByRole('button', { name: 'Actions' }) + .click(); + await page.getByRole('menuitem', { name: 'Edit' }).click(); + await page.getByTestId('toleration-key-input').click(); + await page.getByTestId('toleration-key-input').fill('new-key-update'); + await page.getByRole('button', { name: 'Cancel' }).click(); + await expect(page.getByRole('gridcell', { name: 'new-key' })).toHaveCount(1); + await page + .getByRole('row', { name: 'Equal new-key - - - Actions' }) + .getByRole('button', { name: 'Actions' }) + .click(); + await page.getByRole('menuitem', { name: 'Edit' }).click(); + await page.getByTestId('toleration-key-input').click(); + await page.getByTestId('toleration-key-input').fill('updated-key'); + await page.getByRole('button', { name: 'Update' }).click(); + await expect(page.getByRole('gridcell', { name: 'updated-key' })).toHaveCount(1); + + // test cancel clears fields + await page + .getByRole('row', { name: 'Equal test-key test-value NoExecute 1 seconds(s) Actions' }) + .getByRole('button', { name: 'Actions' }) + .click(); + await page.getByRole('menuitem', { name: 'Edit' }).click(); + await page.getByRole('button', { name: 'Cancel' }).click(); + await page.getByTestId('add-toleration-button').click(); + await expect(page.getByTestId('modal-submit-button')).toBeDisabled(); + await page.getByRole('button', { name: 'Cancel' }).click(); + + // test delete + await page + .getByRole('row', { name: 'Equal test-key test-value NoExecute 1 seconds(s) Actions' }) + .getByRole('button', { name: 'Actions' }) + .click(); + await page.getByRole('menuitem', { name: 'Delete' }).click(); + await page.getByRole('button', { name: 'Actions' }).click(); + await page.getByRole('menuitem', { name: 'Delete' }).click(); + await expect(page.getByRole('heading', { name: 'No tolerations' })).toHaveCount(1); +}); + +test('Edit page has expected values', async ({ page }) => { + await page.goto( + navigateToStory( + 'pages-acceleratorprofiles-manageacceleratorprofile', + 'edit-accelerator-profile', + ), + ); + + // wait for page to load + await page.waitForSelector('text=Details'); + + // test values + expect(await page.getByTestId('accelerator-name-input').inputValue()).toBe('Test Accelerator'); + expect(await page.getByTestId('accelerator-identifier-input').inputValue()).toBe( + 'nvidia.com/gpu', + ); + expect(await page.getByTestId('accelerator-description-input').inputValue()).toBe( + 'Test description', + ); + await expect(page.getByRole('gridcell', { name: 'Exists' })).toHaveCount(1); + await expect(page.getByRole('gridcell', { name: 'nvidia.com/gpu' })).toHaveCount(1); + await expect(page.getByRole('gridcell', { name: '-' }).first()).toHaveCount(1); + await expect(page.getByRole('gridcell', { name: 'NoSchedule' })).toHaveCount(1); + await expect(page.getByRole('gridcell', { name: '-' }).nth(1)).toHaveCount(1); +}); + +test('Invalid id in edit page', async ({ page }) => { + await page.goto( + navigateToStory( + 'pages-acceleratorprofiles-manageacceleratorprofile', + 'invalid-accelerator-profile', + ), + ); + + // test for error message + await expect( + page.getByText('acceleratorprofiles.dashboard.opendatahub.io "test-accelerator" not found'), + ).toHaveCount(1); +}); diff --git a/frontend/src/__tests__/integration/pages/acceleratorProfiles/ManageAcceleratorProfile.stories.tsx b/frontend/src/__tests__/integration/pages/acceleratorProfiles/ManageAcceleratorProfile.stories.tsx new file mode 100644 index 0000000000..578609abe6 --- /dev/null +++ b/frontend/src/__tests__/integration/pages/acceleratorProfiles/ManageAcceleratorProfile.stories.tsx @@ -0,0 +1,146 @@ +import { Meta, StoryObj } from '@storybook/react'; +import { rest } from 'msw'; +import { userEvent, within } from '@storybook/testing-library'; +import React from 'react'; +import { mockK8sResourceList } from '~/__mocks__/mockK8sResourceList'; +import { mockAcceleratorProfile } from '~/__mocks__/mockAcceleratorProfile'; +import { mockProjectK8sResource } from '~/__mocks__/mockProjectK8sResource'; +import { mockStatus } from '~/__mocks__/mockStatus'; +import EditAcceleratorProfileComponent from '~/pages/acceleratorProfiles/screens/manage/EditAcceleratorProfile'; +import useDetectUser from '~/utilities/useDetectUser'; +import ManageAcceleratorProfileComponent from '~/pages/acceleratorProfiles/screens/manage/ManageAcceleratorProfile'; + +export default { + component: ManageAcceleratorProfileComponent, + parameters: { + msw: { + 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()))), + ], + }, + }, + }, +} as Meta; + +const RenderComponent = ({ children }: { children: React.ReactElement }) => { + useDetectUser(); + return children; +}; +export const NewAcceleratorProfile: StoryObj = { + render: () => ( + + + + ), + play: async ({ canvasElement }) => { + // load page and wait until settled + const canvas = within(canvasElement); + await canvas.findByText('Identifier', undefined, { + timeout: 5000, + }); + }, +}; + +export const EditAcceleratorProfile: 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: '/edit/:acceleratorProfileName', + routeParams: { acceleratorProfileName: 'test-accelerator' }, + }, + msw: { + handlers: { + accelerator: rest.get( + '/api/k8s/apis/dashboard.opendatahub.io/v1/namespaces/opendatahub/acceleratorprofiles/test-accelerator', + (req, res, ctx) => + res(ctx.json(mockAcceleratorProfile({ metadata: { name: 'test-accelerator' } }))), + ), + }, + }, + }, +}; + +export const InvalidAcceleratorProfile: StoryObj = { + render: () => ( + + + + ), + play: async ({ canvasElement }) => { + // load page and wait until settled + const canvas = within(canvasElement); + await canvas.findByText('Problem loading accelerator profile', undefined, { + timeout: 5000, + }); + }, + parameters: { + reactRouter: { + routePath: '/edit/:acceleratorProfileName', + routeParams: { acceleratorProfileName: 'test-accelerator' }, + }, + msw: { + handlers: { + accelerator: rest.get( + '/api/k8s/apis/dashboard.opendatahub.io/v1/namespaces/opendatahub/acceleratorprofiles/test-accelerator', + (req, res, ctx) => + res( + ctx.status(404), + ctx.json({ + kind: 'Status', + apiVersion: 'v1', + metadata: {}, + status: 'Failure', + message: + 'acceleratorprofiles.dashboard.opendatahub.io "test-accelerator" not found', + reason: 'NotFound', + details: { + name: 'test-gpud', + group: 'dashboard.opendatahub.io', + kind: 'acceleratorprofiles', + }, + code: 404, + }), + ), + ), + }, + }, + }, +}; + +export const TolerationsModal: StoryObj = { + render: () => ( + + + + ), + parameters: { + a11y: { + element: '.pf-c-backdrop', + }, + }, + play: async ({ canvasElement }) => { + // load page and wait until settled + const canvas = within(canvasElement); + await canvas.findByText('Identifier', undefined, { + timeout: 5000, + }); + + // user flow for deleting a project + await userEvent.click(canvas.getByText('Add toleration', { selector: 'button' })); + }, +}; diff --git a/frontend/src/api/k8s/accelerators.ts b/frontend/src/api/k8s/accelerators.ts index de5d47d1e6..fc3ed1bcfb 100644 --- a/frontend/src/api/k8s/accelerators.ts +++ b/frontend/src/api/k8s/accelerators.ts @@ -1,4 +1,4 @@ -import { k8sListResource } from '@openshift/dynamic-plugin-sdk-utils'; +import { k8sGetResource, k8sListResource } from '@openshift/dynamic-plugin-sdk-utils'; import { AcceleratorKind } from '~/k8sTypes'; import { AcceleratorModel } from '~/api/models'; @@ -9,3 +9,9 @@ export const listAccelerators = async (namespace: string): Promise listResource.items); + +export const getAccelerator = (name: string, namespace: string): Promise => + k8sGetResource({ + model: AcceleratorModel, + queryOptions: { name, ns: namespace }, + }); diff --git a/frontend/src/app/AppRoutes.tsx b/frontend/src/app/AppRoutes.tsx index 88ecd597ae..caab24decb 100644 --- a/frontend/src/app/AppRoutes.tsx +++ b/frontend/src/app/AppRoutes.tsx @@ -37,8 +37,8 @@ const DependencyMissingPage = React.lazy( () => import('../pages/dependencies/DependencyMissingPage'), ); -const AcceleratorProfiles = React.lazy( - () => import('../pages/acceleratorProfiles/AcceleratorProfiles'), +const AcceleratorProfileRoutes = React.lazy( + () => import('../pages/acceleratorProfiles/AcceleratorProfilesRoutes'), ); const AppRoutes: React.FC = () => { @@ -80,7 +80,7 @@ const AppRoutes: React.FC = () => { <> } /> } /> - } /> + } /> } /> } /> diff --git a/frontend/src/concepts/dashboard/DashboardModalFooter.tsx b/frontend/src/concepts/dashboard/DashboardModalFooter.tsx index 1ec2d779d3..62fab64ed4 100644 --- a/frontend/src/concepts/dashboard/DashboardModalFooter.tsx +++ b/frontend/src/concepts/dashboard/DashboardModalFooter.tsx @@ -39,7 +39,13 @@ const DashboardModalFooter: React.FC = ({ - diff --git a/frontend/src/pages/acceleratorProfiles/AcceleratorProfilesRoutes.tsx b/frontend/src/pages/acceleratorProfiles/AcceleratorProfilesRoutes.tsx new file mode 100644 index 0000000000..ef53a19771 --- /dev/null +++ b/frontend/src/pages/acceleratorProfiles/AcceleratorProfilesRoutes.tsx @@ -0,0 +1,16 @@ +import * as React from 'react'; +import { Navigate, Route, Routes } from 'react-router-dom'; +import EditAcceleratorProfile from './screens/manage/EditAcceleratorProfile'; +import AcceleratorProfiles from './screens/list/AcceleratorProfiles'; +import ManageAcceleratorProfile from './screens/manage/ManageAcceleratorProfile'; + +const AcceleratorProfilesRoutes: React.FC = () => ( + + } /> + } /> + } /> + } /> + +); + +export default AcceleratorProfilesRoutes; diff --git a/frontend/src/pages/acceleratorProfiles/AcceleratorProfiles.tsx b/frontend/src/pages/acceleratorProfiles/screens/list/AcceleratorProfiles.tsx similarity index 93% rename from frontend/src/pages/acceleratorProfiles/AcceleratorProfiles.tsx rename to frontend/src/pages/acceleratorProfiles/screens/list/AcceleratorProfiles.tsx index 8cd2938307..f19ed78202 100644 --- a/frontend/src/pages/acceleratorProfiles/AcceleratorProfiles.tsx +++ b/frontend/src/pages/acceleratorProfiles/screens/list/AcceleratorProfiles.tsx @@ -13,6 +13,7 @@ import { Title, } from '@patternfly/react-core'; import { PlusCircleIcon } from '@patternfly/react-icons'; +import { useNavigate } from 'react-router-dom'; import ApplicationsPage from '~/pages/ApplicationsPage'; import useAccelerators from '~/pages/notebookController/screens/server/useAccelerators'; import { useDashboardNamespace } from '~/redux/selectors'; @@ -23,6 +24,8 @@ const AcceleratorProfiles: React.FC = () => { const { dashboardNamespace } = useDashboardNamespace(); const [accelerators, loaded, loadError] = useAccelerators(dashboardNamespace); + const navigate = useNavigate(); + const isEmpty = !accelerators || accelerators.length === 0; const noAcceleratorProfilePageSection = ( @@ -40,7 +43,7 @@ const AcceleratorProfiles: React.FC = () => { diff --git a/frontend/src/pages/acceleratorProfiles/screens/manage/EditAcceleratorProfile.tsx b/frontend/src/pages/acceleratorProfiles/screens/manage/EditAcceleratorProfile.tsx new file mode 100644 index 0000000000..1d5b88b898 --- /dev/null +++ b/frontend/src/pages/acceleratorProfiles/screens/manage/EditAcceleratorProfile.tsx @@ -0,0 +1,52 @@ +import * as React from 'react'; +import { useParams } from 'react-router'; +import { + Bullseye, + Button, + EmptyState, + EmptyStateBody, + EmptyStateIcon, + Spinner, + Title, +} from '@patternfly/react-core'; +import { ExclamationCircleIcon } from '@patternfly/react-icons'; +import { useNavigate } from 'react-router-dom'; +import { useDashboardNamespace } from '~/redux/selectors'; +import useAcceleratorProfile from './useAcceleratorProfile'; +import ManageAcceleratorProfile from './ManageAcceleratorProfile'; + +const EditAcceleratorProfile: React.FC = () => { + const navigate = useNavigate(); + const { acceleratorProfileName } = useParams(); + const { dashboardNamespace } = useDashboardNamespace(); + const [data, , error] = useAcceleratorProfile(dashboardNamespace, acceleratorProfileName); + + if (error) { + return ( + + + + + Problem loading accelerator profile + + {error.message} + + + + ); + } + + if (!data) { + return ( + + + + ); + } + + return ; +}; + +export default EditAcceleratorProfile; diff --git a/frontend/src/pages/acceleratorProfiles/screens/manage/ManageAcceleratorProfile.tsx b/frontend/src/pages/acceleratorProfiles/screens/manage/ManageAcceleratorProfile.tsx new file mode 100644 index 0000000000..4d30cd85f3 --- /dev/null +++ b/frontend/src/pages/acceleratorProfiles/screens/manage/ManageAcceleratorProfile.tsx @@ -0,0 +1,84 @@ +import * as React from 'react'; +import { Link } from 'react-router-dom'; +import { Breadcrumb, BreadcrumbItem, Form, PageSection } from '@patternfly/react-core'; +import ApplicationsPage from '~/pages/ApplicationsPage'; +import useGenericObjectState from '~/utilities/useGenericObjectState'; +import GenericSidebar from '~/components/GenericSidebar'; + +import { AcceleratorKind } from '~/k8sTypes'; +import { ManageAcceleratorProfileFooter } from './ManageAcceleratorProfileFooter'; +import { ManageAcceleratorProfileTolerationsSection } from './ManageAcceleratorProfileTolerationsSection'; +import { ManageAcceleratorSectionID } from './types'; +import { ManageAcceleratorSectionTitles, ScrollableSelectorID } from './const'; +import { ManageAcceleratorProfileDetailsSection } from './ManageAcceleratorProfileDetailsSection'; + +type ManageAcceleratorProfileProps = { + existingAccelerator?: AcceleratorKind; +}; + +const ManageAcceleratorProfile: React.FC = ({ + existingAccelerator, +}) => { + const [state, setState] = useGenericObjectState({ + displayName: '', + identifier: '', + enabled: true, + tolerations: [], + }); + + React.useEffect(() => { + if (existingAccelerator) { + setState('displayName', existingAccelerator.spec.displayName); + setState('identifier', existingAccelerator.spec.identifier); + setState('description', existingAccelerator.spec.description); + setState('enabled', existingAccelerator.spec.enabled); + setState('tolerations', existingAccelerator.spec.tolerations); + } + }, [existingAccelerator, setState]); + + const sectionIDs = Object.values(ManageAcceleratorSectionID); + + return ( + + Accelerator profiles} + /> + + {existingAccelerator ? 'Edit' : 'Create'} accelerator profile + + + } + loaded + empty={false} + > + + +
+ + setState('tolerations', tolerations)} + /> + +
+
+ + + +
+ ); +}; + +export default ManageAcceleratorProfile; diff --git a/frontend/src/pages/acceleratorProfiles/screens/manage/ManageAcceleratorProfileDetailsSection.tsx b/frontend/src/pages/acceleratorProfiles/screens/manage/ManageAcceleratorProfileDetailsSection.tsx new file mode 100644 index 0000000000..1c0dff0850 --- /dev/null +++ b/frontend/src/pages/acceleratorProfiles/screens/manage/ManageAcceleratorProfileDetailsSection.tsx @@ -0,0 +1,100 @@ +import { + FormSection, + Stack, + StackItem, + FormGroup, + TextInput, + TextArea, + Switch, + Popover, +} from '@patternfly/react-core'; +import React from 'react'; +import { HelpIcon } from '@patternfly/react-icons'; +import { UpdateObjectAtPropAndValue } from '~/pages/projects/types'; +import { AcceleratorKind } from '~/k8sTypes'; +import DashboardPopupIconButton from '~/concepts/dashboard/DashboardPopupIconButton'; +import { ManageAcceleratorSectionTitles } from './const'; +import { ManageAcceleratorSectionID } from './types'; + +type ManageAcceleratorProfileDetailsSectionProps = { + state: AcceleratorKind['spec']; + setState: UpdateObjectAtPropAndValue; +}; + +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" + /> + + } + > + setState('identifier', identifier)} + placeholder="Example, nvidia.com/gpu" + aria-label="accelerator-identifier" + data-testid="accelerator-identifier-input" + /> + + + + +