diff --git a/backend/src/routes/api/service/modelregistry/index.ts b/backend/src/routes/api/service/modelregistry/index.ts index f4cd428dad..a236548397 100644 --- a/backend/src/routes/api/service/modelregistry/index.ts +++ b/backend/src/routes/api/service/modelregistry/index.ts @@ -4,31 +4,29 @@ import { DEV_MODE } from '../../../../utils/constants'; import { getParam, setParam } from '../../../../utils/proxy'; export default async (fastify: KubeFastifyInstance): Promise => { - if (DEV_MODE) { - fastify.register(httpProxy, { - upstream: '', - prefix: '/:name', - rewritePrefix: '', - replyOptions: { - // preHandler must set the `upstream` param - getUpstream: (request) => getParam(request, 'upstream'), - }, - preHandler: (request, _, done) => { - const name = getParam(request, 'name'); + fastify.register(httpProxy, { + upstream: '', + prefix: '/:name', + rewritePrefix: '', + replyOptions: { + // preHandler must set the `upstream` param + getUpstream: (request) => getParam(request, 'upstream'), + }, + preHandler: (request, _, done) => { + const name = getParam(request, 'name'); - const upstream = DEV_MODE - ? // Use port forwarding for local development: - // kubectl port-forward -n svc/ : - `http://${process.env.MODEL_REGISTRY_SERVICE_HOST}:${process.env.MODEL_REGISTRY_SERVICE_PORT}` - : // Construct service URL - `http://${name}.odh-model-registries.svc.cluster.local:8080`; + const upstream = DEV_MODE + ? // Use port forwarding for local development: + // kubectl port-forward -n svc/ : + `http://${process.env.MODEL_REGISTRY_SERVICE_HOST}:${process.env.MODEL_REGISTRY_SERVICE_PORT}` + : // Construct service URL + `http://${name}.odh-model-registries.svc.cluster.local:8080`; - // assign the `upstream` param so we can dynamically set the upstream URL for http-proxy - setParam(request, 'upstream', upstream); + // assign the `upstream` param so we can dynamically set the upstream URL for http-proxy + setParam(request, 'upstream', upstream); - fastify.log.info(`Proxy ${request.method} request ${request.url} to ${upstream}`); - done(); - }, - }); - } + fastify.log.info(`Proxy ${request.method} request ${request.url} to ${upstream}`); + done(); + }, + }); }; diff --git a/frontend/src/__tests__/cypress/cypress/e2e/modelRegistry/ModelRegistry.cy.ts b/frontend/src/__tests__/cypress/cypress/e2e/modelRegistry/ModelRegistry.cy.ts index e8781191de..f09391c755 100644 --- a/frontend/src/__tests__/cypress/cypress/e2e/modelRegistry/ModelRegistry.cy.ts +++ b/frontend/src/__tests__/cypress/cypress/e2e/modelRegistry/ModelRegistry.cy.ts @@ -76,7 +76,7 @@ describe('Model Registry', () => { modelRegistry.visit(); modelRegistry.navigate(); - modelRegistry.shouldtableToolbarExist(); + modelRegistry.shouldModelRegistrySelectorExist(); modelRegistry.shouldregisteredModelsEmpty(); }); diff --git a/frontend/src/__tests__/cypress/cypress/pages/modelRegistry.ts b/frontend/src/__tests__/cypress/cypress/pages/modelRegistry.ts index 9069d43e2a..855c9b9833 100644 --- a/frontend/src/__tests__/cypress/cypress/pages/modelRegistry.ts +++ b/frontend/src/__tests__/cypress/cypress/pages/modelRegistry.ts @@ -82,6 +82,10 @@ class ModelRegistry { cy.findByTestId('no-registered-models').should('exist'); } + shouldModelRegistrySelectorExist() { + cy.get('#model-registry-selector-dropdown').should('exist'); + } + shouldtableToolbarExist() { cy.findByTestId('registered-models-table-toolbar').should('exist'); } diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx index 6988612dc8..930c1c78cc 100644 --- a/frontend/src/app/App.tsx +++ b/frontend/src/app/App.tsx @@ -29,7 +29,6 @@ import { useApplicationSettings } from './useApplicationSettings'; import TelemetrySetup from './TelemetrySetup'; import { logout } from './appUtils'; import QuickStarts from './QuickStarts'; - import './App.scss'; const App: React.FC = () => { diff --git a/frontend/src/concepts/modelRegistry/context/ModelRegistryContext.tsx b/frontend/src/concepts/modelRegistry/context/ModelRegistryContext.tsx index b5ddda600d..eb3bdce52d 100644 --- a/frontend/src/concepts/modelRegistry/context/ModelRegistryContext.tsx +++ b/frontend/src/concepts/modelRegistry/context/ModelRegistryContext.tsx @@ -1,8 +1,6 @@ import * as React from 'react'; import { Alert, Bullseye } from '@patternfly/react-core'; import { SupportedArea, conditionalArea } from '~/concepts/areas'; -import { ModelRegistryKind } from '~/k8sTypes'; -import useModelRegistries from '~/concepts/modelRegistry/apiHooks/useModelRegistries'; import { MODEL_REGISTRY_DEFAULT_NAMESPACE } from '~/concepts/modelRegistry/const'; import useModelRegistryAPIState, { ModelRegistryAPIState } from './useModelRegistryAPIState'; import { @@ -19,9 +17,6 @@ export type ModelRegistryContextType = { ignoreTimedOut: () => void; refreshState: () => Promise; refreshAPIState: () => void; - modelRegistries: ModelRegistryKind[]; - preferredModelRegistry: ModelRegistryKind | undefined; - updatePreferredModelRegistry: (modelRegistry: ModelRegistryKind | undefined) => void; }; type ModelRegistryContextProviderProps = { @@ -37,25 +32,18 @@ export const ModelRegistryContext = React.createContext undefined, refreshState: async () => undefined, refreshAPIState: () => undefined, - modelRegistries: [], - preferredModelRegistry: undefined, - updatePreferredModelRegistry: () => undefined, }); export const ModelRegistryContextProvider = conditionalArea( SupportedArea.MODEL_REGISTRY, true, )(({ children, modelRegistryName }) => { - const [modelRegistries] = useModelRegistries(); - const [preferredModelRegistry, setPreferredModelRegistry] = - React.useState(undefined); - - const crState = useModelRegistryNamespaceCR(MODEL_REGISTRY_DEFAULT_NAMESPACE, modelRegistryName); - const [modelRegistryNamespaceCR, crLoaded, crLoadError, refreshCR] = crState; - const isCRReady = isModelRegistryAvailable(crState); + const state = useModelRegistryNamespaceCR(MODEL_REGISTRY_DEFAULT_NAMESPACE, modelRegistryName); + const [modelRegistryCR, crLoaded, crLoadError, refreshCR] = state; + const isCRReady = isModelRegistryAvailable(state); const [disableTimeout, setDisableTimeout] = React.useState(false); - const serverTimedOut = !disableTimeout && hasServerTimedOut(crState, isCRReady); + const serverTimedOut = !disableTimeout && hasServerTimedOut(state, isCRReady); const ignoreTimedOut = React.useCallback(() => { setDisableTimeout(true); }, []); @@ -64,29 +52,16 @@ export const ModelRegistryContextProvider = conditionalArea { - if (modelRegistries.length > 0 && !preferredModelRegistry) { - setPreferredModelRegistry(modelRegistries[0]); - } - }, [modelRegistries, preferredModelRegistry]); - const refreshState = React.useCallback( () => Promise.all([refreshCR()]).then(() => undefined), [refreshCR], ); - const updatePreferredModelRegistry = React.useCallback< - ModelRegistryContextType['updatePreferredModelRegistry'] - >((modelRegistry) => { - setPreferredModelRegistry(modelRegistry); - }, []); - - const error = crLoadError; - if (error) { + if (crLoadError) { return ( - {error.message} + {crLoadError.message} ); @@ -95,16 +70,13 @@ export const ModelRegistryContextProvider = conditionalArea {children} diff --git a/frontend/src/concepts/modelRegistry/context/ModelRegistrySelectorContext.tsx b/frontend/src/concepts/modelRegistry/context/ModelRegistrySelectorContext.tsx new file mode 100644 index 0000000000..155adea01a --- /dev/null +++ b/frontend/src/concepts/modelRegistry/context/ModelRegistrySelectorContext.tsx @@ -0,0 +1,71 @@ +import * as React from 'react'; +import { Alert, Bullseye } from '@patternfly/react-core'; +import { SupportedArea, conditionalArea } from '~/concepts/areas'; +import { ModelRegistryKind } from '~/k8sTypes'; +import useModelRegistries from '~/concepts/modelRegistry/apiHooks/useModelRegistries'; + +export type ModelRegistrySelectorContextType = { + modelRegistries: ModelRegistryKind[]; + preferredModelRegistry: ModelRegistryKind | undefined; + updatePreferredModelRegistry: (modelRegistry: ModelRegistryKind | undefined) => void; +}; + +type ModelRegistrySelectorContextProviderProps = { + children: React.ReactNode; +}; + +export const ModelRegistrySelectorContext = React.createContext({ + modelRegistries: [], + preferredModelRegistry: undefined, + updatePreferredModelRegistry: () => undefined, +}); + +export const ModelRegistrySelectorContextProvider = + conditionalArea( + SupportedArea.MODEL_REGISTRY, + true, + )(({ children }) => { + const [modelRegistries, isLoaded, error] = useModelRegistries(); + const [preferredModelRegistry, setPreferredModelRegistry] = + React.useState(undefined); + + const firstModelRegistry = modelRegistries.length > 0 ? modelRegistries[0] : null; + + React.useEffect(() => { + if (firstModelRegistry && !preferredModelRegistry) { + setPreferredModelRegistry(firstModelRegistry); + } + }, [firstModelRegistry, preferredModelRegistry]); + + const updatePreferredModelRegistry = React.useCallback< + ModelRegistrySelectorContextType['updatePreferredModelRegistry'] + >((modelRegistry) => { + setPreferredModelRegistry(modelRegistry); + }, []); + + if (!isLoaded) { + return Loading model registries...; + } + + if (error) { + return ( + + + {error.message} + + + ); + } + + return ( + + {children} + + ); + }); diff --git a/frontend/src/pages/modelRegistry/ModelRegistryCoreLoader.tsx b/frontend/src/pages/modelRegistry/ModelRegistryCoreLoader.tsx index a5094c5c51..31db81476c 100644 --- a/frontend/src/pages/modelRegistry/ModelRegistryCoreLoader.tsx +++ b/frontend/src/pages/modelRegistry/ModelRegistryCoreLoader.tsx @@ -1,12 +1,77 @@ import * as React from 'react'; -import { Outlet } from 'react-router'; - +import { Navigate, Outlet, useParams } from 'react-router'; import { ModelRegistryContextProvider } from '~/concepts/modelRegistry/context/ModelRegistryContext'; +import ApplicationsPage from '~/pages/ApplicationsPage'; +import TitleWithIcon from '~/concepts/design/TitleWithIcon'; +import { ProjectObjectType } from '~/concepts/design/utils'; + +import { ModelRegistrySelectorContext } from '~/concepts/modelRegistry/context/ModelRegistrySelectorContext'; +import InvalidModelRegistry from './screens/InvalidModelRegistry'; +import ModelRegistrySelector from './screens/ModelRegistrySelector'; +import EmptyRegisteredModels from './screens/EmptyRegisteredModels'; + +type ApplicationPageProps = React.ComponentProps; +type EmptyStateProps = 'emptyStatePage' | 'empty'; + +type ModelRegistryCoreLoaderProps = { + getInvalidRedirectPath: (modelRegistry: string) => string; +}; + +type ApplicationPageRenderState = Pick; // TODO: Parametrize this to make the route dynamic -const ModelRegistryCoreLoader: React.FC = () => ( - - - -); +const ModelRegistryCoreLoader: React.FC = ({ + getInvalidRedirectPath, +}) => { + const { modelRegistry } = useParams<{ modelRegistry: string }>(); + const { modelRegistries, preferredModelRegistry } = React.useContext( + ModelRegistrySelectorContext, + ); + + let renderStateProps: ApplicationPageRenderState & { children?: React.ReactNode }; + if (modelRegistries.length === 0) { + renderStateProps = { + empty: true, + emptyStatePage: , + }; + } else if (modelRegistry) { + const foundModelRegistry = modelRegistries.find((mr) => mr.metadata.name === modelRegistry); + if (foundModelRegistry) { + // 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 + const redirectModelRegistry = preferredModelRegistry ?? modelRegistries[0]; + return ; + } + + return ( + + } + {...renderStateProps} + loaded + headerContent={} + provideChildrenPadding + /> + ); +}; + export default ModelRegistryCoreLoader; diff --git a/frontend/src/pages/modelRegistry/ModelRegistryRoutes.tsx b/frontend/src/pages/modelRegistry/ModelRegistryRoutes.tsx index c8760b5c86..754d37ff1e 100644 --- a/frontend/src/pages/modelRegistry/ModelRegistryRoutes.tsx +++ b/frontend/src/pages/modelRegistry/ModelRegistryRoutes.tsx @@ -1,16 +1,25 @@ import * as React from 'react'; -import { Navigate, Route } from 'react-router-dom'; -import ProjectsRoutes from '~/concepts/projects/ProjectsRoutes'; +import { Navigate, Route, Routes } from 'react-router-dom'; +import { ModelRegistrySelectorContextProvider } from '~/concepts/modelRegistry/context/ModelRegistrySelectorContext'; import ModelRegistryCoreLoader from './ModelRegistryCoreLoader'; import ModelRegistry from './screens/ModelRegistry'; const ModelServingRoutes: React.FC = () => ( - - }> - } /> - } /> - - + + + `/modelRegistry/${modelRegistry}`} + /> + } + > + } /> + } /> + + + ); export default ModelServingRoutes; diff --git a/frontend/src/pages/modelRegistry/screens/EmptyRegisteredModels.tsx b/frontend/src/pages/modelRegistry/screens/EmptyRegisteredModels.tsx index e5fc317e0c..22e894bf57 100644 --- a/frontend/src/pages/modelRegistry/screens/EmptyRegisteredModels.tsx +++ b/frontend/src/pages/modelRegistry/screens/EmptyRegisteredModels.tsx @@ -1,6 +1,8 @@ import { + Button, EmptyState, EmptyStateBody, + EmptyStateFooter, EmptyStateHeader, EmptyStateIcon, EmptyStateVariant, @@ -22,6 +24,18 @@ const EmptyRegisteredModels: React.FC = ({ preferredM
registry or select a different one. + + + , + ); diff --git a/frontend/src/pages/modelRegistry/screens/InvalidModelRegistry.tsx b/frontend/src/pages/modelRegistry/screens/InvalidModelRegistry.tsx new file mode 100644 index 0000000000..00c6057bfb --- /dev/null +++ b/frontend/src/pages/modelRegistry/screens/InvalidModelRegistry.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; +import EmptyStateErrorMessage from '~/components/EmptyStateErrorMessage'; +import ProjectSelectorNavigator from '~/concepts/projects/ProjectSelectorNavigator'; + +type InvalidModelRegistryProps = { + title?: string; + modelRegistry?: string; + getRedirectPath: (namespace: string) => string; +}; + +const InvalidModelRegistry: React.FC = ({ + title, + modelRegistry, + getRedirectPath, +}) => ( + + {/* TODO: Replace this with a model registry selector */} + + +); + +export default InvalidModelRegistry; diff --git a/frontend/src/pages/modelRegistry/screens/ModelRegistry.tsx b/frontend/src/pages/modelRegistry/screens/ModelRegistry.tsx index 3530581e98..8865f18da8 100644 --- a/frontend/src/pages/modelRegistry/screens/ModelRegistry.tsx +++ b/frontend/src/pages/modelRegistry/screens/ModelRegistry.tsx @@ -1,26 +1,28 @@ import React from 'react'; import ApplicationsPage from '~/pages/ApplicationsPage'; -import { ModelRegistryContext } from '~/concepts/modelRegistry/context/ModelRegistryContext'; import useRegisteredModels from '~/concepts/modelRegistry/apiHooks/useRegisteredModels'; +import { ModelRegistrySelectorContext } from '~/concepts/modelRegistry/context/ModelRegistrySelectorContext'; +import TitleWithIcon from '~/concepts/design/TitleWithIcon'; +import { ProjectObjectType } from '~/concepts/design/utils'; import EmptyRegisteredModels from './EmptyRegisteredModels'; -import RegisteredModelsTableToolbar from './RegisteredModelsTableToolbar'; import RegisteredModelListView from './RegisteredModelListView'; +import ModelRegistrySelector from './ModelRegistrySelector'; const ModelRegistry: React.FC = () => { - const { preferredModelRegistry } = React.useContext(ModelRegistryContext); + const { preferredModelRegistry } = React.useContext(ModelRegistrySelectorContext); const [registeredModels, loaded, loadError] = useRegisteredModels(); return ( - - - + + } + title={ + } - title="Registered models" description="View and manage your registered models." + headerContent={} loadError={loadError} loaded={loaded} provideChildrenPadding diff --git a/frontend/src/pages/modelRegistry/screens/ModelRegistrySelector.tsx b/frontend/src/pages/modelRegistry/screens/ModelRegistrySelector.tsx index 2be18d5ba4..6491b752c7 100644 --- a/frontend/src/pages/modelRegistry/screens/ModelRegistrySelector.tsx +++ b/frontend/src/pages/modelRegistry/screens/ModelRegistrySelector.tsx @@ -5,23 +5,27 @@ import { SelectGroup, SelectOption, } from '@patternfly/react-core/deprecated'; -import { ModelRegistryContext } from '~/concepts/modelRegistry/context/ModelRegistryContext'; +import { useNavigate } from 'react-router'; +import { Bullseye, Flex, FlexItem } from '@patternfly/react-core'; import { useBrowserStorage } from '~/components/browserStorage'; +import { ModelRegistrySelectorContext } from '~/concepts/modelRegistry/context/ModelRegistrySelectorContext'; +import { ProjectObjectType, typedObjectImage } from '~/concepts/design/utils'; const MODEL_REGISTRY_FAVORITE_STORAGE_KEY = 'odh.dashboard.model.registry.favorite'; const ModelRegistrySelector: React.FC = () => { const { modelRegistries, preferredModelRegistry, updatePreferredModelRegistry } = - React.useContext(ModelRegistryContext); + React.useContext(ModelRegistrySelectorContext); const [favorites, setFavorites] = useBrowserStorage( MODEL_REGISTRY_FAVORITE_STORAGE_KEY, [], ); + const navigate = useNavigate(); const [isOpen, setIsOpen] = React.useState(false); const options = [ - + {modelRegistries.map((modelRegistry) => ( { , ]; - return ( + const selector = ( ); + + return ( + + + + + Model registry + + {selector} + + + ); }; export default ModelRegistrySelector; diff --git a/frontend/src/pages/modelRegistry/screens/RegisteredModelsTableToolbar.tsx b/frontend/src/pages/modelRegistry/screens/RegisteredModelsTableToolbar.tsx index bb6d0e964f..480a8c93cf 100644 --- a/frontend/src/pages/modelRegistry/screens/RegisteredModelsTableToolbar.tsx +++ b/frontend/src/pages/modelRegistry/screens/RegisteredModelsTableToolbar.tsx @@ -12,7 +12,6 @@ import { ToolbarToggleGroup, } from '@patternfly/react-core'; import { EllipsisVIcon, FilterIcon } from '@patternfly/react-icons'; -import ModelRegistrySelector from './ModelRegistrySelector'; type RegisteredModelsTableToolbarProps = { toggleGroupItems?: React.ReactNode; @@ -29,9 +28,6 @@ const RegisteredModelsTableToolbar: React.FC return ( - - - } breakpoint="xl"> {tableToggleGroupItems}