diff --git a/backend/src/routes/api/accelerator-profiles/acceleratorProfilesUtils.ts b/backend/src/routes/api/accelerator-profiles/acceleratorProfilesUtils.ts index 23557a6fdc..3cb1e8d76e 100644 --- a/backend/src/routes/api/accelerator-profiles/acceleratorProfilesUtils.ts +++ b/backend/src/routes/api/accelerator-profiles/acceleratorProfilesUtils.ts @@ -10,13 +10,14 @@ export const postAcceleratorProfile = async ( ): Promise<{ success: boolean; error: string }> => { const { customObjectsApi } = fastify.kube; const { namespace } = fastify.kube; - const body = request.body as AcceleratorProfileKind['spec']; + const requestBody = request.body as { name?: string } & AcceleratorProfileKind['spec']; + const { name, ...body } = requestBody; const payload: AcceleratorProfileKind = { apiVersion: 'dashboard.opendatahub.io/v1', kind: 'AcceleratorProfile', metadata: { - name: translateDisplayNameForK8s(body.displayName), + name: name || translateDisplayNameForK8s(body.displayName), namespace, annotations: { 'opendatahub.io/modified-date': new Date().toISOString(), diff --git a/backend/src/routes/api/namespaces/const.ts b/backend/src/routes/api/namespaces/const.ts index e45453b137..3efa167a2c 100644 --- a/backend/src/routes/api/namespaces/const.ts +++ b/backend/src/routes/api/namespaces/const.ts @@ -15,4 +15,8 @@ export enum NamespaceApplicationCase { * Nvidia NIMs run on KServe but have different requirements than regular models. */ KSERVE_NIM_PROMOTION, + /** + * Downgrade a project from Modelmesh, Kserve or NIM so the platform can be selected again. + */ + RESET_MODEL_SERVING_PLATFORM, } diff --git a/backend/src/routes/api/namespaces/namespaceUtils.ts b/backend/src/routes/api/namespaces/namespaceUtils.ts index ffff13328e..fa554414f1 100644 --- a/backend/src/routes/api/namespaces/namespaceUtils.ts +++ b/backend/src/routes/api/namespaces/namespaceUtils.ts @@ -100,6 +100,13 @@ export const applyNamespaceChange = async ( checkPermissionsFn = checkEditNamespacePermission; } break; + case NamespaceApplicationCase.RESET_MODEL_SERVING_PLATFORM: + { + annotations = { 'opendatahub.io/nim-support': null }; + labels = { 'modelmesh-enabled': null }; + checkPermissionsFn = checkEditNamespacePermission; + } + break; default: throw createCustomError('Unknown configuration', 'Cannot apply namespace change', 400); } diff --git a/backend/src/types.ts b/backend/src/types.ts index 7406e698d4..3024ff6fbe 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -834,7 +834,7 @@ export type NotebookData = { notebookSizeName: string; imageName: string; imageTagName: string; - acceleratorProfile: AcceleratorProfileState; + acceleratorProfile?: AcceleratorProfileState; envVars: EnvVarReducedTypeKeyValues; state: NotebookState; username?: string; @@ -842,7 +842,7 @@ export type NotebookData = { }; export type AcceleratorProfileState = { - acceleratorProfile?: AcceleratorProfileKind; + acceleratorProfile: AcceleratorProfileKind; count: number; }; diff --git a/backend/src/utils/notebookUtils.ts b/backend/src/utils/notebookUtils.ts index 02335eaa4c..77d87c1567 100644 --- a/backend/src/utils/notebookUtils.ts +++ b/backend/src/utils/notebookUtils.ts @@ -160,7 +160,7 @@ export const assembleNotebook = async ( envName: string, tolerationSettings: NotebookTolerationSettings, ): Promise => { - const { notebookSizeName, imageName, imageTagName, acceleratorProfile, envVars } = data; + const { notebookSizeName, imageName, imageTagName, envVars } = data; const notebookSize = getNotebookSize(notebookSizeName); @@ -196,17 +196,17 @@ export const assembleNotebook = async ( const tolerations: Toleration[] = []; const affinity: NotebookAffinity = {}; - if (acceleratorProfile.count > 0 && acceleratorProfile.acceleratorProfile) { + if (data.acceleratorProfile?.count > 0) { if (!resources.limits) { resources.limits = {}; } if (!resources.requests) { resources.requests = {}; } - resources.limits[acceleratorProfile.acceleratorProfile.spec.identifier] = - acceleratorProfile.count; - resources.requests[acceleratorProfile.acceleratorProfile.spec.identifier] = - acceleratorProfile.count; + resources.limits[data.acceleratorProfile.acceleratorProfile.spec.identifier] = + data.acceleratorProfile.count; + resources.requests[data.acceleratorProfile.acceleratorProfile.spec.identifier] = + data.acceleratorProfile.count; } else { // step type down to string to avoid type errors const containerResourceKeys: string[] = Object.values(ContainerResourceAttributes); @@ -224,8 +224,8 @@ export const assembleNotebook = async ( }); } - if (acceleratorProfile.acceleratorProfile?.spec.tolerations) { - tolerations.push(...acceleratorProfile.acceleratorProfile.spec.tolerations); + if (data.acceleratorProfile?.acceleratorProfile?.spec.tolerations) { + tolerations.push(...data.acceleratorProfile.acceleratorProfile.spec.tolerations); } if (tolerationSettings?.enabled) { @@ -274,7 +274,7 @@ export const assembleNotebook = async ( 'opendatahub.io/username': username, 'kubeflow-resource-stopped': null, 'opendatahub.io/accelerator-name': - acceleratorProfile.acceleratorProfile?.metadata.name || '', + data.acceleratorProfile?.acceleratorProfile.metadata.name || '', }, name: name, namespace: namespace, diff --git a/frontend/src/__mocks__/mockProjectK8sResource.ts b/frontend/src/__mocks__/mockProjectK8sResource.ts index 4ea79943e6..91b9fa8011 100644 --- a/frontend/src/__mocks__/mockProjectK8sResource.ts +++ b/frontend/src/__mocks__/mockProjectK8sResource.ts @@ -10,6 +10,7 @@ type MockResourceConfigType = { k8sName?: string; creationTimestamp?: string; enableModelMesh?: boolean; + enableNIM?: boolean; isDSProject?: boolean; phase?: 'Active' | 'Terminating'; }; @@ -21,6 +22,7 @@ export const mockProjectK8sResource = ({ k8sName = 'test-project', creationTimestamp = '2023-02-14T21:43:59Z', enableModelMesh, + enableNIM = false, description = '', isDSProject = true, phase = 'Active', @@ -36,6 +38,9 @@ export const mockProjectK8sResource = ({ ...(enableModelMesh !== undefined && { [KnownLabels.MODEL_SERVING_PROJECT]: enableModelMesh ? 'true' : 'false', }), + ...(enableNIM && { + 'opendatahub.io/nim-support': 'true', + }), ...(isDSProject && { [KnownLabels.DASHBOARD_RESOURCE]: 'true' }), }, ...(hasAnnotations && { diff --git a/frontend/src/__tests__/cypress/cypress/pages/acceleratorProfile.ts b/frontend/src/__tests__/cypress/cypress/pages/acceleratorProfile.ts index ba103e5b35..ee50c0a55c 100644 --- a/frontend/src/__tests__/cypress/cypress/pages/acceleratorProfile.ts +++ b/frontend/src/__tests__/cypress/cypress/pages/acceleratorProfile.ts @@ -1,3 +1,4 @@ +import { K8sNameDescriptionField } from '~/__tests__/cypress/cypress/pages/components/subComponents/K8sNameDescriptionField'; import { Modal } from './components/Modal'; import { TableToolbar } from './components/TableToolbar'; import { TableRow } from './components/table'; @@ -86,9 +87,7 @@ class AcceleratorProfile { } class ManageAcceleratorProfile { - findAcceleratorNameInput() { - return cy.findByTestId('accelerator-name-input'); - } + k8sNameDescription = new K8sNameDescriptionField('accelerator-profile'); findIdentifierInput() { return cy.findByTestId('accelerator-identifier-input'); @@ -103,10 +102,6 @@ class ManageAcceleratorProfile { return cy.findByTestId('add-toleration-button'); } - findDescriptionInput() { - return cy.findByTestId('accelerator-description-input'); - } - findSubmitButton() { return cy.findByTestId('accelerator-profile-create-button'); } diff --git a/frontend/src/__tests__/cypress/cypress/pages/connectionTypes.ts b/frontend/src/__tests__/cypress/cypress/pages/connectionTypes.ts index 8004fa97c5..e1f63f399f 100644 --- a/frontend/src/__tests__/cypress/cypress/pages/connectionTypes.ts +++ b/frontend/src/__tests__/cypress/cypress/pages/connectionTypes.ts @@ -139,7 +139,7 @@ class CategorySection extends Contextual { } findMultiGroupSelectButton(name: string) { - return cy.findByTestId(`select-multi-typeahead-${name}`).click(); + return cy.findByTestId(`select-multi-typeahead-${name}`); } } diff --git a/frontend/src/__tests__/cypress/cypress/pages/modelServing.ts b/frontend/src/__tests__/cypress/cypress/pages/modelServing.ts index 86f0604f86..9f64df4b4f 100644 --- a/frontend/src/__tests__/cypress/cypress/pages/modelServing.ts +++ b/frontend/src/__tests__/cypress/cypress/pages/modelServing.ts @@ -2,7 +2,6 @@ import { appChrome } from '~/__tests__/cypress/cypress/pages/appChrome'; import { Modal } from '~/__tests__/cypress/cypress/pages/components/Modal'; import { TableRow } from '~/__tests__/cypress/cypress/pages/components/table'; import { mixin } from '~/__tests__/cypress/cypress/utils/mixin'; -import { Contextual } from './components/Contextual'; import { TableToolbar } from './components/TableToolbar'; class ModelServingToolbar extends TableToolbar {} @@ -356,24 +355,12 @@ class InferenceServiceRow extends TableRow { return this.find().find(`[data-label=Project]`); } } -class ServingPlatformCard extends Contextual { - findDeployModelButton() { - return this.find().findByTestId('single-serving-deploy-button'); - } - findAddModelServerButton() { - return this.find().findByTestId('multi-serving-add-server-button'); - } -} class ModelServingSection { find() { return cy.findByTestId('section-model-server'); } - getServingPlatformCard(name: string) { - return new ServingPlatformCard(() => cy.findAllByTestId(`${name}-platform-card`)); - } - private findKServeTable() { return this.find().findByTestId('kserve-inference-service-table'); } diff --git a/frontend/src/__tests__/cypress/cypress/pages/notebookServer.ts b/frontend/src/__tests__/cypress/cypress/pages/notebookServer.ts index a7c44811a4..e61e58f923 100644 --- a/frontend/src/__tests__/cypress/cypress/pages/notebookServer.ts +++ b/frontend/src/__tests__/cypress/cypress/pages/notebookServer.ts @@ -40,6 +40,10 @@ class NotebookServer { findStopNotebookServerButton() { return cy.findByTestId('stop-nb-server-button'); } + + findAcceleratorProfileSelect() { + return cy.findByTestId('accelerator-profile-select'); + } } export const notebookServer = new NotebookServer(); diff --git a/frontend/src/__tests__/cypress/cypress/pages/projects.ts b/frontend/src/__tests__/cypress/cypress/pages/projects.ts index 5413ce2cec..14b4d9bacf 100644 --- a/frontend/src/__tests__/cypress/cypress/pages/projects.ts +++ b/frontend/src/__tests__/cypress/cypress/pages/projects.ts @@ -184,8 +184,8 @@ class ProjectDetails { this.wait(); } - visitSection(project: string, section: string) { - cy.visitWithLogin(`/projects/${project}?section=${section}`); + visitSection(project: string, section: string, extraUrlParams = '') { + cy.visitWithLogin(`/projects/${project}?section=${section}${extraUrlParams}`); this.wait(section); } @@ -248,12 +248,32 @@ class ProjectDetails { return cy.findByTestId('import-pipeline-button', { timeout }); } - findSingleModelDeployButton() { - return this.findModelServingPlatform('single').findByTestId('single-serving-deploy-button'); + findSelectPlatformButton(platform: string) { + return cy.findByTestId(`${platform}-serving-select-button`); } - findMultiModelButton() { - return this.findModelServingPlatform('multi').findByTestId('multi-serving-add-server-button'); + findResetPlatformButton() { + return cy.findByTestId('change-serving-platform-button'); + } + + findErrorSelectingPlatform() { + return cy.findByTestId('error-selecting-serving-platform'); + } + + findDeployModelDropdown() { + return cy.findByTestId('deploy-model-dropdown'); + } + + findBackToRegistryButton() { + return cy.findByTestId('deploy-from-registry'); + } + + findTopLevelDeployModelButton() { + return cy.findByTestId('deploy-button'); + } + + findTopLevelAddModelServerButton() { + return cy.findByTestId('add-server-button'); } findDeployModelTooltip() { diff --git a/frontend/src/__tests__/cypress/cypress/pages/workbench.ts b/frontend/src/__tests__/cypress/cypress/pages/workbench.ts index 7b03d5b6ef..9824aea5fe 100644 --- a/frontend/src/__tests__/cypress/cypress/pages/workbench.ts +++ b/frontend/src/__tests__/cypress/cypress/pages/workbench.ts @@ -234,7 +234,7 @@ class CreateSpawnerPage { cy.findByTestId('persistent-storage-group') .findByRole('button', { name: 'Typeahead menu toggle' }) .click(); - cy.get('[id="dashboard-page-main"]').findByRole('option', { name }).click(); + cy.get('[id="dashboard-page-main"]').contains('button.pf-v5-c-menu__item', name).click(); } selectPVSize(name: string) { diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/acceleratorProfiles/manageAcceleratorProfiles.cy.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/acceleratorProfiles/manageAcceleratorProfiles.cy.ts index 9f9f525387..1b11742620 100644 --- a/frontend/src/__tests__/cypress/cypress/tests/mocked/acceleratorProfiles/manageAcceleratorProfiles.cy.ts +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/acceleratorProfiles/manageAcceleratorProfiles.cy.ts @@ -50,11 +50,34 @@ describe('Manage Accelerator Profile', () => { createAcceleratorProfile.findSubmitButton().should('be.disabled'); // test required fields - createAcceleratorProfile.findAcceleratorNameInput().fill('test-accelerator'); + createAcceleratorProfile.k8sNameDescription.findDisplayNameInput().fill('test-accelerator'); createAcceleratorProfile.findSubmitButton().should('be.disabled'); createAcceleratorProfile.findIdentifierInput().fill('nvidia.com/gpu'); createAcceleratorProfile.findSubmitButton().should('be.enabled'); + // test resource name validation + createAcceleratorProfile.k8sNameDescription.findResourceEditLink().click(); + createAcceleratorProfile.k8sNameDescription + .findResourceNameInput() + .should('have.attr', 'aria-invalid', 'false'); + createAcceleratorProfile.k8sNameDescription + .findResourceNameInput() + .should('have.value', 'test-accelerator'); + // Invalid character k8s names fail + createAcceleratorProfile.k8sNameDescription + .findResourceNameInput() + .clear() + .type('InVaLiD vAlUe!'); + createAcceleratorProfile.k8sNameDescription + .findResourceNameInput() + .should('have.attr', 'aria-invalid', 'true'); + createAcceleratorProfile.findSubmitButton().should('be.disabled'); + createAcceleratorProfile.k8sNameDescription + .findResourceNameInput() + .clear() + .type('test-accelerator-name'); + createAcceleratorProfile.findSubmitButton().should('be.enabled'); + // test tolerations createAcceleratorProfile.shouldHaveModalEmptyState(); @@ -127,7 +150,9 @@ describe('Manage Accelerator Profile', () => { cy.wait('@createAccelerator').then((interception) => { expect(interception.request.body).to.be.eql({ + name: 'test-accelerator-name', displayName: 'test-accelerator', + description: '', identifier: 'nvidia.com/gpu', enabled: true, tolerations: [], @@ -138,9 +163,13 @@ describe('Manage Accelerator Profile', () => { it('edit page has expected values', () => { initIntercepts({}); editAcceleratorProfile.visit('test-accelerator'); - editAcceleratorProfile.findAcceleratorNameInput().should('have.value', 'Test Accelerator'); + editAcceleratorProfile.k8sNameDescription + .findDisplayNameInput() + .should('have.value', 'Test Accelerator'); editAcceleratorProfile.findIdentifierInput().should('have.value', 'nvidia.com/gpu'); - editAcceleratorProfile.findDescriptionInput().should('have.value', 'Test description'); + editAcceleratorProfile.k8sNameDescription + .findDescriptionInput() + .should('have.value', 'Test description'); editAcceleratorProfile .getRow('nvidia.com/gpu') .shouldHaveEffect('NoSchedule') @@ -153,10 +182,11 @@ describe('Manage Accelerator Profile', () => { { path: { name: 'test-accelerator' } }, { success: true }, ).as('updatedAccelerator'); - editAcceleratorProfile.findDescriptionInput().fill('Updated description'); + editAcceleratorProfile.k8sNameDescription.findDescriptionInput().fill('Updated description'); editAcceleratorProfile.findSubmitButton().click(); cy.wait('@updatedAccelerator').then((interception) => { expect(interception.request.body).to.eql({ + name: 'test-accelerator', displayName: 'Test Accelerator', identifier: 'nvidia.com/gpu', enabled: true, diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/applications/notebookServer.cy.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/applications/notebookServer.cy.ts index 163aeeabd6..a920c916a9 100644 --- a/frontend/src/__tests__/cypress/cypress/tests/mocked/applications/notebookServer.cy.ts +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/applications/notebookServer.cy.ts @@ -16,7 +16,11 @@ import { stopNotebookModal, } from '~/__tests__/cypress/cypress/pages/administration'; import { homePage } from '~/__tests__/cypress/cypress/pages/home/home'; -import { StorageClassModel } from '~/__tests__/cypress/cypress/utils/models'; +import { + AcceleratorProfileModel, + StorageClassModel, +} from '~/__tests__/cypress/cypress/utils/models'; +import { mockAcceleratorProfile } from '~/__mocks__/mockAcceleratorProfile'; const groupSubjects: RoleBindingSubject[] = [ { @@ -45,7 +49,17 @@ const initIntercepts = () => { disableStorageClasses: false, }), ); - + cy.interceptK8sList( + AcceleratorProfileModel, + mockK8sResourceList([ + mockAcceleratorProfile({ + name: 'test-gpu', + displayName: 'Test GPU', + namespace: 'opendatahub', + uid: 'uid', + }), + ]), + ); cy.interceptK8sList(StorageClassModel, mockStorageClassList()); }; @@ -75,7 +89,35 @@ describe('NotebookServer', () => { notebookSizeName: 'XSmall', imageName: 'code-server-notebook', imageTagName: '2023.2', - acceleratorProfile: { count: 0, useExistingSettings: false }, + envVars: { configMap: {}, secrets: {} }, + state: 'started', + storageClassName: 'openshift-default-sc', + }); + }); + }); + + it('should start notebook server with accelerator profile', () => { + notebookServer.visit(); + notebookServer.findAcceleratorProfileSelect().click(); + notebookServer.findAcceleratorProfileSelect().findSelectOption('Test GPU').click(); + notebookServer.findAcceleratorProfileSelect().should('contain', 'Test GPU'); + notebookServer.findStartServerButton().should('be.visible'); + notebookServer.findStartServerButton().click(); + + cy.wait('@startNotebookServer').then((interception) => { + expect(interception.request.body).to.eql({ + notebookSizeName: 'XSmall', + imageName: 'code-server-notebook', + imageTagName: '2023.2', + acceleratorProfile: { + count: 1, + acceleratorProfile: mockAcceleratorProfile({ + name: 'test-gpu', + displayName: 'Test GPU', + namespace: 'opendatahub', + uid: 'uid', + }), + }, envVars: { configMap: {}, secrets: {} }, state: 'started', storageClassName: 'openshift-default-sc', diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/connectionTypes/connectionTypes.cy.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/connectionTypes/connectionTypes.cy.ts index 029d535ee7..a98bd7c7ee 100644 --- a/frontend/src/__tests__/cypress/cypress/tests/mocked/connectionTypes/connectionTypes.cy.ts +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/connectionTypes/connectionTypes.cy.ts @@ -34,7 +34,9 @@ it('Connection types should be hidden by feature flag', () => { }), ); + cy.interceptOdh('GET /api/connection-types', []); connectionTypesPage.visit(); + connectionTypesPage.shouldBeEmpty(); }); describe('Connection types', () => { @@ -48,6 +50,12 @@ describe('Connection types', () => { }), ); cy.interceptOdh('GET /api/connection-types', [ + { + ...mockConnectionTypeConfigMap({ + name: 'corrupt', + }), + data: { category: '[[', fields: '{{' }, + }, mockConnectionTypeConfigMap({}), mockConnectionTypeConfigMap({ name: 'no-display-name', @@ -74,6 +82,8 @@ describe('Connection types', () => { it('should show the correct column values', () => { connectionTypesPage.visit(); + connectionTypesPage.findTable().find('tbody tr').should('have.length', 3); + const row = connectionTypesPage.getConnectionTypeRow('Test display name'); row.shouldHaveDescription('Test description'); row.shouldHaveCreator('dashboard-admin'); diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/connectionTypes/createConnectionType.cy.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/connectionTypes/createConnectionType.cy.ts index cca087dc4c..266ce99db9 100644 --- a/frontend/src/__tests__/cypress/cypress/tests/mocked/connectionTypes/createConnectionType.cy.ts +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/connectionTypes/createConnectionType.cy.ts @@ -22,6 +22,7 @@ describe('create', () => { mockConnectionTypeConfigMap({ displayName: 'URI - v1', name: 'uri-v1', + category: ['existing-category'], fields: [ { type: 'uri', @@ -49,15 +50,16 @@ describe('create', () => { createConnectionTypePage.findConnectionTypeName().type('hello'); categorySection.findCategoryTable(); - categorySection.findMultiGroupSelectButton('Object-storage'); + categorySection.findMultiGroupSelectButton('existing-category').should('exist'); + categorySection.findMultiGroupSelectButton('Object-storage').click(); createConnectionTypePage.findSubmitButton().should('be.enabled'); categorySection.findMultiGroupInput().type('Database'); - categorySection.findMultiGroupSelectButton('Database'); + categorySection.findMultiGroupSelectButton('Database').click(); categorySection.findMultiGroupInput().type('New category'); - categorySection.findMultiGroupSelectButton('Option'); + categorySection.findMultiGroupSelectButton('Option').click(); categorySection.findChipItem('New category').should('exist'); categorySection.findMultiGroupInput().type('{esc}'); @@ -88,7 +90,7 @@ describe('create', () => { createConnectionTypePage.findConnectionTypeName().type('hello'); categorySection.findCategoryTable(); - categorySection.findMultiGroupSelectButton('Object-storage'); + categorySection.findMultiGroupSelectButton('Object-storage').click(); createConnectionTypePage.findSubmitButton().should('be.enabled').click(); createConnectionTypePage.findFooterError().should('contain.text', 'returned error message'); diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/modelServing/servingRuntimeList.cy.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/modelServing/servingRuntimeList.cy.ts index 8c04e31a04..dee79199ba 100644 --- a/frontend/src/__tests__/cypress/cypress/tests/mocked/modelServing/servingRuntimeList.cy.ts +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/modelServing/servingRuntimeList.cy.ts @@ -696,11 +696,12 @@ describe('Serving Runtime List', () => { disableKServeConfig: false, servingRuntimes: [], requiredCapabilities: [StackCapability.SERVICE_MESH, StackCapability.SERVICE_MESH_AUTHZ], + projectEnableModelMesh: false, }); projectDetails.visitSection('test-project', 'model-server'); - modelServingSection.getServingPlatformCard('single-serving').findDeployModelButton().click(); + modelServingSection.findDeployModelButton().click(); kserveModal.shouldBeOpen(); @@ -806,11 +807,12 @@ describe('Serving Runtime List', () => { disableKServeConfig: false, disableKServeAuthConfig: true, servingRuntimes: [], + projectEnableModelMesh: false, }); projectDetails.visitSection('test-project', 'model-server'); - modelServingSection.getServingPlatformCard('single-serving').findDeployModelButton().click(); + modelServingSection.findDeployModelButton().click(); kserveModal.shouldBeOpen(); @@ -833,11 +835,12 @@ describe('Serving Runtime List', () => { disableKServeAuthConfig: false, servingRuntimes: [], requiredCapabilities: [], + projectEnableModelMesh: false, }); projectDetails.visitSection('test-project', 'model-server'); - modelServingSection.getServingPlatformCard('single-serving').findDeployModelButton().click(); + modelServingSection.findDeployModelButton().click(); kserveModal.shouldBeOpen(); @@ -852,11 +855,12 @@ describe('Serving Runtime List', () => { disableKServeAuthConfig: false, servingRuntimes: [], requiredCapabilities: [StackCapability.SERVICE_MESH, StackCapability.SERVICE_MESH_AUTHZ], + projectEnableModelMesh: false, }); projectDetails.visitSection('test-project', 'model-server'); - modelServingSection.getServingPlatformCard('single-serving').findDeployModelButton().click(); + modelServingSection.findDeployModelButton().click(); kserveModal.shouldBeOpen(); @@ -864,9 +868,10 @@ describe('Serving Runtime List', () => { kserveModal.findAuthenticationCheckbox().should('exist'); }); - it('Do not deploy KServe model when user cannot edit namespace', () => { + it('Do not deploy KServe model when user cannot edit namespace (only one serving platform enabled)', () => { + // If only one platform is enabled, project platform selection has not happened yet and patching the namespace with the platform happens at deploy time. initIntercepts({ - disableModelMeshConfig: false, + disableModelMeshConfig: true, disableKServeConfig: false, servingRuntimes: [], rejectAddSupportServingPlatformProject: true, @@ -874,7 +879,7 @@ describe('Serving Runtime List', () => { projectDetails.visitSection('test-project', 'model-server'); - modelServingSection.getServingPlatformCard('single-serving').findDeployModelButton().click(); + modelServingSection.findDeployModelButton().click(); kserveModal.shouldBeOpen(); @@ -1094,10 +1099,11 @@ describe('Serving Runtime List', () => { disableModelMeshConfig: false, disableKServeConfig: false, servingRuntimes: [], + projectEnableModelMesh: false, }); projectDetails.visitSection('test-project', 'model-server'); - modelServingSection.getServingPlatformCard('single-serving').findDeployModelButton().click(); + modelServingSection.findDeployModelButton().click(); kserveModal.shouldBeOpen(); @@ -1146,10 +1152,11 @@ describe('Serving Runtime List', () => { disableKServeConfig: false, servingRuntimes: [], requiredCapabilities: [StackCapability.SERVICE_MESH, StackCapability.SERVICE_MESH_AUTHZ], + projectEnableModelMesh: false, }); projectDetails.visitSection('test-project', 'model-server'); - modelServingSection.getServingPlatformCard('single-serving').findDeployModelButton().click(); + modelServingSection.findDeployModelButton().click(); kserveModal.shouldBeOpen(); @@ -1345,18 +1352,15 @@ describe('Serving Runtime List', () => { }); }); - it('Successfully add model server when user can edit namespace', () => { + it('Successfully add model server when user can edit namespace (only one serving platform enabled)', () => { + // If only one platform is enabled, project platform selection has not happened yet and patching the namespace with the platform happens at deploy time. initIntercepts({ - projectEnableModelMesh: undefined, - disableKServeConfig: false, + disableKServeConfig: true, disableModelMeshConfig: false, }); projectDetails.visitSection('test-project', 'model-server'); - modelServingSection - .getServingPlatformCard('multi-serving') - .findAddModelServerButton() - .click(); + modelServingSection.findAddModelServerButton().click(); createServingRuntimeModal.shouldBeOpen(); @@ -1401,19 +1405,16 @@ describe('Serving Runtime List', () => { }); }); - it('Do not add model server when user cannot edit namespace', () => { + it('Do not add model server when user cannot edit namespace (only one serving platform enabled)', () => { + // If only one platform is enabled, project platform selection has not happened yet and patching the namespace with the platform happens at deploy time. initIntercepts({ - projectEnableModelMesh: undefined, - disableKServeConfig: false, + disableKServeConfig: true, disableModelMeshConfig: false, rejectAddSupportServingPlatformProject: true, }); projectDetails.visitSection('test-project', 'model-server'); - modelServingSection - .getServingPlatformCard('multi-serving') - .findAddModelServerButton() - .click(); + modelServingSection.findAddModelServerButton().click(); createServingRuntimeModal.shouldBeOpen(); @@ -1457,16 +1458,13 @@ describe('Serving Runtime List', () => { describe('Model server with SericeAccount and RoleBinding', () => { it('Add model server - do not create ServiceAccount or RoleBinding if token auth is not selected', () => { initIntercepts({ - projectEnableModelMesh: undefined, + projectEnableModelMesh: true, disableKServeConfig: false, disableModelMeshConfig: false, }); projectDetails.visitSection('test-project', 'model-server'); - modelServingSection - .getServingPlatformCard('multi-serving') - .findAddModelServerButton() - .click(); + modelServingSection.findAddModelServerButton().click(); createServingRuntimeModal.shouldBeOpen(); @@ -1523,16 +1521,13 @@ describe('Serving Runtime List', () => { it('Add model server - create ServiceAccount and RoleBinding if token auth is selected', () => { initIntercepts({ - projectEnableModelMesh: undefined, + projectEnableModelMesh: true, disableKServeConfig: false, disableModelMeshConfig: false, }); projectDetails.visitSection('test-project', 'model-server'); - modelServingSection - .getServingPlatformCard('multi-serving') - .findAddModelServerButton() - .click(); + modelServingSection.findAddModelServerButton().click(); createServingRuntimeModal.shouldBeOpen(); @@ -1597,7 +1592,7 @@ describe('Serving Runtime List', () => { it('Add model server - do not create ServiceAccount or RoleBinding if they already exist', () => { initIntercepts({ - projectEnableModelMesh: undefined, + projectEnableModelMesh: true, disableKServeConfig: false, disableModelMeshConfig: false, serviceAccountAlreadyExists: true, @@ -1606,10 +1601,7 @@ describe('Serving Runtime List', () => { }); projectDetails.visitSection('test-project', 'model-server'); - modelServingSection - .getServingPlatformCard('multi-serving') - .findAddModelServerButton() - .click(); + modelServingSection.findAddModelServerButton().click(); createServingRuntimeModal.shouldBeOpen(); diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/projects/modelServingNim.cy.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/projects/modelServingNim.cy.ts index b117700581..335ded4258 100644 --- a/frontend/src/__tests__/cypress/cypress/tests/mocked/projects/modelServingNim.cy.ts +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/projects/modelServingNim.cy.ts @@ -19,15 +19,11 @@ import { deleteModal } from '~/__tests__/cypress/cypress/pages/components/Delete describe('NIM Model Serving', () => { describe('Deploying a model from an existing Project', () => { - it('should be disabled if the modal is empty', () => { - initInterceptsToEnableNim({ hasAllModels: true }); + it('should be disabled if the modal is empty (NIM already selected for project)', () => { + initInterceptsToEnableNim({ hasAllModels: false }); projectDetails.visitSection('test-project', 'model-server'); - // For multiple cards use case - projectDetails - .findModelServingPlatform('nvidia-nim-model') - .findByTestId('nim-serving-deploy-button') - .click(); + cy.findByTestId('deploy-button').click(); // test that you can not submit on empty nimDeployModal.shouldBeOpen(); @@ -168,7 +164,7 @@ describe('NIM Model Serving', () => { nimDeployModal.shouldBeOpen(); }); - it("should allow deploying NIM from a Project's Overview tab when multiple platforms exist", () => { + it("should allow selecting NIM from a Project's Overview tab when multiple platforms exist", () => { initInterceptorsValidatingNimEnablement({ disableKServe: false, disableModelMesh: false, @@ -177,8 +173,14 @@ describe('NIM Model Serving', () => { projectDetailsOverviewTab.visit('test-project'); projectDetailsOverviewTab .findModelServingPlatform('nvidia-nim') - .findByTestId('model-serving-platform-button') - .click(); + .findByTestId('nim-serving-select-button') + .should('be.enabled'); + }); + + it("should allow deploying NIM from a Project's Overview tab when NIM is selected", () => { + initInterceptsToEnableNim({ hasAllModels: false }); + projectDetailsOverviewTab.visit('test-project'); + cy.findByTestId('model-serving-platform-button').click(); nimDeployModal.shouldBeOpen(); }); @@ -189,7 +191,7 @@ describe('NIM Model Serving', () => { nimDeployModal.shouldBeOpen(); }); - it("should allow deploying NIM from a Project's Models tab when multiple platforms exist", () => { + it("should allow selecting NIM from a Project's Models tab when multiple platforms exist", () => { initInterceptorsValidatingNimEnablement({ disableKServe: false, disableModelMesh: false, @@ -198,8 +200,14 @@ describe('NIM Model Serving', () => { projectDetails.visitSection('test-project', 'model-server'); projectDetails .findModelServingPlatform('nvidia-nim-model') - .findByTestId('nim-serving-deploy-button') - .click(); + .findByTestId('nim-serving-select-button') + .should('be.enabled'); + }); + + it("should allow deploying NIM from a Project's Models tab when NIM is selected", () => { + initInterceptsToEnableNim({ hasAllModels: false }); + projectDetails.visitSection('test-project', 'model-server'); + cy.get('button[data-testid=deploy-button]').click(); nimDeployModal.shouldBeOpen(); }); }); @@ -244,7 +252,7 @@ describe('NIM Model Serving', () => { }); projectDetails.visitSection('test-project', 'model-server'); projectDetails.findModelServingPlatform('nvidia-nim-model').should('not.exist'); - cy.findByTestId('nim-serving-deploy-button').should('not.exist'); + cy.findByTestId('nim-serving-select-button').should('not.exist'); }); }); @@ -300,7 +308,7 @@ describe('NIM Model Serving', () => { ); projectDetails.visitSection('test-project', 'model-server'); projectDetails.findModelServingPlatform('nvidia-nim-model').should('not.exist'); - cy.findByTestId('nim-serving-deploy-button').should('not.exist'); + cy.findByTestId('nim-serving-select-button').should('not.exist'); }); }); }); diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/projects/projectDetails.cy.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/projects/projectDetails.cy.ts index d9a354bb35..1933385c42 100644 --- a/frontend/src/__tests__/cypress/cypress/tests/mocked/projects/projectDetails.cy.ts +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/projects/projectDetails.cy.ts @@ -36,6 +36,8 @@ import { import { mockServingRuntimeK8sResource } from '~/__mocks__/mockServingRuntimeK8sResource'; import { mockInferenceServiceK8sResource } from '~/__mocks__/mockInferenceServiceK8sResource'; import { asProjectAdminUser } from '~/__tests__/cypress/cypress/utils/mockUsers'; +import { NamespaceApplicationCase } from '~/pages/projects/types'; +import { mockNimServingRuntimeTemplate } from '~/__mocks__/mockNimResource'; type HandlersProps = { isEmpty?: boolean; @@ -44,6 +46,9 @@ type HandlersProps = { disableKServeConfig?: boolean; disableKServeMetrics?: boolean; disableModelConfig?: boolean; + disableNIMConfig?: boolean; + enableModelMesh?: boolean; + enableNIM?: boolean; isEnabled?: string; isUnknown?: boolean; templates?: boolean; @@ -52,12 +57,16 @@ type HandlersProps = { pipelineServerInstalled?: boolean; pipelineServerInitializing?: boolean; pipelineServerErrorMessage?: string; + rejectAddSupportServingPlatformProject?: boolean; }; const initIntercepts = ({ disableKServeConfig, disableKServeMetrics, disableModelConfig, + disableNIMConfig = true, + enableModelMesh, + enableNIM = false, isEmpty = false, imageStreamName = 'test-image', imageStreamTag = 'latest', @@ -69,6 +78,7 @@ const initIntercepts = ({ pipelineServerInstalled = true, pipelineServerInitializing, pipelineServerErrorMessage, + rejectAddSupportServingPlatformProject = false, }: HandlersProps) => { cy.interceptK8sList( { model: SecretModel, ns: 'test-project' }, @@ -90,14 +100,15 @@ const initIntercepts = ({ 'data-science-pipelines-operator': true, kserve: true, 'model-mesh': true, + 'model-registry-operator': true, }, }), ); cy.interceptK8sList( { model: TemplateModel, ns: 'opendatahub' }, - mockK8sResourceList( - templates + mockK8sResourceList([ + ...(templates ? [ mockServingRuntimeTemplateK8sResource({ name: 'template-1', @@ -105,14 +116,19 @@ const initIntercepts = ({ platforms: [ServingRuntimePlatform.SINGLE, ServingRuntimePlatform.MULTI], }), ] - : [], - ), + : []), + ...(!disableNIMConfig ? [mockNimServingRuntimeTemplate()] : []), + ]), ); + if (!disableNIMConfig) { + cy.interceptK8s(TemplateModel, mockNimServingRuntimeTemplate()); + } cy.interceptOdh( 'GET /api/config', mockDashboardConfig({ disableKServe: disableKServeConfig, disableModelMesh: disableModelConfig, + disableNIMModelServing: disableNIMConfig, disableKServeMetrics, }), ); @@ -137,8 +153,11 @@ const initIntercepts = ({ ); } cy.interceptK8sList(PodModel, mockK8sResourceList([mockPodK8sResource({})])); - cy.interceptK8sList(ProjectModel, mockK8sResourceList([mockProjectK8sResource({})])); - cy.interceptK8s(ProjectModel, mockProjectK8sResource({})); + + const mockProject = mockProjectK8sResource({ enableModelMesh, enableNIM }); + cy.interceptK8sList(ProjectModel, mockK8sResourceList([mockProject])); + cy.interceptK8s(ProjectModel, mockProject); + cy.interceptK8s( RouteModel, mockRouteK8sResource({ @@ -161,10 +180,6 @@ const initIntercepts = ({ }), }, ); - cy.interceptK8sList( - ProjectModel, - mockK8sResourceList([mockProjectK8sResource({ enableModelMesh: undefined })]), - ); cy.interceptK8sList( { @@ -210,8 +225,8 @@ const initIntercepts = ({ cy.interceptOdh( 'GET /api/namespaces/:namespace/:context', { path: { namespace: 'test-project', context: '*' } }, - { applied: true }, - ); + rejectAddSupportServingPlatformProject ? { statusCode: 401 } : { applied: true }, + ).as('addSupportServingPlatformProject'); cy.interceptK8sList( { model: NotebookModel, ns: 'test-project' }, mockK8sResourceList( @@ -330,18 +345,56 @@ describe('Project Details', () => { projectDetails.shouldHaveNoPlatformSelectedText(); }); - it('Both model serving platforms are enabled with no serving runtimes templates', () => { + it('Both model serving platforms are enabled, no platform selected', () => { initIntercepts({ disableKServeConfig: false, disableModelConfig: false }); projectDetails.visitSection('test-project', 'model-server'); + projectDetails.findSelectPlatformButton('single').should('exist'); + projectDetails.findSelectPlatformButton('multi').should('exist'); + }); + + it('Only single serving platform enabled, no serving runtimes templates', () => { + initIntercepts({ + disableKServeConfig: false, + disableModelConfig: true, + }); + projectDetails.visitSection('test-project', 'model-server'); + projectDetails.findTopLevelDeployModelButton().should('have.attr', 'aria-disabled'); + projectDetails.findTopLevelDeployModelButton().trigger('mouseenter'); + projectDetails.findDeployModelTooltip().should('be.visible'); + }); + + it('Only multi serving platform enabled, no serving runtimes templates', () => { + initIntercepts({ + disableKServeConfig: true, + disableModelConfig: false, + }); + projectDetails.visitSection('test-project', 'model-server'); + projectDetails.findTopLevelAddModelServerButton().should('have.attr', 'aria-disabled'); + projectDetails.findTopLevelAddModelServerButton().trigger('mouseenter'); + projectDetails.findDeployModelTooltip().should('be.visible'); + }); - //single-model-serving platform - projectDetails.findSingleModelDeployButton().should('have.attr', 'aria-disabled'); - projectDetails.findSingleModelDeployButton().trigger('mouseenter'); + it('Both model serving platforms are enabled, single-model platform is selected, no serving runtimes templates', () => { + initIntercepts({ + disableKServeConfig: false, + disableModelConfig: false, + enableModelMesh: false, + }); + projectDetails.visitSection('test-project', 'model-server'); + projectDetails.findTopLevelDeployModelButton().should('have.attr', 'aria-disabled'); + projectDetails.findTopLevelDeployModelButton().trigger('mouseenter'); projectDetails.findDeployModelTooltip().should('be.visible'); + }); - //multi-model-serving platform - projectDetails.findMultiModelButton().should('have.attr', 'aria-disabled'); - projectDetails.findMultiModelButton().trigger('mouseenter'); + it('Both model serving platforms are enabled, multi-model platform is selected, no serving runtimes templates', () => { + initIntercepts({ + disableKServeConfig: false, + disableModelConfig: false, + enableModelMesh: true, + }); + projectDetails.visitSection('test-project', 'model-server'); + projectDetails.findTopLevelAddModelServerButton().should('have.attr', 'aria-disabled'); + projectDetails.findTopLevelAddModelServerButton().trigger('mouseenter'); projectDetails.findDeployModelTooltip().should('be.visible'); }); @@ -453,4 +506,328 @@ describe('Project Details', () => { projectDetails.getNotebookRow('test-notebook').shouldHaveNotebookImageName('unknown'); }); }); + + describe('Selecting a model serving platform', () => { + it('Select single-model serving on models tab', () => { + initIntercepts({ disableKServeConfig: false, disableModelConfig: false }); + projectDetails.visitSection('test-project', 'model-server'); + projectDetails.findSelectPlatformButton('single').click(); + cy.wait('@addSupportServingPlatformProject').then((interception) => { + expect(interception.request.url).to.contain( + `/api/namespaces/test-project/${NamespaceApplicationCase.KSERVE_PROMOTION}`, + ); + }); + }); + + it('Un-select single-model serving on models tab', () => { + initIntercepts({ + disableKServeConfig: false, + disableModelConfig: false, + enableModelMesh: false, + }); + projectDetails.visitSection('test-project', 'model-server'); + projectDetails.findResetPlatformButton().click(); + cy.wait('@addSupportServingPlatformProject').then((interception) => { + expect(interception.request.url).to.contain( + `/api/namespaces/test-project/${NamespaceApplicationCase.RESET_MODEL_SERVING_PLATFORM}`, + ); + }); + }); + + it('Select multi-model serving on models tab', () => { + initIntercepts({ + disableKServeConfig: false, + disableModelConfig: false, + }); + projectDetails.visitSection('test-project', 'model-server'); + projectDetails.findSelectPlatformButton('multi').click(); + cy.wait('@addSupportServingPlatformProject').then((interception) => { + expect(interception.request.url).to.contain( + `/api/namespaces/test-project/${NamespaceApplicationCase.MODEL_MESH_PROMOTION}`, + ); + }); + }); + + it('Un-select multi-model serving on models tab', () => { + initIntercepts({ + disableKServeConfig: false, + disableModelConfig: false, + enableModelMesh: true, + }); + projectDetails.visitSection('test-project', 'model-server'); + projectDetails.findResetPlatformButton().click(); + cy.wait('@addSupportServingPlatformProject').then((interception) => { + expect(interception.request.url).to.contain( + `/api/namespaces/test-project/${NamespaceApplicationCase.RESET_MODEL_SERVING_PLATFORM}`, + ); + }); + }); + + it('Select NIM serving on models tab', () => { + initIntercepts({ + disableKServeConfig: false, + disableModelConfig: false, + disableNIMConfig: false, + }); + projectDetails.visitSection('test-project', 'model-server'); + projectDetails.findSelectPlatformButton('nim').click(); + cy.wait('@addSupportServingPlatformProject').then((interception) => { + expect(interception.request.url).to.contain( + `/api/namespaces/test-project/${NamespaceApplicationCase.KSERVE_NIM_PROMOTION}`, + ); + }); + }); + + it('Un-select NIM serving on models tab', () => { + initIntercepts({ + disableKServeConfig: false, + disableModelConfig: false, + disableNIMConfig: false, + enableModelMesh: false, + enableNIM: true, + }); + projectDetails.visitSection('test-project', 'model-server'); + projectDetails.findResetPlatformButton().click(); + cy.wait('@addSupportServingPlatformProject').then((interception) => { + expect(interception.request.url).to.contain( + `/api/namespaces/test-project/${NamespaceApplicationCase.RESET_MODEL_SERVING_PLATFORM}`, + ); + }); + }); + + it('Show error when failed to select platform on overview tab', () => { + initIntercepts({ + disableKServeConfig: false, + disableModelConfig: false, + rejectAddSupportServingPlatformProject: true, + }); + projectDetails.visitSection('test-project', 'overview'); + projectDetails.findSelectPlatformButton('single').click(); + projectDetails.findErrorSelectingPlatform().should('exist'); + }); + + it('Show error when failed to un-select platform on overview tab', () => { + initIntercepts({ + disableKServeConfig: false, + disableModelConfig: false, + enableModelMesh: false, + rejectAddSupportServingPlatformProject: true, + }); + projectDetails.visitSection('test-project', 'overview'); + projectDetails.findResetPlatformButton().click(); + projectDetails.findErrorSelectingPlatform().should('exist'); + }); + + it('Select single-model serving on overview tab', () => { + initIntercepts({ disableKServeConfig: false, disableModelConfig: false }); + projectDetails.visitSection('test-project', 'overview'); + projectDetails.findSelectPlatformButton('single').click(); + cy.wait('@addSupportServingPlatformProject').then((interception) => { + expect(interception.request.url).to.contain( + `/api/namespaces/test-project/${NamespaceApplicationCase.KSERVE_PROMOTION}`, + ); + }); + }); + + it('Un-select single-model serving on overview tab', () => { + initIntercepts({ + disableKServeConfig: false, + disableModelConfig: false, + enableModelMesh: false, + }); + projectDetails.visitSection('test-project', 'overview'); + projectDetails.findResetPlatformButton().click(); + cy.wait('@addSupportServingPlatformProject').then((interception) => { + expect(interception.request.url).to.contain( + `/api/namespaces/test-project/${NamespaceApplicationCase.RESET_MODEL_SERVING_PLATFORM}`, + ); + }); + }); + + it('Select multi-model serving on overview tab', () => { + initIntercepts({ + disableKServeConfig: false, + disableModelConfig: false, + }); + projectDetails.visitSection('test-project', 'overview'); + projectDetails.findSelectPlatformButton('multi').click(); + cy.wait('@addSupportServingPlatformProject').then((interception) => { + expect(interception.request.url).to.contain( + `/api/namespaces/test-project/${NamespaceApplicationCase.MODEL_MESH_PROMOTION}`, + ); + }); + }); + + it('Un-select multi-model serving on overview tab', () => { + initIntercepts({ + disableKServeConfig: false, + disableModelConfig: false, + enableModelMesh: true, + }); + projectDetails.visitSection('test-project', 'overview'); + projectDetails.findResetPlatformButton().click(); + cy.wait('@addSupportServingPlatformProject').then((interception) => { + expect(interception.request.url).to.contain( + `/api/namespaces/test-project/${NamespaceApplicationCase.RESET_MODEL_SERVING_PLATFORM}`, + ); + }); + }); + + it('Select NIM serving on overview tab', () => { + initIntercepts({ + disableKServeConfig: false, + disableModelConfig: false, + disableNIMConfig: false, + }); + projectDetails.visitSection('test-project', 'overview'); + projectDetails.findSelectPlatformButton('nim').click(); + cy.wait('@addSupportServingPlatformProject').then((interception) => { + expect(interception.request.url).to.contain( + `/api/namespaces/test-project/${NamespaceApplicationCase.KSERVE_NIM_PROMOTION}`, + ); + }); + }); + + it('Un-select NIM serving on overview tab', () => { + initIntercepts({ + disableKServeConfig: false, + disableModelConfig: false, + disableNIMConfig: false, + enableModelMesh: false, + enableNIM: true, + }); + projectDetails.visitSection('test-project', 'overview'); + projectDetails.findResetPlatformButton().click(); + cy.wait('@addSupportServingPlatformProject').then((interception) => { + expect(interception.request.url).to.contain( + `/api/namespaces/test-project/${NamespaceApplicationCase.RESET_MODEL_SERVING_PLATFORM}`, + ); + }); + }); + + it('Show error when failed to select platform on overview tab', () => { + initIntercepts({ + disableKServeConfig: false, + disableModelConfig: false, + rejectAddSupportServingPlatformProject: true, + }); + projectDetails.visitSection('test-project', 'model-server'); + projectDetails.findSelectPlatformButton('single').click(); + projectDetails.findErrorSelectingPlatform().should('exist'); + }); + + it('Show error when failed to un-select platform on overview tab', () => { + initIntercepts({ + disableKServeConfig: false, + disableModelConfig: false, + enableModelMesh: false, + rejectAddSupportServingPlatformProject: true, + }); + projectDetails.visitSection('test-project', 'model-server'); + projectDetails.findResetPlatformButton().click(); + projectDetails.findErrorSelectingPlatform().should('exist'); + }); + }); + + describe('Navigating back to model registry after selecting a platform', () => { + it('Navigate back after choosing single-model serving from models tab', () => { + initIntercepts({ + disableKServeConfig: false, + disableModelConfig: false, + enableModelMesh: false, + }); + projectDetails.visitSection( + 'test-project', + 'model-server', + '&modelRegistryName=modelregistry-sample®isteredModelId=1&modelVersionId=2', + ); + projectDetails.findBackToRegistryButton().click(); + cy.url().should( + 'include', + '/modelRegistry/modelregistry-sample/registeredModels/1/versions/2', + ); + }); + + it('Navigate back after choosing multi-model serving from models tab', () => { + initIntercepts({ + disableKServeConfig: false, + disableModelConfig: false, + enableModelMesh: true, + }); + initModelServingIntercepts(); + projectDetails.visitSection( + 'test-project', + 'model-server', + '&modelRegistryName=modelregistry-sample®isteredModelId=1&modelVersionId=2', + ); + projectDetails + .findDeployModelDropdown() + .findDropdownItem('Deploy model from model registry') + .click(); + cy.url().should( + 'include', + '/modelRegistry/modelregistry-sample/registeredModels/1/versions/2', + ); + }); + + it('Navigate back after choosing NIM serving from the models tab', () => { + initIntercepts({ + disableKServeConfig: false, + disableModelConfig: false, + disableNIMConfig: false, + enableModelMesh: false, + enableNIM: true, + }); + projectDetails.visitSection( + 'test-project', + 'model-server', + '&modelRegistryName=modelregistry-sample®isteredModelId=1&modelVersionId=2', + ); + projectDetails.findBackToRegistryButton().click(); + cy.url().should( + 'include', + '/modelRegistry/modelregistry-sample/registeredModels/1/versions/2', + ); + }); + + it('Navigate back after choosing single-model serving from overview tab after switching tabs', () => { + initIntercepts({ + disableKServeConfig: false, + disableModelConfig: false, + enableModelMesh: false, + }); + projectDetails.visitSection( + 'test-project', + 'model-server', + '&modelRegistryName=modelregistry-sample®isteredModelId=1&modelVersionId=2', + ); + projectDetails.findSectionTab('overview').click(); + projectDetails.findBackToRegistryButton().click(); + cy.url().should( + 'include', + '/modelRegistry/modelregistry-sample/registeredModels/1/versions/2', + ); + }); + + it('Navigate back after choosing NIM serving from overview tab after switching tabs', () => { + initIntercepts({ + disableKServeConfig: false, + disableModelConfig: false, + disableNIMConfig: false, + enableModelMesh: false, + enableNIM: true, + }); + projectDetails.visitSection( + 'test-project', + 'model-server', + '&modelRegistryName=modelregistry-sample®isteredModelId=1&modelVersionId=2', + ); + projectDetails.findSectionTab('overview').click(); + projectDetails.findBackToRegistryButton().click(); + cy.url().should( + 'include', + '/modelRegistry/modelregistry-sample/registeredModels/1/versions/2', + ); + }); + }); }); diff --git a/frontend/src/api/k8s/__tests__/projects.spec.ts b/frontend/src/api/k8s/__tests__/projects.spec.ts index 40ad337a29..044167c06c 100644 --- a/frontend/src/api/k8s/__tests__/projects.spec.ts +++ b/frontend/src/api/k8s/__tests__/projects.spec.ts @@ -274,7 +274,7 @@ describe('addSupportServingPlatformProject', () => { await expect( addSupportServingPlatformProject(name, NamespaceApplicationCase.MODEL_MESH_PROMOTION), ).rejects.toThrow( - `Unable to enable model serving platform in your project. Ask a ${ODH_PRODUCT_NAME} admin for assistance.`, + `Unable to select a model serving platform in your project. Ask a ${ODH_PRODUCT_NAME} admin for assistance.`, ); expect(mockedAxios).toHaveBeenCalledTimes(1); expect(mockedAxios).toHaveBeenCalledWith('/api/namespaces/test/1', { params: {} }); @@ -285,7 +285,7 @@ describe('addSupportServingPlatformProject', () => { await expect( addSupportServingPlatformProject(name, NamespaceApplicationCase.MODEL_MESH_PROMOTION), ).rejects.toThrow( - `Unable to enable model serving platform in your project. Ask a ${ODH_PRODUCT_NAME} admin for assistance.`, + `Unable to select a model serving platform in your project. Ask a ${ODH_PRODUCT_NAME} admin for assistance.`, ); expect(mockedAxios).toHaveBeenCalledTimes(1); expect(mockedAxios).toHaveBeenCalledWith('/api/namespaces/test/1', { params: {} }); diff --git a/frontend/src/api/k8s/events.ts b/frontend/src/api/k8s/events.ts index 4ca93854af..2d4a5fdabe 100644 --- a/frontend/src/api/k8s/events.ts +++ b/frontend/src/api/k8s/events.ts @@ -1,6 +1,9 @@ import { k8sListResourceItems } from '@openshift/dynamic-plugin-sdk-utils'; import { EventKind } from '~/k8sTypes'; import { EventModel } from '~/api/models'; +import useK8sWatchResourceList from '~/utilities/useK8sWatchResourceList'; +import { CustomWatchK8sResult } from '~/types'; +import { groupVersionKind } from '..'; export const getNotebookEvents = async ( namespace: string, @@ -18,3 +21,20 @@ export const getNotebookEvents = async ( }, }, }); + +export const useWatchNotebookEvents = ( + namespace: string, + name: string, + podUid?: string, +): CustomWatchK8sResult => + useK8sWatchResourceList( + { + isList: true, + groupVersionKind: groupVersionKind(EventModel), + namespace, + fieldSelector: podUid + ? `involvedObject.kind=Pod,involvedObject.uid=${podUid}` + : `involvedObject.kind=StatefulSet,involvedObject.name=${name}`, + }, + EventModel, + ); diff --git a/frontend/src/api/k8s/projects.ts b/frontend/src/api/k8s/projects.ts index 8f9f800aea..aa6e8e78b8 100644 --- a/frontend/src/api/k8s/projects.ts +++ b/frontend/src/api/k8s/projects.ts @@ -106,7 +106,7 @@ export const addSupportServingPlatformProject = ( const applied = response.data?.applied ?? false; if (!applied) { throw new Error( - `Unable to enable model serving platform in your project. Ask a ${ODH_PRODUCT_NAME} admin for assistance.`, + `Unable to select a model serving platform in your project. Ask a ${ODH_PRODUCT_NAME} admin for assistance.`, ); } return name; diff --git a/frontend/src/api/trustyai/rawTypes.ts b/frontend/src/api/trustyai/rawTypes.ts index 00b6588f89..d3e3aa9107 100644 --- a/frontend/src/api/trustyai/rawTypes.ts +++ b/frontend/src/api/trustyai/rawTypes.ts @@ -21,7 +21,7 @@ export type BaseMetric = { protectedAttribute: string; outcomeName: string; modelId: string; - requestName: string; + requestName?: string; thresholdDelta?: number; batchSize?: number; }; diff --git a/frontend/src/components/EmptyDetailsView.tsx b/frontend/src/components/EmptyDetailsView.tsx index 2ec81f0f13..2b5469faff 100644 --- a/frontend/src/components/EmptyDetailsView.tsx +++ b/frontend/src/components/EmptyDetailsView.tsx @@ -10,11 +10,12 @@ import { type EmptyDetailsViewProps = { title?: string; - description?: string; + description?: React.ReactNode; iconImage?: string; imageAlt?: string; allowCreate?: boolean; createButton?: React.ReactNode; + footerExtraChildren?: React.ReactNode; imageSize?: string; }; @@ -25,6 +26,7 @@ const EmptyDetailsView: React.FC = ({ imageAlt, allowCreate = true, createButton, + footerExtraChildren = null, imageSize = '320px', }) => ( @@ -44,6 +46,7 @@ const EmptyDetailsView: React.FC = ({ {allowCreate && createButton ? ( {createButton} + {footerExtraChildren} ) : null} diff --git a/frontend/src/components/SimpleMenuActions.tsx b/frontend/src/components/SimpleMenuActions.tsx index 46d1ffb53a..c1fec85e77 100644 --- a/frontend/src/components/SimpleMenuActions.tsx +++ b/frontend/src/components/SimpleMenuActions.tsx @@ -6,6 +6,7 @@ import { Divider, DropdownList, TooltipProps, + MenuToggleProps, } from '@patternfly/react-core'; import { EllipsisVIcon } from '@patternfly/react-icons'; @@ -23,6 +24,7 @@ type SimpleDropdownProps = { dropdownItems: (Item | Spacer)[]; testId?: string; toggleLabel?: string; + toggleProps?: Partial; variant?: React.ComponentProps['variant']; } & Omit< React.ComponentProps, @@ -33,6 +35,7 @@ const SimpleMenuActions: React.FC = ({ dropdownItems, testId, toggleLabel, + toggleProps, variant, ...props }) => { @@ -51,6 +54,7 @@ const SimpleMenuActions: React.FC = ({ ref={toggleRef} onClick={() => setOpen(!open)} isExpanded={open} + {...toggleProps} > {toggleLabel ?? } diff --git a/frontend/src/concepts/connectionTypes/ConnectionTypeForm.tsx b/frontend/src/concepts/connectionTypes/ConnectionTypeForm.tsx index 696799aefc..20f520b755 100644 --- a/frontend/src/concepts/connectionTypes/ConnectionTypeForm.tsx +++ b/frontend/src/concepts/connectionTypes/ConnectionTypeForm.tsx @@ -40,7 +40,7 @@ const createSelectOption = ( )} - {connectionType.data?.category?.length && ( + {!!connectionType.data?.category?.length && ( diff --git a/frontend/src/concepts/connectionTypes/utils.ts b/frontend/src/concepts/connectionTypes/utils.ts index d32ddd5046..372310ba37 100644 --- a/frontend/src/concepts/connectionTypes/utils.ts +++ b/frontend/src/concepts/connectionTypes/utils.ts @@ -37,15 +37,21 @@ export const getConnectionTypeRef = (connection: Connection | undefined): string export const toConnectionTypeConfigMapObj = ( configMap: ConnectionTypeConfigMap, -): ConnectionTypeConfigMapObj => ({ - ...configMap, - data: configMap.data - ? { - category: configMap.data.category ? JSON.parse(configMap.data.category) : undefined, - fields: configMap.data.fields ? JSON.parse(configMap.data.fields) : undefined, - } - : undefined, -}); +): ConnectionTypeConfigMapObj => { + try { + return { + ...configMap, + data: configMap.data + ? { + category: configMap.data.category ? JSON.parse(configMap.data.category) : undefined, + fields: configMap.data.fields ? JSON.parse(configMap.data.fields) : undefined, + } + : undefined, + }; + } catch (e) { + throw new Error('Failed to parse connection type data.'); + } +}; export const toConnectionTypeConfigMap = ( obj: ConnectionTypeConfigMapObj, diff --git a/frontend/src/concepts/k8s/K8sNameDescriptionField/ResourceNameField.tsx b/frontend/src/concepts/k8s/K8sNameDescriptionField/ResourceNameField.tsx index b254a82e11..98f9fd4ab2 100644 --- a/frontend/src/concepts/k8s/K8sNameDescriptionField/ResourceNameField.tsx +++ b/frontend/src/concepts/k8s/K8sNameDescriptionField/ResourceNameField.tsx @@ -48,6 +48,7 @@ const ResourceNameField: React.FC = ({ return ( !!x && 'k8sName' in x; + export const setupDefaults = ({ initialData, limitNameResourceType, @@ -43,11 +49,16 @@ export const setupDefaults = ({ let initialK8sNameValue = ''; let configuredMaxLength = MAX_RESOURCE_NAME_LENGTH; - if (isK8sDSGResource(initialData)) { + if (isK8sNameDescriptionType(initialData)) { + initialName = initialData.name || ''; + initialDescription = initialData.description || ''; + initialK8sNameValue = initialData.k8sName || ''; + } else if (isK8sDSGResource(initialData)) { initialName = getDisplayNameFromK8sResource(initialData); initialDescription = getDescriptionFromK8sResource(initialData); initialK8sNameValue = initialData.metadata.name; } + if (limitNameResourceType != null) { configuredMaxLength = ROUTE_BASED_NAME_LENGTH; } diff --git a/frontend/src/concepts/trustyai/utils.ts b/frontend/src/concepts/trustyai/utils.ts index b3cba0eba2..2070e4183c 100644 --- a/frontend/src/concepts/trustyai/utils.ts +++ b/frontend/src/concepts/trustyai/utils.ts @@ -16,7 +16,7 @@ export const formatListResponse = (x: BaseMetricListResponse): BiasMetricConfig[ id: m.id, metricType: m.request.metricName, modelId: m.request.modelId, - name: m.request.requestName, + name: m.request.requestName ?? `${m.request.metricName}-${m.request.modelId}`, outcomeName: m.request.outcomeName, privilegedAttribute: m.request.privilegedAttribute.value, protectedAttribute: m.request.protectedAttribute, diff --git a/frontend/src/pages/acceleratorProfiles/screens/manage/ManageAcceleratorProfile.tsx b/frontend/src/pages/acceleratorProfiles/screens/manage/ManageAcceleratorProfile.tsx index 8c5d6f6b09..8d781e4c38 100644 --- a/frontend/src/pages/acceleratorProfiles/screens/manage/ManageAcceleratorProfile.tsx +++ b/frontend/src/pages/acceleratorProfiles/screens/manage/ManageAcceleratorProfile.tsx @@ -1,14 +1,26 @@ import * as React from 'react'; import { Link } from 'react-router-dom'; -import { Breadcrumb, BreadcrumbItem, Form, PageSection } from '@patternfly/react-core'; +import { + Breadcrumb, + BreadcrumbItem, + Form, + FormSection, + PageSection, + Stack, + StackItem, +} from '@patternfly/react-core'; import ApplicationsPage from '~/pages/ApplicationsPage'; import useGenericObjectState from '~/utilities/useGenericObjectState'; import GenericSidebar from '~/components/GenericSidebar'; import { AcceleratorProfileKind } from '~/k8sTypes'; +import K8sNameDescriptionField, { + useK8sNameDescriptionFieldData, +} from '~/concepts/k8s/K8sNameDescriptionField/K8sNameDescriptionField'; +import { isK8sNameDescriptionDataValid } from '~/concepts/k8s/K8sNameDescriptionField/utils'; import { ManageAcceleratorProfileFooter } from './ManageAcceleratorProfileFooter'; import { ManageAcceleratorProfileTolerationsSection } from './ManageAcceleratorProfileTolerationsSection'; -import { ManageAcceleratorProfileSectionID } from './types'; +import { AcceleratorProfileFormData, ManageAcceleratorProfileSectionID } from './types'; import { ManageAcceleratorProfileSectionTitles, ScrollableSelectorID } from './const'; import { ManageAcceleratorProfileDetailsSection } from './ManageAcceleratorProfileDetailsSection'; @@ -25,19 +37,39 @@ const ManageAcceleratorProfile: React.FC = ({ enabled: true, tolerations: [], }); + const { data: profileNameDesc, onDataChange: setProfileNameDesc } = + useK8sNameDescriptionFieldData({ + initialData: existingAcceleratorProfile + ? { + name: existingAcceleratorProfile.spec.displayName, + k8sName: existingAcceleratorProfile.metadata.name, + description: existingAcceleratorProfile.spec.description, + } + : undefined, + }); React.useEffect(() => { if (existingAcceleratorProfile) { - setState('displayName', existingAcceleratorProfile.spec.displayName); setState('identifier', existingAcceleratorProfile.spec.identifier); - setState('description', existingAcceleratorProfile.spec.description); setState('enabled', existingAcceleratorProfile.spec.enabled); setState('tolerations', existingAcceleratorProfile.spec.tolerations); } }, [existingAcceleratorProfile, setState]); + const formState: AcceleratorProfileFormData = React.useMemo( + () => ({ + ...state, + name: profileNameDesc.k8sName.value, + displayName: profileNameDesc.name, + description: profileNameDesc.description, + }), + [state, profileNameDesc], + ); + const sectionIDs = Object.values(ManageAcceleratorProfileSectionID); + const validFormData = isK8sNameDescriptionDataValid(profileNameDesc) && !!state.identifier; + return ( = ({ >
- + + + + + + + + setState('tolerations', tolerations)} @@ -76,8 +127,9 @@ const ManageAcceleratorProfile: React.FC = ({ diff --git a/frontend/src/pages/acceleratorProfiles/screens/manage/ManageAcceleratorProfileDetailsSection.tsx b/frontend/src/pages/acceleratorProfiles/screens/manage/ManageAcceleratorProfileDetailsSection.tsx index 667d3829c8..f1b907fb22 100644 --- a/frontend/src/pages/acceleratorProfiles/screens/manage/ManageAcceleratorProfileDetailsSection.tsx +++ b/frontend/src/pages/acceleratorProfiles/screens/manage/ManageAcceleratorProfileDetailsSection.tsx @@ -1,25 +1,15 @@ -import { - FormSection, - Stack, - StackItem, - FormGroup, - TextInput, - TextArea, - Switch, - Popover, -} from '@patternfly/react-core'; +import { StackItem, FormGroup, Switch, Popover } from '@patternfly/react-core'; import React from 'react'; import { OutlinedQuestionCircleIcon } from '@patternfly/react-icons'; import { useSearchParams } from 'react-router-dom'; import { UpdateObjectAtPropAndValue } from '~/pages/projects/types'; import { AcceleratorProfileKind } from '~/k8sTypes'; import DashboardPopupIconButton from '~/concepts/dashboard/DashboardPopupIconButton'; -import { ManageAcceleratorProfileSectionTitles } from './const'; -import { ManageAcceleratorProfileSectionID } from './types'; +import { AcceleratorProfileFormData } from './types'; import { IdentifierSelectField } from './IdentifierSelectField'; type ManageAcceleratorProfileDetailsSectionProps = { - state: AcceleratorProfileKind['spec']; + state: AcceleratorProfileFormData; setState: UpdateObjectAtPropAndValue; }; @@ -34,69 +24,37 @@ export const ManageAcceleratorProfileDetailsSection: React.FC< ); return ( - - - - - setState('displayName', name)} - aria-label="Name" - data-testid="accelerator-name-input" - /> - - - - - } - aria-label="More info for identifier field" - /> - - } - > - setState('identifier', identifier)} - identifierOptions={acceleratorIdentifiers} - /> - - - - -