diff --git a/frontend/src/__tests__/integration/pages/modelServing/ModelServingGlobal.spec.ts b/frontend/src/__tests__/integration/pages/modelServing/ModelServingGlobal.spec.ts index 12fd9cc979..7509d507da 100644 --- a/frontend/src/__tests__/integration/pages/modelServing/ModelServingGlobal.spec.ts +++ b/frontend/src/__tests__/integration/pages/modelServing/ModelServingGlobal.spec.ts @@ -28,6 +28,21 @@ test('Empty State No Inference Service', async ({ page }) => { await expect(await page.getByRole('button', { name: 'Deploy', exact: true })).toBeDisabled(); }); +test('No serving platform installed', async ({ page }) => { + await page.goto( + navigateToStory('pages-modelserving-modelservingglobal', 'no-platform-installed'), + ); + + // wait for page to load + await page.waitForSelector('text=Problem loading model serving page'); + + // Test that the button is enabled + await page.getByRole('button', { name: 'View my projects' }).isEnabled(); + + // test that you can not submit on empty + await expect(await page.getByText('No model serving platform installed')).toBeVisible(); +}); + test('Delete model', async ({ page }) => { await page.goto(navigateToStory('pages-modelserving-modelservingglobal', 'delete-model')); diff --git a/frontend/src/__tests__/integration/pages/modelServing/ModelServingGlobal.stories.tsx b/frontend/src/__tests__/integration/pages/modelServing/ModelServingGlobal.stories.tsx index ad72d908c6..f716487b85 100644 --- a/frontend/src/__tests__/integration/pages/modelServing/ModelServingGlobal.stories.tsx +++ b/frontend/src/__tests__/integration/pages/modelServing/ModelServingGlobal.stories.tsx @@ -13,7 +13,6 @@ import { mockInferenceServicek8sError, } from '~/__mocks__/mockInferenceServiceK8sResource'; 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'; @@ -30,6 +29,7 @@ import useDetectUser from '~/utilities/useDetectUser'; import { useApplicationSettings } from '~/app/useApplicationSettings'; import { AppContext } from '~/app/AppContext'; import { InferenceServiceKind, ServingRuntimeKind } from '~/k8sTypes'; +import GlobalModelServingCoreLoader from '~/pages/modelServing/screens/global/GlobalModelServingCoreLoader'; type HandlersProps = { disableKServeConfig?: boolean; @@ -145,39 +145,55 @@ export default { }, } as Meta; -const Template: StoryFn = (args) => { - useDetectUser(); - const { dashboardConfig, loaded } = useApplicationSettings(); +type TemplateProps = { + kServeInstalled?: boolean; + modelMeshInstalled?: boolean; +}; - return loaded && dashboardConfig ? ( - - - - - }> - } /> - } /> - - - - - - ) : ( - - ); +const Template: (props: TemplateProps) => StoryFn = ({ + kServeInstalled = true, + modelMeshInstalled = true, +}) => { + const InnerTemplate: StoryFn = (args) => { + useDetectUser(); + const { dashboardConfig, loaded } = useApplicationSettings(); + return loaded && dashboardConfig ? ( + + + + + `/modelServing/${namespace}`} + /> + } + > + } /> + + + + + + ) : ( + + ); + }; + return InnerTemplate; }; export const EmptyStateNoServingRuntime: StoryObj = { - render: Template, + render: Template({}), parameters: { msw: { @@ -193,7 +209,7 @@ export const EmptyStateNoServingRuntime: StoryObj = { }; export const EmptyStateNoInferenceServices: StoryObj = { - render: Template, + render: Template({}), parameters: { msw: { @@ -205,8 +221,22 @@ export const EmptyStateNoInferenceServices: StoryObj = { }, }; +export const NoPlatformInstalled: StoryObj = { + render: Template({ kServeInstalled: false, modelMeshInstalled: false }), + + parameters: { + msw: { + handlers: getHandlers({ + projectEnableModelMesh: true, + servingRuntimes: [], + inferenceServices: [], + }), + }, + }, +}; + export const EditModel: StoryObj = { - render: Template, + render: Template({}), parameters: { a11y: { @@ -230,7 +260,7 @@ export const EditModel: StoryObj = { }; export const DeleteModel: StoryObj = { - render: Template, + render: Template({}), parameters: { a11y: { @@ -253,7 +283,7 @@ export const DeleteModel: StoryObj = { }; export const DeployModelModelMesh: StoryObj = { - render: Template, + render: Template({}), parameters: { a11y: { @@ -278,7 +308,7 @@ export const DeployModelModelMesh: StoryObj = { }; export const DeployModelModelKServe: StoryObj = { - render: Template, + render: Template({}), parameters: { a11y: { diff --git a/frontend/src/pages/clusterSettings/ClusterSettings.tsx b/frontend/src/pages/clusterSettings/ClusterSettings.tsx index 161d08a3d2..3eeef69991 100644 --- a/frontend/src/pages/clusterSettings/ClusterSettings.tsx +++ b/frontend/src/pages/clusterSettings/ClusterSettings.tsx @@ -35,7 +35,7 @@ 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 modelServingEnabled = useIsAreaAvailable(SupportedArea.MODEL_SERVING).status; const isJupyterEnabled = useCheckJupyterEnabled(); const [notebookTolerationSettings, setNotebookTolerationSettings] = React.useState({ diff --git a/frontend/src/pages/modelServing/ModelServingContext.tsx b/frontend/src/pages/modelServing/ModelServingContext.tsx index 4ee5ac953d..eb1cd29a0a 100644 --- a/frontend/src/pages/modelServing/ModelServingContext.tsx +++ b/frontend/src/pages/modelServing/ModelServingContext.tsx @@ -1,17 +1,15 @@ import * as React from 'react'; -import { Outlet, useParams } from 'react-router-dom'; import { Bullseye, Button, EmptyState, EmptyStateBody, EmptyStateIcon, - Spinner, Title, } from '@patternfly/react-core'; import { useNavigate } from 'react-router-dom'; import { ExclamationCircleIcon } from '@patternfly/react-icons'; -import { ServingRuntimeKind, InferenceServiceKind, TemplateKind } from '~/k8sTypes'; +import { ServingRuntimeKind, InferenceServiceKind, TemplateKind, ProjectKind } from '~/k8sTypes'; import { DEFAULT_CONTEXT_DATA } from '~/utilities/const'; import { ContextResourceData } from '~/types'; import { useContextResourceData } from '~/utilities/useContextResourceData'; @@ -20,6 +18,8 @@ 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 useServingPlatformStatuses from '~/pages/modelServing/useServingPlatformStatuses'; import useInferenceServices from './useInferenceServices'; import useServingRuntimes from './useServingRuntimes'; import useTemplates from './customServingRuntimes/useTemplates'; @@ -34,6 +34,12 @@ type ModelServingContextType = { servingRuntimeTemplateDisablement: ContextResourceData; servingRuntimes: ContextResourceData; inferenceServices: ContextResourceData; + project: ProjectKind | null; +}; + +type ModelServingContextProviderProps = { + children: React.ReactNode; + namespace?: string; }; export const ModelServingContext = React.createContext({ @@ -44,12 +50,15 @@ export const ModelServingContext = React.createContext( servingRuntimeTemplateDisablement: DEFAULT_CONTEXT_DATA, servingRuntimes: DEFAULT_CONTEXT_DATA, inferenceServices: DEFAULT_CONTEXT_DATA, + project: null, }); -const ModelServingContextProvider: React.FC = () => { +const ModelServingContextProvider = conditionalArea( + SupportedArea.MODEL_SERVING, + true, +)(({ children, namespace }) => { const { dashboardNamespace } = useDashboardNamespace(); const navigate = useNavigate(); - const { namespace } = useParams<{ namespace: string }>(); const { projects } = React.useContext(ProjectsContext); const project = projects.find(byName(namespace)) ?? null; useSyncPreferredProject(project); @@ -77,7 +86,18 @@ const ModelServingContextProvider: React.FC = () => { dataConnectionRefresh(); }, [servingRuntimeRefresh, inferenceServiceRefresh, dataConnectionRefresh]); + const { + kServe: { installed: kServeInstalled }, + modelMesh: { installed: modelMeshInstalled }, + } = useServingPlatformStatuses(); + + const notInstalledError = + !kServeInstalled && !modelMeshInstalled + ? new Error('No model serving platform installed') + : undefined; + if ( + notInstalledError || servingRuntimes.error || inferenceServices.error || servingRuntimeTemplates.error || @@ -93,7 +113,8 @@ const ModelServingContextProvider: React.FC = () => { Problem loading model serving page - {servingRuntimes.error?.message || + {notInstalledError?.message || + servingRuntimes.error?.message || inferenceServices.error?.message || servingRuntimeTemplates.error?.message || servingRuntimeTemplateOrder.error?.message || @@ -108,20 +129,6 @@ const ModelServingContextProvider: React.FC = () => { ); } - if ( - !servingRuntimes.loaded || - !inferenceServices.loaded || - !servingRuntimeTemplates.loaded || - !servingRuntimeTemplateOrder.loaded || - !servingRuntimeTemplateDisablement.loaded - ) { - return ( - - - - ); - } - return ( { servingRuntimeTemplateDisablement, dataConnections, refreshAllData, + project, }} > - + {children} ); -}; +}); export default ModelServingContextProvider; diff --git a/frontend/src/pages/modelServing/ModelServingRoutes.tsx b/frontend/src/pages/modelServing/ModelServingRoutes.tsx index b0804b2893..b6254462a7 100644 --- a/frontend/src/pages/modelServing/ModelServingRoutes.tsx +++ b/frontend/src/pages/modelServing/ModelServingRoutes.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { Navigate, Route } from 'react-router-dom'; import ProjectsRoutes from '~/concepts/projects/ProjectsRoutes'; -import ModelServingContextProvider from './ModelServingContext'; +import GlobalModelServingCoreLoader from '~/pages/modelServing/screens/global/GlobalModelServingCoreLoader'; import ModelServingMetricsWrapper from './screens/metrics/ModelServingMetricsWrapper'; import ModelServingGlobal from './screens/global/ModelServingGlobal'; import useModelMetricsEnabled from './useModelMetricsEnabled'; @@ -11,11 +11,17 @@ const ModelServingRoutes: React.FC = () => { return ( - }> + `/modelServing/${namespace}`} + /> + } + > } /> - } /> : } diff --git a/frontend/src/pages/modelServing/screens/global/EmptyModelServing.tsx b/frontend/src/pages/modelServing/screens/global/EmptyModelServing.tsx index 377506e4d7..2e42fd5eae 100644 --- a/frontend/src/pages/modelServing/screens/global/EmptyModelServing.tsx +++ b/frontend/src/pages/modelServing/screens/global/EmptyModelServing.tsx @@ -8,13 +8,11 @@ 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'; @@ -23,13 +21,10 @@ const EmptyModelServing: React.FC = () => { const navigate = useNavigate(); const { servingRuntimes: { data: servingRuntimes }, + project, } = React.useContext(ModelServingContext); - const { projects } = React.useContext(ProjectsContext); - const { namespace } = useParams<{ namespace: string }>(); const servingPlatformStatuses = useServingPlatformStatuses(); - const project = projects.find(byName(namespace)); - if ( getProjectModelServingPlatform(project, servingPlatformStatuses).platform !== ServingRuntimePlatform.SINGLE && diff --git a/frontend/src/pages/modelServing/screens/global/GlobalModelServingCoreLoader.tsx b/frontend/src/pages/modelServing/screens/global/GlobalModelServingCoreLoader.tsx new file mode 100644 index 0000000000..e949b449ed --- /dev/null +++ b/frontend/src/pages/modelServing/screens/global/GlobalModelServingCoreLoader.tsx @@ -0,0 +1,75 @@ +import * as React from 'react'; +import { Navigate, Outlet, useParams } from 'react-router-dom'; +import ApplicationsPage from '~/pages/ApplicationsPage'; +import { byName, ProjectsContext } from '~/concepts/projects/ProjectsContext'; +import InvalidProject from '~/concepts/projects/InvalidProject'; +import ModelServingContextProvider from '~/pages/modelServing/ModelServingContext'; +import ModelServingNoProjects from '~/pages/modelServing/screens/global/ModelServingNoProjects'; +import ModelServingProjectSelection from '~/pages/modelServing/screens/global/ModelServingProjectSelection'; + +type ApplicationPageProps = React.ComponentProps; +type EmptyStateProps = 'emptyStatePage' | 'empty'; + +type GlobalModelServingCoreLoaderProps = { + getInvalidRedirectPath: (namespace: string) => string; +}; + +type ApplicationPageRenderState = Pick; + +const GlobalModelServingCoreLoader: React.FC = ({ + getInvalidRedirectPath, +}) => { + const { namespace } = useParams<{ namespace: string }>(); + const { projects, preferredProject } = React.useContext(ProjectsContext); + + let renderStateProps: ApplicationPageRenderState & { children?: React.ReactNode }; + if (projects.length === 0) { + renderStateProps = { + empty: true, + emptyStatePage: , + }; + } else { + if (namespace) { + const foundProject = projects.find(byName(namespace)); + if (foundProject) { + // Render the content + return ( + + + + ); + } + + // They ended up on a non-valid project path + renderStateProps = { + empty: true, + emptyStatePage: ( + + ), + }; + } else { + // Redirect the namespace suffix into the URL + if (preferredProject) { + return ; + } + // Go with All projects path + return ( + + + + ); + } + } + + return ( + } + provideChildrenPadding + /> + ); +}; +export default GlobalModelServingCoreLoader; diff --git a/frontend/src/pages/modelServing/screens/global/ModelServingGlobal.tsx b/frontend/src/pages/modelServing/screens/global/ModelServingGlobal.tsx index 79dc66d2f7..d9fc98f7f6 100644 --- a/frontend/src/pages/modelServing/screens/global/ModelServingGlobal.tsx +++ b/frontend/src/pages/modelServing/screens/global/ModelServingGlobal.tsx @@ -1,90 +1,38 @@ import React from 'react'; -import { Navigate, 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 }, + servingRuntimes: { data: servingRuntimes, loaded: servingRuntimesLoaded }, + inferenceServices: { data: inferenceServices, loaded: inferenceServicesLoaded }, + project: currentProject, } = React.useContext(ModelServingContext); - const { projects, preferredProject } = React.useContext(ProjectsContext); - - const getRedirectPath = (projectName: string) => `/modelServing/${projectName}`; - 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) { - if (!currentProject) { - renderStateProps = { - empty: true, - emptyStatePage: ( - - ), - }; - } - } else { - // Redirect the namespace suffix into the URL - if (preferredProject) { - return ; - } - } - } - return ( } title="Deployed models" description="Manage and view the health and performance of your deployed models." - loadError={loadError} - loaded - headerContent={} + loadError={notInstalledError} + loaded={servingRuntimesLoaded && inferenceServicesLoaded} + headerContent={ + `/modelServing/${namespace}`} + /> + } provideChildrenPadding > { - const { project: projectName, inferenceService: modelName } = useParams<{ - project: string; + const { namespace: projectName, inferenceService: modelName } = useParams<{ + namespace: string; inferenceService: string; }>(); const { diff --git a/frontend/src/pages/modelServing/screens/projects/ModelServingPlatform.tsx b/frontend/src/pages/modelServing/screens/projects/ModelServingPlatform.tsx index d2600cf68e..2fc12d23cb 100644 --- a/frontend/src/pages/modelServing/screens/projects/ModelServingPlatform.tsx +++ b/frontend/src/pages/modelServing/screens/projects/ModelServingPlatform.tsx @@ -102,7 +102,7 @@ const ModelServingPlatform: React.FC = () => { } isLoading={!servingRuntimesLoaded && !templatesLoaded} isEmpty={!shouldShowPlatformSelection && emptyModelServer} - loadError={servingRuntimeError || templateError || platformError} + loadError={platformError || servingRuntimeError || templateError} emptyState={ { const { kServe: { enabled: kServeEnabled, installed: kServeInstalled }, modelMesh: { enabled: modelMeshEnabled, installed: modelMeshInstalled }, } = platformStatuses; - if (project === undefined) { + if (!project) { return {}; } if (project.metadata.labels[KnownLabels.MODEL_SERVING_PROJECT] === undefined) { diff --git a/frontend/src/pages/modelServing/useInferenceServices.ts b/frontend/src/pages/modelServing/useInferenceServices.ts index 31b1b348cb..82d428f8ce 100644 --- a/frontend/src/pages/modelServing/useInferenceServices.ts +++ b/frontend/src/pages/modelServing/useInferenceServices.ts @@ -16,7 +16,9 @@ const useInferenceServices = (namespace?: string): FetchState(getServingInferences, []); + return useFetchState(getServingInferences, [], { + initialPromisePurity: true, + }); }; export default useInferenceServices; diff --git a/frontend/src/pages/modelServing/useServingRuntimes.ts b/frontend/src/pages/modelServing/useServingRuntimes.ts index d2a3d2c9f9..3afa3f775b 100644 --- a/frontend/src/pages/modelServing/useServingRuntimes.ts +++ b/frontend/src/pages/modelServing/useServingRuntimes.ts @@ -28,7 +28,9 @@ const useServingRuntimes = ( }); }, [namespace, modelServingEnabled, notReady]); - return useFetchState(getServingRuntimes, []); + return useFetchState(getServingRuntimes, [], { + initialPromisePurity: true, + }); }; export default useServingRuntimes;