diff --git a/frontend/src/__mocks__/mockNimAccount.ts b/frontend/src/__mocks__/mockNimAccount.ts index 3567d5b21d..5d0b414a5a 100644 --- a/frontend/src/__mocks__/mockNimAccount.ts +++ b/frontend/src/__mocks__/mockNimAccount.ts @@ -19,7 +19,15 @@ export const mockNimAccount = ({ nimConfigName = 'mock-nvidia-nim-images-data', runtimeTemplateName = 'mock-nvidia-nim-serving-template', nimPullSecretName = 'mock-nvidia-nim-image-pull', - conditions = [], + conditions = [ + { + type: 'AccountStatus', + status: 'True', + lastTransitionTime: new Date().toISOString(), + reason: 'AccountSuccessful', + message: 'reconciled successfully', + }, + ], }: MockResourceConfigType): NIMAccountKind => ({ apiVersion: 'nim.opendatahub.io/v1', kind: 'Account', diff --git a/frontend/src/__mocks__/mockOdhApplication.ts b/frontend/src/__mocks__/mockOdhApplication.ts new file mode 100644 index 0000000000..657e173a38 --- /dev/null +++ b/frontend/src/__mocks__/mockOdhApplication.ts @@ -0,0 +1,114 @@ +import { OdhApplication, OdhApplicationCategory } from '~/types'; + +type MockOdhApplicationConfig = { + name?: string; + displayName?: string; + provider?: string; + description?: string; + route?: string | null; + routeNamespace?: string | null; + routeSuffix?: string | null; + serviceName?: string | null; + endpoint?: string | null; + link?: string | null; + img?: string; + docsLink?: string; + hidden?: boolean | null; + getStartedLink?: string; + getStartedMarkDown?: string; + category?: OdhApplicationCategory | string; + support?: string; + quickStart?: string | null; + comingSoon?: boolean | null; + beta?: boolean | null; + betaTitle?: string | null; + betaText?: string | null; + shownOnEnabledPage?: boolean | null; + isEnabled?: boolean | null; + kfdefApplications?: string[]; + csvName?: string; + annotations?: { [key: string]: string }; + enable?: { + title: string; + actionLabel: string; + description?: string; + linkPreface?: string; + link?: string; + variables?: { [key: string]: string }; + variableDisplayText?: { [key: string]: string }; + variableHelpText?: { [key: string]: string }; + validationSecret: string; + validationJob: string; + validationConfigMap?: string; + }; + featureFlag?: string; + internalRoute?: string; +}; + +export const mockOdhApplication = ({ + name = 'nvidia-nim', + displayName = 'Test Application', + provider = 'Test Provider', + description = 'Test Description', + route = null, + routeNamespace = null, + routeSuffix = null, + serviceName = null, + endpoint = null, + link = null, + img = 'test-image.png', + docsLink = 'https://test-docs.com', + hidden = null, + getStartedLink = 'https://test-getting-started.com', + getStartedMarkDown = '# Getting Started', + category = 'category-1', + support = 'test-support', + quickStart = null, + comingSoon = null, + beta = null, + betaTitle = null, + betaText = null, + shownOnEnabledPage = null, + isEnabled = null, + kfdefApplications = [], + csvName = undefined, + annotations = {}, + enable = undefined, + featureFlag = undefined, + internalRoute = '/api/integrations/nim', +}: MockOdhApplicationConfig = {}): OdhApplication => ({ + metadata: { + name, + annotations, + }, + spec: { + displayName, + provider, + description, + route, + routeNamespace, + routeSuffix, + serviceName, + endpoint, + link, + img, + docsLink, + hidden, + getStartedLink, + getStartedMarkDown, + category, + support, + quickStart, + comingSoon, + beta, + betaTitle, + betaText, + shownOnEnabledPage, + isEnabled, + kfdefApplications, + csvName, + enable, + featureFlag, + internalRoute, + }, +}); diff --git a/frontend/src/__tests__/cypress/cypress/support/commands/odh.ts b/frontend/src/__tests__/cypress/cypress/support/commands/odh.ts index 3e2d9787db..6db63923d4 100644 --- a/frontend/src/__tests__/cypress/cypress/support/commands/odh.ts +++ b/frontend/src/__tests__/cypress/cypress/support/commands/odh.ts @@ -33,6 +33,7 @@ import type { ClusterSettingsType, DetectedAccelerators, ImageInfo, + IntegrationAppStatus, OdhApplication, OdhDocument, PrometheusQueryRangeResponse, @@ -701,6 +702,15 @@ declare global { }, response: OdhResponse, ) => Cypress.Chainable) & + (( + type: 'GET /api/integrations/:internalRoute', + options: { + path: { + internalRoute: string; + }; + }, + response: OdhResponse, + ) => Cypress.Chainable) & (( type: 'PATCH /api/service/modelregistry/:serviceName/api/model_registry/:apiVersion/registered_models/:registeredModelId', options: { diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/projects/projectDetails.cy.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/projects/projectDetails.cy.ts index 3bdb8d3766..6a189daaae 100644 --- a/frontend/src/__tests__/cypress/cypress/tests/mocked/projects/projectDetails.cy.ts +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/projects/projectDetails.cy.ts @@ -40,6 +40,7 @@ import { asProjectAdminUser } from '~/__tests__/cypress/cypress/utils/mockUsers' import { NamespaceApplicationCase } from '~/pages/projects/types'; import { mockNimServingRuntimeTemplate } from '~/__mocks__/mockNimResource'; import { mockNimAccount } from '~/__mocks__/mockNimAccount'; +import { mockOdhApplication } from '~/__mocks__/mockOdhApplication'; type HandlersProps = { isEmpty?: boolean; @@ -274,6 +275,17 @@ const initIntercepts = ({ buildMockPipelines(isEmpty ? [] : [mockPipelineKF({})]), ); + cy.interceptOdh('GET /api/components', null, [mockOdhApplication({})]); + cy.interceptOdh( + 'GET /api/integrations/:internalRoute', + { path: { internalRoute: 'nim' } }, + { + isInstalled: true, + isEnabled: true, + canInstall: false, + error: '', + }, + ); cy.interceptK8sList(NIMAccountModel, mockK8sResourceList([mockNimAccount({})])); }; diff --git a/frontend/src/__tests__/cypress/cypress/utils/nimUtils.ts b/frontend/src/__tests__/cypress/cypress/utils/nimUtils.ts index 7bf22a7c6c..0a8727de52 100644 --- a/frontend/src/__tests__/cypress/cypress/utils/nimUtils.ts +++ b/frontend/src/__tests__/cypress/cypress/utils/nimUtils.ts @@ -32,6 +32,7 @@ import { import { mockAcceleratorProfile } from '~/__mocks__/mockAcceleratorProfile'; import type { InferenceServiceKind } from '~/k8sTypes'; import { mockNimAccount } from '~/__mocks__/mockNimAccount'; +import { mockOdhApplication } from '~/__mocks__/mockOdhApplication'; /* ################################################### ###### Interception Initialization Utilities ###### @@ -62,6 +63,20 @@ export const initInterceptsToEnableNim = ({ hasAllModels = false }: EnableNimCon }), ); + cy.interceptOdh('GET /api/components', null, [mockOdhApplication({})]); + + cy.interceptOdh( + 'GET /api/integrations/:internalRoute', + { path: { internalRoute: 'nim' } }, + { + isInstalled: true, + isEnabled: true, + canInstall: false, + error: '', + }, + ); + + cy.interceptK8sList(NIMAccountModel, mockK8sResourceList([mockNimAccount({})])); cy.interceptK8sList(ProjectModel, mockK8sResourceList([mockNimProject(hasAllModels)])); const templateMock = mockNimServingRuntimeTemplate(); @@ -79,8 +94,6 @@ export const initInterceptsToEnableNim = ({ hasAllModels = false }: EnableNimCon total: { 'nvidia.com/gpu': 1 }, allocated: { 'nvidia.com/gpu': 1 }, }); - - cy.interceptK8sList(NIMAccountModel, mockK8sResourceList([mockNimAccount({})])); }; // intercept all APIs required for deploying new NIM models in existing projects @@ -149,16 +162,35 @@ export const initInterceptorsValidatingNimEnablement = ( ): void => { cy.interceptOdh('GET /api/config', mockDashboardConfig(dashboardConfig)); + cy.interceptOdh('GET /api/components', null, [mockOdhApplication({})]); + + cy.interceptOdh( + 'GET /api/integrations/:internalRoute', + { path: { internalRoute: 'nim' } }, + { + isInstalled: true, + isEnabled: false, + canInstall: false, + error: '', + }, + ); + cy.interceptK8sList(NIMAccountModel, mockK8sResourceList([mockNimAccount({})])); + if (!disableServingRuntime) { - const templateMock = mockNimServingRuntimeTemplate(); - cy.interceptK8sList(TemplateModel, mockK8sResourceList([templateMock])); - cy.interceptK8s(TemplateModel, templateMock); + cy.interceptOdh( + 'GET /api/integrations/:internalRoute', + { path: { internalRoute: 'nim' } }, + { + isInstalled: true, + isEnabled: true, + canInstall: false, + error: '', + }, + ); } cy.interceptK8sList( ProjectModel, mockK8sResourceList([mockProjectK8sResource({ hasAnnotations: true })]), ); - - cy.interceptK8sList(NIMAccountModel, mockK8sResourceList([mockNimAccount({})])); }; diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx index 8c4d452ee5..614cc536a2 100644 --- a/frontend/src/app/App.tsx +++ b/frontend/src/app/App.tsx @@ -22,6 +22,7 @@ import ProjectsContextProvider from '~/concepts/projects/ProjectsContext'; import { ModelRegistrySelectorContextProvider } from '~/concepts/modelRegistry/context/ModelRegistrySelectorContext'; import useStorageClasses from '~/concepts/k8s/useStorageClasses'; import AreaContextProvider from '~/concepts/areas/AreaContext'; +import { NimContextProvider } from '~/concepts/nimServing/NIMAvailabilityContext'; import useDevFeatureFlags from './useDevFeatureFlags'; import Header from './Header'; import AppRoutes from './AppRoutes'; @@ -130,13 +131,15 @@ const App: React.FC = () => { } > - - - - - - - + + + + + + + + + diff --git a/frontend/src/concepts/nimServing/NIMAvailabilityContext.tsx b/frontend/src/concepts/nimServing/NIMAvailabilityContext.tsx new file mode 100644 index 0000000000..2994592385 --- /dev/null +++ b/frontend/src/concepts/nimServing/NIMAvailabilityContext.tsx @@ -0,0 +1,37 @@ +import * as React from 'react'; +import { useIsNIMAvailable } from '~/pages/modelServing/screens/projects/useIsNIMAvailable'; + +export type NIMAvailabilityContextType = { + isNIMAvailable: boolean; + loaded: boolean; +}; + +type NIMAvailabilityContextProviderProps = { + children: React.ReactNode; +}; + +export const NIMAvailabilityContext = React.createContext({ + isNIMAvailable: false, + loaded: false, +}); + +export const NimContextProvider: React.FC = ({ + children, + ...props +}) => { + return {children}; + + return children; +}; + +const EnabledNimContextProvider: React.FC = ({ children }) => { + const [isNIMAvailable, loaded] = useIsNIMAvailable(); + + const contextValue = React.useMemo(() => ({ isNIMAvailable, loaded }), [isNIMAvailable, loaded]); + + return ( + + {children} + + ); +}; diff --git a/frontend/src/pages/modelServing/ModelServingContext.tsx b/frontend/src/pages/modelServing/ModelServingContext.tsx index 398ee439e5..4d9fc75bf7 100644 --- a/frontend/src/pages/modelServing/ModelServingContext.tsx +++ b/frontend/src/pages/modelServing/ModelServingContext.tsx @@ -9,11 +9,11 @@ import { import { useNavigate } from 'react-router-dom'; import { ExclamationCircleIcon } from '@patternfly/react-icons'; import { - ServingRuntimeKind, InferenceServiceKind, - TemplateKind, ProjectKind, SecretKind, + ServingRuntimeKind, + TemplateKind, } from '~/k8sTypes'; import { DEFAULT_CONTEXT_DATA, DEFAULT_LIST_WATCH_RESULT } from '~/utilities/const'; import { ContextResourceData, CustomWatchK8sResult } from '~/types'; @@ -22,8 +22,8 @@ import { useDashboardNamespace } from '~/redux/selectors'; import { DataConnection } from '~/pages/projects/types'; import useDataConnections from '~/pages/projects/screens/detail/data-connections/useDataConnections'; import useSyncPreferredProject from '~/concepts/projects/useSyncPreferredProject'; -import { ProjectsContext, byName } from '~/concepts/projects/ProjectsContext'; -import { SupportedArea, conditionalArea } from '~/concepts/areas'; +import { byName, ProjectsContext } from '~/concepts/projects/ProjectsContext'; +import { conditionalArea, SupportedArea } from '~/concepts/areas'; import useServingPlatformStatuses from '~/pages/modelServing/useServingPlatformStatuses'; import { useTemplates } from '~/api'; import useInferenceServices from './useInferenceServices'; diff --git a/frontend/src/pages/modelServing/screens/global/InferenceServiceTableRow.tsx b/frontend/src/pages/modelServing/screens/global/InferenceServiceTableRow.tsx index ff34ef8080..dee0176440 100644 --- a/frontend/src/pages/modelServing/screens/global/InferenceServiceTableRow.tsx +++ b/frontend/src/pages/modelServing/screens/global/InferenceServiceTableRow.tsx @@ -11,6 +11,7 @@ import useIsAreaAvailable from '~/concepts/areas/useIsAreaAvailable'; import { getDisplayNameFromK8sResource } from '~/concepts/k8s/utils'; import { byName, ProjectsContext } from '~/concepts/projects/ProjectsContext'; import { isProjectNIMSupported } from '~/pages/modelServing/screens/projects/nimUtils'; +import useServingPlatformStatuses from '~/pages/modelServing/useServingPlatformStatuses'; import InferenceServiceEndpoint from './InferenceServiceEndpoint'; import InferenceServiceProject from './InferenceServiceProject'; import InferenceServiceStatus from './InferenceServiceStatus'; @@ -38,6 +39,8 @@ const InferenceServiceTableRow: React.FC = ({ const { projects } = React.useContext(ProjectsContext); const project = projects.find(byName(inferenceService.metadata.namespace)) ?? null; const isKServeNIMEnabled = project ? isProjectNIMSupported(project) : false; + const servingPlatformStatuses = useServingPlatformStatuses(); + const isNIMAvailable = servingPlatformStatuses.kServeNIM.enabled; const [modelMetricsEnabled] = useModelMetricsEnabled(); const kserveMetricsEnabled = useIsAreaAvailable(SupportedArea.K_SERVE_METRICS).status; @@ -112,6 +115,7 @@ const InferenceServiceTableRow: React.FC = ({ onClick: () => { onEditInferenceService(inferenceService); }, + isDisabled: !isNIMAvailable && isKServeNIMEnabled, }, { isSeparator: true }, { diff --git a/frontend/src/pages/modelServing/screens/global/ServeModelButton.tsx b/frontend/src/pages/modelServing/screens/global/ServeModelButton.tsx index c7a5d9b5d1..529ae373c2 100644 --- a/frontend/src/pages/modelServing/screens/global/ServeModelButton.tsx +++ b/frontend/src/pages/modelServing/screens/global/ServeModelButton.tsx @@ -12,9 +12,9 @@ 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'; import { isProjectNIMSupported } from '~/pages/modelServing/screens/projects/nimUtils'; import ManageNIMServingModal from '~/pages/modelServing/screens/projects/NIMServiceModal/ManageNIMServingModal'; +import useServingPlatformStatuses from '~/pages/modelServing/useServingPlatformStatuses'; const ServeModelButton: React.FC = () => { const [platformSelected, setPlatformSelected] = React.useState< @@ -31,6 +31,7 @@ const ServeModelButton: React.FC = () => { const { projects } = React.useContext(ProjectsContext); const { namespace } = useParams<{ namespace: string }>(); const servingPlatformStatuses = useServingPlatformStatuses(); + const isNIMAvailable = servingPlatformStatuses.kServeNIM.enabled; const project = projects.find(byName(namespace)); @@ -58,7 +59,9 @@ const ServeModelButton: React.FC = () => { getProjectModelServingPlatform(project, servingPlatformStatuses).platform, ) } - isAriaDisabled={!project || !templatesEnabled} + isAriaDisabled={ + !project || templatesEnabled.length === 0 || (!isNIMAvailable && isKServeNIMEnabled) + } > Deploy model @@ -72,6 +75,12 @@ const ServeModelButton: React.FC = () => { ); } + if (!isNIMAvailable && isKServeNIMEnabled) { + return ( + {deployButton} + ); + } + return ( <> {deployButton} diff --git a/frontend/src/pages/modelServing/screens/projects/EmptySingleModelServingCard.tsx b/frontend/src/pages/modelServing/screens/projects/EmptySingleModelServingCard.tsx index b950d71714..76cc83dc86 100644 --- a/frontend/src/pages/modelServing/screens/projects/EmptySingleModelServingCard.tsx +++ b/frontend/src/pages/modelServing/screens/projects/EmptySingleModelServingCard.tsx @@ -20,6 +20,7 @@ const EmptySingleModelServingCard: React.FC = setErrorSelectingPlatform, }) => { const { currentProject } = React.useContext(ProjectDetailsContext); + return ( { const servingPlatformStatuses = useServingPlatformStatuses(); const kServeEnabled = servingPlatformStatuses.kServe.enabled; - const isNIMAvailable = servingPlatformStatuses.kServeNIM.enabled; const modelMeshEnabled = servingPlatformStatuses.modelMesh.enabled; + const isNIMAvailable = servingPlatformStatuses.kServeNIM.enabled; const { servingRuntimes: { diff --git a/frontend/src/pages/modelServing/screens/projects/ModelServingPlatformButtonAction.tsx b/frontend/src/pages/modelServing/screens/projects/ModelServingPlatformButtonAction.tsx index 4f7057ce24..1eecf4588f 100644 --- a/frontend/src/pages/modelServing/screens/projects/ModelServingPlatformButtonAction.tsx +++ b/frontend/src/pages/modelServing/screens/projects/ModelServingPlatformButtonAction.tsx @@ -1,6 +1,8 @@ import * as React from 'react'; -import { Button, Tooltip, Content, ButtonProps } from '@patternfly/react-core'; +import { Button, ButtonProps, Content, Tooltip } from '@patternfly/react-core'; import { ProjectDetailsContext } from '~/pages/projects/ProjectDetailsContext'; +import { isProjectNIMSupported } from '~/pages/modelServing/screens/projects/nimUtils'; +import useServingPlatformStatuses from '~/pages/modelServing/useServingPlatformStatuses'; type ModelServingPlatformButtonActionProps = ButtonProps & { isProjectModelMesh: boolean; @@ -17,13 +19,18 @@ const ModelServingPlatformButtonAction: React.FC { const { servingRuntimeTemplates: [, templatesLoaded], + currentProject, } = React.useContext(ProjectDetailsContext); + const servingPlatformStatuses = useServingPlatformStatuses(); + const isNIMAvailable = servingPlatformStatuses.kServeNIM.enabled; + const isKServeNIMEnabled = isProjectNIMSupported(currentProject); + const isNimDisabled = !isNIMAvailable && isKServeNIMEnabled; - const actionButton = () => ( + const actionButton = (