diff --git a/frontend/src/__tests__/integration/pages/modelServing/ModelServingGlobal.spec.ts b/frontend/src/__tests__/integration/pages/modelServing/ModelServingGlobal.spec.ts index 8cfec79207..48f676521c 100644 --- a/frontend/src/__tests__/integration/pages/modelServing/ModelServingGlobal.spec.ts +++ b/frontend/src/__tests__/integration/pages/modelServing/ModelServingGlobal.spec.ts @@ -13,20 +13,20 @@ test('Empty State No Serving Runtime', async ({ page }) => { await expect(page.getByRole('button', { name: 'Go to the Projects page' })).toBeTruthy(); }); -// test('Empty State No Inference Service', async ({ page }) => { -// await page.goto( -// navigateToStory('pages-modelserving-modelservingglobal', 'empty-state-no-inference-service'), -// ); +test('Empty State No Inference Service', async ({ page }) => { + await page.goto( + navigateToStory('pages-modelserving-modelservingglobal', 'empty-state-no-inference-services'), + ); -// // wait for page to load -// await page.waitForSelector('text=No deployed models'); + // wait for page to load + await page.waitForSelector('text=No deployed models'); -// // Test that the button is enabled -// await page.getByRole('button', { name: 'Deploy model' }).click(); + // Test that the button is enabled + await page.getByRole('button', { name: 'Deploy model' }).click(); -// // test that you can not submit on empty -// await expect(await page.getByRole('button', { name: 'Deploy' })).toBeDisabled(); -// }); + // test that you can not submit on empty + await expect(await page.getByRole('button', { name: 'Deploy', exact: true })).toBeDisabled(); +}); test('Delete model', async ({ page }) => { await page.goto(navigateToStory('pages-modelserving-modelservingglobal', 'delete-model')); @@ -80,85 +80,85 @@ test('Edit model', async ({ page }) => { await expect(page.getByRole('button', { name: 'Deploy', exact: true })).toBeEnabled(); }); -// test('Create model', async ({ page }) => { -// await page.goto(navigateToStory('pages-modelserving-modelservingglobal', 'deploy-model')); - -// // wait for page to load -// await page.waitForSelector('text=Deployed models'); - -// // test that you can not submit on empty -// await expect(await page.getByRole('button', { name: 'Deploy' })).toBeDisabled(); - -// // test filling in minimum required fields -// await page.locator('#existing-project-selection').click(); -// await page.getByRole('option', { name: 'Test Project' }).click(); -// await page.getByLabel('Model Name *').fill('Test Name'); -// await page.locator('#inference-service-model-selection').click(); -// await page.getByRole('option', { name: 'ovms' }).click(); -// await expect(page.getByText('Model framework (name - version)')).toBeTruthy(); -// await page.locator('#inference-service-framework-selection').click(); -// await page.getByRole('option', { name: 'onnx - 1' }).click(); -// await expect(await page.getByRole('button', { name: 'Deploy' })).toBeDisabled(); -// await page -// .getByRole('group', { name: 'Model location' }) -// .getByRole('button', { name: 'Options menu' }) -// .click(); -// await page.getByRole('option', { name: 'Test Secret' }).click(); -// await page.getByLabel('Path').fill('test-model/'); -// await expect(await page.getByRole('button', { name: 'Deploy' })).toBeEnabled(); -// await page.getByText('New data connection').click(); -// await page.getByLabel('Path').fill(''); -// await expect(await page.getByRole('button', { name: 'Deploy' })).toBeDisabled(); -// await page.getByLabel('Path').fill('/'); -// await expect(await page.getByRole('button', { name: 'Deploy' })).toBeDisabled(); -// await page.getByRole('textbox', { name: 'Field list Name' }).fill('Test Name'); -// await page.getByRole('textbox', { name: 'Field list AWS_ACCESS_KEY_ID' }).fill('test-key'); -// await page -// .getByRole('textbox', { name: 'Field list AWS_SECRET_ACCESS_KEY' }) -// .fill('test-secret-key'); -// await page.getByRole('textbox', { name: 'Field list AWS_S3_ENDPOINT' }).fill('test-endpoint'); -// await page.getByRole('textbox', { name: 'Field list AWS_S3_BUCKET' }).fill('test-bucket'); -// await page.getByLabel('Path').fill('test-model/'); -// await expect(await page.getByRole('button', { name: 'Deploy' })).toBeEnabled(); -// }); - -// test('Create model error', async ({ page }) => { -// await page.goto(navigateToStory('pages-modelserving-modelservingglobal', 'deploy-model')); - -// // wait for page to load -// await page.waitForSelector('text=Deployed models'); - -// // test that you can not submit on empty -// await expect(await page.getByRole('button', { name: 'Deploy' })).toBeDisabled(); - -// // test filling in minimum required fields -// await page.locator('#existing-project-selection').click(); -// await page.getByRole('option', { name: 'Test Project' }).click(); -// await page.getByLabel('Model Name *').fill('trigger-error'); -// await page.locator('#inference-service-model-selection').click(); -// await page.getByRole('option', { name: 'ovms' }).click(); -// await expect(page.getByText('Model framework (name - version)')).toBeTruthy(); -// await page.locator('#inference-service-framework-selection').click(); -// await page.getByRole('option', { name: 'onnx - 1' }).click(); -// await expect(await page.getByRole('button', { name: 'Deploy' })).toBeDisabled(); -// await page -// .getByRole('group', { name: 'Model location' }) -// .getByRole('button', { name: 'Options menu' }) -// .click(); -// await page.getByRole('option', { name: 'Test Secret' }).click(); -// await page.getByLabel('Path').fill('test-model/'); -// await expect(await page.getByRole('button', { name: 'Deploy' })).toBeEnabled(); -// await page.getByLabel('Path').fill('test-model/'); -// await expect(await page.getByRole('button', { name: 'Deploy' })).toBeEnabled(); - -// // Submit and check the invalid error message -// await page.getByRole('button', { name: 'Deploy' }).click(); -// await page.waitForSelector('text=Error creating model server'); - -// // Close the modal -// await page.getByRole('button', { name: 'Cancel' }).click(); - -// // Check that the error message is gone -// await page.getByRole('button', { name: 'Deploy model' }).click(); -// expect(await page.isVisible('text=Error creating model server')).toBeFalsy(); -// }); +test('Create model', async ({ page }) => { + await page.goto( + navigateToStory('pages-modelserving-modelservingglobal', 'deploy-model-model-mesh'), + ); + + // wait for page to load + await page.waitForSelector('text=Deploy model'); + + // test that you can not submit on empty + await expect(await page.getByRole('button', { name: 'Deploy', exact: true })).toBeDisabled(); + + // test filling in minimum required fields + await page.getByLabel('Model Name *').fill('Test Name'); + await page.locator('#inference-service-model-selection').click(); + await page.getByRole('option', { name: 'ovms' }).click(); + await expect(page.getByText('Model framework (name - version)')).toBeTruthy(); + await page.locator('#inference-service-framework-selection').click(); + await page.getByRole('option', { name: 'onnx - 1' }).click(); + await expect(await page.getByRole('button', { name: 'Deploy', exact: true })).toBeDisabled(); + await page + .getByRole('group', { name: 'Model location' }) + .getByRole('button', { name: 'Options menu' }) + .click(); + await page.getByRole('option', { name: 'Test Secret' }).click(); + await page.getByLabel('Path').fill('test-model/'); + await expect(await page.getByRole('button', { name: 'Deploy', exact: true })).toBeEnabled(); + await page.getByText('New data connection').click(); + await page.getByLabel('Path').fill(''); + await expect(await page.getByRole('button', { name: 'Deploy', exact: true })).toBeDisabled(); + await page.getByLabel('Path').fill('/'); + await expect(await page.getByRole('button', { name: 'Deploy', exact: true })).toBeDisabled(); + await page.getByRole('textbox', { name: 'Field list Name' }).fill('Test Name'); + await page.getByRole('textbox', { name: 'Field list AWS_ACCESS_KEY_ID' }).fill('test-key'); + await page + .getByRole('textbox', { name: 'Field list AWS_SECRET_ACCESS_KEY' }) + .fill('test-secret-key'); + await page.getByRole('textbox', { name: 'Field list AWS_S3_ENDPOINT' }).fill('test-endpoint'); + await page.getByRole('textbox', { name: 'Field list AWS_S3_BUCKET' }).fill('test-bucket'); + await page.getByLabel('Path').fill('test-model/'); + await expect(await page.getByRole('button', { name: 'Deploy', exact: true })).toBeEnabled(); +}); + +test('Create model error', async ({ page }) => { + await page.goto( + navigateToStory('pages-modelserving-modelservingglobal', 'deploy-model-model-mesh'), + ); + + // wait for page to load + await page.waitForSelector('text=Deploy model'); + + // test that you can not submit on empty + await expect(await page.getByRole('button', { name: 'Deploy', exact: true })).toBeDisabled(); + + // test filling in minimum required fields + await page.getByLabel('Model Name *').fill('trigger-error'); + await page.locator('#inference-service-model-selection').click(); + await page.getByRole('option', { name: 'ovms' }).click(); + await expect(page.getByText('Model framework (name - version)')).toBeTruthy(); + await page.locator('#inference-service-framework-selection').click(); + await page.getByRole('option', { name: 'onnx - 1' }).click(); + await expect(await page.getByRole('button', { name: 'Deploy', exact: true })).toBeDisabled(); + await page + .getByRole('group', { name: 'Model location' }) + .getByRole('button', { name: 'Options menu' }) + .click(); + await page.getByRole('option', { name: 'Test Secret' }).click(); + await page.getByLabel('Path').fill('test-model/'); + await expect(await page.getByRole('button', { name: 'Deploy', exact: true })).toBeEnabled(); + await page.getByLabel('Path').fill('test-model/'); + await expect(await page.getByRole('button', { name: 'Deploy', exact: true })).toBeEnabled(); + + // Submit and check the invalid error message + await page.getByRole('button', { name: 'Deploy', exact: true }).click(); + await page.waitForSelector('text=Error creating model server'); + + // Close the modal + await page.getByRole('button', { name: 'Cancel', exact: true }).click(); + + // Check that the error message is gone + await page.getByRole('button', { name: 'Deploy model', exact: true }).click(); + expect(await page.isVisible('text=Error creating model server')).toBeFalsy(); +}); diff --git a/frontend/src/__tests__/integration/pages/modelServing/ModelServingGlobal.stories.tsx b/frontend/src/__tests__/integration/pages/modelServing/ModelServingGlobal.stories.tsx index 5700620b71..ef28a35a04 100644 --- a/frontend/src/__tests__/integration/pages/modelServing/ModelServingGlobal.stories.tsx +++ b/frontend/src/__tests__/integration/pages/modelServing/ModelServingGlobal.stories.tsx @@ -5,6 +5,7 @@ import { rest } from 'msw'; import { within, userEvent } from '@storybook/testing-library'; // import { expect } from '@storybook/jest'; import { Route, Routes } from 'react-router-dom'; +import { Spinner } from '@patternfly/react-core'; import { mockK8sResourceList } from '~/__mocks__/mockK8sResourceList'; import { mockProjectK8sResource } from '~/__mocks__/mockProjectK8sResource'; import { mockServingRuntimeK8sResource } from '~/__mocks__/mockServingRuntimeK8sResource'; @@ -15,72 +16,165 @@ import { import { mockSecretK8sResource } from '~/__mocks__/mockSecretK8sResource'; import ModelServingContextProvider from '~/pages/modelServing/ModelServingContext'; import ModelServingGlobal from '~/pages/modelServing/screens/global/ModelServingGlobal'; +import ProjectsContextProvider from '~/concepts/projects/ProjectsContext'; +import { + mockInvalidTemplateK8sResource, + mockServingRuntimeTemplateK8sResource, +} from '~/__mocks__/mockServingRuntimeTemplateK8sResource'; +import { ServingRuntimePlatform } from '~/types'; +import { mockDashboardConfig } from '~/__mocks__/mockDashboardConfig'; +import { mockStatus } from '~/__mocks__/mockStatus'; +import useDetectUser from '~/utilities/useDetectUser'; +import { useApplicationSettings } from '~/app/useApplicationSettings'; +import { AppContext } from '~/app/AppContext'; +import { InferenceServiceKind, ServingRuntimeKind } from '~/k8sTypes'; + +type HandlersProps = { + disableKServeConfig?: boolean; + disableModelMeshConfig?: boolean; + projectEnableModelMesh?: boolean; + servingRuntimes?: ServingRuntimeKind[]; + inferenceServices?: InferenceServiceKind[]; +}; + +const getHandlers = ({ + disableKServeConfig, + disableModelMeshConfig, + projectEnableModelMesh, + servingRuntimes = [mockServingRuntimeK8sResource({})], + inferenceServices = [mockInferenceServiceK8sResource({})], +}: HandlersProps) => [ + rest.get('/api/status', (req, res, ctx) => res(ctx.json(mockStatus()))), + rest.get('/api/config', (req, res, ctx) => + res( + ctx.json( + mockDashboardConfig({ + disableKServe: disableKServeConfig, + disableModelMesh: disableModelMeshConfig, + }), + ), + ), + ), + rest.get( + 'api/k8s/apis/serving.kserve.io/v1alpha1/namespaces/test-project/servingruntimes', + (req, res, ctx) => res(ctx.json(mockK8sResourceList(servingRuntimes))), + ), + rest.get( + 'api/k8s/apis/serving.kserve.io/v1beta1/namespaces/test-project/inferenceservices', + (req, res, ctx) => res(ctx.json(mockK8sResourceList(inferenceServices))), + ), + rest.get('/api/k8s/api/v1/namespaces/test-project/secrets', (req, res, ctx) => + res(ctx.json(mockK8sResourceList([mockSecretK8sResource({})]))), + ), + rest.get( + 'api/k8s/apis/serving.kserve.io/v1alpha1/namespaces/modelServing/servingruntimes', + (req, res, ctx) => res(ctx.json(mockK8sResourceList(servingRuntimes))), + ), + rest.get( + 'api/k8s/apis/serving.kserve.io/v1beta1/namespaces/modelServing/inferenceservices', + (req, res, ctx) => res(ctx.json(mockK8sResourceList(inferenceServices))), + ), + rest.get('/api/k8s/api/v1/namespaces/modelServing/secrets', (req, res, ctx) => + res(ctx.json(mockK8sResourceList([mockSecretK8sResource({})]))), + ), + rest.get( + '/api/k8s/apis/serving.kserve.io/v1alpha1/namespaces/test-project/servingruntimes/test-model', + (req, res, ctx) => res(ctx.json(mockServingRuntimeK8sResource({}))), + ), + rest.get('/api/k8s/apis/project.openshift.io/v1/projects', (req, res, ctx) => + res( + ctx.json( + mockK8sResourceList([mockProjectK8sResource({ enableModelMesh: projectEnableModelMesh })]), + ), + ), + ), + rest.post( + 'api/k8s/apis/serving.kserve.io/v1beta1/namespaces/test-project/inferenceservices/test', + (req, res, ctx) => res(ctx.json(mockInferenceServiceK8sResource({}))), + ), + rest.post( + 'api/k8s/apis/serving.kserve.io/v1beta1/namespaces/test-project/inferenceservices/trigger-error', + (req, res, ctx) => + res(ctx.status(422, 'Unprocessable Entity'), ctx.json(mockInferenceServicek8sError())), + ), + rest.get( + 'api/k8s/apis/opendatahub.io/v1alpha/namespaces/opendatahub/odhdashboardconfigs/odh-dashboard-config', + (req, res, ctx) => res(ctx.json(mockDashboardConfig({}))), + ), + rest.get( + '/api/k8s/apis/template.openshift.io/v1/namespaces/opendatahub/templates', + (req, res, ctx) => + res( + ctx.json( + mockK8sResourceList([ + mockServingRuntimeTemplateK8sResource({ + name: 'template-1', + displayName: 'Multi Platform', + platforms: [ServingRuntimePlatform.SINGLE, ServingRuntimePlatform.MULTI], + }), + mockServingRuntimeTemplateK8sResource({ + name: 'template-2', + displayName: 'Caikit', + platforms: [ServingRuntimePlatform.SINGLE], + }), + mockServingRuntimeTemplateK8sResource({ + name: 'template-3', + displayName: 'New OVMS Server', + platforms: [ServingRuntimePlatform.MULTI], + }), + mockServingRuntimeTemplateK8sResource({ + name: 'template-4', + displayName: 'Serving Runtime with No Annotations', + }), + mockInvalidTemplateK8sResource({}), + ]), + ), + ), + ), +]; export default { component: ModelServingGlobal, parameters: { - msw: { - handlers: [ - rest.get( - 'api/k8s/apis/serving.kserve.io/v1alpha1/namespaces/test-project/servingruntimes', - (req, res, ctx) => - res(ctx.json(mockK8sResourceList([mockServingRuntimeK8sResource({})]))), - ), - rest.get( - 'api/k8s/apis/serving.kserve.io/v1beta1/namespaces/test-project/inferenceservices', - (req, res, ctx) => - res(ctx.json(mockK8sResourceList([mockInferenceServiceK8sResource({})]))), - ), - rest.get('/api/k8s/api/v1/namespaces/test-project/secrets', (req, res, ctx) => - res(ctx.json(mockK8sResourceList([mockSecretK8sResource({})]))), - ), - rest.get( - '/api/k8s/apis/serving.kserve.io/v1alpha1/namespaces/test-project/servingruntimes/test-model', - (req, res, ctx) => res(ctx.json(mockServingRuntimeK8sResource({}))), - ), - rest.get('/api/k8s/apis/project.openshift.io/v1/projects', (req, res, ctx) => - res(ctx.json(mockK8sResourceList([mockProjectK8sResource({})]))), - ), - rest.post( - 'api/k8s/apis/serving.kserve.io/v1beta1/namespaces/test-project/inferenceservices/test', - (req, res, ctx) => res(ctx.json(mockInferenceServiceK8sResource({}))), - ), - rest.post( - 'api/k8s/apis/serving.kserve.io/v1beta1/namespaces/test-project/inferenceservices/trigger-error', - (req, res, ctx) => - res(ctx.status(422, 'Unprocessable Entity'), ctx.json(mockInferenceServicek8sError())), - ), - ], + reactRouter: { + routePath: '/modelServing/:namespace/*', + routeParams: { namespace: 'test-project' }, }, }, } as Meta; -const Template: StoryFn = (args) => ( - - }> - } /> - - -); +const Template: StoryFn = (args) => { + useDetectUser(); + const { dashboardConfig, loaded } = useApplicationSettings(); + + return loaded && dashboardConfig ? ( + + + + }> + } /> + } /> + + + + + ) : ( + + ); +}; export const EmptyStateNoServingRuntime: StoryObj = { render: Template, parameters: { msw: { - handlers: [ - rest.get( - 'api/k8s/apis/serving.kserve.io/v1alpha1/namespaces/test-project/servingruntimes', - (req, res, ctx) => res(ctx.json(mockK8sResourceList([]))), - ), - rest.get( - 'api/k8s/apis/serving.kserve.io/v1beta1/namespaces/test-project/inferenceservices', - (req, res, ctx) => res(ctx.json(mockK8sResourceList([]))), - ), - rest.get('/api/k8s/apis/project.openshift.io/v1/projects', (req, res, ctx) => - res(ctx.json(mockK8sResourceList([mockProjectK8sResource({})]))), - ), - ], + handlers: getHandlers({ + disableKServeConfig: false, + disableModelMeshConfig: false, + projectEnableModelMesh: true, + servingRuntimes: [], + inferenceServices: [], + }), }, }, }; @@ -90,20 +184,10 @@ export const EmptyStateNoInferenceServices: StoryObj = { parameters: { msw: { - handlers: [ - rest.get( - 'api/k8s/apis/serving.kserve.io/v1alpha1/namespaces/test-project/servingruntimes', - (req, res, ctx) => - res(ctx.json(mockK8sResourceList([mockServingRuntimeK8sResource({})]))), - ), - rest.get( - 'api/k8s/apis/serving.kserve.io/v1beta1/namespaces/test-project/inferenceservices', - (req, res, ctx) => res(ctx.json(mockK8sResourceList([]))), - ), - rest.get('/api/k8s/apis/project.openshift.io/v1/projects', (req, res, ctx) => - res(ctx.json(mockK8sResourceList([mockProjectK8sResource({})]))), - ), - ], + handlers: getHandlers({ + projectEnableModelMesh: false, + inferenceServices: [], + }), }, }, }; @@ -116,6 +200,9 @@ export const EditModel: StoryObj = { // need to select modal as root element: '.pf-c-backdrop', }, + msw: { + handlers: getHandlers({}), + }, }, play: async ({ canvasElement }) => { @@ -136,6 +223,9 @@ export const DeleteModel: StoryObj = { a11y: { element: '.pf-c-backdrop', }, + msw: { + handlers: getHandlers({}), + }, }, play: async ({ canvasElement }) => { @@ -149,31 +239,70 @@ export const DeleteModel: StoryObj = { }, }; -// export const DeployModel: StoryObj = { -// render: Template, - -// parameters: { -// a11y: { -// // need to select modal as root -// element: '.pf-c-backdrop', -// }, -// }, - -// play: async ({ canvasElement }) => { -// // load page and wait until settled -// const canvas = within(canvasElement); -// await canvas.findByText('Test Inference Service', undefined, { timeout: 5000 }); - -// // user flow for editing a project -// await userEvent.click(canvas.getByText('Deploy model', { selector: 'button' })); - -// // get modal -// const body = within(canvasElement.ownerDocument.body); -// const nameInput = body.getByRole('textbox', { name: 'Model Name' }); -// const updateButton = body.getByText('Deploy', { selector: 'button' }); - -// // test that you can not submit on empty -// await userEvent.clear(nameInput); -// expect(updateButton).toBeDisabled(); -// }, -// }; +export const DeployModelModelMesh: StoryObj = { + render: Template, + + parameters: { + a11y: { + // need to select modal as root + element: '.pf-c-backdrop', + }, + msw: { + handlers: getHandlers({ + projectEnableModelMesh: true, + }), + }, + }, + + play: async ({ canvasElement }) => { + // load page and wait until settled + const canvas = within(canvasElement); + await canvas.findByText('Test Inference Service', undefined, { timeout: 5000 }); + + // user flow for editing a project + await userEvent.click(canvas.getByText('Deploy model', { selector: 'button' })); + + // get modal + const body = within(canvasElement.ownerDocument.body); + const nameInput = body.getByRole('textbox', { name: 'Model Name' }); + const updateButton = body.getByText('Deploy model', { selector: 'button' }); + + // test that you can not submit on empty + await userEvent.clear(nameInput); + expect(updateButton).toBeDisabled(); + }, +}; + +export const DeployModelModelKServe: StoryObj = { + render: Template, + + parameters: { + a11y: { + // need to select modal as root + element: '.pf-c-backdrop', + }, + msw: { + handlers: getHandlers({ + projectEnableModelMesh: false, + }), + }, + }, + + play: async ({ canvasElement }) => { + // load page and wait until settled + const canvas = within(canvasElement); + await canvas.findByText('Test Inference Service', undefined, { timeout: 5000 }); + + // user flow for editing a project + await userEvent.click(canvas.getByText('Deploy model', { selector: 'button' })); + + // get modal + const body = within(canvasElement.ownerDocument.body); + const nameInput = body.getByRole('textbox', { name: 'Model Name' }); + const updateButton = body.getByText('Deploy model', { selector: 'button' }); + + // test that you can not submit on empty + await userEvent.clear(nameInput); + expect(updateButton).toBeDisabled(); + }, +}; diff --git a/frontend/src/concepts/projects/ProjectSelector.tsx b/frontend/src/concepts/projects/ProjectSelector.tsx index 7d87b5156d..b30511a807 100644 --- a/frontend/src/concepts/projects/ProjectSelector.tsx +++ b/frontend/src/concepts/projects/ProjectSelector.tsx @@ -9,14 +9,18 @@ type ProjectSelectorProps = { onSelection: (project: ProjectKind) => void; namespace: string; invalidDropdownPlaceholder?: string; + selectAllProjects?: boolean; primary?: boolean; + filterLabel?: string; }; const ProjectSelector: React.FC = ({ onSelection, namespace, invalidDropdownPlaceholder, + selectAllProjects, primary, + filterLabel, }) => { const { projects } = React.useContext(ProjectsContext); useMountProjectRefresh(); @@ -27,6 +31,10 @@ const ProjectSelector: React.FC = ({ ? getProjectDisplayName(selection) : invalidDropdownPlaceholder ?? namespace; + const filteredProjects = filterLabel + ? projects.filter((project) => project.metadata.labels[filterLabel] !== undefined) + : projects; + return ( = ({ } isOpen={dropdownOpen} - dropdownItems={projects.map((project) => ( - { - setDropdownOpen(false); - onSelection(project); - }} - > - {getProjectDisplayName(project)} - - ))} + dropdownItems={[ + ...(selectAllProjects + ? [ + { + setDropdownOpen(false); + onSelection({ metadata: { name: '' } } as ProjectKind); + }} + > + {'All projects'} + , + ] + : []), + ...filteredProjects.map((project) => ( + { + setDropdownOpen(false); + onSelection(project); + }} + > + {getProjectDisplayName(project)} + + )), + ]} /> ); }; diff --git a/frontend/src/pages/modelServing/ModelServingContext.tsx b/frontend/src/pages/modelServing/ModelServingContext.tsx index de164404ad..1d8f38f34b 100644 --- a/frontend/src/pages/modelServing/ModelServingContext.tsx +++ b/frontend/src/pages/modelServing/ModelServingContext.tsx @@ -11,41 +11,75 @@ import { } from '@patternfly/react-core'; import { useNavigate } from 'react-router-dom'; import { ExclamationCircleIcon } from '@patternfly/react-icons'; -import { ServingRuntimeKind, InferenceServiceKind } from '~/k8sTypes'; +import { ServingRuntimeKind, InferenceServiceKind, TemplateKind } from '~/k8sTypes'; import { DEFAULT_CONTEXT_DATA } from '~/utilities/const'; import { ContextResourceData } from '~/types'; import { useContextResourceData } from '~/utilities/useContextResourceData'; +import { useDashboardNamespace } from '~/redux/selectors'; +import { DataConnection } from '~/pages/projects/types'; +import useDataConnections from '~/pages/projects/screens/detail/data-connections/useDataConnections'; import useInferenceServices from './useInferenceServices'; import useServingRuntimes from './useServingRuntimes'; +import useTemplates from './customServingRuntimes/useTemplates'; +import useTemplateOrder from './customServingRuntimes/useTemplateOrder'; +import useTemplateDisablement from './customServingRuntimes/useTemplateDisablement'; type ModelServingContextType = { refreshAllData: () => void; + dataConnections: ContextResourceData; + servingRuntimeTemplates: ContextResourceData; + servingRuntimeTemplateOrder: ContextResourceData; + servingRuntimeTemplateDisablement: ContextResourceData; servingRuntimes: ContextResourceData; inferenceServices: ContextResourceData; }; export const ModelServingContext = React.createContext({ refreshAllData: () => undefined, + dataConnections: DEFAULT_CONTEXT_DATA, + servingRuntimeTemplates: DEFAULT_CONTEXT_DATA, + servingRuntimeTemplateOrder: DEFAULT_CONTEXT_DATA, + servingRuntimeTemplateDisablement: DEFAULT_CONTEXT_DATA, servingRuntimes: DEFAULT_CONTEXT_DATA, inferenceServices: DEFAULT_CONTEXT_DATA, }); const ModelServingContextProvider: React.FC = () => { + const { dashboardNamespace } = useDashboardNamespace(); const navigate = useNavigate(); const { namespace } = useParams<{ namespace: string }>(); + const servingRuntimeTemplates = useContextResourceData( + useTemplates(dashboardNamespace), + ); + const servingRuntimeTemplateOrder = useContextResourceData( + useTemplateOrder(dashboardNamespace), + ); + const servingRuntimeTemplateDisablement = useContextResourceData( + useTemplateDisablement(dashboardNamespace), + ); const servingRuntimes = useContextResourceData(useServingRuntimes(namespace)); const inferenceServices = useContextResourceData( useInferenceServices(namespace), ); + const dataConnections = useContextResourceData(useDataConnections(namespace)); const servingRuntimeRefresh = servingRuntimes.refresh; const inferenceServiceRefresh = inferenceServices.refresh; + const dataConnectionRefresh = dataConnections.refresh; const refreshAllData = React.useCallback(() => { servingRuntimeRefresh(); inferenceServiceRefresh(); - }, [servingRuntimeRefresh, inferenceServiceRefresh]); + dataConnectionRefresh(); + }, [servingRuntimeRefresh, inferenceServiceRefresh, dataConnectionRefresh]); - if (servingRuntimes.error || inferenceServices.error) { + if ( + servingRuntimes.error || + inferenceServices.error || + servingRuntimeTemplates.error || + servingRuntimeTemplateOrder.error || + servingRuntimeTemplateDisablement.error || + dataConnections.error + ) { return ( @@ -54,7 +88,12 @@ const ModelServingContextProvider: React.FC = () => { Problem loading model serving page - {servingRuntimes.error?.message || inferenceServices.error?.message} + {servingRuntimes.error?.message || + inferenceServices.error?.message || + servingRuntimeTemplates.error?.message || + servingRuntimeTemplateOrder.error?.message || + servingRuntimeTemplateDisablement.error?.message || + dataConnections.error?.message} @@ -46,9 +62,7 @@ const EmptyModelServing: React.FC = () => { No deployed models To get started, use existing model servers to serve a model. - {/* TODO: Re implemnt this once we can deploy kServe in global view - - */} + ); }; diff --git a/frontend/src/pages/modelServing/screens/global/InferenceServiceListView.tsx b/frontend/src/pages/modelServing/screens/global/InferenceServiceListView.tsx index 752f304826..f5b07317b6 100644 --- a/frontend/src/pages/modelServing/screens/global/InferenceServiceListView.tsx +++ b/frontend/src/pages/modelServing/screens/global/InferenceServiceListView.tsx @@ -7,6 +7,7 @@ import { ProjectsContext } from '~/concepts/projects/ProjectsContext'; import { getInferenceServiceDisplayName, getInferenceServiceProjectDisplayName } from './utils'; //import ServeModelButton from './ServeModelButton'; import InferenceServiceTable from './InferenceServiceTable'; +import ServeModelButton from './ServeModelButton'; type InferenceServiceListViewProps = { inferenceServices: InferenceServiceKind[]; @@ -70,10 +71,9 @@ const InferenceServiceListView: React.FC = ({ }} /> - {/* TODO: Reimplement ServeModelButton once we can deploy kServe - */} + } /> diff --git a/frontend/src/pages/modelServing/screens/global/InferenceServiceStatus.tsx b/frontend/src/pages/modelServing/screens/global/InferenceServiceStatus.tsx index 77cc32ab97..65c6c2563e 100644 --- a/frontend/src/pages/modelServing/screens/global/InferenceServiceStatus.tsx +++ b/frontend/src/pages/modelServing/screens/global/InferenceServiceStatus.tsx @@ -7,7 +7,7 @@ import { } from '@patternfly/react-icons'; import { InferenceServiceKind } from '~/k8sTypes'; import { InferenceServiceModelState } from '~/pages/modelServing/screens/types'; -import { getInferenceServiceActiveModelState, getInferenceServiceErrorMessage } from './utils'; +import { getInferenceServiceActiveModelState, getInferenceServiceStatusMessage } from './utils'; type InferenceServiceStatusProps = { inferenceService: InferenceServiceKind; @@ -57,7 +57,7 @@ const InferenceServiceStatus: React.FC = ({ inferen {getInferenceServiceErrorMessage(inferenceService)}} + content={{getInferenceServiceStatusMessage(inferenceService)}} > {StatusIcon()} diff --git a/frontend/src/pages/modelServing/screens/global/ModelServingGlobal.tsx b/frontend/src/pages/modelServing/screens/global/ModelServingGlobal.tsx index abd7911b46..f050dffcf4 100644 --- a/frontend/src/pages/modelServing/screens/global/ModelServingGlobal.tsx +++ b/frontend/src/pages/modelServing/screens/global/ModelServingGlobal.tsx @@ -1,8 +1,18 @@ import React from 'react'; +import { useParams } from 'react-router-dom'; import ApplicationsPage from '~/pages/ApplicationsPage'; import { ModelServingContext } from '~/pages/modelServing/ModelServingContext'; +import { byName, ProjectsContext } from '~/concepts/projects/ProjectsContext'; +import InvalidProject from '~/concepts/projects/InvalidProject'; import EmptyModelServing from './EmptyModelServing'; import InferenceServiceListView from './InferenceServiceListView'; +import ModelServingProjectSelection from './ModelServingProjectSelection'; +import ModelServingNoProjects from './ModelServingNoProjects'; + +type ApplicationPageProps = React.ComponentProps; +type EmptyStateProps = 'emptyStatePage' | 'empty'; + +type ApplicationPageRenderState = Pick; const ModelServingGlobal: React.FC = () => { const { @@ -10,13 +20,50 @@ const ModelServingGlobal: React.FC = () => { inferenceServices: { data: inferenceServices }, } = React.useContext(ModelServingContext); + const { projects } = React.useContext(ProjectsContext); + const { namespace } = useParams<{ namespace: string }>(); + + let renderStateProps: ApplicationPageRenderState = { + empty: false, + emptyStatePage: undefined, + }; + + if (projects.length === 0) { + renderStateProps = { + empty: true, + emptyStatePage: , + }; + } else { + if (servingRuntimes.length === 0 || inferenceServices.length === 0) { + renderStateProps = { + empty: true, + emptyStatePage: , + }; + } + if (namespace && !projects.find(byName(namespace))) { + renderStateProps = { + empty: true, + emptyStatePage: ( + `/modelServing/${namespace}`} + /> + ), + }; + } + } + return ( } + headerContent={ + `/modelServing/${namespace}`} + /> + } provideChildrenPadding > { + const navigate = useNavigate(); + + return ( + + + + No data science projects + + To deploy a model, first create a data science project. + navigate(`/modelServing/${projectName}`)} + /> + + ); +}; + +export default ModelServingNoProjects; diff --git a/frontend/src/pages/modelServing/screens/global/ModelServingProjectSelection.tsx b/frontend/src/pages/modelServing/screens/global/ModelServingProjectSelection.tsx new file mode 100644 index 0000000000..a31feeed50 --- /dev/null +++ b/frontend/src/pages/modelServing/screens/global/ModelServingProjectSelection.tsx @@ -0,0 +1,27 @@ +import * as React from 'react'; +import { Bullseye, Split, SplitItem } from '@patternfly/react-core'; +import ProjectSelectorNavigator from '~/concepts/projects/ProjectSelectorNavigator'; + +type ModelServingProjectSelectionProps = { + getRedirectPath: (namespace: string) => string; +}; + +const ModelServingProjectSelection: React.FC = ({ + getRedirectPath, +}) => ( + + + Project + + + {/* Maybe we want to filter the projects with no deployed models that's why I added the filterLable prop */} + + + +); + +export default ModelServingProjectSelection; diff --git a/frontend/src/pages/modelServing/screens/global/ServeModelButton.tsx b/frontend/src/pages/modelServing/screens/global/ServeModelButton.tsx index 6cfe6cd0d1..f25c802bee 100644 --- a/frontend/src/pages/modelServing/screens/global/ServeModelButton.tsx +++ b/frontend/src/pages/modelServing/screens/global/ServeModelButton.tsx @@ -1,28 +1,82 @@ import * as React from 'react'; import { Button } from '@patternfly/react-core'; +import { useParams } from 'react-router-dom'; import ManageInferenceServiceModal from '~/pages/modelServing/screens/projects/InferenceServiceModal/ManageInferenceServiceModal'; import { ModelServingContext } from '~/pages/modelServing/ModelServingContext'; +import { + getSortedTemplates, + getTemplateEnabled, + getTemplateEnabledForPlatform, +} from '~/pages/modelServing/customServingRuntimes/utils'; +import { ServingRuntimePlatform } from '~/types'; +import { getProjectModelServingPlatform } from '~/pages/modelServing/screens/projects/utils'; +import ManageKServeModal from '~/pages/modelServing/screens/projects/kServeModal/ManageKServeModal'; +import { byName, ProjectsContext } from '~/concepts/projects/ProjectsContext'; const ServeModelButton: React.FC = () => { - const [open, setOpen] = React.useState(false); + const [platformSelected, setPlatformSelected] = React.useState< + ServingRuntimePlatform | undefined + >(undefined); const { inferenceServices: { refresh }, + servingRuntimeTemplates: { data: templates }, + servingRuntimeTemplateOrder: { data: templateOrder }, + servingRuntimeTemplateDisablement: { data: templateDisablement }, + dataConnections: { data: dataConnections }, } = React.useContext(ModelServingContext); + const { projects } = React.useContext(ProjectsContext); + const { namespace } = useParams<{ namespace: string }>(); + + const project = projects.find(byName(namespace)); + + const templatesSorted = getSortedTemplates(templates, templateOrder); + const templatesEnabled = templatesSorted.filter((template) => + getTemplateEnabled(template, templateDisablement), + ); + + const onSubmit = (submit: boolean) => { + if (submit) { + refresh(); + } + setPlatformSelected(undefined); + }; return ( <> - - { - if (submit) { - refresh(); - } - setOpen(false); - }} - /> + {project && ( + <> + { + onSubmit(submit); + }} + /> + + getTemplateEnabledForPlatform(template, ServingRuntimePlatform.SINGLE), + )} + onClose={(submit: boolean) => { + onSubmit(submit); + }} + /> + + )} ); }; diff --git a/frontend/src/pages/modelServing/screens/global/utils.ts b/frontend/src/pages/modelServing/screens/global/utils.ts index 470d6094a7..59b91c5d10 100644 --- a/frontend/src/pages/modelServing/screens/global/utils.ts +++ b/frontend/src/pages/modelServing/screens/global/utils.ts @@ -15,13 +15,9 @@ export const getInferenceServiceActiveModelState = ( is.status?.modelStatus.states?.targetModelState || InferenceServiceModelState.UNKNOWN; -export const getInferenceServiceErrorMessage = (is: InferenceServiceKind): string => - is.status?.modelStatus.lastFailureInfo?.message || - is.status?.modelStatus.states?.activeModelState || - 'Unknown'; -export const getInferenceServiceErrorMessageTitle = (is: InferenceServiceKind): string => - is.status?.modelStatus.lastFailureInfo?.reason || +export const getInferenceServiceStatusMessage = (is: InferenceServiceKind): string => is.status?.modelStatus.states?.activeModelState || + is.status?.modelStatus.lastFailureInfo?.message || 'Unknown'; export const getInferenceServiceProjectDisplayName = ( diff --git a/frontend/src/pages/modelServing/screens/projects/InferenceServiceModal/ExistingProjectField.tsx b/frontend/src/pages/modelServing/screens/projects/InferenceServiceModal/ExistingProjectField.tsx deleted file mode 100644 index 79be9d81d7..0000000000 --- a/frontend/src/pages/modelServing/screens/projects/InferenceServiceModal/ExistingProjectField.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import * as React from 'react'; -import { Alert, FormGroup, Select, SelectOption, Skeleton } from '@patternfly/react-core'; -import { getProjectDisplayName } from '~/pages/projects/utils'; -import useModelServingProjects from './useModelServingProjects'; - -type ExistingProjectFieldProps = { - fieldId: string; - selectedProject?: string; - onSelect: (selection?: string) => void; - disabled?: boolean; - selectDirection?: 'up' | 'down'; - menuAppendTo?: HTMLElement | 'parent'; -}; - -const ExistingProjectField: React.FC = ({ - fieldId, - selectedProject, - onSelect, - disabled, - selectDirection = 'down', - menuAppendTo = 'parent', -}) => { - const [isOpen, setOpen] = React.useState(false); - - const [projects, loaded, loadError] = useModelServingProjects(); - - if (!loaded) { - return ; - } - - if (loadError) { - return ( - - {loadError.message} - - ); - } - - const options = projects.map((project) => ( - - {getProjectDisplayName(project)} - - )); - - return ( - - - - ); -}; - -export default ExistingProjectField; diff --git a/frontend/src/pages/modelServing/screens/projects/InferenceServiceModal/ManageInferenceServiceModal.tsx b/frontend/src/pages/modelServing/screens/projects/InferenceServiceModal/ManageInferenceServiceModal.tsx index 798c10c58c..104e3272c1 100644 --- a/frontend/src/pages/modelServing/screens/projects/InferenceServiceModal/ManageInferenceServiceModal.tsx +++ b/frontend/src/pages/modelServing/screens/projects/InferenceServiceModal/ManageInferenceServiceModal.tsx @@ -11,6 +11,7 @@ import DashboardModalFooter from '~/concepts/dashboard/DashboardModalFooter'; import { InferenceServiceStorageType } from '~/pages/modelServing/screens/types'; import { isAWSValid } from '~/pages/projects/screens/spawner/spawnerUtils'; import { AWS_KEYS } from '~/pages/projects/dataConnections/const'; +import { getProjectDisplayName } from '~/pages/projects/utils'; import DataConnectionSection from './DataConnectionSection'; import ProjectSection from './ProjectSection'; import InferenceServiceFrameworkSection from './InferenceServiceFrameworkSection'; @@ -25,7 +26,7 @@ type ManageInferenceServiceModalProps = { { projectContext?: { currentProject: ProjectKind; - currentServingRuntime: ServingRuntimeKind; + currentServingRuntime?: ServingRuntimeKind; dataConnections: DataConnection[]; }; } @@ -45,7 +46,7 @@ const ManageInferenceServiceModal: React.FC = if (projectContext) { const { currentProject, currentServingRuntime } = projectContext; setCreateData('project', currentProject.metadata.name); - setCreateData('servingRuntimeName', currentServingRuntime.metadata.name); + setCreateData('servingRuntimeName', currentServingRuntime?.metadata.name || ''); } }, [projectContext, setCreateData]); @@ -116,10 +117,12 @@ const ManageInferenceServiceModal: React.FC = diff --git a/frontend/src/pages/modelServing/screens/projects/InferenceServiceModal/ProjectSection.tsx b/frontend/src/pages/modelServing/screens/projects/InferenceServiceModal/ProjectSection.tsx index 8f7a5afa96..72e51c85f6 100644 --- a/frontend/src/pages/modelServing/screens/projects/InferenceServiceModal/ProjectSection.tsx +++ b/frontend/src/pages/modelServing/screens/projects/InferenceServiceModal/ProjectSection.tsx @@ -1,50 +1,14 @@ import * as React from 'react'; import { FormGroup, Text } from '@patternfly/react-core'; -import { CreatingInferenceServiceObject } from '~/pages/modelServing/screens/types'; -import { UpdateObjectAtPropAndValue } from '~/pages/projects/types'; -import { getProjectDisplayName } from '~/pages/projects/utils'; -import ExistingProjectField from '~/pages/modelServing/screens/projects/InferenceServiceModal/ExistingProjectField'; -import { InferenceServiceKind, ProjectKind } from '~/k8sTypes'; -import { defaultInferenceService } from '~/pages/modelServing/screens/projects/utils'; type ProjectSectionType = { - data: CreatingInferenceServiceObject; - setData: UpdateObjectAtPropAndValue; - editInfo?: InferenceServiceKind; - project?: ProjectKind; + projectName: string; }; -const ProjectSection: React.FC = ({ data, setData, project, editInfo }) => { - const updateProject = (projectName: string) => { - setData('project', projectName); - setData('servingRuntimeName', ''); - setData('format', ''); - setData('storage', defaultInferenceService.storage); - setData('format', defaultInferenceService.format); - }; - - return ( - <> - {project ? ( - - {getProjectDisplayName(project)} - - ) : ( - { - if (projectSelected) { - updateProject(projectSelected); - } else { - updateProject(''); - } - }} - /> - )} - - ); -}; +const ProjectSection: React.FC = ({ projectName }) => ( + + {projectName} + +); export default ProjectSection; diff --git a/frontend/src/pages/modelServing/screens/projects/InferenceServiceModal/useModelServingProjects.ts b/frontend/src/pages/modelServing/screens/projects/InferenceServiceModal/useModelServingProjects.ts deleted file mode 100644 index 2427c6741b..0000000000 --- a/frontend/src/pages/modelServing/screens/projects/InferenceServiceModal/useModelServingProjects.ts +++ /dev/null @@ -1,12 +0,0 @@ -import * as React from 'react'; -import { getModelServingProjectsAvailable } from '~/api'; -import { ProjectKind } from '~/k8sTypes'; -import useFetchState, { FetchState } from '~/utilities/useFetchState'; - -const useModelServingProjects = (): FetchState => { - const fetchProjects = React.useCallback(() => getModelServingProjectsAvailable(), []); - - return useFetchState(fetchProjects, []); -}; - -export default useModelServingProjects; diff --git a/frontend/src/pages/modelServing/screens/projects/kServeModal/ManageKServeModal.tsx b/frontend/src/pages/modelServing/screens/projects/kServeModal/ManageKServeModal.tsx index 8357d7a12a..d8164f10c2 100644 --- a/frontend/src/pages/modelServing/screens/projects/kServeModal/ManageKServeModal.tsx +++ b/frontend/src/pages/modelServing/screens/projects/kServeModal/ManageKServeModal.tsx @@ -27,7 +27,7 @@ import { isAWSValid } from '~/pages/projects/screens/spawner/spawnerUtils'; import InferenceServiceNameSection from '~/pages/modelServing/screens/projects/InferenceServiceModal/InferenceServiceNameSection'; import InferenceServiceFrameworkSection from '~/pages/modelServing/screens/projects/InferenceServiceModal/InferenceServiceFrameworkSection'; import DataConnectionSection from '~/pages/modelServing/screens/projects/InferenceServiceModal/DataConnectionSection'; -import { translateDisplayNameForK8s } from '~/pages/projects/utils'; +import { getProjectDisplayName, translateDisplayNameForK8s } from '~/pages/projects/utils'; type ManageKServeModalProps = { isOpen: boolean; @@ -194,10 +194,12 @@ const ManageKServeModal: React.FC = ({ diff --git a/frontend/src/pages/modelServing/screens/projects/utils.ts b/frontend/src/pages/modelServing/screens/projects/utils.ts index 16ccc9b479..ce4f936000 100644 --- a/frontend/src/pages/modelServing/screens/projects/utils.ts +++ b/frontend/src/pages/modelServing/screens/projects/utils.ts @@ -200,10 +200,13 @@ export const useCreateInferenceServiceObject = ( }; export const getProjectModelServingPlatform = ( - project: ProjectKind, - disableKServe: boolean, - disableModelMesh: boolean, + project: ProjectKind | undefined, + disableKServe?: boolean, + disableModelMesh?: boolean, ) => { + if (!project) { + return undefined; + } if (project.metadata.labels[KnownLabels.MODEL_SERVING_PROJECT] === undefined) { if ((!disableKServe && !disableModelMesh) || (disableKServe && disableModelMesh)) { return undefined; diff --git a/frontend/src/pages/modelServing/useModelServingProjects.ts b/frontend/src/pages/modelServing/useModelServingProjects.ts new file mode 100644 index 0000000000..b51e47160f --- /dev/null +++ b/frontend/src/pages/modelServing/useModelServingProjects.ts @@ -0,0 +1,18 @@ +import * as React from 'react'; +import { ProjectKind } from '~/k8sTypes'; +import useFetchState, { FetchState, NotReadyError } from '~/utilities/useFetchState'; +import { getModelServingProjects } from '~/api'; + +const useModelServingProjects = (namespace?: string): FetchState => { + const getProjects = React.useCallback(() => { + if (namespace) { + return Promise.reject(new NotReadyError('Does not needed when namespace is provided')); + } + + return getModelServingProjects(); + }, [namespace]); + + return useFetchState(getProjects, []); +}; + +export default useModelServingProjects;