diff --git a/backend/src/routes/api/cluster-settings/clusterSettingsUtils.ts b/backend/src/routes/api/cluster-settings/clusterSettingsUtils.ts index 4fab3ccd15..5d543d3fb7 100644 --- a/backend/src/routes/api/cluster-settings/clusterSettingsUtils.ts +++ b/backend/src/routes/api/cluster-settings/clusterSettingsUtils.ts @@ -18,6 +18,10 @@ const DEFAULT_CLUSTER_SETTINGS: ClusterSettings = { cullerTimeout: DEFAULT_CULLER_TIMEOUT, userTrackingEnabled: false, notebookTolerationSettings: { enabled: false, key: 'NotebooksOnly' }, + modelServingPlatformEnabled: { + kServe: true, + modelMesh: false, + }, }; export const updateClusterSettings = async ( @@ -28,10 +32,30 @@ export const updateClusterSettings = async ( ): Promise<{ success: boolean; error: string }> => { const coreV1Api = fastify.kube.coreV1Api; const namespace = fastify.kube.namespace; - const { pvcSize, cullerTimeout, userTrackingEnabled, notebookTolerationSettings } = request.body; + const { + pvcSize, + cullerTimeout, + userTrackingEnabled, + notebookTolerationSettings, + modelServingPlatformEnabled, + } = request.body; const dashConfig = getDashboardConfig(); const isJupyterEnabled = checkJupyterEnabled(); try { + if ( + modelServingPlatformEnabled.kServe !== !dashConfig.spec.dashboardConfig.disableKServe || + modelServingPlatformEnabled.modelMesh !== !dashConfig.spec.dashboardConfig.disableModelMesh + ) { + await setDashboardConfig(fastify, { + spec: { + dashboardConfig: { + disableKServe: !modelServingPlatformEnabled.kServe, + disableModelMesh: !modelServingPlatformEnabled.modelMesh, + }, + }, + }); + } + await patchCM(fastify, segmentKeyCfg, { data: { segmentKeyEnabled: String(userTrackingEnabled) }, }).catch((e) => { @@ -41,7 +65,6 @@ export const updateClusterSettings = async ( if (isJupyterEnabled) { await setDashboardConfig(fastify, { spec: { - dashboardConfig: dashConfig.spec.dashboardConfig, notebookController: { enabled: isJupyterEnabled, pvcSize: `${pvcSize}Gi`, @@ -124,10 +147,14 @@ export const getClusterSettings = async ( ): Promise => { const coreV1Api = fastify.kube.coreV1Api; const namespace = fastify.kube.namespace; - const clusterSettings = { + const dashConfig = getDashboardConfig(); + const clusterSettings: ClusterSettings = { ...DEFAULT_CLUSTER_SETTINGS, + modelServingPlatformEnabled: { + kServe: !dashConfig.spec.dashboardConfig.disableKServe, + modelMesh: !dashConfig.spec.dashboardConfig.disableModelMesh, + }, }; - const dashConfig = getDashboardConfig(); const isJupyterEnabled = checkJupyterEnabled(); if (!dashConfig.spec.dashboardConfig.disableTracking) { try { diff --git a/backend/src/routes/api/namespaces/const.ts b/backend/src/routes/api/namespaces/const.ts index ecb92eda5a..08e493a647 100644 --- a/backend/src/routes/api/namespaces/const.ts +++ b/backend/src/routes/api/namespaces/const.ts @@ -4,7 +4,11 @@ export enum NamespaceApplicationCase { */ DSG_CREATION, /** - * Upgrade an existing DSG project to work with model serving. + * Upgrade an existing DSG project to work with model mesh. */ - MODEL_SERVING_PROMOTION, + MODEL_MESH_PROMOTION, + /** + * Upgrade an existing DSG project to work with model kserve. + */ + KSERVE_PROMOTION, } diff --git a/backend/src/routes/api/namespaces/namespaceUtils.ts b/backend/src/routes/api/namespaces/namespaceUtils.ts index c55387debb..f22d2c9b1c 100644 --- a/backend/src/routes/api/namespaces/namespaceUtils.ts +++ b/backend/src/routes/api/namespaces/namespaceUtils.ts @@ -67,11 +67,16 @@ export const applyNamespaceChange = async ( 'opendatahub.io/dashboard': 'true', }; break; - case NamespaceApplicationCase.MODEL_SERVING_PROMOTION: + case NamespaceApplicationCase.MODEL_MESH_PROMOTION: labels = { 'modelmesh-enabled': 'true', }; break; + case NamespaceApplicationCase.KSERVE_PROMOTION: + labels = { + 'modelmesh-enabled': 'false', + }; + break; default: throw createCustomError('Unknown configuration', 'Cannot apply namespace change', 400); } diff --git a/backend/src/types.ts b/backend/src/types.ts index 1eb8da42d8..6284472c19 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -29,6 +29,8 @@ export type DashboardConfig = K8sResourceCommon & { disableCustomServingRuntimes: boolean; modelMetricsNamespace: string; disablePipelines: boolean; + disableKServe: boolean; + disableModelMesh: boolean; }; groupsConfig?: { adminGroups: string; @@ -100,6 +102,10 @@ export type ClusterSettings = { cullerTimeout: number; userTrackingEnabled: boolean; notebookTolerationSettings: NotebookTolerationSettings | null; + modelServingPlatformEnabled: { + kServe: boolean; + modelMesh: boolean; + }; }; // Add a minimal QuickStart type here as there is no way to get types without pulling in frontend (React) modules @@ -947,7 +953,7 @@ type ComponentNames = export type DataScienceClusterKindStatus = { conditions: []; - installedComponents: { [key in ComponentNames]: boolean }; + installedComponents: { [key in ComponentNames]?: boolean }; phase?: string; }; diff --git a/backend/src/utils/constants.ts b/backend/src/utils/constants.ts index 1699c0eac1..0604d40052 100644 --- a/backend/src/utils/constants.ts +++ b/backend/src/utils/constants.ts @@ -53,6 +53,8 @@ export const blankDashboardCR: DashboardConfig = { disableCustomServingRuntimes: false, modelMetricsNamespace: '', disablePipelines: false, + disableKServe: false, + disableModelMesh: true, }, notebookController: { enabled: true, diff --git a/backend/src/utils/resourceUtils.ts b/backend/src/utils/resourceUtils.ts index 3fe6353169..d75a54de00 100644 --- a/backend/src/utils/resourceUtils.ts +++ b/backend/src/utils/resourceUtils.ts @@ -105,6 +105,21 @@ const fetchOrCreateDashboardCR = async ( ) .then((res) => { const dashboardCR = res?.body as DashboardConfig; + if ( + dashboardCR && + dashboardCR.spec.dashboardConfig.disableKServe === undefined && + dashboardCR.spec.dashboardConfig.disableModelMesh === undefined + ) { + // return a merge between dashboardCR and blankDashboardCR but changing spec.disableKServe to true and spec.disableModelMesh to false + return _.merge({}, blankDashboardCR, dashboardCR, { + spec: { + dashboardConfig: { + disableKServe: true, + disableModelMesh: false, + }, + }, + }); + } return _.merge({}, blankDashboardCR, dashboardCR); // merge with blank CR to prevent any missing values }) .catch((e) => { diff --git a/docs/dashboard-config.md b/docs/dashboard-config.md index cf20d49269..8fe24e3e41 100644 --- a/docs/dashboard-config.md +++ b/docs/dashboard-config.md @@ -24,6 +24,8 @@ The following are a list of features that are supported, along with there defaul | disableModelServing | false | Disables Model Serving from the dashboard and from Data Science Projects. | | disableProjectSharing | false | Disables Project Sharing from Data Science Projects. | | disableCustomServingRuntimes | false | Disables Custom Serving Runtimes from the Admin Panel. | +| disableKServe | false | Disables the ability to select KServe as a Serving Platform. | +| disableModelMesh | true | Disables the ability to select ModelMesh as a Serving Platform. | | modelMetricsNamespace | false | Enables the namespace in which the Model Serving Metrics' Prometheus Operator is installed. | ## Defaults diff --git a/frontend/src/__mocks__/mockClusterSettings.ts b/frontend/src/__mocks__/mockClusterSettings.ts index 884d25e8cc..5c726e8203 100644 --- a/frontend/src/__mocks__/mockClusterSettings.ts +++ b/frontend/src/__mocks__/mockClusterSettings.ts @@ -9,9 +9,14 @@ export const mockClusterSettings = ({ key: 'NotebooksOnlyChange', enabled: true, }, + modelServingPlatformEnabled = { + kServe: true, + modelMesh: true, + }, }: Partial): ClusterSettingsType => ({ userTrackingEnabled, cullerTimeout, pvcSize, notebookTolerationSettings, + modelServingPlatformEnabled, }); diff --git a/frontend/src/__mocks__/mockDashboardConfig.ts b/frontend/src/__mocks__/mockDashboardConfig.ts index 960f2ed437..2e2f7a5de9 100644 --- a/frontend/src/__mocks__/mockDashboardConfig.ts +++ b/frontend/src/__mocks__/mockDashboardConfig.ts @@ -13,6 +13,8 @@ type MockDashboardConfigType = { disablePipelines?: boolean; disableModelServing?: boolean; disableCustomServingRuntimes?: boolean; + disableKServe?: boolean; + disableModelMesh?: boolean; }; export const mockDashboardConfig = ({ @@ -28,6 +30,8 @@ export const mockDashboardConfig = ({ disableModelServing = false, disableCustomServingRuntimes = false, disablePipelines = false, + disableKServe = false, + disableModelMesh = true, }: MockDashboardConfigType): DashboardConfigKind => ({ apiVersion: 'opendatahub.io/v1alpha', kind: 'OdhDashboardConfig', @@ -55,6 +59,8 @@ export const mockDashboardConfig = ({ disablePipelines, modelMetricsNamespace: 'test-project', disableProjectSharing: false, + disableKServe, + disableModelMesh, }, notebookController: { enabled: true, diff --git a/frontend/src/__mocks__/mockDscStatus.ts b/frontend/src/__mocks__/mockDscStatus.ts index c3428b5b5c..b589cb3795 100644 --- a/frontend/src/__mocks__/mockDscStatus.ts +++ b/frontend/src/__mocks__/mockDscStatus.ts @@ -1,14 +1,110 @@ -import { DataScienceClusterKindStatus } from '~/k8sTypes'; +import { DataScienceClusterKindStatus, K8sCondition } from '~/k8sTypes'; import { StackComponent } from '~/concepts/areas/types'; -type MockDscStatus = { +export type MockDscStatus = { + conditions?: K8sCondition[]; + phase?: string; installedComponents?: DataScienceClusterKindStatus['installedComponents']; }; export const mockDscStatus = ({ installedComponents, + conditions = [], + phase = 'Ready', }: MockDscStatus): DataScienceClusterKindStatus => ({ - conditions: [], + conditions: [ + ...[ + { + lastHeartbeatTime: '2023-10-20T11:44:48Z', + lastTransitionTime: '2023-10-15T19:04:21Z', + message: 'DataScienceCluster resource reconciled successfully', + reason: 'ReconcileCompleted', + status: 'True', + type: 'ReconcileComplete', + }, + { + lastHeartbeatTime: '2023-10-20T11:44:48Z', + lastTransitionTime: '2023-10-15T19:04:21Z', + message: 'DataScienceCluster resource reconciled successfully', + reason: 'ReconcileCompleted', + status: 'True', + type: 'Available', + }, + { + lastHeartbeatTime: '2023-10-20T11:44:48Z', + lastTransitionTime: '2023-10-15T19:04:21Z', + message: 'DataScienceCluster resource reconciled successfully', + reason: 'ReconcileCompleted', + status: 'False', + type: 'Progressing', + }, + { + lastHeartbeatTime: '2023-10-20T11:44:48Z', + lastTransitionTime: '2023-10-15T19:04:10Z', + message: 'DataScienceCluster resource reconciled successfully', + reason: 'ReconcileCompleted', + status: 'False', + type: 'Degraded', + }, + { + lastHeartbeatTime: '2023-10-20T11:44:48Z', + lastTransitionTime: '2023-10-15T19:04:21Z', + message: 'DataScienceCluster resource reconciled successfully', + reason: 'ReconcileCompleted', + status: 'True', + type: 'Upgradeable', + }, + { + lastHeartbeatTime: '2023-10-20T11:44:59Z', + lastTransitionTime: '2023-10-20T11:44:59Z', + message: 'Component reconciled successfully', + reason: 'ReconcileCompleted', + status: 'True', + type: 'odh-dashboardReady', + }, + { + lastHeartbeatTime: '2023-10-20T11:44:59Z', + lastTransitionTime: '2023-10-20T11:44:59Z', + message: 'Component reconciled successfully', + reason: 'ReconcileCompleted', + status: 'True', + type: 'data-science-pipelines-operatorReady', + }, + { + lastHeartbeatTime: '2023-10-20T11:45:01Z', + lastTransitionTime: '2023-10-20T11:45:01Z', + message: 'Component reconciled successfully', + reason: 'ReconcileCompleted', + status: 'True', + type: 'workbenchesReady', + }, + { + lastHeartbeatTime: '2023-10-20T11:45:04Z', + lastTransitionTime: '2023-10-20T11:45:04Z', + message: 'Component reconciled successfully', + reason: 'ReconcileCompleted', + status: 'True', + type: 'kserveReady', + }, + { + lastHeartbeatTime: '2023-10-20T11:45:04Z', + lastTransitionTime: '2023-10-20T11:45:04Z', + message: 'Component reconciled successfully', + reason: 'ReconcileCompleted', + status: 'True', + type: 'model-meshReady', + }, + { + lastHeartbeatTime: '2023-10-20T11:45:06Z', + lastTransitionTime: '2023-10-20T11:45:06Z', + message: 'Component is disabled', + reason: 'ReconcileInit', + status: 'Unknown', + type: 'rayReady', + }, + ], + ...conditions, + ], installedComponents: Object.values(StackComponent).reduce( (acc, component) => ({ ...acc, @@ -16,5 +112,5 @@ export const mockDscStatus = ({ }), {}, ), - phase: 'Ready', + phase, }); diff --git a/frontend/src/__mocks__/mockInferenceServiceK8sResource.ts b/frontend/src/__mocks__/mockInferenceServiceK8sResource.ts index 723b5dc3e3..fe0c380bb4 100644 --- a/frontend/src/__mocks__/mockInferenceServiceK8sResource.ts +++ b/frontend/src/__mocks__/mockInferenceServiceK8sResource.ts @@ -7,6 +7,10 @@ type MockResourceConfigType = { displayName?: string; modelName?: string; secretName?: string; + deleted?: boolean; + isModelMesh?: boolean; + activeModelState?: string; + url?: string; }; export const mockInferenceServicek8sError = () => ({ @@ -37,15 +41,26 @@ export const mockInferenceServiceK8sResource = ({ displayName = 'Test Inference Service', modelName = 'test-model', secretName = 'test-secret', + deleted = false, + isModelMesh = false, + activeModelState = 'Pending', + url = '', }: MockResourceConfigType): InferenceServiceKind => ({ apiVersion: 'serving.kserve.io/v1beta1', kind: 'InferenceService', metadata: { annotations: { 'openshift.io/display-name': displayName, - 'serving.kserve.io/deploymentMode': 'ModelMesh', + ...(isModelMesh + ? { 'serving.kserve.io/deploymentMode': 'ModelMesh' } + : { + 'serving.knative.openshift.io/enablePassthrough': 'true', + 'sidecar.istio.io/inject': 'true', + 'sidecar.istio.io/rewriteAppHTTPProbers': 'true', + }), }, creationTimestamp: '2023-03-17T16:12:41Z', + ...(deleted ? { deletionTimestamp: new Date().toUTCString() } : {}), generation: 1, labels: { name: name, @@ -73,7 +88,7 @@ export const mockInferenceServiceK8sResource = ({ }, status: { components: {}, - url: '', + url, conditions: [ { lastTransitionTime: '2023-03-17T16:12:41Z', @@ -99,7 +114,7 @@ export const mockInferenceServiceK8sResource = ({ time: '', }, states: { - activeModelState: 'Pending', + activeModelState, targetModelState: '', }, transitionStatus: '', diff --git a/frontend/src/__mocks__/mockProjectK8sResource.ts b/frontend/src/__mocks__/mockProjectK8sResource.ts index 14033257d4..fba6723871 100644 --- a/frontend/src/__mocks__/mockProjectK8sResource.ts +++ b/frontend/src/__mocks__/mockProjectK8sResource.ts @@ -1,4 +1,5 @@ import { K8sResourceListResult } from '@openshift/dynamic-plugin-sdk-utils'; +import { genUID } from '~/__mocks__/mockUtils'; import { KnownLabels, ProjectKind } from '~/k8sTypes'; type MockResourceConfigType = { @@ -14,7 +15,7 @@ export const mockProjectK8sResource = ({ username = 'test-user', displayName = 'Test Project', k8sName = 'test-project', - enableModelMesh = true, + enableModelMesh, description = '', isDSProject = true, }: MockResourceConfigType): ProjectKind => ({ @@ -22,10 +23,13 @@ export const mockProjectK8sResource = ({ apiVersion: 'project.openshift.io/v1', metadata: { name: k8sName, + uid: genUID('project'), creationTimestamp: '2023-02-14T21:43:59Z', labels: { 'kubernetes.io/metadata.name': k8sName, - [KnownLabels.MODEL_SERVING_PROJECT]: enableModelMesh ? 'true' : 'false', + ...(enableModelMesh !== undefined && { + [KnownLabels.MODEL_SERVING_PROJECT]: enableModelMesh ? 'true' : 'false', + }), ...(isDSProject && { [KnownLabels.DASHBOARD_RESOURCE]: 'true' }), }, annotations: { diff --git a/frontend/src/__mocks__/mockServingRuntimeTemplateK8sResource.ts b/frontend/src/__mocks__/mockServingRuntimeTemplateK8sResource.ts index 2fed7615a9..84dbebc0db 100644 --- a/frontend/src/__mocks__/mockServingRuntimeTemplateK8sResource.ts +++ b/frontend/src/__mocks__/mockServingRuntimeTemplateK8sResource.ts @@ -1,27 +1,29 @@ import { TemplateKind } from '~/k8sTypes'; +import { ServingRuntimePlatform } from '~/types'; type MockResourceConfigType = { name?: string; namespace?: string; + displayName?: string; + platforms?: ServingRuntimePlatform[]; }; -export const mockTemplateK8sResource = ({ - name = 'test-model', +export const mockServingRuntimeTemplateK8sResource = ({ + name = 'template-1', namespace = 'opendatahub', + displayName = 'New OVMS Server', + platforms, }: MockResourceConfigType): TemplateKind => ({ apiVersion: 'template.openshift.io/v1', kind: 'Template', metadata: { - name: 'template-ar2pcc', + name, namespace, - uid: '31277020-b60a-40c9-91bc-5ee3e2bb25ec', - resourceVersion: '164740435', - creationTimestamp: '2023-05-03T21:58:17Z', labels: { 'opendatahub.io/dashboard': 'true', }, annotations: { - tags: 'new-one,servingruntime', + 'opendatahub.io/modelServingSupport': JSON.stringify(platforms), }, }, objects: [ @@ -31,7 +33,7 @@ export const mockTemplateK8sResource = ({ metadata: { name, annotations: { - 'openshift.io/display-name': 'New OVMS Server', + 'openshift.io/display-name': displayName, }, labels: { 'opendatahub.io/dashboard': 'true', @@ -92,3 +94,79 @@ export const mockTemplateK8sResource = ({ ], parameters: [], }); + +export const mockInvalidTemplateK8sResource = ({ + name = 'test-model-invalid', + namespace = 'opendatahub', +}: MockResourceConfigType): TemplateKind => ({ + apiVersion: 'template.openshift.io/v1', + kind: 'Template', + metadata: { + name: 'template-ar2pcd', + namespace, + uid: '31277020-b60a-40c9-91bc-5ee3e2bb25ed', + resourceVersion: '164740436', + creationTimestamp: '2023-05-03T21:58:17Z', + labels: { + 'opendatahub.io/dashboard': 'true', + }, + annotations: { + tags: 'new-one,servingruntime', + }, + }, + objects: [ + { + apiVersion: 'serving.kserve.io/v1alpha1', + kind: 'ServingRuntime', + metadata: { + name, + annotations: { + 'openshift.io/display-name': 'New OVMS Server Invalid', + }, + labels: { + 'opendatahub.io/dashboard': 'true', + }, + }, + spec: { + builtInAdapter: { + memBufferBytes: 134217728, + modelLoadingTimeoutMillis: 90000, + runtimeManagementPort: 8888, + serverType: 'ovms', + }, + containers: [ + { + args: [ + '--port=8001', + '--rest_port=8888', + '--config_path=/models/model_config_list.json', + '--file_system_poll_wait_seconds=0', + '--grpc_bind_address=127.0.0.1', + '--rest_bind_address=127.0.0.1', + '--target_device=NVIDIA', + ], + image: + 'quay.io/modh/openvino-model-server@sha256:c89f76386bc8b59f0748cf173868e5beef21ac7d2f78dada69089c4d37c44116', + name: 'ovms', + resources: { + limits: { + cpu: '0', + memory: '0Gi', + }, + requests: { + cpu: '0', + memory: '0Gi', + }, + }, + }, + ], + grpcDataEndpoint: 'port:8001', + grpcEndpoint: 'port:8085', + multiModel: true, + protocolVersions: ['grpc-v1'], + replicas: 1, + }, + }, + ], + parameters: [], +}); diff --git a/frontend/src/__tests__/integration/pages/clusterSettings/ClusterSettings.spec.ts b/frontend/src/__tests__/integration/pages/clusterSettings/ClusterSettings.spec.ts index b4b861499a..9bc4523261 100644 --- a/frontend/src/__tests__/integration/pages/clusterSettings/ClusterSettings.spec.ts +++ b/frontend/src/__tests__/integration/pages/clusterSettings/ClusterSettings.spec.ts @@ -6,6 +6,31 @@ test('Cluster settings', async ({ page }) => { // wait for page to load await page.waitForSelector('text=Save changes'); const submitButton = page.locator('[data-id="submit-cluster-settings"]'); + + // check serving platform field + const singlePlatformCheckbox = page.locator( + '[data-id="single-model-serving-platform-enabled-checkbox"]', + ); + const multiPlatformCheckbox = page.locator( + '[data-id="multi-model-serving-platform-enabled-checkbox"]', + ); + const warningAlert = page.locator('[data-id="serving-platform-warning-alert"]'); + await expect(singlePlatformCheckbox).toBeChecked(); + await expect(multiPlatformCheckbox).toBeChecked(); + await expect(submitButton).toBeDisabled(); + await multiPlatformCheckbox.uncheck(); + await expect(warningAlert).toBeVisible(); + expect(warningAlert.getByLabel('Info Alert')).toBeTruthy(); + await expect(submitButton).toBeEnabled(); + await singlePlatformCheckbox.uncheck(); + expect(warningAlert.getByLabel('Warning Alert')).toBeTruthy(); + await multiPlatformCheckbox.check(); + await expect(warningAlert).toBeVisible(); + expect(warningAlert.getByLabel('Info Alert')).toBeTruthy(); + await singlePlatformCheckbox.check(); + await expect(warningAlert).toBeHidden(); + await expect(submitButton).toBeDisabled(); + // check PVC size field const pvcInputField = page.locator('[data-id="pvc-size-input"]'); const pvcHint = page.locator('[data-id="pvc-size-helper-text"]'); diff --git a/frontend/src/__tests__/integration/pages/clusterSettings/ClusterSettings.stories.tsx b/frontend/src/__tests__/integration/pages/clusterSettings/ClusterSettings.stories.tsx index 20326a91f6..56605c1ac1 100644 --- a/frontend/src/__tests__/integration/pages/clusterSettings/ClusterSettings.stories.tsx +++ b/frontend/src/__tests__/integration/pages/clusterSettings/ClusterSettings.stories.tsx @@ -4,6 +4,9 @@ import { rest } from 'msw'; import { within } from '@storybook/testing-library'; import { mockClusterSettings } from '~/__mocks__/mockClusterSettings'; import ClusterSettings from '~/pages/clusterSettings/ClusterSettings'; +import { AreaContext } from '~/concepts/areas/AreaContext'; +import { mockDscStatus } from '~/__mocks__/mockDscStatus'; +import { StackComponent } from '~/concepts/areas'; export default { component: ClusterSettings, @@ -18,7 +21,17 @@ export default { }, } as Meta; -const Template: StoryFn = (args) => ; +const Template: StoryFn = (args) => ( + + + +); export const Default: StoryObj = { render: Template, diff --git a/frontend/src/__tests__/integration/pages/customServingRuntimes/CustomServingRuntimes.spec.ts b/frontend/src/__tests__/integration/pages/customServingRuntimes/CustomServingRuntimes.spec.ts new file mode 100644 index 0000000000..54da7e2a13 --- /dev/null +++ b/frontend/src/__tests__/integration/pages/customServingRuntimes/CustomServingRuntimes.spec.ts @@ -0,0 +1,26 @@ +import { test, expect } from '@playwright/test'; +import { navigateToStory } from '~/__tests__/integration/utils'; + +test('Custom serving runtimes', async ({ page }) => { + await page.goto(navigateToStory('pages-customservingruntimes-customservingruntimes', 'default')); + // wait for page to load + await page.waitForSelector('text=Serving runtimes'); + + // check the platform setting labels in the header + await expect(page.getByText('Single model serving enabled')).toBeVisible(); + await expect(page.getByText('Multi-model serving enabled')).toBeHidden(); + + // check the platform labels in the table row + await expect(page.locator('#template-1').getByLabel('Label group category')).toHaveText( + 'Single modelMulti-model', + ); + await expect(page.locator('#template-2').getByLabel('Label group category')).toHaveText( + 'Single model', + ); + await expect(page.locator('#template-3').getByLabel('Label group category')).toHaveText( + 'Multi-model', + ); + await expect(page.locator('#template-4').getByLabel('Label group category')).toHaveText( + 'Multi-model', + ); +}); diff --git a/frontend/src/__tests__/integration/pages/customServingRuntimes/CustomServingRuntimes.stories.tsx b/frontend/src/__tests__/integration/pages/customServingRuntimes/CustomServingRuntimes.stories.tsx new file mode 100644 index 0000000000..ec1caa50aa --- /dev/null +++ b/frontend/src/__tests__/integration/pages/customServingRuntimes/CustomServingRuntimes.stories.tsx @@ -0,0 +1,80 @@ +import React from 'react'; + +import { StoryFn, Meta, StoryObj } from '@storybook/react'; +import { rest } from 'msw'; +import { within } from '@storybook/testing-library'; +import { Route, Routes } from 'react-router-dom'; +import { mockK8sResourceList } from '~/__mocks__/mockK8sResourceList'; +import CustomServingRuntimeView from '~/pages/modelServing/customServingRuntimes/CustomServingRuntimeView'; +import { mockServingRuntimeTemplateK8sResource } from '~/__mocks__/mockServingRuntimeTemplateK8sResource'; +import CustomServingRuntimeContextProvider from '~/pages/modelServing/customServingRuntimes/CustomServingRuntimeContext'; +import { mockStatus } from '~/__mocks__/mockStatus'; +import { mockProjectK8sResource } from '~/__mocks__/mockProjectK8sResource'; +import useDetectUser from '~/utilities/useDetectUser'; +import { mockDashboardConfig } from '~/__mocks__/mockDashboardConfig'; +import { ServingRuntimePlatform } from '~/types'; + +export default { + component: CustomServingRuntimeView, + parameters: { + msw: { + handlers: [ + rest.get('/api/status', (req, res, ctx) => res(ctx.json(mockStatus()))), + rest.get('/api/templates/opendatahub', (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: 'OVMS', + platforms: [ServingRuntimePlatform.MULTI], + }), + mockServingRuntimeTemplateK8sResource({ + name: 'template-4', + displayName: 'Serving Runtime with No Annotations', + }), + ]), + ), + ), + ), + rest.get('/api/k8s/apis/project.openshift.io/v1/projects', (req, res, ctx) => + res(ctx.json(mockK8sResourceList([mockProjectK8sResource({})]))), + ), + rest.get('/api/dashboardConfig/opendatahub/odh-dashboard-config', (req, res, ctx) => + res(ctx.json(mockDashboardConfig({}))), + ), + ], + }, + }, +} as Meta; + +const Template: StoryFn = (args) => { + useDetectUser(); + return ( + + }> + } /> + + + ); +}; + +export const Default: StoryObj = { + render: Template, + + play: async ({ canvasElement }) => { + // load page and wait until settled + const canvas = within(canvasElement); + await canvas.findByText('Serving runtimes', undefined, { timeout: 5000 }); + }, +}; diff --git a/frontend/src/__tests__/integration/pages/modelServing/ModelServingGlobal.spec.ts b/frontend/src/__tests__/integration/pages/modelServing/ModelServingGlobal.spec.ts index b79b6d0605..12fd9cc979 100644 --- a/frontend/src/__tests__/integration/pages/modelServing/ModelServingGlobal.spec.ts +++ b/frontend/src/__tests__/integration/pages/modelServing/ModelServingGlobal.spec.ts @@ -10,12 +10,12 @@ test('Empty State No Serving Runtime', async ({ page }) => { await page.waitForSelector('text=No deployed models yet'); // Test that the button is enabled - await expect(page.getByRole('button', { name: 'Go to the Projects page' })).toBeTruthy(); + await expect(page.getByRole('button', { name: 'Go to Test Project' })).toBeTruthy(); }); test('Empty State No Inference Service', async ({ page }) => { await page.goto( - navigateToStory('pages-modelserving-modelservingglobal', 'empty-state-no-inference-service'), + navigateToStory('pages-modelserving-modelservingglobal', 'empty-state-no-inference-services'), ); // wait for page to load @@ -25,7 +25,7 @@ test('Empty State No Inference Service', async ({ page }) => { 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(); + await expect(await page.getByRole('button', { name: 'Deploy', exact: true })).toBeDisabled(); }); test('Delete model', async ({ page }) => { @@ -48,7 +48,7 @@ test('Edit model', async ({ page }) => { await page.goto(navigateToStory('pages-modelserving-modelservingglobal', 'edit-model')); // wait for page to load - await page.waitForSelector('text=Deploy model'); + await page.waitForSelector('text=Deployed models'); // test that you can not submit on empty await await page.getByLabel('Model Name *').fill(''); @@ -75,88 +75,90 @@ test('Edit model', async ({ 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(page.getByRole('button', { name: 'Deploy', exact: true })).toBeEnabled(); }); test('Create model', async ({ page }) => { - await page.goto(navigateToStory('pages-modelserving-modelservingglobal', 'deploy-model')); + 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' })).toBeDisabled(); + await expect(await page.getByRole('button', { name: 'Deploy', exact: true })).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 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' })).toBeEnabled(); + 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' })).toBeDisabled(); + await expect(await page.getByRole('button', { name: 'Deploy', exact: true })).toBeDisabled(); await page.getByLabel('Path').fill('/'); - await expect(await page.getByRole('button', { name: 'Deploy' })).toBeDisabled(); + 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' })).toBeEnabled(); + 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')); + 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' })).toBeDisabled(); + await expect(await page.getByRole('button', { name: 'Deploy', exact: true })).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 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' })).toBeEnabled(); + 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' })).toBeEnabled(); + await expect(await page.getByRole('button', { name: 'Deploy', exact: true })).toBeEnabled(); // Submit and check the invalid error message - await page.getByRole('button', { name: 'Deploy' }).click(); + 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' }).click(); + await page.getByRole('button', { name: 'Cancel', exact: true }).click(); // Check that the error message is gone - await page.getByRole('button', { name: 'Deploy model' }).click(); + 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 348d608ea5..a0598e3e0e 100644 --- a/frontend/src/__tests__/integration/pages/modelServing/ModelServingGlobal.stories.tsx +++ b/frontend/src/__tests__/integration/pages/modelServing/ModelServingGlobal.stories.tsx @@ -3,8 +3,9 @@ import React from 'react'; import { StoryFn, Meta, StoryObj } from '@storybook/react'; import { rest } from 'msw'; import { within, userEvent } from '@storybook/testing-library'; -import { expect } from '@storybook/jest'; +// 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,179 @@ import { import { mockSecretK8sResource } from '~/__mocks__/mockSecretK8sResource'; import ModelServingContextProvider from '~/pages/modelServing/ModelServingContext'; import ModelServingGlobal from '~/pages/modelServing/screens/global/ModelServingGlobal'; +import { AreaContext } from '~/concepts/areas/AreaContext'; +import { mockDscStatus } from '~/__mocks__/mockDscStatus'; +import { StackComponent } from '~/concepts/areas'; +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 +198,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 +214,9 @@ export const EditModel: StoryObj = { // need to select modal as root element: '.pf-c-backdrop', }, + msw: { + handlers: getHandlers({}), + }, }, play: async ({ canvasElement }) => { @@ -136,6 +237,9 @@ export const DeleteModel: StoryObj = { a11y: { element: '.pf-c-backdrop', }, + msw: { + handlers: getHandlers({}), + }, }, play: async ({ canvasElement }) => { @@ -149,7 +253,7 @@ export const DeleteModel: StoryObj = { }, }; -export const DeployModel: StoryObj = { +export const DeployModelModelMesh: StoryObj = { render: Template, parameters: { @@ -157,6 +261,11 @@ export const DeployModel: StoryObj = { // need to select modal as root element: '.pf-c-backdrop', }, + msw: { + handlers: getHandlers({ + projectEnableModelMesh: true, + }), + }, }, play: async ({ canvasElement }) => { @@ -166,14 +275,30 @@ export const DeployModel: StoryObj = { // 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' }); +export const DeployModelModelKServe: StoryObj = { + render: Template, - // test that you can not submit on empty - await userEvent.clear(nameInput); - expect(updateButton).toBeDisabled(); + 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' })); }, }; diff --git a/frontend/src/__tests__/integration/pages/modelServing/ServingRuntimeList.spec.ts b/frontend/src/__tests__/integration/pages/modelServing/ServingRuntimeList.spec.ts index 6c0afbd519..dbf3ae9e50 100644 --- a/frontend/src/__tests__/integration/pages/modelServing/ServingRuntimeList.spec.ts +++ b/frontend/src/__tests__/integration/pages/modelServing/ServingRuntimeList.spec.ts @@ -1,12 +1,20 @@ import { test, expect } from '@playwright/test'; import { navigateToStory } from '~/__tests__/integration/utils'; -test('Deploy model', async ({ page }) => { - await page.goto(navigateToStory('pages-modelserving-servingruntimelist', 'deploy-model')); +test('Deploy ModelMesh model', async ({ page }) => { + await page.goto( + navigateToStory('pages-modelserving-servingruntimelist', 'model-mesh-list-available-models'), + ); // wait for page to load await page.waitForSelector('text=Deploy model'); + await page + .getByRole('rowgroup') + .filter({ has: page.getByRole('button', { name: 'ovms', exact: true }) }) + .getByText('Deploy model') + .click(); + // test that you can not submit on empty await expect(await page.getByRole('button', { name: 'Deploy', exact: true })).toBeDisabled(); @@ -31,17 +39,74 @@ test('Deploy model', async ({ 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('Legacy Serving Runtime', async ({ page }) => { +test('Deploy KServe model', async ({ page }) => { await page.goto( - navigateToStory('pages-modelserving-servingruntimelist', 'list-available-models'), + navigateToStory( + 'pages-modelserving-servingruntimelist', + 'both-platform-enabled-and-project-not-labelled', + ), ); // wait for page to load - await page.waitForSelector('text=Add server'); + await page.waitForSelector('text=Deploy model'); + + await page.getByRole('button', { name: 'Deploy model', exact: true }).click(); + + // 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('#serving-runtime-template-selection').click(); + await page.getByRole('menuitem', { name: 'Caikit' }).click(); + await expect(page.getByRole('menuitem', { name: 'New OVMS Server Invalid' })).toBeHidden(); + 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.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('No model serving platform available', async ({ page }) => { + await page.goto( + navigateToStory( + 'pages-modelserving-servingruntimelist', + 'neither-platform-enabled-and-project-not-labelled', + ), + ); + + expect(page.getByText('No model serving platform selected')).toBeTruthy(); +}); + +test('ModelMesh ServingRuntime list', async ({ page }) => { + await page.goto( + navigateToStory('pages-modelserving-servingruntimelist', 'model-mesh-list-available-models'), + ); + + // wait for page to load + await page.waitForSelector('text=Add model server'); // Check that the legacy serving runtime is shown with the default runtime name expect(page.getByText('ovms')).toBeTruthy(); @@ -75,18 +140,155 @@ test('Legacy Serving Runtime', async ({ page }) => { await expect(secondRow).not.toHaveClass('pf-m-expanded'); }); -test('Add model server', async ({ page }) => { - await page.goto(navigateToStory('pages-modelserving-servingruntimelist', 'add-server')); +test('KServe Model list', async ({ page }) => { + await page.goto( + navigateToStory('pages-modelserving-servingruntimelist', 'kserve-list-available-models'), + ); // wait for page to load - await page.waitForSelector('text=Add server'); + await page.waitForSelector('text=Deploy model'); + + // Check that we get the correct model name + expect(page.getByText('Test Inference Service')).toBeTruthy(); + + // Check that the serving runtime displays the correct Serving Runtime + expect(page.getByText('OpenVINO Serving Runtime (Supports GPUs)')).toBeTruthy(); + + // Check for resource marked for deletion + expect(page.getByText('Another Inference Service')).toBeTruthy(); + const actionButton = page + .getByRole('row') + .first() + .getByRole('button', { name: 'This resource is marked for deletion.' }); + expect(actionButton).toBeTruthy(); +}); + +test('Add ModelMesh model server', async ({ page }) => { + await page.goto( + navigateToStory('pages-modelserving-servingruntimelist', 'model-mesh-list-available-model'), + ); + + // wait for page to load + await page.waitForSelector('text=Add model server'); + + await page.getByRole('button', { name: 'Add model server', exact: true }).click(); // test that you can not submit on empty - await expect(await page.getByRole('button', { name: 'Add', exact: true })).toBeDisabled(); + await expect(page.getByRole('button', { name: 'Add', exact: true })).toBeDisabled(); // test filling in minimum required fields - await page.getByLabel('Model server name *').fill('Test Server Name'); + await page.getByLabel('Model server name *').fill('Test Name'); await page.locator('#serving-runtime-template-selection').click(); - await page.getByText('New OVMS Server').click(); - await expect(await page.getByRole('button', { name: 'Add', exact: true })).toBeEnabled(); + await page.getByRole('menuitem', { name: 'New OVMS Server' }).click(); + await expect(page.getByRole('menuitem', { name: 'New OVMS Server Invalid' })).toBeHidden(); + await expect(page.getByRole('button', { name: 'Add', exact: true })).toBeEnabled(); + + //test Add model server tooltips + const expectedContent = [ + { + ariaLabel: 'Model server replicas info', + content: + 'Consider network traffic and failover scenarios when specifying the number of model server replicas.', + }, + { + ariaLabel: 'Model server size info', + content: + 'Select a server size that will accommodate your largest model. See the product documentation for more information.', + }, + { + ariaLabel: 'Accelerator info', + content: + 'Ensure that appropriate tolerations are in place before adding an accelerator to your model server.', + }, + ]; + + for (const item of expectedContent) { + const iconPopover = await page.getByRole('button', { name: item.ariaLabel, exact: true }); + if (await iconPopover.isVisible()) { + await iconPopover.click(); + const popoverContent = await page.locator('div.pf-c-popover__content').textContent(); + expect(popoverContent).toContain(item.content); + + const closeButton = await page.locator('div.pf-c-popover__content>button'); + if (closeButton) { + closeButton.click(); + } + } + } + // test the if the alert is visible when route is external while token is not set + await expect(page.locator('#alt-form-checkbox-route')).not.toBeChecked(); + await expect(page.locator('#alt-form-checkbox-auth')).not.toBeChecked(); + await expect(page.locator('#external-route-no-token-alert')).toBeHidden(); + // check external route, token should be checked and no alert + await page.locator('#alt-form-checkbox-route').check(); + await expect(page.locator('#alt-form-checkbox-auth')).toBeChecked(); + await expect(page.locator('#external-route-no-token-alert')).toBeHidden(); + await expect(page.locator('#service-account-form-name')).toBeVisible(); + await expect(page.locator('#service-account-form-name')).toHaveValue('default-name'); + // check external route, uncheck token, show alert + await page.locator('#alt-form-checkbox-auth').uncheck(); + await expect(page.locator('#external-route-no-token-alert')).toBeVisible(); + // internal route, set token, no alert + await page.locator('#alt-form-checkbox-route').uncheck(); + await page.locator('#alt-form-checkbox-auth').check(); + await expect(page.locator('#external-route-no-token-alert')).toBeHidden(); +}); + +test('Edit ModelMesh model server', async ({ page }) => { + await page.goto( + navigateToStory('pages-modelserving-servingruntimelist', 'model-mesh-list-available-model'), + ); + + // wait for page to load + await page.waitForSelector('text=Add model server'); + + // click on the toggle button and open edit model server + await page + .getByRole('rowgroup') + .filter({ has: page.getByRole('button', { name: 'ovms', exact: true }) }) + .getByLabel('Actions') + .click(); + await page.getByText('Edit model server').click(); + + const updateButton = page.getByRole('button', { name: 'Update', exact: true }); + + // test name field + await expect(updateButton).toBeDisabled(); + await page.locator('#serving-runtime-name-input').fill('New name'); + await expect(updateButton).toBeEnabled(); + await page.locator('#serving-runtime-name-input').fill('test-model-legacy'); + await expect(updateButton).toBeDisabled(); + + // test replicas field + await page.getByRole('button', { name: 'Plus', exact: true }).click(); + await expect(updateButton).toBeEnabled(); + await page.getByRole('button', { name: 'Minus', exact: true }).click(); + await expect(updateButton).toBeDisabled(); + + // test size field + await page + .getByRole('group', { name: 'Compute resources per replica' }) + .getByRole('button', { name: 'Options menu' }) + .click(); + await page.getByRole('option', { name: 'Medium' }).click(); + await expect(updateButton).toBeEnabled(); + await page + .getByRole('group', { name: 'Compute resources per replica' }) + .getByRole('button', { name: 'Options menu' }) + .click(); + await page.getByRole('option', { name: 'Small' }).click(); + await expect(updateButton).toBeDisabled(); + + // test external route field + await page.locator('#alt-form-checkbox-route').check(); + await expect(updateButton).toBeEnabled(); + await page.locator('#alt-form-checkbox-route').uncheck(); + await page.locator('#alt-form-checkbox-auth').uncheck(); + await expect(updateButton).toBeDisabled(); + + // test tokens field + await page.locator('#alt-form-checkbox-auth').check(); + await expect(updateButton).toBeEnabled(); + await page.locator('#alt-form-checkbox-auth').uncheck(); + await expect(updateButton).toBeDisabled(); }); diff --git a/frontend/src/__tests__/integration/pages/modelServing/ServingRuntimeList.stories.tsx b/frontend/src/__tests__/integration/pages/modelServing/ServingRuntimeList.stories.tsx index 45fdf5b94a..0757b2fc1d 100644 --- a/frontend/src/__tests__/integration/pages/modelServing/ServingRuntimeList.stories.tsx +++ b/frontend/src/__tests__/integration/pages/modelServing/ServingRuntimeList.stories.tsx @@ -2,8 +2,8 @@ import React from 'react'; import { StoryFn, Meta, StoryObj } from '@storybook/react'; import { rest } from 'msw'; -import { userEvent, within } from '@storybook/testing-library'; import { Route } from 'react-router-dom'; +import { Spinner } from '@patternfly/react-core'; import { mockRouteK8sResource, mockRouteK8sResourceModelServing, @@ -20,146 +20,281 @@ import { import { mockProjectK8sResource } from '~/__mocks__/mockProjectK8sResource'; import { mockPVCK8sResource } from '~/__mocks__/mockPVCK8sResource'; import ProjectsRoutes from '~/concepts/projects/ProjectsRoutes'; -import { mockTemplateK8sResource } from '~/__mocks__/mockServingRuntimeTemplateK8sResource'; +import { + mockInvalidTemplateK8sResource, + mockServingRuntimeTemplateK8sResource, +} from '~/__mocks__/mockServingRuntimeTemplateK8sResource'; import { mockDashboardConfig } from '~/__mocks__/mockDashboardConfig'; import { mockStatus } from '~/__mocks__/mockStatus'; -import useDetectUser from '~/utilities/useDetectUser'; -import { fetchDashboardConfig } from '~/services/dashboardConfigService'; -import ServingRuntimeList from '~/pages/modelServing/screens/projects/ServingRuntimeList'; +import ModelServingPlatform from '~/pages/modelServing/screens/projects/ModelServingPlatform'; import { mockInferenceServiceK8sResource } from '~/__mocks__/mockInferenceServiceK8sResource'; +import useDetectUser from '~/utilities/useDetectUser'; +import { AppContext } from '~/app/AppContext'; +import { useApplicationSettings } from '~/app/useApplicationSettings'; +import { InferenceServiceKind, ServingRuntimeKind } from '~/k8sTypes'; +import { ServingRuntimePlatform } from '~/types'; +import { AreaContext } from '~/concepts/areas/AreaContext'; +import { mockDscStatus } from '~/__mocks__/mockDscStatus'; +import { StackComponent } from '~/concepts/areas'; + +type HandlersProps = { + disableKServeConfig?: boolean; + disableModelMeshConfig?: boolean; + projectEnableModelMesh?: boolean; + servingRuntimes?: ServingRuntimeKind[]; + inferenceServices?: InferenceServiceKind[]; +}; + +const getHandlers = ({ + disableKServeConfig, + disableModelMeshConfig, + projectEnableModelMesh, + servingRuntimes = [ + mockServingRuntimeK8sResourceLegacy({}), + mockServingRuntimeK8sResource({ + name: 'test-model', + namespace: 'test-project', + auth: true, + route: true, + }), + ], + inferenceServices = [ + mockInferenceServiceK8sResource({ name: 'test-inference' }), + mockInferenceServiceK8sResource({ + name: 'another-inference-service', + displayName: 'Another Inference Service', + deleted: true, + }), + mockInferenceServiceK8sResource({ + name: 'llama-caikit', + displayName: 'Llama Caikit', + url: 'http://llama-caikit.test-project.svc.cluster.local', + activeModelState: 'Loaded', + }), + ], +}: 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/api/v1/namespaces/test-project/pods', (req, res, ctx) => + res(ctx.json(mockK8sResourceList([mockPodK8sResource({})]))), + ), + rest.get( + '/api/k8s/apis/route.openshift.io/v1/namespaces/test-project/routes/test-notebook', + (req, res, ctx) => res(ctx.json(mockRouteK8sResource({}))), + ), + rest.get('/api/k8s/apis/kubeflow.org/v1/namespaces/test-project/notebooks', (req, res, ctx) => + res(ctx.json(mockK8sResourceList([mockNotebookK8sResource({})]))), + ), + rest.get('/api/k8s/apis/project.openshift.io/v1/projects', (req, res, ctx) => + res( + ctx.json( + mockK8sResourceList([mockProjectK8sResource({ enableModelMesh: projectEnableModelMesh })]), + ), + ), + ), + rest.get('/api/k8s/api/v1/namespaces/test-project/persistentvolumeclaims', (req, res, ctx) => + res(ctx.json(mockK8sResourceList([mockPVCK8sResource({})]))), + ), + rest.get('/api/k8s/apis/project.openshift.io/v1/projects/test-project', (req, res, ctx) => + res(ctx.json(mockProjectK8sResource({ enableModelMesh: projectEnableModelMesh }))), + ), + 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/test-project/servingruntimes', + (req, res, ctx) => res(ctx.json(mockK8sResourceList(servingRuntimes))), + ), + rest.get( + '/api/k8s/apis/route.openshift.io/v1/namespaces/test-project/routes/test-inference', + (req, res, ctx) => + res( + ctx.json( + mockRouteK8sResourceModelServing({ + inferenceServiceName: 'test-inference', + namespace: 'test-project', + }), + ), + ), + ), + rest.get( + '/api/k8s/apis/route.openshift.io/v1/namespaces/test-project/routes/another-inference-service', + (req, res, ctx) => + res( + ctx.json( + mockRouteK8sResourceModelServing({ + inferenceServiceName: 'another-inference-service', + namespace: 'test-project', + }), + ), + ), + ), + 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/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: ServingRuntimeList, + component: ModelServingPlatform, parameters: { reactRouter: { routePath: '/projects/:namespace/*', routeParams: { namespace: 'test-project' }, }, - msw: { - handlers: [ - rest.get('/api/status', (req, res, ctx) => res(ctx.json(mockStatus()))), - rest.get('/api/config', (req, res, ctx) => res(ctx.json(mockDashboardConfig))), - rest.get('/api/k8s/api/v1/namespaces/test-project/pods', (req, res, ctx) => - res(ctx.json(mockK8sResourceList([mockPodK8sResource({})]))), - ), - rest.get( - '/api/k8s/apis/route.openshift.io/v1/namespaces/test-project/routes/test-notebook', - (req, res, ctx) => res(ctx.json(mockRouteK8sResource({}))), - ), - rest.get( - '/api/k8s/apis/kubeflow.org/v1/namespaces/test-project/notebooks', - (req, res, ctx) => res(ctx.json(mockK8sResourceList([mockNotebookK8sResource({})]))), - ), - rest.get('/api/k8s/apis/project.openshift.io/v1/projects', (req, res, ctx) => - res(ctx.json(mockK8sResourceList([mockProjectK8sResource({})]))), - ), - rest.get( - '/api/k8s/api/v1/namespaces/test-project/persistentvolumeclaims', - (req, res, ctx) => res(ctx.json(mockK8sResourceList([mockPVCK8sResource({})]))), - ), - rest.get('/api/k8s/apis/project.openshift.io/v1/projects/test-project', (req, res, ctx) => - res(ctx.json(mockProjectK8sResource({}))), - ), - rest.get( - 'api/k8s/apis/serving.kserve.io/v1beta1/namespaces/test-project/inferenceservices', - (req, res, ctx) => - res( - ctx.json( - mockK8sResourceList([mockInferenceServiceK8sResource({ name: 'test-inference' })]), - ), - ), - ), - 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', - (req, res, ctx) => - res( - ctx.json( - mockK8sResourceList([ - mockServingRuntimeK8sResourceLegacy({}), - mockServingRuntimeK8sResource({ - name: 'test-model', - namespace: 'test-project', - auth: true, - route: true, - }), - ]), - ), - ), - ), - rest.get( - '/api/k8s/apis/route.openshift.io/v1/namespaces/test-project/routes/test-inference', - (req, res, ctx) => - res( - ctx.json( - mockRouteK8sResourceModelServing({ - inferenceServiceName: 'test-inference', - namespace: 'test-project', - }), - ), - ), - ), - 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/template.openshift.io/v1/namespaces/opendatahub/templates', - (req, res, ctx) => res(ctx.json(mockK8sResourceList([mockTemplateK8sResource({})]))), - ), - rest.get( - '/api/k8s/apis/opendatahub.io/v1alpha/namespaces/opendatahub/odhdashboardconfigs/odh-dashboard-config', - (req, res, ctx) => res(ctx.json(mockDashboardConfig)), - ), - ], - }, }, -} as Meta; +} as Meta; -const Template: StoryFn = (args) => { - fetchDashboardConfig(); +const Template: StoryFn = (args) => { useDetectUser(); - return ( - - }> - } /> - - + const { dashboardConfig, loaded } = useApplicationSettings(); + return loaded && dashboardConfig ? ( + + + + }> + } /> + + + + + ) : ( + ); }; -export const ListAvailableModels: StoryObj = { +export const BothPlatformEnabledAndProjectNotLabelled: StoryObj = { render: Template, - play: async ({ canvasElement }) => { - // load page and wait until settled - const canvas = within(canvasElement); - await canvas.findByText('ovms', undefined, { timeout: 5000 }); - await canvas.findByText('OVMS Model Serving', undefined, { timeout: 5000 }); + parameters: { + msw: { + handlers: getHandlers({ + disableModelMeshConfig: false, + disableKServeConfig: false, + servingRuntimes: [], + }), + }, + }, +}; + +export const OnlyEnabledModelMeshAndProjectNotLabelled: StoryObj = { + render: Template, + + parameters: { + msw: { + handlers: getHandlers({ + disableModelMeshConfig: false, + disableKServeConfig: true, + servingRuntimes: [], + }), + }, }, }; -export const DeployModel: StoryObj = { +export const NeitherPlatformEnabledAndProjectNotLabelled: StoryObj = { render: Template, - play: async ({ canvasElement }) => { - // load page and wait until settled - const canvas = within(canvasElement); - await canvas.findByText('ovms', undefined, { timeout: 5000 }); + parameters: { + msw: { + handlers: getHandlers({ + disableModelMeshConfig: false, + disableKServeConfig: false, + servingRuntimes: [], + }), + }, + }, +}; + +export const ModelMeshListAvailableModels: StoryObj = { + render: Template, - await userEvent.click(canvas.getAllByText('Deploy model', { selector: 'button' })[0]); + parameters: { + msw: { + handlers: getHandlers({ + projectEnableModelMesh: true, + disableKServeConfig: false, + disableModelMeshConfig: true, + inferenceServices: [ + mockInferenceServiceK8sResource({ name: 'test-inference', isModelMesh: true }), + mockInferenceServiceK8sResource({ + name: 'another-inference-service', + displayName: 'Another Inference Service', + deleted: true, + isModelMesh: true, + }), + mockInferenceServiceK8sResource({ + name: 'ovms-testing', + displayName: 'OVMS ONNX', + isModelMesh: true, + }), + ], + }), + }, }, }; -export const AddServer: StoryObj = { +export const KserveListAvailableModels: StoryObj = { render: Template, - play: async ({ canvasElement }) => { - // load page and wait until settled - const canvas = within(canvasElement); - await canvas.findByText('ovms', undefined, { timeout: 5000 }); - await userEvent.click(canvas.getByText('Add server', { selector: 'button' })); + parameters: { + msw: { + handlers: getHandlers({ + projectEnableModelMesh: false, + disableKServeConfig: false, + disableModelMeshConfig: false, + }), + }, }, }; diff --git a/frontend/src/__tests__/integration/pages/projects/ProjectDetails.spec.ts b/frontend/src/__tests__/integration/pages/projects/ProjectDetails.spec.ts index bfd39340dc..2f374c7b1f 100644 --- a/frontend/src/__tests__/integration/pages/projects/ProjectDetails.spec.ts +++ b/frontend/src/__tests__/integration/pages/projects/ProjectDetails.spec.ts @@ -5,7 +5,7 @@ test('Empty project', async ({ page }) => { await page.goto(navigateToStory('pages-projects-projectdetails', 'empty-details-page')); // wait for page to load - await page.waitForSelector('text=No model servers'); + await page.waitForSelector('text=Models and model servers'); // the dividers number should always 1 less than the section number const sections = await page.locator('[data-id="details-page-section"]').all(); diff --git a/frontend/src/__tests__/integration/pages/projects/ProjectDetails.stories.tsx b/frontend/src/__tests__/integration/pages/projects/ProjectDetails.stories.tsx index 1c0d4cf8e7..61c3d5a485 100644 --- a/frontend/src/__tests__/integration/pages/projects/ProjectDetails.stories.tsx +++ b/frontend/src/__tests__/integration/pages/projects/ProjectDetails.stories.tsx @@ -1,8 +1,8 @@ import React from 'react'; import { StoryFn, Meta, StoryObj } from '@storybook/react'; import { DefaultBodyType, MockedRequest, rest, RestHandler } from 'msw'; -import { within } from '@storybook/testing-library'; import { Route } from 'react-router-dom'; +import { within } from '@testing-library/react'; import { mockRouteK8sResource, mockRouteK8sResourceModelServing, @@ -22,9 +22,12 @@ import { mockPVCK8sResource } from '~/__mocks__/mockPVCK8sResource'; import useDetectUser from '~/utilities/useDetectUser'; import ProjectsRoutes from '~/concepts/projects/ProjectsRoutes'; import { mockStatus } from '~/__mocks__/mockStatus'; -import { mockTemplateK8sResource } from '~/__mocks__/mockServingRuntimeTemplateK8sResource'; +import { mockServingRuntimeTemplateK8sResource } from '~/__mocks__/mockServingRuntimeTemplateK8sResource'; import { mockDashboardConfig } from '~/__mocks__/mockDashboardConfig'; import ProjectDetails from '~/pages/projects/screens/detail/ProjectDetails'; +import { AreaContext } from '~/concepts/areas/AreaContext'; +import { mockDscStatus } from '~/__mocks__/mockDscStatus'; +import { StackComponent } from '~/concepts/areas'; import { mockImageStreamK8sResource } from '~/__mocks__/mockImageStreamK8sResource'; const handlers = (isEmpty: boolean): RestHandler>[] => [ @@ -105,7 +108,7 @@ const handlers = (isEmpty: boolean): RestHandler> ), ), rest.get('/api/k8s/apis/project.openshift.io/v1/projects', (req, res, ctx) => - res(ctx.json(mockK8sResourceList([mockProjectK8sResource({})]))), + res(ctx.json(mockK8sResourceList([mockProjectK8sResource({ enableModelMesh: true })]))), ), rest.get('/api/k8s/api/v1/namespaces/test-project/persistentvolumeclaims', (req, res, ctx) => res(ctx.json(mockK8sResourceList(isEmpty ? [] : [mockPVCK8sResource({})]))), @@ -163,11 +166,12 @@ const handlers = (isEmpty: boolean): RestHandler> ), rest.get( '/api/k8s/apis/template.openshift.io/v1/namespaces/opendatahub/templates', - (req, res, ctx) => res(ctx.json(mockK8sResourceList([mockTemplateK8sResource({})]))), + (req, res, ctx) => + res(ctx.json(mockK8sResourceList([mockServingRuntimeTemplateK8sResource({})]))), ), rest.get( '/api/k8s/apis/opendatahub.io/v1alpha/namespaces/opendatahub/odhdashboardconfigs/odh-dashboard-config', - (req, res, ctx) => res(ctx.json(mockDashboardConfig)), + (req, res, ctx) => res(ctx.json(mockDashboardConfig({}))), ), ]; @@ -187,21 +191,28 @@ export default { const Template: StoryFn = (args) => { useDetectUser(); return ( - - }> - } /> - - + + + }> + } /> + + + ); }; export const Default: StoryObj = { render: Template, - play: async ({ canvasElement }) => { - // load page and wait until settled - const canvas = within(canvasElement); - await canvas.findByText('Test Notebook', undefined, { timeout: 5000 }); - }, }; export const EmptyDetailsPage: StoryObj = { @@ -212,12 +223,6 @@ export const EmptyDetailsPage: StoryObj = { handlers: handlers(true), }, }, - - play: async ({ canvasElement }) => { - // load page and wait until settled - const canvas = within(canvasElement); - await canvas.findByText('No model servers', undefined, { timeout: 5000 }); - }, }; export const DisabledImage: StoryObj = { diff --git a/frontend/src/api/__tests__/apiMergeUtils.spec.ts b/frontend/src/api/__tests__/apiMergeUtils.spec.ts new file mode 100644 index 0000000000..80ade95be1 --- /dev/null +++ b/frontend/src/api/__tests__/apiMergeUtils.spec.ts @@ -0,0 +1,59 @@ +import { K8sResourceBaseOptions } from '@openshift/dynamic-plugin-sdk-utils'; +import { applyK8sAPIOptions } from '~/api/apiMergeUtils'; +import { ConfigMapModel } from '~/api/models'; + +describe('applyK8sAPIOptions', () => { + const mockBaseOptions: K8sResourceBaseOptions = { model: ConfigMapModel }; + const defaultExpect = { + model: ConfigMapModel, + fetchOptions: { requestInit: {} }, + queryOptions: { queryParams: {} }, + }; + const signal = new AbortController().signal; + + it('should not apply any options', () => { + expect(applyK8sAPIOptions({}, mockBaseOptions)).toStrictEqual(defaultExpect); + }); + + it('should apply dryRun option to payload and query options', () => { + expect( + applyK8sAPIOptions( + { dryRun: true }, + { ...mockBaseOptions, queryOptions: { name: 'test', queryParams: { foo: 'bar' } } }, + ), + ).toStrictEqual({ + ...defaultExpect, + payload: { dryRun: ['All'] }, + queryOptions: { name: 'test', queryParams: { foo: 'bar', dryRun: 'All' } }, + }); + }); + + it('should apply signal to fetch options', () => { + expect( + applyK8sAPIOptions( + { signal: new AbortController().signal }, + { ...mockBaseOptions, fetchOptions: { timeout: 100, requestInit: { pathPrefix: 'test' } } }, + ), + ).toStrictEqual({ + ...defaultExpect, + fetchOptions: { timeout: 100, requestInit: { pathPrefix: 'test', signal } }, + }); + }); + + it('should not override payload with dryRun option', () => { + expect( + applyK8sAPIOptions({ dryRun: true }, { ...mockBaseOptions, payload: 'testing' }), + ).toStrictEqual({ + ...defaultExpect, + payload: 'testing', + queryOptions: { queryParams: { dryRun: 'All' } }, + }); + }); + + it('should include all API Data', () => { + expect(applyK8sAPIOptions({}, { ...mockBaseOptions, foo: 'bar' })).toStrictEqual({ + ...defaultExpect, + foo: 'bar', + }); + }); +}); diff --git a/frontend/src/api/__tests__/inferenceServices.spec.ts b/frontend/src/api/__tests__/inferenceServices.spec.ts new file mode 100644 index 0000000000..40ffce16b2 --- /dev/null +++ b/frontend/src/api/__tests__/inferenceServices.spec.ts @@ -0,0 +1,71 @@ +import { assembleInferenceService } from '~/api/k8s/inferenceServices'; +import { InferenceServiceStorageType } from '~/pages/modelServing/screens/types'; + +global.structuredClone = (val: unknown) => JSON.parse(JSON.stringify(val)); + +describe('assembleInferenceService', () => { + it('should have the right annotations when creating for Kserve', async () => { + const inferenceService = assembleInferenceService({ + name: 'my-inference-service', + project: 'caikit-example', + servingRuntimeName: 'caikit', + storage: { + type: InferenceServiceStorageType.NEW_STORAGE, + path: '/caikit-llama', + dataConnection: 'aws-data-connection', + awsData: [], + }, + format: { + name: 'caikit', + version: '1.0.0', + }, + }); + + expect(inferenceService.metadata.annotations).toBeDefined(); + expect(inferenceService.metadata.annotations?.['serving.kserve.io/deploymentMode']).toBe( + undefined, + ); + expect( + inferenceService.metadata.annotations?.['serving.knative.openshift.io/enablePassthrough'], + ).toBe('true'); + expect(inferenceService.metadata.annotations?.['sidecar.istio.io/inject']).toBe('true'); + expect(inferenceService.metadata.annotations?.['sidecar.istio.io/rewriteAppHTTPProbers']).toBe( + 'true', + ); + }); + + it('should have the right annotations when creating for modelmesh', async () => { + const inferenceService = assembleInferenceService( + { + name: 'my-inference-service', + project: 'caikit-example', + servingRuntimeName: 'caikit', + storage: { + type: InferenceServiceStorageType.NEW_STORAGE, + path: '/caikit-llama', + dataConnection: 'aws-data-connection', + awsData: [], + }, + format: { + name: 'caikit', + version: '1.0.0', + }, + }, + undefined, + undefined, + true, + ); + + expect(inferenceService.metadata.annotations).toBeDefined(); + expect(inferenceService.metadata.annotations?.['serving.kserve.io/deploymentMode']).toBe( + 'ModelMesh', + ); + expect( + inferenceService.metadata.annotations?.['serving.knative.openshift.io/enablePassthrough'], + ).toBe(undefined); + expect(inferenceService.metadata.annotations?.['sidecar.istio.io/inject']).toBe(undefined); + expect(inferenceService.metadata.annotations?.['sidecar.istio.io/rewriteAppHTTPProbers']).toBe( + undefined, + ); + }); +}); diff --git a/frontend/src/api/__tests__/k8sUtils.spec.ts b/frontend/src/api/__tests__/k8sUtils.spec.ts new file mode 100644 index 0000000000..d8b17f76aa --- /dev/null +++ b/frontend/src/api/__tests__/k8sUtils.spec.ts @@ -0,0 +1,57 @@ +import { mockProjectK8sResource } from '~/__mocks__/mockProjectK8sResource'; +import { mockSecretK8sResource } from '~/__mocks__/mockSecretK8sResource'; +import { addOwnerReference } from '~/api/k8sUtils'; + +describe('addOwnerReference', () => { + it('should not add any owner reference for undefined owner', () => { + const resource = mockSecretK8sResource({}); + const target = addOwnerReference(resource, undefined); + expect(target).toBe(resource); + expect(target.metadata?.ownerReferences).toBeUndefined(); + }); + + it('should add owner reference only once', () => { + const resource = mockSecretK8sResource({}); + const owner = mockProjectK8sResource({}); + let target = addOwnerReference(resource, owner); + expect(target).not.toBe(resource); + expect(target).toStrictEqual({ + ...resource, + metadata: { + ...resource.metadata, + ownerReferences: [ + { + uid: owner.metadata.uid, + name: owner.metadata.name, + apiVersion: owner.apiVersion, + kind: owner.kind, + blockOwnerDeletion: false, + }, + ], + }, + }); + target = addOwnerReference(resource, owner); + expect(target.metadata.ownerReferences).toHaveLength(1); + }); + + it('should override blockOwnerDeletion', () => { + const resource = mockSecretK8sResource({}); + const owner = mockProjectK8sResource({}); + const target = addOwnerReference(resource, owner, true); + expect(target).toStrictEqual({ + ...resource, + metadata: { + ...resource.metadata, + ownerReferences: [ + { + uid: owner.metadata.uid, + name: owner.metadata.name, + apiVersion: owner.apiVersion, + kind: owner.kind, + blockOwnerDeletion: true, + }, + ], + }, + }); + }); +}); diff --git a/frontend/src/api/__tests__/servingRuntimes.spec.ts b/frontend/src/api/__tests__/servingRuntimes.spec.ts new file mode 100644 index 0000000000..0b7c5117f1 --- /dev/null +++ b/frontend/src/api/__tests__/servingRuntimes.spec.ts @@ -0,0 +1,100 @@ +import { mockServingRuntimeK8sResource } from '~/__mocks__/mockServingRuntimeK8sResource'; +import { mockServingRuntimeTemplateK8sResource } from '~/__mocks__/mockServingRuntimeTemplateK8sResource'; +import { assembleServingRuntime } from '~/api/k8s/servingRuntimes'; +import { ServingRuntimeKind } from '~/k8sTypes'; + +global.structuredClone = (val: unknown) => JSON.parse(JSON.stringify(val)); + +describe('assembleServingRuntime', () => { + it('should omit enable-xxxx annotations when creating', async () => { + const servingRuntime = assembleServingRuntime( + { + name: 'my-serving-runtime', + servingRuntimeTemplateName: 'ovms', + numReplicas: 2, + modelSize: { name: 'Small', resources: {} }, + tokens: [], + // test false values + externalRoute: false, + tokenAuth: false, + }, + 'test', + mockServingRuntimeTemplateK8sResource({}).objects[0] as ServingRuntimeKind, + false, + false, // isEditing + ); + + expect(servingRuntime.metadata.annotations).toBeDefined(); + expect(servingRuntime.metadata.annotations?.['enable-auth']).toBe(undefined); + expect(servingRuntime.metadata.annotations?.['enable-route']).toBe(undefined); + }); + + it('should remove enable-xxxx annotations when editing', async () => { + const servingRuntime = assembleServingRuntime( + { + name: 'my-serving-runtime', + servingRuntimeTemplateName: 'ovms', + numReplicas: 2, + modelSize: { name: 'Small', resources: {} }, + tokens: [], + // test false values + externalRoute: false, + tokenAuth: false, + }, + 'test', + mockServingRuntimeK8sResource({ auth: true, route: true }), + false, + true, // isEditing + ); + + expect(servingRuntime.metadata.annotations).toBeDefined(); + expect(servingRuntime.metadata.annotations?.['enable-auth']).toBe(undefined); + expect(servingRuntime.metadata.annotations?.['enable-route']).toBe(undefined); + }); + + it('should add enable-xxxx annotations when creating', async () => { + const servingRuntime = assembleServingRuntime( + { + name: 'my-serving-runtime', + servingRuntimeTemplateName: 'ovms', + numReplicas: 2, + modelSize: { name: 'Small', resources: {} }, + tokens: [], + // test true values + externalRoute: true, + tokenAuth: true, + }, + 'test', + mockServingRuntimeTemplateK8sResource({}).objects[0] as ServingRuntimeKind, + false, + false, // isEditing + ); + + expect(servingRuntime.metadata.annotations).toBeDefined(); + expect(servingRuntime.metadata.annotations?.['enable-auth']).toBe('true'); + expect(servingRuntime.metadata.annotations?.['enable-route']).toBe('true'); + }); + + it('should add enable-xxxx annotations when editing', async () => { + const servingRuntime = assembleServingRuntime( + { + name: 'my-serving-runtime', + servingRuntimeTemplateName: 'ovms', + numReplicas: 2, + modelSize: { name: 'Small', resources: {} }, + tokens: [], + // test true values + externalRoute: true, + tokenAuth: true, + }, + 'test', + mockServingRuntimeK8sResource({ auth: false, route: false }), + false, + true, // isEditing + ); + + expect(servingRuntime.metadata.annotations).toBeDefined(); + expect(servingRuntime.metadata.annotations?.['enable-auth']).toBe('true'); + expect(servingRuntime.metadata.annotations?.['enable-route']).toBe('true'); + }); +}); diff --git a/frontend/src/api/apiMergeUtils.ts b/frontend/src/api/apiMergeUtils.ts index c6fbc479e0..d63bae2cc4 100644 --- a/frontend/src/api/apiMergeUtils.ts +++ b/frontend/src/api/apiMergeUtils.ts @@ -1,6 +1,13 @@ -import { K8sResourceBaseOptions, QueryParams } from '@openshift/dynamic-plugin-sdk-utils'; +import { + K8sResourceBaseOptions, + K8sResourceDeleteOptions, + QueryParams, +} from '@openshift/dynamic-plugin-sdk-utils'; import { K8sAPIOptions } from '~/k8sTypes'; +const dryRunPayload = (dryRun?: boolean): Pick => + dryRun ? { payload: { dryRun: ['All'] } } : {}; + const mergeK8sQueryParams = ( opts: K8sAPIOptions = {}, specificOpts: QueryParams = {}, @@ -21,6 +28,7 @@ export const applyK8sAPIOptions = ( opts: K8sAPIOptions = {}, apiData: T, ): T => ({ + ...dryRunPayload(opts.dryRun), ...apiData, queryOptions: { ...apiData.queryOptions, diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 9d8db270cf..0cbed48e87 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -32,3 +32,6 @@ export * from './errorUtils'; // User access review hook export * from './useAccessReview'; + +// Generic K8s utils +export * from './k8sUtils'; diff --git a/frontend/src/api/k8s/inferenceServices.ts b/frontend/src/api/k8s/inferenceServices.ts index 9eaf5854f0..d527973f44 100644 --- a/frontend/src/api/k8s/inferenceServices.ts +++ b/frontend/src/api/k8s/inferenceServices.ts @@ -13,10 +13,11 @@ import { translateDisplayNameForK8s } from '~/pages/projects/utils'; import { applyK8sAPIOptions } from '~/api/apiMergeUtils'; import { getModelServingProjects } from './projects'; -const assembleInferenceService = ( +export const assembleInferenceService = ( data: CreatingInferenceServiceObject, secretKey?: string, editName?: string, + isModelMesh?: boolean, ): InferenceServiceKind => { const { storage, format, servingRuntimeName, project } = data; const name = editName || translateDisplayNameForK8s(data.name); @@ -35,7 +36,13 @@ const assembleInferenceService = ( }, annotations: { 'openshift.io/display-name': data.name.trim(), - 'serving.kserve.io/deploymentMode': 'ModelMesh', + ...(isModelMesh + ? { 'serving.kserve.io/deploymentMode': 'ModelMesh' } + : { + 'serving.knative.openshift.io/enablePassthrough': 'true', + 'sidecar.istio.io/inject': 'true', + 'sidecar.istio.io/rewriteAppHTTPProbers': 'true', + }), }, }, spec: { @@ -107,8 +114,9 @@ export const getInferenceService = ( export const createInferenceService = ( data: CreatingInferenceServiceObject, secretKey?: string, + isModelMesh?: boolean, ): Promise => { - const inferenceService = assembleInferenceService(data, secretKey); + const inferenceService = assembleInferenceService(data, secretKey, undefined, isModelMesh); return k8sCreateResource({ model: InferenceServiceModel, resource: inferenceService, @@ -119,8 +127,14 @@ export const updateInferenceService = ( data: CreatingInferenceServiceObject, existingData: InferenceServiceKind, secretKey?: string, + isModelMesh?: boolean, ): Promise => { - const inferenceService = assembleInferenceService(data, secretKey, existingData.metadata.name); + const inferenceService = assembleInferenceService( + data, + secretKey, + existingData.metadata.name, + isModelMesh, + ); return k8sUpdateResource({ model: InferenceServiceModel, diff --git a/frontend/src/api/k8s/projects.ts b/frontend/src/api/k8s/projects.ts index 6426c79578..2c838e2c67 100644 --- a/frontend/src/api/k8s/projects.ts +++ b/frontend/src/api/k8s/projects.ts @@ -13,6 +13,7 @@ import { ProjectModel } from '~/api/models'; import { translateDisplayNameForK8s } from '~/pages/projects/utils'; import { ODH_PRODUCT_NAME } from '~/utilities/const'; import { LABEL_SELECTOR_DASHBOARD_RESOURCE, LABEL_SELECTOR_MODEL_SERVING_PROJECT } from '~/const'; +import { NamespaceApplicationCase } from '~/pages/projects/types'; import { listServingRuntimes } from './servingRuntimes'; export const getProject = (projectName: string): Promise => @@ -108,12 +109,15 @@ export const getModelServingProjectsAvailable = async (): Promise }), ); -export const addSupportModelMeshProject = (name: string): Promise => - axios(`/api/namespaces/${name}/1`).then((response) => { +export const addSupportServingPlatformProject = ( + name: string, + servingPlatform: NamespaceApplicationCase, +): Promise => + axios(`/api/namespaces/${name}/${servingPlatform}`).then((response) => { const applied = response.data?.applied ?? false; if (!applied) { throw new Error( - `Unable to enable model serving in your project. Ask a ${ODH_PRODUCT_NAME} admin for assistance.`, + `Unable to enable model serving platform in your project. Ask a ${ODH_PRODUCT_NAME} admin for assistance.`, ); } return name; diff --git a/frontend/src/api/k8s/servingRuntimes.ts b/frontend/src/api/k8s/servingRuntimes.ts index f1e757d932..899109086b 100644 --- a/frontend/src/api/k8s/servingRuntimes.ts +++ b/frontend/src/api/k8s/servingRuntimes.ts @@ -7,7 +7,12 @@ import { k8sUpdateResource, } from '@openshift/dynamic-plugin-sdk-utils'; import { ServingRuntimeModel } from '~/api/models'; -import { K8sAPIOptions, ServingContainer, ServingRuntimeKind } from '~/k8sTypes'; +import { + K8sAPIOptions, + ServingContainer, + ServingRuntimeAnnotations, + ServingRuntimeKind, +} from '~/k8sTypes'; import { CreatingServingRuntimeObject } from '~/pages/modelServing/screens/types'; import { ContainerResources } from '~/types'; import { getModelServingRuntimeName } from '~/pages/modelServing/utils'; @@ -17,7 +22,7 @@ import { AcceleratorState } from '~/utilities/useAcceleratorState'; import { getModelServingProjects } from './projects'; import { assemblePodSpecOptions, getshmVolume, getshmVolumeMount } from './utils'; -const assembleServingRuntime = ( +export const assembleServingRuntime = ( data: CreatingServingRuntimeObject, namespace: string, servingRuntime: ServingRuntimeKind, @@ -31,6 +36,21 @@ const assembleServingRuntime = ( : getModelServingRuntimeName(namespace); const updatedServingRuntime = { ...servingRuntime }; + const annotations: ServingRuntimeAnnotations = { + ...updatedServingRuntime.metadata.annotations, + }; + + if (externalRoute) { + annotations['enable-route'] = 'true'; + } else { + delete annotations['enable-route']; + } + if (tokenAuth) { + annotations['enable-auth'] = 'true'; + } else { + delete annotations['enable-auth']; + } + // TODO: Enable GRPC if (!isEditing) { updatedServingRuntime.metadata = { @@ -43,9 +63,7 @@ const assembleServingRuntime = ( 'opendatahub.io/dashboard': 'true', }, annotations: { - ...updatedServingRuntime.metadata.annotations, - 'enable-route': externalRoute ? 'true' : 'false', - 'enable-auth': tokenAuth ? 'true' : 'false', + ...annotations, ...(isCustomServingRuntimesEnabled && { 'openshift.io/display-name': displayName.trim() }), ...(isCustomServingRuntimesEnabled && { 'opendatahub.io/template-name': servingRuntime.metadata.name, @@ -60,9 +78,7 @@ const assembleServingRuntime = ( updatedServingRuntime.metadata = { ...updatedServingRuntime.metadata, annotations: { - ...updatedServingRuntime.metadata.annotations, - 'enable-route': externalRoute ? 'true' : 'false', - 'enable-auth': tokenAuth ? 'true' : 'false', + ...annotations, 'opendatahub.io/accelerator-name': acceleratorState?.accelerator?.metadata.name || '', ...(isCustomServingRuntimesEnabled && { 'openshift.io/display-name': displayName.trim() }), }, diff --git a/frontend/src/api/k8s/templates.ts b/frontend/src/api/k8s/templates.ts index 694ed95d39..1b1aac2501 100644 --- a/frontend/src/api/k8s/templates.ts +++ b/frontend/src/api/k8s/templates.ts @@ -9,10 +9,12 @@ import { ServingRuntimeKind, TemplateKind } from '~/k8sTypes'; import { ServingRuntimeModel, TemplateModel } from '~/api/models'; import { applyK8sAPIOptions } from '~/api/apiMergeUtils'; import { genRandomChars } from '~/utilities/string'; +import { ServingRuntimePlatform } from '~/types'; export const assembleServingRuntimeTemplate = ( body: string, namespace: string, + platforms: ServingRuntimePlatform[], templateName?: string, ): TemplateKind & { objects: ServingRuntimeKind[] } => { const servingRuntime: ServingRuntimeKind = YAML.parse(body); @@ -32,6 +34,9 @@ export const assembleServingRuntimeTemplate = ( labels: { 'opendatahub.io/dashboard': 'true', }, + annotations: { + 'opendatahub.io/modelServingSupport': JSON.stringify(platforms), + }, }, objects: [servingRuntime], parameters: [], @@ -86,9 +91,10 @@ const dryRunServingRuntimeForTemplateCreation = ( export const createServingRuntimeTemplate = async ( body: string, namespace: string, + platforms: ServingRuntimePlatform[], ): Promise => { try { - const template = assembleServingRuntimeTemplate(body, namespace); + const template = assembleServingRuntimeTemplate(body, namespace, platforms); const servingRuntime = template.objects[0]; const servingRuntimeName = servingRuntime.metadata.name; @@ -109,12 +115,14 @@ export const createServingRuntimeTemplate = async ( }; export const updateServingRuntimeTemplate = ( - templateName: string, - servingRuntimeName: string, + existingTemplate: TemplateKind, body: string, namespace: string, + platforms: ServingRuntimePlatform[], ): Promise => { try { + const templateName = existingTemplate.metadata.name; + const servingRuntimeName = existingTemplate.objects[0].metadata.name; const servingRuntime: ServingRuntimeKind = YAML.parse(body); if (!servingRuntime.metadata.name) { throw new Error('Serving runtime name is required.'); @@ -134,6 +142,19 @@ export const updateServingRuntimeTemplate = ( path: '/objects/0', value: servingRuntime, }, + existingTemplate.metadata.annotations + ? { + op: 'replace', + path: '/metadata/annotations/opendatahub.io~1modelServingSupport', + value: JSON.stringify(platforms), + } + : { + op: 'add', + path: '/metadata/annotations', + value: { + 'opendatahub.io/modelServingSupport': JSON.stringify(platforms), + }, + }, ], }), ); diff --git a/frontend/src/api/k8sUtils.ts b/frontend/src/api/k8sUtils.ts new file mode 100644 index 0000000000..0a97eda66a --- /dev/null +++ b/frontend/src/api/k8sUtils.ts @@ -0,0 +1,32 @@ +import { K8sResourceCommon } from '@openshift/dynamic-plugin-sdk-utils'; + +export const addOwnerReference = ( + resource: R, + owner?: K8sResourceCommon, + blockOwnerDeletion = false, +): R => { + if (!owner) { + return resource; + } + const ownerReferences = resource.metadata?.ownerReferences || []; + if ( + owner.metadata?.uid && + owner.metadata?.name && + !ownerReferences.find((r) => r.uid === owner.metadata?.uid) + ) { + ownerReferences.push({ + uid: owner.metadata.uid, + name: owner.metadata.name, + apiVersion: owner.apiVersion, + kind: owner.kind, + blockOwnerDeletion, + }); + } + return { + ...resource, + metadata: { + ...resource.metadata, + ownerReferences, + }, + }; +}; diff --git a/frontend/src/components/ResourceActionsColumn.tsx b/frontend/src/components/ResourceActionsColumn.tsx new file mode 100644 index 0000000000..4b4df50929 --- /dev/null +++ b/frontend/src/components/ResourceActionsColumn.tsx @@ -0,0 +1,29 @@ +import * as React from 'react'; +import { K8sResourceCommon } from '@openshift/dynamic-plugin-sdk-utils'; +import { Button, Timestamp, Tooltip } from '@patternfly/react-core'; +import { BanIcon } from '@patternfly/react-icons'; +import { ActionsColumn } from '@patternfly/react-table'; + +type Props = React.ComponentProps & { + resource: K8sResourceCommon; +}; + +const ResourceActionsColumn: React.FC = ({ resource, ...props }) => + !resource.metadata?.deletionTimestamp ? ( + + ) : ( + + This resource is marked for deletion:{' '} + + + } + > + + + ); + +export default ResourceActionsColumn; diff --git a/frontend/src/components/ResourceTr.scss b/frontend/src/components/ResourceTr.scss new file mode 100644 index 0000000000..dd173dd841 --- /dev/null +++ b/frontend/src/components/ResourceTr.scss @@ -0,0 +1,5 @@ +.odh-resource-tr--deleting { + opacity: 0.5; + transition: var(--pf-global-Transition); + transition-property: opacity; +} diff --git a/frontend/src/components/ResourceTr.tsx b/frontend/src/components/ResourceTr.tsx new file mode 100644 index 0000000000..fe6e72c4d5 --- /dev/null +++ b/frontend/src/components/ResourceTr.tsx @@ -0,0 +1,25 @@ +import * as React from 'react'; +import classNames from 'classnames'; +import { Tr } from '@patternfly/react-table'; +import { K8sResourceCommon } from '@openshift/dynamic-plugin-sdk-utils'; + +import './ResourceTr.scss'; + +type Props = Omit, 'resource'> & { + resource: K8sResourceCommon; +}; + +const ResourceTr: React.ForwardRefRenderFunction = ( + { resource, className, ...props }, + ref, +) => ( + +); + +export default React.forwardRef(ResourceTr); diff --git a/frontend/src/components/SettingSection.tsx b/frontend/src/components/SettingSection.tsx index cf6b35df47..c608576dcb 100644 --- a/frontend/src/components/SettingSection.tsx +++ b/frontend/src/components/SettingSection.tsx @@ -4,7 +4,7 @@ import { Card, CardBody, CardFooter, CardTitle, Stack, StackItem } from '@patter type SettingSectionProps = { children: React.ReactNode; title: string; - description?: string; + description?: React.ReactNode; footer?: React.ReactNode; }; diff --git a/frontend/src/concepts/areas/__tests__/utils.spec.ts b/frontend/src/concepts/areas/__tests__/utils.spec.ts index 70af261f3a..8dc10110fd 100644 --- a/frontend/src/concepts/areas/__tests__/utils.spec.ts +++ b/frontend/src/concepts/areas/__tests__/utils.spec.ts @@ -1,6 +1,6 @@ import { isAreaAvailable, SupportedArea } from '~/concepts/areas'; -import { mockDashboardConfig } from '~/__mocks__/mockDashboardConfig'; import { mockDscStatus } from '~/__mocks__/mockDscStatus'; +import { mockDashboardConfig } from '~/__mocks__/mockDashboardConfig'; import { StackComponent } from '~/concepts/areas/types'; import { SupportedAreasStateMap } from '~/concepts/areas/const'; @@ -128,71 +128,50 @@ describe('isAreaAvailable', () => { }); /** - * These tests rely on Model Serving being in a specific configuration, we may need to replace + * These tests rely on Custom Serving Runtime being in a specific configuration, we may need to replace * these tests if these become obsolete. */ describe('reliantAreas', () => { it('should enable area if at least one reliant area is enabled', () => { // Make sure this test is valid - expect(SupportedAreasStateMap[SupportedArea.MODEL_SERVING].reliantAreas).toEqual([ - SupportedArea.K_SERVE, - SupportedArea.MODEL_MESH, + expect(SupportedAreasStateMap[SupportedArea.CUSTOM_RUNTIMES].reliantAreas).toEqual([ + SupportedArea.MODEL_SERVING, ]); // Test both reliant areas - const isAvailableReliantModelMesh = isAreaAvailable( - SupportedArea.MODEL_SERVING, + const isAvailableReliantCustomRuntimes = isAreaAvailable( + SupportedArea.CUSTOM_RUNTIMES, mockDashboardConfig({ disableModelServing: false }).spec, - mockDscStatus({ installedComponents: { [StackComponent.MODEL_MESH]: true } }), + mockDscStatus({}), ); - expect(isAvailableReliantModelMesh.status).toBe(true); - expect(isAvailableReliantModelMesh.featureFlags).toEqual({ ['disableModelServing']: 'on' }); - expect(isAvailableReliantModelMesh.reliantAreas).toEqual({ - [SupportedArea.K_SERVE]: false, - [SupportedArea.MODEL_MESH]: true, + expect(isAvailableReliantCustomRuntimes.status).toBe(true); + expect(isAvailableReliantCustomRuntimes.featureFlags).toEqual({ + ['disableCustomServingRuntimes']: 'on', }); - expect(isAvailableReliantModelMesh.requiredComponents).toBe(null); - - const isAvailableReliantKServe = isAreaAvailable( - SupportedArea.MODEL_SERVING, - mockDashboardConfig({ disableModelServing: false }).spec, - mockDscStatus({ installedComponents: { [StackComponent.K_SERVE]: true } }), - ); - - expect(isAvailableReliantKServe.status).toBe(true); - expect(isAvailableReliantKServe.featureFlags).toEqual({ ['disableModelServing']: 'on' }); - expect(isAvailableReliantKServe.reliantAreas).toEqual({ - [SupportedArea.K_SERVE]: true, - [SupportedArea.MODEL_MESH]: false, + expect(isAvailableReliantCustomRuntimes.reliantAreas).toEqual({ + [SupportedArea.MODEL_SERVING]: true, }); - expect(isAvailableReliantKServe.requiredComponents).toBe(null); + expect(isAvailableReliantCustomRuntimes.requiredComponents).toBe(null); }); it('should disable area if reliant areas are all disabled', () => { // Make sure this test is valid - expect(SupportedAreasStateMap[SupportedArea.MODEL_SERVING].reliantAreas).toEqual([ - SupportedArea.K_SERVE, - SupportedArea.MODEL_MESH, + expect(SupportedAreasStateMap[SupportedArea.CUSTOM_RUNTIMES].reliantAreas).toEqual([ + SupportedArea.MODEL_SERVING, ]); - // Test both areas disabled + // Test areas disabled const isAvailable = isAreaAvailable( - SupportedArea.MODEL_SERVING, - mockDashboardConfig({ disableModelServing: false }).spec, - mockDscStatus({ - installedComponents: { - [StackComponent.K_SERVE]: false, - [StackComponent.MODEL_MESH]: false, - }, - }), + SupportedArea.CUSTOM_RUNTIMES, + mockDashboardConfig({ disableModelServing: true }).spec, + mockDscStatus({}), ); expect(isAvailable.status).not.toBe(true); - expect(isAvailable.featureFlags).toEqual({ ['disableModelServing']: 'on' }); + expect(isAvailable.featureFlags).toEqual({ ['disableCustomServingRuntimes']: 'on' }); expect(isAvailable.reliantAreas).toEqual({ - [SupportedArea.K_SERVE]: false, - [SupportedArea.MODEL_MESH]: false, + [SupportedArea.MODEL_SERVING]: false, }); expect(isAvailable.requiredComponents).toBe(null); }); diff --git a/frontend/src/concepts/areas/const.ts b/frontend/src/concepts/areas/const.ts index 31c9b2b30b..a26b8ed072 100644 --- a/frontend/src/concepts/areas/const.ts +++ b/frontend/src/concepts/areas/const.ts @@ -23,16 +23,15 @@ export const SupportedAreasStateMap: SupportedAreasState = { reliantAreas: [SupportedArea.DS_PROJECTS_VIEW], }, [SupportedArea.K_SERVE]: { - //featureFlags: ['disableKServe'], // TODO: validate KServe feature flag + featureFlags: ['disableKServe'], requiredComponents: [StackComponent.K_SERVE], }, [SupportedArea.MODEL_MESH]: { - //featureFlags: ['disableModelMesh'], // TODO: validate ModelMesh feature flag + featureFlags: ['disableModelMesh'], requiredComponents: [StackComponent.MODEL_MESH], }, [SupportedArea.MODEL_SERVING]: { featureFlags: ['disableModelServing'], - reliantAreas: [SupportedArea.K_SERVE, SupportedArea.MODEL_MESH], }, [SupportedArea.USER_MANAGEMENT]: { featureFlags: ['disableUserManagement'], diff --git a/frontend/src/concepts/areas/index.ts b/frontend/src/concepts/areas/index.ts index d4ab163880..cf05395a9b 100644 --- a/frontend/src/concepts/areas/index.ts +++ b/frontend/src/concepts/areas/index.ts @@ -6,6 +6,6 @@ determine the state we are in. */ export { default as AreaComponent, conditionalArea } from './AreaComponent'; -export { SupportedArea } from './types'; +export { SupportedArea, StackComponent } from './types'; export { default as useIsAreaAvailable } from './useIsAreaAvailable'; export { isAreaAvailable } from './utils'; 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/const.ts b/frontend/src/const.ts index 73be687c75..6785cec682 100644 --- a/frontend/src/const.ts +++ b/frontend/src/const.ts @@ -1,6 +1,6 @@ import { KnownLabels } from '~/k8sTypes'; export const LABEL_SELECTOR_DASHBOARD_RESOURCE = `${KnownLabels.DASHBOARD_RESOURCE}=true`; -export const LABEL_SELECTOR_MODEL_SERVING_PROJECT = `${KnownLabels.MODEL_SERVING_PROJECT}=true`; +export const LABEL_SELECTOR_MODEL_SERVING_PROJECT = KnownLabels.MODEL_SERVING_PROJECT; export const LABEL_SELECTOR_DATA_CONNECTION_AWS = `${KnownLabels.DATA_CONNECTION_AWS}=true`; export const LABEL_SELECTOR_PROJECT_SHARING = `${KnownLabels.PROJECT_SHARING}=true`; diff --git a/frontend/src/k8sTypes.ts b/frontend/src/k8sTypes.ts index ea280dd4bf..ef343aacbd 100644 --- a/frontend/src/k8sTypes.ts +++ b/frontend/src/k8sTypes.ts @@ -1,4 +1,5 @@ import { K8sResourceCommon } from '@openshift/dynamic-plugin-sdk-utils'; +import { EitherOrNone } from '@openshift/dynamic-plugin-sdk'; import { AWS_KEYS } from '~/pages/projects/dataConnections/const'; import { StackComponent } from '~/concepts/areas/types'; import { @@ -91,6 +92,7 @@ export type K8sCondition = { reason?: string; message?: string; lastTransitionTime?: string; + lastHeartbeatTime?: string; }; export type ServingRuntimeAnnotations = Partial<{ @@ -101,6 +103,7 @@ export type ServingRuntimeAnnotations = Partial<{ 'opendatahub.io/accelerator-name': string; 'enable-route': string; 'enable-auth': string; + 'modelmesh-enabled': 'true' | 'false'; }>; export type BuildConfigKind = K8sResourceCommon & { @@ -291,12 +294,14 @@ export type PodKind = K8sResourceCommon & { }; }; +/** Assumed Dashboard Project -- if we need more beyond that we should break this type up */ export type ProjectKind = K8sResourceCommon & { metadata: { annotations?: DisplayNameAnnotations & Partial<{ 'openshift.io/requester': string; // the username of the user that requested this project }>; + labels: Partial & Partial; name: string; }; status?: { @@ -359,6 +364,17 @@ export type InferenceServiceKind = K8sResourceCommon & { metadata: { name: string; namespace: string; + annotations?: DisplayNameAnnotations & + EitherOrNone< + { + 'serving.kserve.io/deploymentMode': 'ModelMesh'; + }, + { + 'serving.knative.openshift.io/enablePassthrough': 'true'; + 'sidecar.istio.io/inject': 'true'; + 'sidecar.istio.io/rewriteAppHTTPProbers': 'true'; + } + >; }; spec: { predictor: { @@ -708,6 +724,7 @@ export type TemplateKind = K8sResourceCommon & { tags: string; iconClass?: string; 'opendatahub.io/template-enabled': string; + 'opendatahub.io/modelServingSupport': string; }>; name: string; namespace: string; @@ -740,6 +757,8 @@ export type DashboardCommonConfig = { disableCustomServingRuntimes: boolean; modelMetricsNamespace: string; disablePipelines: boolean; + disableKServe: boolean; + disableModelMesh: boolean; }; export type OperatorStatus = { diff --git a/frontend/src/pages/clusterSettings/ClusterSettings.tsx b/frontend/src/pages/clusterSettings/ClusterSettings.tsx index 1ff293a685..161d08a3d2 100644 --- a/frontend/src/pages/clusterSettings/ClusterSettings.tsx +++ b/frontend/src/pages/clusterSettings/ClusterSettings.tsx @@ -4,7 +4,11 @@ import { Button, Stack, StackItem } from '@patternfly/react-core'; import ApplicationsPage from '~/pages/ApplicationsPage'; import { useAppContext } from '~/app/AppContext'; import { fetchClusterSettings, updateClusterSettings } from '~/services/clusterSettingsService'; -import { ClusterSettingsType, NotebookTolerationFormSettings } from '~/types'; +import { + ClusterSettingsType, + ModelServingPlatformEnabled, + NotebookTolerationFormSettings, +} from '~/types'; import { addNotification } from '~/redux/actions/actions'; import { useCheckJupyterEnabled } from '~/utilities/notebookControllerUtils'; import { useAppDispatch } from '~/redux/hooks'; @@ -12,6 +16,8 @@ import PVCSizeSettings from '~/pages/clusterSettings/PVCSizeSettings'; import CullerSettings from '~/pages/clusterSettings/CullerSettings'; import TelemetrySettings from '~/pages/clusterSettings/TelemetrySettings'; import TolerationSettings from '~/pages/clusterSettings/TolerationSettings'; +import ModelServingPlatformSettings from '~/pages/clusterSettings/ModelServingPlatformSettings'; +import { SupportedArea, useIsAreaAvailable } from '~/concepts/areas'; import { DEFAULT_CONFIG, DEFAULT_PVC_SIZE, @@ -29,18 +35,22 @@ const ClusterSettings: React.FC = () => { const [userTrackingEnabled, setUserTrackingEnabled] = React.useState(false); const [cullerTimeout, setCullerTimeout] = React.useState(DEFAULT_CULLER_TIMEOUT); const { dashboardConfig } = useAppContext(); + const modelServingEnabled = useIsAreaAvailable(SupportedArea.MODEL_SERVING); const isJupyterEnabled = useCheckJupyterEnabled(); const [notebookTolerationSettings, setNotebookTolerationSettings] = React.useState({ enabled: false, key: isJupyterEnabled ? DEFAULT_TOLERATION_VALUE : '', }); + const [modelServingEnabledPlatforms, setModelServingEnabledPlatforms] = + React.useState(clusterSettings.modelServingPlatformEnabled); const dispatch = useAppDispatch(); React.useEffect(() => { fetchClusterSettings() .then((clusterSettings: ClusterSettingsType) => { setClusterSettings(clusterSettings); + setModelServingEnabledPlatforms(clusterSettings.modelServingPlatformEnabled); setLoaded(true); setLoadError(undefined); }) @@ -59,8 +69,16 @@ const ClusterSettings: React.FC = () => { enabled: notebookTolerationSettings.enabled, key: notebookTolerationSettings.key, }, + modelServingPlatformEnabled: modelServingEnabledPlatforms, }), - [pvcSize, cullerTimeout, userTrackingEnabled, clusterSettings, notebookTolerationSettings], + [ + pvcSize, + cullerTimeout, + userTrackingEnabled, + clusterSettings, + notebookTolerationSettings, + modelServingEnabledPlatforms, + ], ); const handleSaveButtonClicked = () => { @@ -72,6 +90,7 @@ const ClusterSettings: React.FC = () => { enabled: notebookTolerationSettings.enabled, key: notebookTolerationSettings.key, }, + modelServingPlatformEnabled: modelServingEnabledPlatforms, }; if (!_.isEqual(clusterSettings, newClusterSettings)) { if ( @@ -123,6 +142,15 @@ const ClusterSettings: React.FC = () => { provideChildrenPadding > + {modelServingEnabled && ( + + + + )} void; +}; + +const accessReviewResource: AccessReviewResourceAttributes = { + group: 'datasciencecluster.opendatahub.io/v1', + resource: 'DataScienceCluster', + verb: 'update', +}; + +const ModelServingPlatformSettings: React.FC = ({ + initialValue, + enabledPlatforms, + setEnabledPlatforms, +}) => { + const [alert, setAlert] = React.useState<{ variant: AlertVariant; message: string }>(); + const { + kServe: { installed: kServeInstalled }, + modelMesh: { installed: modelMeshInstalled }, + } = useServingPlatformStatuses(); + + const [allowUpdate] = useAccessReview(accessReviewResource); + const url = useOpenShiftURL(); + + React.useEffect(() => { + const kServeDisabled = !enabledPlatforms.kServe || !kServeInstalled; + const modelMeshDisabled = !enabledPlatforms.modelMesh || !modelMeshInstalled; + if (kServeDisabled && modelMeshDisabled) { + setAlert({ + variant: AlertVariant.warning, + message: + 'Disabling both model serving platforms prevents new projects from deploying models. Models can still be deployed from existing projects that already have a serving platform.', + }); + } else { + if (initialValue.modelMesh && !enabledPlatforms.modelMesh) { + setAlert({ + variant: AlertVariant.info, + message: + 'Disabling multi-model serving means that models in new projects or existing projects with no currently deployed models will be deployed from their own model server. Existing projects with currently deployed models will continue to use the serving platform selected for that project.', + }); + } else if (initialValue.kServe && !enabledPlatforms.kServe) { + setAlert({ + variant: AlertVariant.info, + message: + 'Disabling single model serving means that models in new projects or existing projects with no currently deployed models will be deployed from a shared model server. Existing projects with currently deployed models will continue to use the serving platform selected for that project.', + }); + } else { + setAlert(undefined); + } + } + }, [enabledPlatforms, initialValue, kServeInstalled, modelMeshInstalled]); + + return ( + + + Select the serving platforms that projects on this cluster can use for deploying models. + + + To modify the availability of model serving platforms, ask your cluster admin to + manage the respective components in the{' '} + {allowUpdate && url ? ( + + ) : ( + 'DataScienceCluster' + )}{' '} + resource. + + } + > + + + + } + > + + + { + const newEnabledPlatforms: ModelServingPlatformEnabled = { + ...enabledPlatforms, + kServe: enabled, + }; + setEnabledPlatforms(newEnabledPlatforms); + }} + aria-label="Single model serving platform enabled checkbox" + id="single-model-serving-platform-enabled-checkbox" + data-id="single-model-serving-platform-enabled-checkbox" + name="singleModelServingPlatformEnabledCheckbox" + /> + + + { + const newEnabledPlatforms: ModelServingPlatformEnabled = { + ...enabledPlatforms, + modelMesh: enabled, + }; + setEnabledPlatforms(newEnabledPlatforms); + }} + aria-label="Multi-model serving platform enabled checkbox" + id="multi-model-serving-platform-enabled-checkbox" + data-id="multi-model-serving-platform-enabled-checkbox" + name="multiModelServingPlatformEnabledCheckbox" + /> + + {alert && ( + + setAlert(undefined)} />} + /> + + )} + + + ); +}; + +export default ModelServingPlatformSettings; diff --git a/frontend/src/pages/clusterSettings/const.ts b/frontend/src/pages/clusterSettings/const.ts index cf8326eabe..5a4613778e 100644 --- a/frontend/src/pages/clusterSettings/const.ts +++ b/frontend/src/pages/clusterSettings/const.ts @@ -17,6 +17,10 @@ export const DEFAULT_CONFIG: ClusterSettingsType = { cullerTimeout: DEFAULT_CULLER_TIMEOUT, userTrackingEnabled: false, notebookTolerationSettings: null, + modelServingPlatformEnabled: { + kServe: true, + modelMesh: false, + }, }; export const DEFAULT_TOLERATION_VALUE = 'NotebooksOnly'; export const TOLERATION_FORMAT = /^([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]$/; 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} - - - - - - + + + + ); }; diff --git a/frontend/src/pages/modelServing/customServingRuntimes/CustomServingRuntimeHeaderLabels.tsx b/frontend/src/pages/modelServing/customServingRuntimes/CustomServingRuntimeHeaderLabels.tsx new file mode 100644 index 0000000000..fd08bda513 --- /dev/null +++ b/frontend/src/pages/modelServing/customServingRuntimes/CustomServingRuntimeHeaderLabels.tsx @@ -0,0 +1,42 @@ +import * as React from 'react'; +import { Button, Icon, Label, LabelGroup, Popover } from '@patternfly/react-core'; +import { OutlinedQuestionCircleIcon } from '@patternfly/react-icons'; +import { Link } from 'react-router-dom'; +import DashboardPopupIconButton from '~/concepts/dashboard/DashboardPopupIconButton'; +import { SupportedArea, useIsAreaAvailable } from '~/concepts/areas'; + +const CustomServingRuntimeHeaderLabels: React.FC = () => { + const kServeEnabled = useIsAreaAvailable(SupportedArea.K_SERVE).status; + const modelMeshEnabled = useIsAreaAvailable(SupportedArea.MODEL_MESH).status; + + if (!kServeEnabled && !modelMeshEnabled) { + return null; + } + + return ( + <> + + {kServeEnabled && } + {modelMeshEnabled && } + + + You can change which model serving platforms are enabled in the{' '} + + . + + } + > + + } aria-label="More info" /> + + + + ); +}; + +export default CustomServingRuntimeHeaderLabels; diff --git a/frontend/src/pages/modelServing/customServingRuntimes/CustomServingRuntimeListView.tsx b/frontend/src/pages/modelServing/customServingRuntimes/CustomServingRuntimeListView.tsx index c8fba8316a..cab542ef6d 100644 --- a/frontend/src/pages/modelServing/customServingRuntimes/CustomServingRuntimeListView.tsx +++ b/frontend/src/pages/modelServing/customServingRuntimes/CustomServingRuntimeListView.tsx @@ -24,6 +24,7 @@ const CustomServingRuntimeListView: React.FC = () => { const navigate = useNavigate(); const [deleteTemplate, setDeleteTemplate] = React.useState(); + const sortedTemplates = React.useMemo( () => getSortedTemplates(unsortedTemplates, templateOrder), [unsortedTemplates, templateOrder], diff --git a/frontend/src/pages/modelServing/customServingRuntimes/CustomServingRuntimePlatformsLabelGroup.tsx b/frontend/src/pages/modelServing/customServingRuntimes/CustomServingRuntimePlatformsLabelGroup.tsx new file mode 100644 index 0000000000..c014a6e95b --- /dev/null +++ b/frontend/src/pages/modelServing/customServingRuntimes/CustomServingRuntimePlatformsLabelGroup.tsx @@ -0,0 +1,34 @@ +import { Label, LabelGroup } from '@patternfly/react-core'; +import * as React from 'react'; +import { TemplateKind } from '~/k8sTypes'; +import { getEnabledPlatformsFromTemplate } from '~/pages/modelServing/customServingRuntimes/utils'; +import { ServingRuntimePlatform } from '~/types'; + +type CustomServingRuntimePlatformsLabelGroupProps = { + template: TemplateKind; +}; + +const ServingRuntimePlatformLabels = { + [ServingRuntimePlatform.SINGLE]: 'Single model', + [ServingRuntimePlatform.MULTI]: 'Multi-model', +}; + +const CustomServingRuntimePlatformsLabelGroup: React.FC< + CustomServingRuntimePlatformsLabelGroupProps +> = ({ template }) => { + const platforms = getEnabledPlatformsFromTemplate(template); + + if (platforms.length === 0) { + return null; + } + + return ( + + {platforms.map((platform, i) => ( + + ))} + + ); +}; + +export default CustomServingRuntimePlatformsLabelGroup; diff --git a/frontend/src/pages/modelServing/customServingRuntimes/CustomServingRuntimePlatformsSelector.tsx b/frontend/src/pages/modelServing/customServingRuntimes/CustomServingRuntimePlatformsSelector.tsx new file mode 100644 index 0000000000..d18fdcf4c9 --- /dev/null +++ b/frontend/src/pages/modelServing/customServingRuntimes/CustomServingRuntimePlatformsSelector.tsx @@ -0,0 +1,71 @@ +import * as React from 'react'; +import { FormGroup } from '@patternfly/react-core'; +import { ServingRuntimePlatform } from '~/types'; +import SimpleDropdownSelect from '~/components/SimpleDropdownSelect'; + +type CustomServingRuntimePlatformsSelectorProps = { + isSinglePlatformEnabled: boolean; + isMultiPlatformEnabled: boolean; + setSelectedPlatforms: (platforms: ServingRuntimePlatform[]) => void; +}; + +const RuntimePlatformSelectOptionLabels = { + [ServingRuntimePlatform.SINGLE]: 'Single model serving platform', + [ServingRuntimePlatform.MULTI]: 'Multi-model serving platform', + both: 'Both single and multi-model serving platforms', +}; + +const CustomServingRuntimePlatformsSelector: React.FC< + CustomServingRuntimePlatformsSelectorProps +> = ({ isSinglePlatformEnabled, isMultiPlatformEnabled, setSelectedPlatforms }) => { + const options = [ + { + key: ServingRuntimePlatform.SINGLE, + label: RuntimePlatformSelectOptionLabels[ServingRuntimePlatform.SINGLE], + }, + { + key: ServingRuntimePlatform.MULTI, + label: RuntimePlatformSelectOptionLabels[ServingRuntimePlatform.MULTI], + }, + { + key: 'both', + label: RuntimePlatformSelectOptionLabels['both'], + }, + ]; + + const selection = + isSinglePlatformEnabled && isMultiPlatformEnabled + ? 'both' + : isSinglePlatformEnabled + ? ServingRuntimePlatform.SINGLE + : isMultiPlatformEnabled + ? ServingRuntimePlatform.MULTI + : ''; + return ( + + { + if (key === 'both') { + setSelectedPlatforms([ServingRuntimePlatform.SINGLE, ServingRuntimePlatform.MULTI]); + } else if ( + key === ServingRuntimePlatform.SINGLE || + key === ServingRuntimePlatform.MULTI + ) { + setSelectedPlatforms([key]); + } + }} + /> + + ); +}; + +export default CustomServingRuntimePlatformsSelector; diff --git a/frontend/src/pages/modelServing/customServingRuntimes/CustomServingRuntimeTableRow.tsx b/frontend/src/pages/modelServing/customServingRuntimes/CustomServingRuntimeTableRow.tsx index 4a991b205e..eed5607a9c 100644 --- a/frontend/src/pages/modelServing/customServingRuntimes/CustomServingRuntimeTableRow.tsx +++ b/frontend/src/pages/modelServing/customServingRuntimes/CustomServingRuntimeTableRow.tsx @@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom'; import { Label } from '@patternfly/react-core'; import { TemplateKind } from '~/k8sTypes'; import ResourceNameTooltip from '~/components/ResourceNameTooltip'; +import CustomServingRuntimePlatformsLabelGroup from '~/pages/modelServing/customServingRuntimes/CustomServingRuntimePlatformsLabelGroup'; import CustomServingRuntimeEnabledToggle from './CustomServingRuntimeEnabledToggle'; import { getServingRuntimeDisplayNameFromTemplate, @@ -34,7 +35,7 @@ const CustomServingRuntimeTableRow: React.FC id: `draggable-row-${servingRuntimeName}`, }} /> - + {getServingRuntimeDisplayNameFromTemplate(template)} @@ -43,6 +44,9 @@ const CustomServingRuntimeTableRow: React.FC + + + { empty={servingRuntimeTemplates.length === 0} emptyStatePage={} provideChildrenPadding + headerContent={} > diff --git a/frontend/src/pages/modelServing/customServingRuntimes/templatedData.tsx b/frontend/src/pages/modelServing/customServingRuntimes/templatedData.tsx index 921d4eef68..fb8928785a 100644 --- a/frontend/src/pages/modelServing/customServingRuntimes/templatedData.tsx +++ b/frontend/src/pages/modelServing/customServingRuntimes/templatedData.tsx @@ -23,6 +23,11 @@ export const columns: SortableData[] = [ }, }, }, + { + field: 'platforms', + label: 'Serving platforms supported', + sortable: false, + }, { field: 'kebab', label: '', diff --git a/frontend/src/pages/modelServing/customServingRuntimes/utils.ts b/frontend/src/pages/modelServing/customServingRuntimes/utils.ts index e24abc320e..5e7be1e0e0 100644 --- a/frontend/src/pages/modelServing/customServingRuntimes/utils.ts +++ b/frontend/src/pages/modelServing/customServingRuntimes/utils.ts @@ -1,10 +1,16 @@ import { K8sResourceCommon } from '@openshift/dynamic-plugin-sdk-utils'; import { ServingRuntimeKind, TemplateKind } from '~/k8sTypes'; import { getDisplayNameFromK8sResource } from '~/pages/projects/utils'; +import { ServingRuntimePlatform } from '~/types'; export const getTemplateEnabled = (template: TemplateKind, templateDisablement: string[]) => !templateDisablement.includes(getServingRuntimeNameFromTemplate(template)); +export const getTemplateEnabledForPlatform = ( + template: TemplateKind, + platform: ServingRuntimePlatform, +) => getEnabledPlatformsFromTemplate(template).includes(platform); + export const isTemplateOOTB = (template: TemplateKind) => template.metadata.labels?.['opendatahub.io/ootb'] === 'true'; @@ -41,29 +47,44 @@ export const getServingRuntimeDisplayNameFromTemplate = (template: TemplateKind) export const getServingRuntimeNameFromTemplate = (template: TemplateKind) => template.objects[0].metadata.name; -export const isServingRuntimeKind = (obj: K8sResourceCommon): obj is ServingRuntimeKind => - obj.kind === 'ServingRuntime' && - obj.spec?.containers !== undefined && - obj.spec?.supportedModelFormats !== undefined; +const createServingRuntimeCustomError = (name: string, message: string) => { + const error = new Error(message); + error.name = name; + return error; +}; + +export const isServingRuntimeKind = (obj: K8sResourceCommon): obj is ServingRuntimeKind => { + if (obj.kind !== 'ServingRuntime') { + throw createServingRuntimeCustomError('Invalid parameter', 'kind: must be ServingRuntime.'); + } + if (!obj.spec?.containers) { + throw createServingRuntimeCustomError('Missing parameter', 'spec.containers: is required.'); + } + if (!obj.spec?.supportedModelFormats) { + throw createServingRuntimeCustomError( + 'Missing parameter', + 'spec.supportedModelFormats: is required.', + ); + } + return true; +}; export const getServingRuntimeFromName = ( templateName: string, - templateList?: TemplateKind[], + templateList: TemplateKind[] = [], ): ServingRuntimeKind | undefined => { - if (!templateList) { - return undefined; - } const template = templateList.find((t) => getServingRuntimeNameFromTemplate(t) === templateName); - if (!template) { - return undefined; - } return getServingRuntimeFromTemplate(template); }; export const getServingRuntimeFromTemplate = ( - template: TemplateKind, + template?: TemplateKind, ): ServingRuntimeKind | undefined => { - if (!isServingRuntimeKind(template.objects[0])) { + try { + if (!template || !isServingRuntimeKind(template.objects[0])) { + return undefined; + } + } catch (e) { return undefined; } return template.objects[0]; @@ -78,3 +99,24 @@ export const getDisplayNameFromServingRuntimeTemplate = (resource: ServingRuntim return templateName || legacyTemplateName || 'Unknown Serving Runtime'; }; + +export const getEnabledPlatformsFromTemplate = ( + template: TemplateKind, +): ServingRuntimePlatform[] => { + if (!template.metadata.annotations?.['opendatahub.io/modelServingSupport']) { + // By default, old Custom Serving Runtimes with no annotation will only be supported in modelmesh + return [ServingRuntimePlatform.MULTI]; + } + + try { + const platforms = JSON.parse( + template.metadata.annotations?.['opendatahub.io/modelServingSupport'], + ); + if (platforms.length === 0) { + return [ServingRuntimePlatform.MULTI]; + } + return platforms; + } catch (e) { + return [ServingRuntimePlatform.MULTI]; + } +}; diff --git a/frontend/src/pages/modelServing/screens/global/DeleteInferenceServiceModal.tsx b/frontend/src/pages/modelServing/screens/global/DeleteInferenceServiceModal.tsx index e8d18b45cb..d0748e9aba 100644 --- a/frontend/src/pages/modelServing/screens/global/DeleteInferenceServiceModal.tsx +++ b/frontend/src/pages/modelServing/screens/global/DeleteInferenceServiceModal.tsx @@ -1,17 +1,21 @@ import * as React from 'react'; import DeleteModal from '~/pages/projects/components/DeleteModal'; -import { InferenceServiceKind } from '~/k8sTypes'; -import { deleteInferenceService } from '~/api'; +import { InferenceServiceKind, ServingRuntimeKind } from '~/k8sTypes'; +import { deleteInferenceService, deleteServingRuntime } from '~/api'; import { getInferenceServiceDisplayName } from './utils'; type DeleteInferenceServiceModalProps = { inferenceService?: InferenceServiceKind; + servingRuntime?: ServingRuntimeKind; onClose: (deleted: boolean) => void; + isOpen?: boolean; }; const DeleteInferenceServiceModal: React.FC = ({ inferenceService, + servingRuntime, onClose, + isOpen = false, }) => { const [isDeleting, setIsDeleting] = React.useState(false); const [error, setError] = React.useState(); @@ -29,16 +33,27 @@ const DeleteInferenceServiceModal: React.FC = return ( onBeforeClose(false)} submitButtonLabel="Delete deployed model" onDelete={() => { if (inferenceService) { setIsDeleting(true); - deleteInferenceService( - inferenceService.metadata.name, - inferenceService.metadata.namespace, - ) + Promise.all([ + deleteInferenceService( + inferenceService.metadata.name, + inferenceService.metadata.namespace, + ), + ...(servingRuntime + ? [ + deleteServingRuntime( + servingRuntime.metadata.name, + servingRuntime.metadata.namespace, + ), + ] + : []), + ]) + .then(() => { onBeforeClose(true); }) diff --git a/frontend/src/pages/modelServing/screens/global/EmptyModelServing.tsx b/frontend/src/pages/modelServing/screens/global/EmptyModelServing.tsx index d1f1954348..377506e4d7 100644 --- a/frontend/src/pages/modelServing/screens/global/EmptyModelServing.tsx +++ b/frontend/src/pages/modelServing/screens/global/EmptyModelServing.tsx @@ -8,9 +8,15 @@ import { EmptyStateSecondaryActions, Title, } from '@patternfly/react-core'; +import { useParams } from 'react-router-dom'; import { PlusCircleIcon, WrenchIcon } from '@patternfly/react-icons'; import { useNavigate } from 'react-router-dom'; import { ModelServingContext } from '~/pages/modelServing/ModelServingContext'; +import { getProjectModelServingPlatform } from '~/pages/modelServing/screens/projects/utils'; +import { ServingRuntimePlatform } from '~/types'; +import { byName, ProjectsContext } from '~/concepts/projects/ProjectsContext'; +import useServingPlatformStatuses from '~/pages/modelServing/useServingPlatformStatuses'; +import { getProjectDisplayName } from '~/pages/projects/utils'; import ServeModelButton from './ServeModelButton'; const EmptyModelServing: React.FC = () => { @@ -18,8 +24,17 @@ const EmptyModelServing: React.FC = () => { const { servingRuntimes: { data: servingRuntimes }, } = React.useContext(ModelServingContext); + const { projects } = React.useContext(ProjectsContext); + const { namespace } = useParams<{ namespace: string }>(); + const servingPlatformStatuses = useServingPlatformStatuses(); - if (servingRuntimes.length === 0) { + const project = projects.find(byName(namespace)); + + if ( + getProjectModelServingPlatform(project, servingPlatformStatuses).platform !== + ServingRuntimePlatform.SINGLE && + servingRuntimes.length === 0 + ) { return ( @@ -31,8 +46,11 @@ const EmptyModelServing: React.FC = () => { of a project. - diff --git a/frontend/src/pages/modelServing/screens/global/InferenceServiceEndpoint.tsx b/frontend/src/pages/modelServing/screens/global/InferenceServiceEndpoint.tsx index 2221bc3ab2..a15539f7fc 100644 --- a/frontend/src/pages/modelServing/screens/global/InferenceServiceEndpoint.tsx +++ b/frontend/src/pages/modelServing/screens/global/InferenceServiceEndpoint.tsx @@ -15,11 +15,13 @@ import InternalServicePopoverContent from './InternalServicePopoverContent'; type InferenceServiceEndpointProps = { inferenceService: InferenceServiceKind; servingRuntime?: ServingRuntimeKind; + isKserve?: boolean; }; const InferenceServiceEndpoint: React.FC = ({ inferenceService, servingRuntime, + isKserve, }) => { const isRouteEnabled = servingRuntime !== undefined && isServingRuntimeRouteEnabled(servingRuntime); @@ -27,9 +29,10 @@ const InferenceServiceEndpoint: React.FC = ({ const [routeLink, loaded, loadError] = useRouteForInferenceService( inferenceService, isRouteEnabled, + isKserve, ); - if (!isRouteEnabled) { + if (!isKserve && !isRouteEnabled) { return ( = ({ ); } - if (!routeLink || !loaded) { + if (!loaded) { return ; } @@ -60,7 +63,7 @@ const InferenceServiceEndpoint: React.FC = ({ return ( - {`${routeLink}/infer`} + {isKserve ? routeLink : `${routeLink}/infer`} ); }; diff --git a/frontend/src/pages/modelServing/screens/global/InferenceServiceListView.tsx b/frontend/src/pages/modelServing/screens/global/InferenceServiceListView.tsx index a80991f8f0..f5b07317b6 100644 --- a/frontend/src/pages/modelServing/screens/global/InferenceServiceListView.tsx +++ b/frontend/src/pages/modelServing/screens/global/InferenceServiceListView.tsx @@ -5,8 +5,9 @@ import SearchField, { SearchType } from '~/pages/projects/components/SearchField import { ModelServingContext } from '~/pages/modelServing/ModelServingContext'; import { ProjectsContext } from '~/concepts/projects/ProjectsContext'; import { getInferenceServiceDisplayName, getInferenceServiceProjectDisplayName } from './utils'; -import ServeModelButton from './ServeModelButton'; +//import ServeModelButton from './ServeModelButton'; import InferenceServiceTable from './InferenceServiceTable'; +import ServeModelButton from './ServeModelButton'; type InferenceServiceListViewProps = { inferenceServices: InferenceServiceKind[]; diff --git a/frontend/src/pages/modelServing/screens/global/InferenceServiceModel.tsx b/frontend/src/pages/modelServing/screens/global/InferenceServiceModel.tsx deleted file mode 100644 index ff7f73286b..0000000000 --- a/frontend/src/pages/modelServing/screens/global/InferenceServiceModel.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import * as React from 'react'; -import { HelperText, HelperTextItem, Skeleton } from '@patternfly/react-core'; -import { InferenceServiceKind } from '~/k8sTypes'; -import { getDisplayNameFromK8sResource } from '~/pages/projects/utils'; -import { ModelServingContext } from '~/pages/modelServing/ModelServingContext'; - -type InferenceServiceModelProps = { - inferenceService: InferenceServiceKind; -}; - -const InferenceServiceModel: React.FC = ({ inferenceService }) => { - const { - servingRuntimes: { data: servingRuntimes, loaded, error }, - } = React.useContext(ModelServingContext); - const servingRuntime = servingRuntimes.find( - ({ metadata: { name } }) => name === inferenceService.spec.predictor.model.runtime, - ); - - if (!loaded) { - return ; - } - - if (error) { - return ( - - - Failed to get model server for this deployed model. {error.message}. - - - ); - } - - return <>{servingRuntime ? getDisplayNameFromK8sResource(servingRuntime) : 'Unknown'}; -}; - -export default InferenceServiceModel; diff --git a/frontend/src/pages/modelServing/screens/global/InferenceServiceProject.tsx b/frontend/src/pages/modelServing/screens/global/InferenceServiceProject.tsx index 797195b60e..9a20826851 100644 --- a/frontend/src/pages/modelServing/screens/global/InferenceServiceProject.tsx +++ b/frontend/src/pages/modelServing/screens/global/InferenceServiceProject.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { HelperText, HelperTextItem, Skeleton } from '@patternfly/react-core'; +import { HelperText, HelperTextItem, Label, Skeleton } from '@patternfly/react-core'; import { InferenceServiceKind } from '~/k8sTypes'; import { getProjectDisplayName } from '~/pages/projects/utils'; import { byName, ProjectsContext } from '~/concepts/projects/ProjectsContext'; @@ -27,7 +27,22 @@ const InferenceServiceProject: React.FC = ({ infer const project = modelServingProjects.find(byName(inferenceService.metadata.namespace)); - return <>{project ? getProjectDisplayName(project) : 'Unknown'}; + return ( + <> + {project ? ( + <> + {getProjectDisplayName(project)}{' '} + + + ) : ( + 'Unknown' + )} + + ); }; export default InferenceServiceProject; diff --git a/frontend/src/pages/modelServing/screens/global/InferenceServiceServingRuntime.tsx b/frontend/src/pages/modelServing/screens/global/InferenceServiceServingRuntime.tsx new file mode 100644 index 0000000000..2365eae69e --- /dev/null +++ b/frontend/src/pages/modelServing/screens/global/InferenceServiceServingRuntime.tsx @@ -0,0 +1,13 @@ +import * as React from 'react'; +import { ServingRuntimeKind } from '~/k8sTypes'; +import { getDisplayNameFromServingRuntimeTemplate } from '~/pages/modelServing/customServingRuntimes/utils'; + +type Props = { + servingRuntime?: ServingRuntimeKind; +}; + +const InferenceServiceServingRuntime: React.FC = ({ servingRuntime }) => ( + <>{servingRuntime ? getDisplayNameFromServingRuntimeTemplate(servingRuntime) : 'Unknown'} +); + +export default InferenceServiceServingRuntime; 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/InferenceServiceTable.tsx b/frontend/src/pages/modelServing/screens/global/InferenceServiceTable.tsx index e6ec893a3f..d116798b9a 100644 --- a/frontend/src/pages/modelServing/screens/global/InferenceServiceTable.tsx +++ b/frontend/src/pages/modelServing/screens/global/InferenceServiceTable.tsx @@ -4,6 +4,9 @@ import ManageInferenceServiceModal from '~/pages/modelServing/screens/projects/I import { Table } from '~/components/table'; import { InferenceServiceKind, ServingRuntimeKind } from '~/k8sTypes'; import { ProjectsContext } from '~/concepts/projects/ProjectsContext'; +import { isModelMesh } from '~/pages/modelServing/utils'; +import ManageKServeModal from '~/pages/modelServing/screens/projects/kServeModal/ManageKServeModal'; +import ResourceTr from '~/components/ResourceTr'; import InferenceServiceTableRow from './InferenceServiceTableRow'; import { getGlobalInferenceServiceColumns, getProjectInferenceServiceColumns } from './data'; import DeleteInferenceServiceModal from './DeleteInferenceServiceModal'; @@ -50,20 +53,30 @@ const InferenceServiceTable: React.FC = ({ ) : undefined } rowRenderer={(is) => ( - sr.metadata.name === is.spec.predictor.model.runtime, - )} - isGlobal={isGlobal} - onDeleteInferenceService={setDeleteInferenceService} - onEditInferenceService={setEditInferenceService} - /> + + sr.metadata.name === is.spec.predictor.model.runtime, + )} + isGlobal={isGlobal} + showServingRuntime={isGlobal} + onDeleteInferenceService={setDeleteInferenceService} + onEditInferenceService={setEditInferenceService} + /> + )} /> sr.metadata.name === deleteInferenceService.spec.predictor.model.runtime, + ) + : undefined + } onClose={(deleted) => { if (deleted) { refresh(); @@ -72,7 +85,7 @@ const InferenceServiceTable: React.FC = ({ }} /> { if (edited) { @@ -81,6 +94,26 @@ const InferenceServiceTable: React.FC = ({ setEditInferenceService(undefined); }} /> + sr.metadata.name === editInferenceService.spec.predictor.model.runtime, + ) + : undefined, + secrets: [], + }, + }} + onClose={(edited) => { + if (edited) { + refresh(); + } + setEditInferenceService(undefined); + }} + /> ); }; diff --git a/frontend/src/pages/modelServing/screens/global/InferenceServiceTableRow.tsx b/frontend/src/pages/modelServing/screens/global/InferenceServiceTableRow.tsx index 705e4152da..b09b89200c 100644 --- a/frontend/src/pages/modelServing/screens/global/InferenceServiceTableRow.tsx +++ b/frontend/src/pages/modelServing/screens/global/InferenceServiceTableRow.tsx @@ -1,15 +1,17 @@ import * as React from 'react'; import { DropdownDirection } from '@patternfly/react-core'; -import { ActionsColumn, Td, Tr } from '@patternfly/react-table'; +import { Td } from '@patternfly/react-table'; import { Link } from 'react-router-dom'; import ResourceNameTooltip from '~/components/ResourceNameTooltip'; import useModelMetricsEnabled from '~/pages/modelServing/useModelMetricsEnabled'; import { InferenceServiceKind, ServingRuntimeKind } from '~/k8sTypes'; +import { isModelMesh } from '~/pages/modelServing/utils'; +import ResourceActionsColumn from '~/components/ResourceActionsColumn'; import { getInferenceServiceDisplayName } from './utils'; import InferenceServiceEndpoint from './InferenceServiceEndpoint'; import InferenceServiceProject from './InferenceServiceProject'; -import InferenceServiceModel from './InferenceServiceModel'; import InferenceServiceStatus from './InferenceServiceStatus'; +import InferenceServiceServingRuntime from './InferenceServiceServingRuntime'; type InferenceServiceTableRowProps = { obj: InferenceServiceKind; @@ -17,6 +19,7 @@ type InferenceServiceTableRowProps = { servingRuntime?: ServingRuntimeKind; onDeleteInferenceService: (obj: InferenceServiceKind) => void; onEditInferenceService: (obj: InferenceServiceKind) => void; + showServingRuntime?: boolean; }; const InferenceServiceTableRow: React.FC = ({ @@ -25,11 +28,12 @@ const InferenceServiceTableRow: React.FC = ({ onDeleteInferenceService, onEditInferenceService, isGlobal, + showServingRuntime, }) => { const [modelMetricsEnabled] = useModelMetricsEnabled(); return ( - + <> {modelMetricsEnabled ? ( @@ -52,22 +56,24 @@ const InferenceServiceTableRow: React.FC = ({ )} - {isGlobal && ( - - + {showServingRuntime && ( + + )} - = ({ ]} /> - + ); }; diff --git a/frontend/src/pages/modelServing/screens/global/ModelServingGlobal.tsx b/frontend/src/pages/modelServing/screens/global/ModelServingGlobal.tsx index b131ec7cac..10add0b155 100644 --- a/frontend/src/pages/modelServing/screens/global/ModelServingGlobal.tsx +++ b/frontend/src/pages/modelServing/screens/global/ModelServingGlobal.tsx @@ -1,22 +1,88 @@ import React from 'react'; +import { useParams } from 'react-router-dom'; import ApplicationsPage from '~/pages/ApplicationsPage'; import { ModelServingContext } from '~/pages/modelServing/ModelServingContext'; +import useServingPlatformStatuses from '~/pages/modelServing/useServingPlatformStatuses'; +import { byName, ProjectsContext } from '~/concepts/projects/ProjectsContext'; +import InvalidProject from '~/concepts/projects/InvalidProject'; +import { getProjectModelServingPlatform } from '~/pages/modelServing/screens/projects/utils'; 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 { servingRuntimes: { data: servingRuntimes }, inferenceServices: { data: inferenceServices }, } = React.useContext(ModelServingContext); + const { dataScienceProjects: projects } = React.useContext(ProjectsContext); + + const { namespace } = useParams<{ namespace: string }>(); + const currentProject = projects.find(byName(namespace)); + const servingPlatformStatuses = useServingPlatformStatuses(); + const { + kServe: { installed: kServeInstalled }, + modelMesh: { installed: modelMeshInstalled }, + } = servingPlatformStatuses; + + const { error: notInstalledError } = getProjectModelServingPlatform( + currentProject, + servingPlatformStatuses, + ); + + const loadError = + !kServeInstalled && !modelMeshInstalled + ? new Error('No model serving platform installed') + : notInstalledError; + + 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 && !currentProject) { + 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..59f5297943 --- /dev/null +++ b/frontend/src/pages/modelServing/screens/global/ModelServingProjectSelection.tsx @@ -0,0 +1,29 @@ +import * as React from 'react'; +import { Bullseye, Split, SplitItem } from '@patternfly/react-core'; +import ProjectSelectorNavigator from '~/concepts/projects/ProjectSelectorNavigator'; +import { KnownLabels } from '~/k8sTypes'; + +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..1feae689f4 100644 --- a/frontend/src/pages/modelServing/screens/global/ServeModelButton.tsx +++ b/frontend/src/pages/modelServing/screens/global/ServeModelButton.tsx @@ -1,28 +1,92 @@ 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'; +import useServingPlatformStatuses from '~/pages/modelServing/useServingPlatformStatuses'; const ServeModelButton: React.FC = () => { - const [open, setOpen] = React.useState(false); + const [platformSelected, setPlatformSelected] = React.useState< + ServingRuntimePlatform | undefined + >(undefined); const { - inferenceServices: { refresh }, + inferenceServices: { refresh: refreshInferenceServices }, + servingRuntimes: { refresh: refreshServingRuntimes }, + servingRuntimeTemplates: { data: templates }, + servingRuntimeTemplateOrder: { data: templateOrder }, + servingRuntimeTemplateDisablement: { data: templateDisablement }, + dataConnections: { data: dataConnections }, } = React.useContext(ModelServingContext); + const { projects, refresh: refreshProjects } = React.useContext(ProjectsContext); + const { namespace } = useParams<{ namespace: string }>(); + const servingPlatformStatuses = useServingPlatformStatuses(); + + 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) { + refreshProjects(); + refreshInferenceServices(); + refreshServingRuntimes(); + } + 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/__tests__/InferenceServiceProject.spec.tsx b/frontend/src/pages/modelServing/screens/global/__tests__/InferenceServiceProject.spec.tsx new file mode 100644 index 0000000000..fe4c278c5e --- /dev/null +++ b/frontend/src/pages/modelServing/screens/global/__tests__/InferenceServiceProject.spec.tsx @@ -0,0 +1,127 @@ +import * as React from 'react'; +import { render } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { mockInferenceServiceK8sResource } from '~/__mocks__/mockInferenceServiceK8sResource'; +import InferenceServiceProject from '~/pages/modelServing/screens/global/InferenceServiceProject'; +import { ProjectsContext } from '~/concepts/projects/ProjectsContext'; +import { mockProjectK8sResource } from '~/__mocks__/mockProjectK8sResource'; +import { ProjectKind } from '~/k8sTypes'; + +describe('InferenceServiceProject', () => { + it('should render error if loading fails', () => { + const result = render( + , + { + wrapper: ({ children }) => ( + ['value'] + } + > + {children} + + ), + }, + ); + + expect(result.queryByText(/test loading error/)).toBeInTheDocument(); + }); + + it('should render modelmesh project', () => { + const result = render( + , + { + wrapper: ({ children }) => ( + ['value'] + } + > + {children} + + ), + }, + ); + + expect(result.queryByText('My Project')).toBeInTheDocument(); + expect(result.queryByText('Multi-model serving enabled')).toBeInTheDocument(); + }); + + it('should render kserve project', () => { + const result = render( + , + { + wrapper: ({ children }) => ( + ['value'] + } + > + {children} + + ), + }, + ); + + expect(result.queryByText('My Project')).toBeInTheDocument(); + expect(result.queryByText('Single model serving enabled')).toBeInTheDocument(); + }); + + it('should render kserve project', () => { + const result = render( + , + { + wrapper: ({ children }) => ( + ['value'] + } + > + {children} + + ), + }, + ); + + expect(result.queryByText('My Project')).not.toBeInTheDocument(); + expect(result.queryByText('Unknown')).toBeInTheDocument(); + expect(result.queryByText('Single model serving enabled')).not.toBeInTheDocument(); + expect(result.queryByText('Multi-model serving enabled')).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/pages/modelServing/screens/global/__tests__/InferenceServiceServingRuntime.spec.tsx b/frontend/src/pages/modelServing/screens/global/__tests__/InferenceServiceServingRuntime.spec.tsx new file mode 100644 index 0000000000..37687ecfb8 --- /dev/null +++ b/frontend/src/pages/modelServing/screens/global/__tests__/InferenceServiceServingRuntime.spec.tsx @@ -0,0 +1,17 @@ +import * as React from 'react'; +import { render } from '@testing-library/react'; +import InferenceServiceServingRuntime from '~/pages/modelServing/screens/global/InferenceServiceServingRuntime'; +import { mockServingRuntimeK8sResource } from '~/__mocks__/mockServingRuntimeK8sResource'; + +describe('InferenceServiceServingRuntime', () => { + it('should handle undefined serving runtime', () => { + const wrapper = render(); + expect(wrapper.container.textContent).toBe('Unknown'); + }); + + it('should display serving runtime name', () => { + const mockServingRuntime = mockServingRuntimeK8sResource({}); + const wrapper = render(); + expect(wrapper.container.textContent).toBe('OpenVINO Serving Runtime (Supports GPUs)'); + }); +}); diff --git a/frontend/src/pages/modelServing/screens/global/data.ts b/frontend/src/pages/modelServing/screens/global/data.ts index 50a9ec2ca0..459e0008ad 100644 --- a/frontend/src/pages/modelServing/screens/global/data.ts +++ b/frontend/src/pages/modelServing/screens/global/data.ts @@ -3,6 +3,11 @@ import { SortableData } from '~/components/table'; import { getProjectDisplayName } from '~/pages/projects/utils'; import { getInferenceServiceDisplayName, getTokenDisplayName } from './utils'; +const COL_EXPAND: SortableData = { + field: 'expand', + label: '', + sortable: false, +}; const COL_NAME: SortableData = { field: 'name', label: 'Model name', @@ -39,9 +44,9 @@ const COL_ENDPOINT: SortableData = { sortable: false, }; -const COL_MODEL_SERVER: SortableData = { - field: 'model', - label: 'Model server', +const COL_SERVING_RUNTIME: SortableData = { + field: 'servingRuntime', + label: 'Serving runtime', width: 20, sortable: false, }; @@ -62,7 +67,7 @@ export const getGlobalInferenceServiceColumns = ( ): SortableData[] => [ COL_NAME, buildProjectCol(projects), - COL_MODEL_SERVER, + COL_SERVING_RUNTIME, COL_ENDPOINT, COL_STATUS, COL_KEBAB, @@ -73,6 +78,14 @@ export const getProjectInferenceServiceColumns = (): SortableData[] => [ + COL_EXPAND, + COL_NAME, + COL_SERVING_RUNTIME, + COL_ENDPOINT, + COL_STATUS, + COL_KEBAB, +]; export const tokenColumns: SortableData[] = [ { diff --git a/frontend/src/pages/modelServing/screens/global/useRouteForInferenceService.ts b/frontend/src/pages/modelServing/screens/global/useRouteForInferenceService.ts index 87e19428fd..eb84143f92 100644 --- a/frontend/src/pages/modelServing/screens/global/useRouteForInferenceService.ts +++ b/frontend/src/pages/modelServing/screens/global/useRouteForInferenceService.ts @@ -1,10 +1,14 @@ import * as React from 'react'; import { InferenceServiceKind } from '~/k8sTypes'; import { getRoute } from '~/api'; +import { getUrlFromKserveInferenceService } from '~/pages/modelServing/screens/projects/utils'; +import { InferenceServiceModelState } from '~/pages/modelServing/screens/types'; +import { getInferenceServiceActiveModelState } from './utils'; const useRouteForInferenceService = ( inferenceService: InferenceServiceKind, isRouteEnabled: boolean, + isKServe?: boolean, ): [routeLink: string | null, loaded: boolean, loadError: Error | null] => { const [route, setRoute] = React.useState(null); const [loaded, setLoaded] = React.useState(false); @@ -12,8 +16,17 @@ const useRouteForInferenceService = ( const routeName = inferenceService.metadata.name; const routeNamespace = inferenceService.metadata.namespace; + const kserveRoute = isKServe ? getUrlFromKserveInferenceService(inferenceService) : null; + const state = getInferenceServiceActiveModelState(inferenceService); + const kserveLoaded = state === InferenceServiceModelState.LOADED; React.useEffect(() => { + if (isKServe) { + setRoute(kserveRoute || null); + setLoaded(kserveLoaded); + setLoadError(kserveLoaded ? (kserveRoute ? null : new Error('Route not found')) : null); + return; + } if (!isRouteEnabled) { setLoadError(null); setLoaded(true); @@ -28,7 +41,7 @@ const useRouteForInferenceService = ( setLoadError(e); setLoaded(true); }); - }, [routeName, routeNamespace, isRouteEnabled]); + }, [routeName, routeNamespace, isRouteEnabled, kserveRoute, isKServe, kserveLoaded]); return [route, loaded, loadError]; }; diff --git a/frontend/src/pages/modelServing/screens/global/utils.ts b/frontend/src/pages/modelServing/screens/global/utils.ts index 10437090d5..59b91c5d10 100644 --- a/frontend/src/pages/modelServing/screens/global/utils.ts +++ b/frontend/src/pages/modelServing/screens/global/utils.ts @@ -12,15 +12,12 @@ export const getInferenceServiceActiveModelState = ( is: InferenceServiceKind, ): InferenceServiceModelState => is.status?.modelStatus.states?.activeModelState || + 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/EmptyModelServingPlatform.tsx b/frontend/src/pages/modelServing/screens/projects/EmptyModelServingPlatform.tsx new file mode 100644 index 0000000000..02789b6225 --- /dev/null +++ b/frontend/src/pages/modelServing/screens/projects/EmptyModelServingPlatform.tsx @@ -0,0 +1,18 @@ +import * as React from 'react'; +import { EmptyState, EmptyStateBody, EmptyStateIcon, Title } from '@patternfly/react-core'; +import { WrenchIcon } from '@patternfly/react-icons'; + +const EmptyModelServingPlatform: React.FC = () => ( + + + + No model serving platform selected + + + To enable model serving, an administrator must first select a model serving platform in the + cluster settings. + + +); + +export default EmptyModelServingPlatform; diff --git a/frontend/src/pages/modelServing/screens/projects/InferenceServiceModal/DataConnectionSection.tsx b/frontend/src/pages/modelServing/screens/projects/InferenceServiceModal/DataConnectionSection.tsx index 39800aaae3..5a1a2571ac 100644 --- a/frontend/src/pages/modelServing/screens/projects/InferenceServiceModal/DataConnectionSection.tsx +++ b/frontend/src/pages/modelServing/screens/projects/InferenceServiceModal/DataConnectionSection.tsx @@ -7,6 +7,7 @@ import { } from '~/pages/modelServing/screens/types'; import AWSField from '~/pages/projects/dataConnections/AWSField'; import useDataConnections from '~/pages/projects/screens/detail/data-connections/useDataConnections'; +import { AWS_KEYS } from '~/pages/projects/dataConnections/const'; import DataConnectionExistingField from './DataConnectionExistingField'; import DataConnectionFolderPathField from './DataConnectionFolderPathField'; @@ -84,6 +85,7 @@ const DataConnectionSection: React.FC = ({ setData('storage', { ...data.storage, awsData })} + additionalRequiredFields={[AWS_KEYS.AWS_S3_BUCKET]} /> 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/InferenceServiceNameSection.tsx b/frontend/src/pages/modelServing/screens/projects/InferenceServiceModal/InferenceServiceNameSection.tsx new file mode 100644 index 0000000000..7fb17f3c1d --- /dev/null +++ b/frontend/src/pages/modelServing/screens/projects/InferenceServiceModal/InferenceServiceNameSection.tsx @@ -0,0 +1,25 @@ +import * as React from 'react'; +import { FormGroup, TextInput } from '@patternfly/react-core'; +import { UpdateObjectAtPropAndValue } from '~/pages/projects/types'; +import { CreatingInferenceServiceObject } from '~/pages/modelServing/screens/types'; + +type InferenceServiceNameSectionProps = { + data: CreatingInferenceServiceObject; + setData: UpdateObjectAtPropAndValue; +}; + +const InferenceServiceNameSection: React.FC = ({ + data, + setData, +}) => ( + + setData('name', name)} + /> + +); + +export default InferenceServiceNameSection; diff --git a/frontend/src/pages/modelServing/screens/projects/InferenceServiceModal/ManageInferenceServiceModal.tsx b/frontend/src/pages/modelServing/screens/projects/InferenceServiceModal/ManageInferenceServiceModal.tsx index 763db81cb9..104e3272c1 100644 --- a/frontend/src/pages/modelServing/screens/projects/InferenceServiceModal/ManageInferenceServiceModal.tsx +++ b/frontend/src/pages/modelServing/screens/projects/InferenceServiceModal/ManageInferenceServiceModal.tsx @@ -1,30 +1,22 @@ import * as React from 'react'; -import { - Form, - FormGroup, - FormSection, - Modal, - TextInput, - Stack, - StackItem, -} from '@patternfly/react-core'; +import { Form, FormSection, Modal, Stack, StackItem } from '@patternfly/react-core'; import { EitherOrNone } from '@openshift/dynamic-plugin-sdk'; -import { useCreateInferenceServiceObject } from '~/pages/modelServing/screens/projects/utils'; import { - assembleSecret, - createInferenceService, - createSecret, - updateInferenceService, -} from '~/api'; -import { InferenceServiceKind, ProjectKind, SecretKind, ServingRuntimeKind } from '~/k8sTypes'; + submitInferenceServiceResource, + useCreateInferenceServiceObject, +} from '~/pages/modelServing/screens/projects/utils'; +import { InferenceServiceKind, ProjectKind, ServingRuntimeKind } from '~/k8sTypes'; import { DataConnection } from '~/pages/projects/types'; 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'; import InferenceServiceServingRuntimeSection from './InferenceServiceServingRuntimeSection'; +import InferenceServiceNameSection from './InferenceServiceNameSection'; type ManageInferenceServiceModalProps = { isOpen: boolean; @@ -34,7 +26,7 @@ type ManageInferenceServiceModalProps = { { projectContext?: { currentProject: ProjectKind; - currentServingRuntime: ServingRuntimeKind; + currentServingRuntime?: ServingRuntimeKind; dataConnections: DataConnection[]; }; } @@ -54,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]); @@ -62,19 +54,18 @@ const ManageInferenceServiceModal: React.FC = if (createData.storage.type === InferenceServiceStorageType.EXISTING_STORAGE) { return createData.storage.dataConnection !== ''; } - return isAWSValid(createData.storage.awsData); + return isAWSValid(createData.storage.awsData, [AWS_KEYS.AWS_S3_BUCKET]); }; - const canCreate = - !actionInProgress && - createData.name.trim() !== '' && - createData.project !== '' && - createData.format.name !== '' && - createData.project !== '' && - createData.storage.path !== '' && - createData.storage.path !== '/' && - !createData.storage.path.includes('//') && - storageCanCreate(); + const isDisabled = + actionInProgress || + createData.name.trim() === '' || + createData.project === '' || + createData.format.name === '' || + createData.storage.path.includes('//') || + createData.storage.path === '' || + createData.storage.path === '/' || + !storageCanCreate(); const onBeforeClose = (submitted: boolean) => { onClose(submitted); @@ -83,64 +74,24 @@ const ManageInferenceServiceModal: React.FC = resetData(); }; + const onSuccess = () => { + onBeforeClose(true); + }; + const setErrorModal = (error: Error) => { setError(error); setActionInProgress(false); }; - const createAWSSecret = (): Promise => - createSecret( - assembleSecret( - createData.project, - createData.storage.awsData.reduce>( - (acc, { key, value }) => ({ ...acc, [key]: value }), - {}, - ), - 'aws', - ), - ); - - const createModel = (): Promise => { - if (createData.storage.type === InferenceServiceStorageType.EXISTING_STORAGE) { - return createInferenceService(createData); - } - return createAWSSecret().then((secret) => - createInferenceService(createData, secret.metadata.name), - ); - }; - - const updateModel = (): Promise => { - if (!editInfo) { - return Promise.reject(new Error('No model to update')); - } - - if (createData.storage.type === InferenceServiceStorageType.EXISTING_STORAGE) { - return updateInferenceService(createData, editInfo); - } - return createAWSSecret().then((secret) => - updateInferenceService(createData, editInfo, secret.metadata.name), - ); - }; - const submit = () => { setError(undefined); setActionInProgress(true); - if (editInfo) { - updateModel() - .then(() => { - setActionInProgress(false); - onBeforeClose(true); - }) - .catch(setErrorModal); - } else { - createModel() - .then(() => { - setActionInProgress(false); - onBeforeClose(true); - }) - .catch(setErrorModal); - } + submitInferenceServiceResource(createData, editInfo, undefined, true) + .then(() => onSuccess()) + .catch((e) => { + setErrorModal(e); + }); }; return ( @@ -155,7 +106,7 @@ const ManageInferenceServiceModal: React.FC = submitLabel="Deploy" onSubmit={submit} onCancel={() => onBeforeClose(false)} - isSubmitDisabled={!canCreate} + isSubmitDisabled={isDisabled} error={error} alertTitle="Error creating model server" /> @@ -166,21 +117,16 @@ const ManageInferenceServiceModal: React.FC = - - setCreateData('name', name)} - /> - + ; - 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/KServeSection/KServeInferenceServiceTable.tsx b/frontend/src/pages/modelServing/screens/projects/KServeSection/KServeInferenceServiceTable.tsx new file mode 100644 index 0000000000..1be7b08511 --- /dev/null +++ b/frontend/src/pages/modelServing/screens/projects/KServeSection/KServeInferenceServiceTable.tsx @@ -0,0 +1,83 @@ +import * as React from 'react'; +import { Table } from '~/components/table'; +import { InferenceServiceKind, ServingRuntimeKind } from '~/k8sTypes'; +import { getKServeInferenceServiceColumns } from '~/pages/modelServing/screens/global/data'; +import KServeInferenceServiceTableRow from '~/pages/modelServing/screens/projects/KServeSection/KServeInferenceServiceTableRow'; +import { ProjectDetailsContext } from '~/pages/projects/ProjectDetailsContext'; +import ManageKServeModal from '~/pages/modelServing/screens/projects/kServeModal/ManageKServeModal'; +import DeleteInferenceServiceModal from '~/pages/modelServing/screens/global/DeleteInferenceServiceModal'; + +const KServeInferenceServiceTable: React.FC = () => { + const [editKserveResources, setEditKServeResources] = React.useState< + | { + inferenceService: InferenceServiceKind; + servingRuntime: ServingRuntimeKind; + } + | undefined + >(undefined); + const [deleteKserveResources, setDeleteKServeResources] = React.useState< + | { + inferenceService: InferenceServiceKind; + servingRuntime: ServingRuntimeKind; + } + | undefined + >(undefined); + + const { + servingRuntimes: { refresh: refreshServingRuntime }, + dataConnections: { refresh: refreshDataConnections }, + inferenceServices: { data: inferenceServices, refresh: refreshInferenceServices }, + } = React.useContext(ProjectDetailsContext); + + return ( + <> + ( + setEditKServeResources(obj)} + onDeleteKServe={(obj) => setDeleteKServeResources(obj)} + rowIndex={rowIndex} + /> + )} + /> + { + if (deleted) { + refreshServingRuntime(); + refreshInferenceServices(); + } + setDeleteKServeResources(undefined); + }} + /> + { + setEditKServeResources(undefined); + if (submit) { + refreshServingRuntime(); + refreshInferenceServices(); + refreshDataConnections(); + } + }} + /> + + ); +}; + +export default KServeInferenceServiceTable; diff --git a/frontend/src/pages/modelServing/screens/projects/KServeSection/KServeInferenceServiceTableRow.tsx b/frontend/src/pages/modelServing/screens/projects/KServeSection/KServeInferenceServiceTableRow.tsx new file mode 100644 index 0000000000..7c28c0430c --- /dev/null +++ b/frontend/src/pages/modelServing/screens/projects/KServeSection/KServeInferenceServiceTableRow.tsx @@ -0,0 +1,110 @@ +import * as React from 'react'; +import { ExpandableRowContent, Tbody, Td } from '@patternfly/react-table'; +import { + DescriptionList, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, + Stack, + StackItem, +} from '@patternfly/react-core'; +import { InferenceServiceKind, ServingRuntimeKind } from '~/k8sTypes'; +import InferenceServiceTableRow from '~/pages/modelServing/screens/global/InferenceServiceTableRow'; +import { ProjectDetailsContext } from '~/pages/projects/ProjectDetailsContext'; +import ServingRuntimeDetails from '~/pages/modelServing/screens/projects/ModelMeshSection/ServingRuntimeDetails'; +import ResourceTr from '~/components/ResourceTr'; + +type KServeInferenceServiceTableRowProps = { + obj: InferenceServiceKind; + onEditKServe: (obj: { + inferenceService: InferenceServiceKind; + servingRuntime: ServingRuntimeKind; + }) => void; + onDeleteKServe: (obj: { + inferenceService: InferenceServiceKind; + servingRuntime: ServingRuntimeKind; + }) => void; + rowIndex: number; +}; + +const KServeInferenceServiceTableRow: React.FC = ({ + obj, + rowIndex, + onEditKServe, + onDeleteKServe, +}) => { + const [isExpanded, setExpanded] = React.useState(false); + const { + servingRuntimes: { data: servingRuntimes }, + } = React.useContext(ProjectDetailsContext); + + const frameworkName = obj.spec.predictor.model.modelFormat.name; + const frameworkVersion = obj.spec.predictor.model.modelFormat.version; + + const servingRuntime = servingRuntimes.find( + (sr) => sr.metadata.name === obj.spec.predictor.model.runtime, + ); + + return ( + + + + + + ); +}; + +export default KServeInferenceServiceTableRow; diff --git a/frontend/src/pages/modelServing/screens/projects/EmptyInferenceServicesCell.tsx b/frontend/src/pages/modelServing/screens/projects/ModelMeshSection/EmptyInferenceServicesCell.tsx similarity index 100% rename from frontend/src/pages/modelServing/screens/projects/EmptyInferenceServicesCell.tsx rename to frontend/src/pages/modelServing/screens/projects/ModelMeshSection/EmptyInferenceServicesCell.tsx diff --git a/frontend/src/pages/modelServing/screens/projects/ServingRuntimeDetails.tsx b/frontend/src/pages/modelServing/screens/projects/ModelMeshSection/ServingRuntimeDetails.tsx similarity index 93% rename from frontend/src/pages/modelServing/screens/projects/ServingRuntimeDetails.tsx rename to frontend/src/pages/modelServing/screens/projects/ModelMeshSection/ServingRuntimeDetails.tsx index 53d3099e33..0734bba1f9 100644 --- a/frontend/src/pages/modelServing/screens/projects/ServingRuntimeDetails.tsx +++ b/frontend/src/pages/modelServing/screens/projects/ModelMeshSection/ServingRuntimeDetails.tsx @@ -10,8 +10,8 @@ import { } from '@patternfly/react-core'; import { AppContext } from '~/app/AppContext'; import { ServingRuntimeKind } from '~/k8sTypes'; -import { getServingRuntimeSizes } from './utils'; -import useServingAccelerator from './useServingAccelerator'; +import { getServingRuntimeSizes } from '~/pages/modelServing/screens/projects/utils'; +import useServingAccelerator from '~/pages/modelServing/screens/projects/useServingAccelerator'; type ServingRuntimeDetailsProps = { obj: ServingRuntimeKind; @@ -19,10 +19,10 @@ type ServingRuntimeDetailsProps = { const ServingRuntimeDetails: React.FC = ({ obj }) => { const { dashboardConfig } = React.useContext(AppContext); + const [accelerator] = useServingAccelerator(obj); const container = obj.spec.containers[0]; // can we assume the first container? const sizes = getServingRuntimeSizes(dashboardConfig); const size = sizes.find((size) => _.isEqual(size.resources, container.resources)); - const [accelerator] = useServingAccelerator(obj); return ( diff --git a/frontend/src/pages/modelServing/screens/projects/ServingRuntimeTable.tsx b/frontend/src/pages/modelServing/screens/projects/ModelMeshSection/ServingRuntimeTable.tsx similarity index 86% rename from frontend/src/pages/modelServing/screens/projects/ServingRuntimeTable.tsx rename to frontend/src/pages/modelServing/screens/projects/ModelMeshSection/ServingRuntimeTable.tsx index 7a9b4db89b..a5e5bb7072 100644 --- a/frontend/src/pages/modelServing/screens/projects/ServingRuntimeTable.tsx +++ b/frontend/src/pages/modelServing/screens/projects/ModelMeshSection/ServingRuntimeTable.tsx @@ -3,11 +3,11 @@ import { Table } from '~/components/table'; import { AccessReviewResourceAttributes, ServingRuntimeKind } from '~/k8sTypes'; import { ProjectDetailsContext } from '~/pages/projects/ProjectDetailsContext'; import { useAccessReview } from '~/api'; -import { columns } from './data'; -import ServingRuntimeTableRow from './ServingRuntimeTableRow'; -import DeleteServingRuntimeModal from './DeleteServingRuntimeModal'; -import ManageServingRuntimeModal from './ServingRuntimeModal/ManageServingRuntimeModal'; -import ManageInferenceServiceModal from './InferenceServiceModal/ManageInferenceServiceModal'; +import { columns } from '~/pages/modelServing/screens/projects/data'; +import ServingRuntimeTableRow from '~/pages/modelServing/screens/projects/ModelMeshSection/ServingRuntimeTableRow'; +import DeleteServingRuntimeModal from '~/pages/modelServing/screens/projects/ServingRuntimeModal/DeleteServingRuntimeModal'; +import ManageServingRuntimeModal from '~/pages/modelServing/screens/projects/ServingRuntimeModal/ManageServingRuntimeModal'; +import ManageInferenceServiceModal from '~/pages/modelServing/screens/projects/InferenceServiceModal/ManageInferenceServiceModal'; const accessReviewResource: AccessReviewResourceAttributes = { group: 'rbac.authorization.k8s.io', @@ -59,7 +59,6 @@ const ServingRuntimeTable: React.FC = () => { {allowDelete && ( { if (deleted) { diff --git a/frontend/src/pages/modelServing/screens/projects/ServingRuntimeTableExpandedSection.tsx b/frontend/src/pages/modelServing/screens/projects/ModelMeshSection/ServingRuntimeTableExpandedSection.tsx similarity index 87% rename from frontend/src/pages/modelServing/screens/projects/ServingRuntimeTableExpandedSection.tsx rename to frontend/src/pages/modelServing/screens/projects/ModelMeshSection/ServingRuntimeTableExpandedSection.tsx index 666a0f762e..e3e98738d5 100644 --- a/frontend/src/pages/modelServing/screens/projects/ServingRuntimeTableExpandedSection.tsx +++ b/frontend/src/pages/modelServing/screens/projects/ModelMeshSection/ServingRuntimeTableExpandedSection.tsx @@ -6,10 +6,13 @@ import { ProjectDetailsContext } from '~/pages/projects/ProjectDetailsContext'; import InferenceServiceTable from '~/pages/modelServing/screens/global/InferenceServiceTable'; import { ServingRuntimeTableTabs } from '~/pages/modelServing/screens/types'; import ScrollViewOnMount from '~/components/ScrollViewOnMount'; -import EmptyInferenceServicesCell from './EmptyInferenceServicesCell'; -import { getInferenceServiceFromServingRuntime, isServingRuntimeTokenEnabled } from './utils'; +import ServingRuntimeTokensTable from '~/pages/modelServing/screens/projects/ModelMeshSection/ServingRuntimeTokensTable'; +import EmptyInferenceServicesCell from '~/pages/modelServing/screens/projects/ModelMeshSection/EmptyInferenceServicesCell'; +import { + getInferenceServiceFromServingRuntime, + isServingRuntimeTokenEnabled, +} from '~/pages/modelServing/screens/projects/utils'; import ServingRuntimeDetails from './ServingRuntimeDetails'; -import ServingRuntimeTokensTable from './ServingRuntimeTokensTable'; type ServingRuntimeTableExpandedSectionProps = { activeColumn?: ServingRuntimeTableTabs; diff --git a/frontend/src/pages/modelServing/screens/projects/ServingRuntimeTableRow.tsx b/frontend/src/pages/modelServing/screens/projects/ModelMeshSection/ServingRuntimeTableRow.tsx similarity index 97% rename from frontend/src/pages/modelServing/screens/projects/ServingRuntimeTableRow.tsx rename to frontend/src/pages/modelServing/screens/projects/ModelMeshSection/ServingRuntimeTableRow.tsx index 5d8f14391c..ca4046d33a 100644 --- a/frontend/src/pages/modelServing/screens/projects/ServingRuntimeTableRow.tsx +++ b/frontend/src/pages/modelServing/screens/projects/ModelMeshSection/ServingRuntimeTableRow.tsx @@ -8,8 +8,11 @@ import { ProjectDetailsContext } from '~/pages/projects/ProjectDetailsContext'; import { ServingRuntimeTableTabs } from '~/pages/modelServing/screens/types'; import { ProjectSectionID } from '~/pages/projects/screens/detail/types'; import { getDisplayNameFromServingRuntimeTemplate } from '~/pages/modelServing/customServingRuntimes/utils'; +import { + getInferenceServiceFromServingRuntime, + isServingRuntimeTokenEnabled, +} from '~/pages/modelServing/screens/projects/utils'; import ServingRuntimeTableExpandedSection from './ServingRuntimeTableExpandedSection'; -import { getInferenceServiceFromServingRuntime, isServingRuntimeTokenEnabled } from './utils'; type ServingRuntimeTableRowProps = { obj: ServingRuntimeKind; diff --git a/frontend/src/pages/modelServing/screens/projects/ServingRuntimeTokenDisplay.tsx b/frontend/src/pages/modelServing/screens/projects/ModelMeshSection/ServingRuntimeTokenDisplay.tsx similarity index 100% rename from frontend/src/pages/modelServing/screens/projects/ServingRuntimeTokenDisplay.tsx rename to frontend/src/pages/modelServing/screens/projects/ModelMeshSection/ServingRuntimeTokenDisplay.tsx diff --git a/frontend/src/pages/modelServing/screens/projects/ServingRuntimeTokenTableRow.tsx b/frontend/src/pages/modelServing/screens/projects/ModelMeshSection/ServingRuntimeTokenTableRow.tsx similarity index 100% rename from frontend/src/pages/modelServing/screens/projects/ServingRuntimeTokenTableRow.tsx rename to frontend/src/pages/modelServing/screens/projects/ModelMeshSection/ServingRuntimeTokenTableRow.tsx diff --git a/frontend/src/pages/modelServing/screens/projects/ServingRuntimeTokensTable.tsx b/frontend/src/pages/modelServing/screens/projects/ModelMeshSection/ServingRuntimeTokensTable.tsx similarity index 90% rename from frontend/src/pages/modelServing/screens/projects/ServingRuntimeTokensTable.tsx rename to frontend/src/pages/modelServing/screens/projects/ModelMeshSection/ServingRuntimeTokensTable.tsx index fc5e2c2449..d3a8f02413 100644 --- a/frontend/src/pages/modelServing/screens/projects/ServingRuntimeTokensTable.tsx +++ b/frontend/src/pages/modelServing/screens/projects/ModelMeshSection/ServingRuntimeTokensTable.tsx @@ -4,7 +4,7 @@ import { Table } from '~/components/table'; import { ProjectDetailsContext } from '~/pages/projects/ProjectDetailsContext'; import { tokenColumns } from '~/pages/modelServing/screens/global/data'; import { ServingRuntimeKind } from '~/k8sTypes'; -import ServingRuntimeTokenTableRow from './ServingRuntimeTokenTableRow'; +import ServingRuntimeTokenTableRow from '~/pages/modelServing/screens/projects/ModelMeshSection/ServingRuntimeTokenTableRow'; type ServingRuntimeTokensTableProps = { obj: ServingRuntimeKind; diff --git a/frontend/src/pages/modelServing/screens/projects/ModelServingPlatform.tsx b/frontend/src/pages/modelServing/screens/projects/ModelServingPlatform.tsx new file mode 100644 index 0000000000..d2600cf68e --- /dev/null +++ b/frontend/src/pages/modelServing/screens/projects/ModelServingPlatform.tsx @@ -0,0 +1,166 @@ +import * as React from 'react'; +import { PlusCircleIcon } from '@patternfly/react-icons'; +import { Label } from '@patternfly/react-core'; +import EmptyDetailsList from '~/pages/projects/screens/detail/EmptyDetailsList'; +import DetailsSection from '~/pages/projects/screens/detail/DetailsSection'; +import { ProjectSectionID } from '~/pages/projects/screens/detail/types'; +import { ProjectDetailsContext } from '~/pages/projects/ProjectDetailsContext'; +import { ProjectSectionTitles } from '~/pages/projects/screens/detail/const'; +import { + getSortedTemplates, + getTemplateEnabled, + getTemplateEnabledForPlatform, +} from '~/pages/modelServing/customServingRuntimes/utils'; +import { ServingRuntimePlatform } from '~/types'; +import ModelServingPlatformSelect from '~/pages/modelServing/screens/projects/ModelServingPlatformSelect'; +import { getProjectModelServingPlatform } from '~/pages/modelServing/screens/projects/utils'; +import { ProjectsContext } from '~/concepts/projects/ProjectsContext'; +import KServeInferenceServiceTable from '~/pages/modelServing/screens/projects/KServeSection/KServeInferenceServiceTable'; +import useServingPlatformStatuses from '~/pages/modelServing/useServingPlatformStatuses'; +import ManageServingRuntimeModal from './ServingRuntimeModal/ManageServingRuntimeModal'; +import ModelMeshServingRuntimeTable from './ModelMeshSection/ServingRuntimeTable'; +import ModelServingPlatformButtonAction from './ModelServingPlatformButtonAction'; +import ManageKServeModal from './kServeModal/ManageKServeModal'; + +const ModelServingPlatform: React.FC = () => { + const [platformSelected, setPlatformSelected] = React.useState< + ServingRuntimePlatform | undefined + >(undefined); + + const servingPlatformStatuses = useServingPlatformStatuses(); + + const kServeEnabled = servingPlatformStatuses.kServe.enabled; + const modelMeshEnabled = servingPlatformStatuses.modelMesh.enabled; + + const { + servingRuntimes: { + data: servingRuntimes, + loaded: servingRuntimesLoaded, + error: servingRuntimeError, + refresh: refreshServingRuntime, + }, + servingRuntimeTemplates: { data: templates, loaded: templatesLoaded, error: templateError }, + servingRuntimeTemplateOrder: { data: templateOrder }, + servingRuntimeTemplateDisablement: { data: templateDisablement }, + dataConnections: { data: dataConnections }, + serverSecrets: { refresh: refreshTokens }, + inferenceServices: { refresh: refreshInferenceServices }, + currentProject, + } = React.useContext(ProjectDetailsContext); + + const { refresh: refreshAllProjects } = React.useContext(ProjectsContext); + + const templatesSorted = getSortedTemplates(templates, templateOrder); + const templatesEnabled = templatesSorted.filter((template) => + getTemplateEnabled(template, templateDisablement), + ); + + const emptyTemplates = templatesEnabled.length === 0; + const emptyModelServer = servingRuntimes.length === 0; + + const { platform: currentProjectServingPlatform, error: platformError } = + getProjectModelServingPlatform(currentProject, servingPlatformStatuses); + + const shouldShowPlatformSelection = + ((kServeEnabled && modelMeshEnabled) || (!kServeEnabled && !modelMeshEnabled)) && + !currentProjectServingPlatform; + + const isProjectModelMesh = currentProjectServingPlatform === ServingRuntimePlatform.MULTI; + + const onSubmit = (submit: boolean) => { + setPlatformSelected(undefined); + if (submit) { + refreshAllProjects(); + refreshServingRuntime(); + refreshInferenceServices(); + setTimeout(refreshTokens, 500); // need a timeout to wait for tokens creation + } + }; + + return ( + <> + { + setPlatformSelected( + isProjectModelMesh + ? ServingRuntimePlatform.MULTI + : ServingRuntimePlatform.SINGLE, + ); + }} + key="serving-runtime-actions" + />, + ] + } + isLoading={!servingRuntimesLoaded && !templatesLoaded} + isEmpty={!shouldShowPlatformSelection && emptyModelServer} + loadError={servingRuntimeError || templateError || platformError} + emptyState={ + + } + labels={ + currentProjectServingPlatform && [ + , + ] + } + > + {shouldShowPlatformSelection ? ( + { + setPlatformSelected(selectedPlatform); + }} + emptyTemplates={emptyTemplates} + emptyPlatforms={!modelMeshEnabled && !kServeEnabled} + /> + ) : isProjectModelMesh ? ( + + ) : ( + + )} + + + getTemplateEnabledForPlatform(template, ServingRuntimePlatform.MULTI), + )} + onClose={(submit: boolean) => { + onSubmit(submit); + }} + /> + + getTemplateEnabledForPlatform(template, ServingRuntimePlatform.SINGLE), + )} + onClose={(submit: boolean) => { + onSubmit(submit); + }} + /> + + ); +}; + +export default ModelServingPlatform; diff --git a/frontend/src/pages/modelServing/screens/projects/ModelServingPlatformButtonAction.tsx b/frontend/src/pages/modelServing/screens/projects/ModelServingPlatformButtonAction.tsx new file mode 100644 index 0000000000..648b347841 --- /dev/null +++ b/frontend/src/pages/modelServing/screens/projects/ModelServingPlatformButtonAction.tsx @@ -0,0 +1,50 @@ +import * as React from 'react'; +import { Button, Tooltip, Text } from '@patternfly/react-core'; +import { ProjectDetailsContext } from '~/pages/projects/ProjectDetailsContext'; + +type ModelServingPlatformButtonActionProps = { + isProjectModelMesh: boolean; + emptyTemplates: boolean; + onClick: () => void; +}; + +const ModelServingPlatformButtonAction: React.FC = ({ + onClick, + emptyTemplates, + isProjectModelMesh, +}) => { + const { + servingRuntimeTemplates: { loaded: templatesLoaded }, + } = React.useContext(ProjectDetailsContext); + + const actionButton = () => ( + + ); + + if (!emptyTemplates) { + return actionButton(); + } + + return ( + {`At least one serving runtime must be enabled to ${ + isProjectModelMesh ? 'add a model server' : 'deploy a model' + }. Contact your administrator.`} + } + > + {actionButton()} + + ); +}; + +export default ModelServingPlatformButtonAction; diff --git a/frontend/src/pages/modelServing/screens/projects/ModelServingPlatformCard.tsx b/frontend/src/pages/modelServing/screens/projects/ModelServingPlatformCard.tsx new file mode 100644 index 0000000000..c56d54cc99 --- /dev/null +++ b/frontend/src/pages/modelServing/screens/projects/ModelServingPlatformCard.tsx @@ -0,0 +1,40 @@ +import * as React from 'react'; +import { + Card, + CardBody, + CardFooter, + CardHeader, + CardHeaderMain, + CardTitle, + Icon, +} from '@patternfly/react-core'; +import { CogsIcon } from '@patternfly/react-icons'; + +type ModelServingPlatformCardProps = { + id: string; + title: string; + description: string; + action: React.ReactNode; +}; + +const ModelServingPlatformCard: React.FC = ({ + id, + title, + description, + action, +}) => ( + + + + + + + + + {title} + {description} + {action} + +); + +export default ModelServingPlatformCard; diff --git a/frontend/src/pages/modelServing/screens/projects/ModelServingPlatformSelect.tsx b/frontend/src/pages/modelServing/screens/projects/ModelServingPlatformSelect.tsx new file mode 100644 index 0000000000..7d30dd8364 --- /dev/null +++ b/frontend/src/pages/modelServing/screens/projects/ModelServingPlatformSelect.tsx @@ -0,0 +1,82 @@ +import * as React from 'react'; +import { + Alert, + AlertActionCloseButton, + Gallery, + GalleryItem, + Stack, + StackItem, +} from '@patternfly/react-core'; +import { ServingRuntimePlatform } from '~/types'; +import ModelServingPlatformCard from '~/pages/modelServing/screens/projects/ModelServingPlatformCard'; +import ModelServingPlatformButtonAction from '~/pages/modelServing/screens/projects/ModelServingPlatformButtonAction'; +import EmptyModelServingPlatform from '~/pages/modelServing/screens/projects/EmptyModelServingPlatform'; + +type ModelServingPlatformSelectProps = { + onSelect: (platform: ServingRuntimePlatform) => void; + emptyTemplates: boolean; + emptyPlatforms: boolean; +}; + +const ModelServingPlatformSelect: React.FC = ({ + onSelect, + emptyTemplates, + emptyPlatforms, +}) => { + const [alertShown, setAlertShown] = React.useState(true); + if (emptyPlatforms) { + return ; + } + + return ( + + + Select the type model serving platform to be used when deploying models in this project. + + + + + onSelect(ServingRuntimePlatform.SINGLE)} + isProjectModelMesh={false} + /> + } + title="Single model serving platform" + description="Each model is deployed from its own model server. Choose this option when you have a small number of large models to deploy." + /> + + + onSelect(ServingRuntimePlatform.MULTI)} + isProjectModelMesh + /> + } + title="Multi-model serving platform" + description="Multiple models can be deployed from a single model server. Choose this option when you have a large number of small models to deploy that can share server resources." + /> + + + + {alertShown && ( + + setAlertShown(false)} />} + /> + + )} + + ); +}; + +export default ModelServingPlatformSelect; diff --git a/frontend/src/pages/modelServing/screens/projects/ServingRuntimeList.tsx b/frontend/src/pages/modelServing/screens/projects/ServingRuntimeList.tsx deleted file mode 100644 index 49f3a5e8b9..0000000000 --- a/frontend/src/pages/modelServing/screens/projects/ServingRuntimeList.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import * as React from 'react'; -import { PlusCircleIcon } from '@patternfly/react-icons'; -import EmptyDetailsList from '~/pages/projects/screens/detail/EmptyDetailsList'; -import DetailsSection from '~/pages/projects/screens/detail/DetailsSection'; -import { ProjectSectionID } from '~/pages/projects/screens/detail/types'; -import { ProjectDetailsContext } from '~/pages/projects/ProjectDetailsContext'; -import { ProjectSectionTitles } from '~/pages/projects/screens/detail/const'; -import { - getSortedTemplates, - getTemplateEnabled, -} from '~/pages/modelServing/customServingRuntimes/utils'; -import ManageServingRuntimeModal from './ServingRuntimeModal/ManageServingRuntimeModal'; -import ServingRuntimeTable from './ServingRuntimeTable'; -import ServingRuntimeListButtonAction from './ServingRuntimeListButtonAction'; - -const ServingRuntimeList: React.FC = () => { - const [isOpen, setOpen] = React.useState(false); - - const { - servingRuntimes: { - data: servingRuntimes, - loaded: servingRuntimesLoaded, - error: servingRuntimeError, - refresh: refreshServingRuntime, - }, - servingRuntimeTemplates: { data: templates, loaded: templatesLoaded, error: templateError }, - servingRuntimeTemplateOrder: { data: templateOrder }, - servingRuntimeTemplateDisablement: { data: templateDisablement }, - serverSecrets: { refresh: refreshTokens }, - inferenceServices: { refresh: refreshInferenceServices }, - currentProject, - } = React.useContext(ProjectDetailsContext); - - const templatesSorted = getSortedTemplates(templates, templateOrder); - const templatesEnabled = templatesSorted.filter((template) => - getTemplateEnabled(template, templateDisablement), - ); - - const emptyTemplates = templatesEnabled?.length === 0; - const emptyModelServer = servingRuntimes.length === 0; - - return ( - <> - setOpen(true)} - key="serving-runtime-actions" - />, - ]} - isLoading={!servingRuntimesLoaded && !templatesLoaded} - isEmpty={emptyModelServer} - loadError={servingRuntimeError || templateError} - emptyState={ - - } - > - - - { - setOpen(false); - if (submit) { - refreshServingRuntime(); - refreshInferenceServices(); - setTimeout(refreshTokens, 500); // need a timeout to wait for tokens creation - } - }} - /> - - ); -}; - -export default ServingRuntimeList; diff --git a/frontend/src/pages/modelServing/screens/projects/ServingRuntimeListButtonAction.tsx b/frontend/src/pages/modelServing/screens/projects/ServingRuntimeListButtonAction.tsx deleted file mode 100644 index e80acfd763..0000000000 --- a/frontend/src/pages/modelServing/screens/projects/ServingRuntimeListButtonAction.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import * as React from 'react'; -import { Button, Tooltip, Text } from '@patternfly/react-core'; - -type ServingRuntimeListButtonActionProps = { - emptyTemplates: boolean; - templatesLoaded: boolean; - onClick: () => void; -}; - -const ServingRuntimeListButtonAction: React.FC = ({ - emptyTemplates, - templatesLoaded, - onClick, -}) => { - if (emptyTemplates) { - return ( - - At least one serving runtime must be enabled to add a model server. Contact your - administrator - - } - > - - - ); - } - - return ( - - ); -}; - -export default ServingRuntimeListButtonAction; diff --git a/frontend/src/pages/modelServing/screens/projects/DeleteServingRuntimeModal.tsx b/frontend/src/pages/modelServing/screens/projects/ServingRuntimeModal/DeleteServingRuntimeModal.tsx similarity index 91% rename from frontend/src/pages/modelServing/screens/projects/DeleteServingRuntimeModal.tsx rename to frontend/src/pages/modelServing/screens/projects/ServingRuntimeModal/DeleteServingRuntimeModal.tsx index fd02a95a68..cc020ae6f2 100644 --- a/frontend/src/pages/modelServing/screens/projects/DeleteServingRuntimeModal.tsx +++ b/frontend/src/pages/modelServing/screens/projects/ServingRuntimeModal/DeleteServingRuntimeModal.tsx @@ -1,10 +1,9 @@ import * as React from 'react'; import DeleteModal from '~/pages/projects/components/DeleteModal'; -import { InferenceServiceKind, K8sStatus, SecretKind, ServingRuntimeKind } from '~/k8sTypes'; +import { InferenceServiceKind, K8sStatus, ServingRuntimeKind } from '~/k8sTypes'; import { deleteInferenceService, deleteRoleBinding, - deleteSecret, deleteServiceAccount, deleteServingRuntime, } from '~/api'; @@ -13,14 +12,12 @@ import { getTokenNames } from '~/pages/modelServing/utils'; type DeleteServingRuntimeModalProps = { servingRuntime?: ServingRuntimeKind; inferenceServices: InferenceServiceKind[]; - tokens: SecretKind[]; onClose: (deleted: boolean) => void; }; const DeleteServingRuntimeModal: React.FC = ({ servingRuntime, inferenceServices, - tokens, onClose, }) => { const [isDeleting, setIsDeleting] = React.useState(false); @@ -49,9 +46,6 @@ const DeleteServingRuntimeModal: React.FC = ({ Promise.allSettled([ deleteServingRuntime(servingRuntime.metadata.name, servingRuntime.metadata.namespace), - deleteServiceAccount(serviceAccountName, servingRuntime.metadata.namespace), - deleteRoleBinding(roleBindingName, servingRuntime.metadata.namespace), - ...tokens.map((token) => deleteSecret(token.metadata.namespace, token.metadata.name)), ...inferenceServices .filter( (inferenceService) => @@ -63,6 +57,9 @@ const DeleteServingRuntimeModal: React.FC = ({ inferenceService.metadata.namespace, ), ), + // for compatibility continue to delete related resources + deleteServiceAccount(serviceAccountName, servingRuntime.metadata.namespace), + deleteRoleBinding(roleBindingName, servingRuntime.metadata.namespace), ]) .then(() => { onBeforeClose(true); diff --git a/frontend/src/pages/modelServing/screens/projects/ServingRuntimeModal/ManageServingRuntimeModal.tsx b/frontend/src/pages/modelServing/screens/projects/ServingRuntimeModal/ManageServingRuntimeModal.tsx index 32b3637164..218af12ed7 100644 --- a/frontend/src/pages/modelServing/screens/projects/ServingRuntimeModal/ManageServingRuntimeModal.tsx +++ b/frontend/src/pages/modelServing/screens/projects/ServingRuntimeModal/ManageServingRuntimeModal.tsx @@ -7,40 +7,35 @@ import { FormGroup, FormSection, Modal, + Popover, Stack, StackItem, + getUniqueId, } from '@patternfly/react-core'; import { EitherOrNone } from '@openshift/dynamic-plugin-sdk'; +import { HelpIcon } from '@patternfly/react-icons'; import { - isGpuDisabled, + submitServingRuntimeResources, useCreateServingRuntimeObject, } from '~/pages/modelServing/screens/projects/utils'; +import { TemplateKind, ProjectKind, AccessReviewResourceAttributes } from '~/k8sTypes'; +import { useAccessReview } from '~/api'; import { - ServingRuntimeKind, - SecretKind, - TemplateKind, - ProjectKind, - AccessReviewResourceAttributes, -} from '~/k8sTypes'; -import { - addSupportModelMeshProject, - createServingRuntime, - updateServingRuntime, - useAccessReview, -} from '~/api'; -import { + isModelServerEditInfoChanged, requestsUnderLimits, resourcesArePositive, - setUpTokenAuth, } from '~/pages/modelServing/utils'; import useCustomServingRuntimesEnabled from '~/pages/modelServing/customServingRuntimes/useCustomServingRuntimesEnabled'; import { getServingRuntimeFromName } from '~/pages/modelServing/customServingRuntimes/utils'; -import { translateDisplayNameForK8s } from '~/pages/projects/utils'; import useServingAccelerator from '~/pages/modelServing/screens/projects/useServingAccelerator'; +import DashboardModalFooter from '~/concepts/dashboard/DashboardModalFooter'; +import { NamespaceApplicationCase } from '~/pages/projects/types'; +import { ServingRuntimeEditInfo } from '~/pages/modelServing/screens/types'; import ServingRuntimeReplicaSection from './ServingRuntimeReplicaSection'; import ServingRuntimeSizeSection from './ServingRuntimeSizeSection'; import ServingRuntimeTokenSection from './ServingRuntimeTokenSection'; import ServingRuntimeTemplateSection from './ServingRuntimeTemplateSection'; +import ServingRuntimeNameSection from './ServingRuntimeNameSection'; type ManageServingRuntimeModalProps = { isOpen: boolean; @@ -49,10 +44,7 @@ type ManageServingRuntimeModalProps = { } & EitherOrNone< { servingRuntimeTemplates?: TemplateKind[] }, { - editInfo?: { - servingRuntime?: ServingRuntimeKind; - secrets: SecretKind[]; - }; + editInfo?: ServingRuntimeEditInfo; } >; @@ -80,7 +72,7 @@ const ManageServingRuntimeModal: React.FC = ({ const namespace = currentProject.metadata.name; - const [allowCreate, rbacLoaded] = useAccessReview({ + const [allowCreate] = useAccessReview({ ...accessReviewResource, namespace, }); @@ -96,7 +88,11 @@ const ManageServingRuntimeModal: React.FC = ({ const inputValueValid = customServingRuntimesEnabled ? baseInputValueValid && createData.name && servingRuntimeTemplateNameValid : baseInputValueValid; - const canCreate = !actionInProgress && !tokenErrors && inputValueValid && rbacLoaded; + const isDisabled = + actionInProgress || + tokenErrors || + !inputValueValid || + !isModelServerEditInfoChanged(createData, sizes, acceleratorState, editInfo); const servingRuntimeSelected = React.useMemo( () => @@ -118,194 +114,160 @@ const ManageServingRuntimeModal: React.FC = ({ setActionInProgress(false); }; + const onSuccess = () => { + onBeforeClose(true); + }; + const submit = () => { setError(undefined); setActionInProgress(true); - if (!servingRuntimeSelected) { - setErrorModal( - new Error( - 'Error, the Serving Runtime selected might be malformed or could not have been retrieved.', - ), - ); - return; - } - const servingRuntimeData = { - ...createData, - existingTolerations: servingRuntimeSelected.spec.tolerations || [], - }; - const servingRuntimeName = translateDisplayNameForK8s(servingRuntimeData.name); - const createRolebinding = servingRuntimeData.tokenAuth && allowCreate; - - const accelerator = isGpuDisabled(servingRuntimeSelected) - ? { count: 0, accelerators: [], useExisting: false } - : acceleratorState; - - Promise.all([ - ...(editInfo?.servingRuntime - ? [ - updateServingRuntime({ - data: servingRuntimeData, - existingData: editInfo?.servingRuntime, - isCustomServingRuntimesEnabled: customServingRuntimesEnabled, - opts: { - dryRun: true, - }, - acceleratorState: accelerator, - }), - ] - : [ - createServingRuntime({ - data: servingRuntimeData, - namespace, - servingRuntime: servingRuntimeSelected, - isCustomServingRuntimesEnabled: customServingRuntimesEnabled, - opts: { - dryRun: true, - }, - acceleratorState: accelerator, - }), - ]), - setUpTokenAuth( - servingRuntimeData, - servingRuntimeName, - namespace, - createRolebinding, - editInfo?.secrets, - { - dryRun: true, - }, - ), - ]) - .then(() => - Promise.all([ - ...(currentProject.metadata.labels?.['modelmesh-enabled'] === undefined && allowCreate - ? [addSupportModelMeshProject(currentProject.metadata.name)] - : []), - ...(editInfo?.servingRuntime - ? [ - updateServingRuntime({ - data: servingRuntimeData, - existingData: editInfo?.servingRuntime, - isCustomServingRuntimesEnabled: customServingRuntimesEnabled, - - acceleratorState: accelerator, - }), - ] - : [ - createServingRuntime({ - data: servingRuntimeData, - namespace, - servingRuntime: servingRuntimeSelected, - isCustomServingRuntimesEnabled: customServingRuntimesEnabled, - acceleratorState: accelerator, - }), - ]), - setUpTokenAuth( - servingRuntimeData, - servingRuntimeName, - namespace, - createRolebinding, - editInfo?.secrets, - ), - ]) - .then(() => { - setActionInProgress(false); - onBeforeClose(true); - }) - .catch((e) => { - setErrorModal(e); - }), - ) + submitServingRuntimeResources( + servingRuntimeSelected, + createData, + customServingRuntimesEnabled, + namespace, + editInfo, + allowCreate, + acceleratorState, + NamespaceApplicationCase.MODEL_MESH_PROMOTION, + currentProject, + ) + .then(() => onSuccess()) .catch((e) => { setErrorModal(e); }); }; + const createNewToken = React.useCallback(() => { + const name = 'default-name'; + const duplicated = createData.tokens.filter((token) => token.name === name); + const error = duplicated.length > 0 ? 'Duplicates are invalid' : ''; + setCreateData('tokens', [ + ...createData.tokens, + { + name, + uuid: getUniqueId('ml'), + error, + }, + ]); + }, [createData.tokens, setCreateData]); + return ( onBeforeClose(false)} showClose - actions={[ - , - , - ]} + footer={ + onBeforeClose(false)} + alertTitle={`Error ${editInfo ? 'updating' : 'creating'} model server`} + error={error} + /> + } > - - -
{ - e.preventDefault(); - submit(); - }} - > - - - - - - - - - - - - setCreateData('externalRoute', check)} - /> - - - - - { + e.preventDefault(); + submit(); + }} + > + + + + + + + + + + + + + + {!allowCreate && ( + + + + + + )} + + + + { + setCreateData('externalRoute', check); + if (check && allowCreate) { + setCreateData('tokenAuth', check); + if (createData.tokens.length === 0) { + createNewToken(); + } + } + }} /> - - - - - - {error && ( + + +
- - {error.message} - + - )} -
+ {createData.externalRoute && !createData.tokenAuth && ( + + + + )} + +
); }; diff --git a/frontend/src/pages/modelServing/screens/projects/ServingRuntimeModal/ServingRuntimeNameSection.tsx b/frontend/src/pages/modelServing/screens/projects/ServingRuntimeModal/ServingRuntimeNameSection.tsx new file mode 100644 index 0000000000..95cee01495 --- /dev/null +++ b/frontend/src/pages/modelServing/screens/projects/ServingRuntimeModal/ServingRuntimeNameSection.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; +import { FormGroup, TextInput } from '@patternfly/react-core'; +import { UpdateObjectAtPropAndValue } from '~/pages/projects/types'; +import { CreatingServingRuntimeObject } from '~/pages/modelServing/screens/types'; + +type ServingRuntimeNameSectionProps = { + data: CreatingServingRuntimeObject; + setData: UpdateObjectAtPropAndValue; +}; + +const ServingRuntimeNameSection: React.FC = ({ data, setData }) => ( + + setData('name', name)} + /> + +); + +export default ServingRuntimeNameSection; diff --git a/frontend/src/pages/modelServing/screens/projects/ServingRuntimeModal/ServingRuntimeReplicaSection.tsx b/frontend/src/pages/modelServing/screens/projects/ServingRuntimeModal/ServingRuntimeReplicaSection.tsx index f3b41b1fbe..35ddb18f26 100644 --- a/frontend/src/pages/modelServing/screens/projects/ServingRuntimeModal/ServingRuntimeReplicaSection.tsx +++ b/frontend/src/pages/modelServing/screens/projects/ServingRuntimeModal/ServingRuntimeReplicaSection.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; -import { FormGroup, FormSection, NumberInput } from '@patternfly/react-core'; +import { FormGroup, FormSection, NumberInput, Popover, Icon } from '@patternfly/react-core'; +import { OutlinedQuestionCircleIcon } from '@patternfly/react-icons'; import { UpdateObjectAtPropAndValue } from '~/pages/projects/types'; import { CreatingServingRuntimeObject } from '~/pages/modelServing/screens/types'; import { isHTMLInputElement, normalizeBetween } from '~/utilities/utils'; @@ -7,11 +8,13 @@ import { isHTMLInputElement, normalizeBetween } from '~/utilities/utils'; type ServingRuntimeReplicaSectionProps = { data: CreatingServingRuntimeObject; setData: UpdateObjectAtPropAndValue; + infoContent?: string; }; const ServingRuntimeReplicaSection: React.FC = ({ data, setData, + infoContent, }) => { const MIN_SIZE = 0; @@ -21,7 +24,18 @@ const ServingRuntimeReplicaSection: React.FC return ( - + {infoContent}}> + + + + + ) : undefined + } + > ; + infoContent?: string; }; const ServingRuntimeSizeSection: React.FC = ({ @@ -35,6 +39,7 @@ const ServingRuntimeSizeSection: React.FC = ({ servingRuntimeSelected, acceleratorState, setAcceleratorState, + infoContent, }) => { const [sizeDropdownOpen, setSizeDropdownOpen] = React.useState(false); const [supportedAccelerators, setSupportedAccelerators] = React.useState(); @@ -72,7 +77,18 @@ const ServingRuntimeSizeSection: React.FC = ({ return ( - + {infoContent}}> + + + + + ) : undefined + } + >
setExpanded(!isExpanded), + }} + /> + { + if (servingRuntime) { + onDeleteKServe({ + inferenceService: obj, + servingRuntime, + }); + } + }} + onEditInferenceService={() => { + if (servingRuntime) { + onEditKServe({ + inferenceService: obj, + servingRuntime, + }); + } + }} + /> + + + + + + + + + + Framework + + {frameworkVersion ? `${frameworkName}-${frameworkVersion}` : frameworkName} + + + + + {servingRuntime && ( + + + + )} + + +