diff --git a/clients/ui/.env.development b/clients/ui/.env.development new file mode 100644 index 00000000..7d7cc8e8 --- /dev/null +++ b/clients/ui/.env.development @@ -0,0 +1,3 @@ +APP_ENV=development +MOCK_AUTH=true +DEPLOYMENT_MODE=standalone \ No newline at end of file diff --git a/clients/ui/.env.production b/clients/ui/.env.production new file mode 100644 index 00000000..a904f4a9 --- /dev/null +++ b/clients/ui/.env.production @@ -0,0 +1 @@ +APP_ENV=production diff --git a/clients/ui/Makefile b/clients/ui/Makefile index 648d578c..cf3dbc4d 100644 --- a/clients/ui/Makefile +++ b/clients/ui/Makefile @@ -32,7 +32,7 @@ dev-install-dependencies: .PHONY: dev-bff dev-bff: - cd bff && make run PORT=4000 MOCK_K8S_CLIENT=true MOCK_MR_CLIENT=true + cd bff && make run PORT=4000 MOCK_K8S_CLIENT=true MOCK_MR_CLIENT=true DEV_MODE=true STANDALONE_MODE=true .PHONY: dev-frontend dev-frontend: diff --git a/clients/ui/bff/Makefile b/clients/ui/bff/Makefile index 103d914d..98707718 100644 --- a/clients/ui/bff/Makefile +++ b/clients/ui/bff/Makefile @@ -5,6 +5,7 @@ MOCK_K8S_CLIENT ?= false MOCK_MR_CLIENT ?= false DEV_MODE ?= false DEV_MODE_PORT ?= 8080 +STANDALONE_MODE ?= true # ENVTEST_K8S_VERSION refers to the version of kubebuilder assets to be downloaded by envtest binary. ENVTEST_K8S_VERSION = 1.29.0 @@ -47,7 +48,7 @@ build: fmt vet test ## Builds the project to produce a binary executable. .PHONY: run run: fmt vet envtest ## Runs the project. ENVTEST_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" \ - go run ./cmd/main.go --port=$(PORT) --mock-k8s-client=$(MOCK_K8S_CLIENT) --mock-mr-client=$(MOCK_MR_CLIENT) --dev-mode=$(DEV_MODE) --dev-mode-port=$(DEV_MODE_PORT) + go run ./cmd/main.go --port=$(PORT) --mock-k8s-client=$(MOCK_K8S_CLIENT) --mock-mr-client=$(MOCK_MR_CLIENT) --dev-mode=$(DEV_MODE) --dev-mode-port=$(DEV_MODE_PORT) --standalone-mode=$(STANDALONE_MODE) .PHONY: docker-build docker-build: ## Builds a container for the project. diff --git a/clients/ui/bff/cmd/main.go b/clients/ui/bff/cmd/main.go index eb719229..5ed0979d 100644 --- a/clients/ui/bff/cmd/main.go +++ b/clients/ui/bff/cmd/main.go @@ -24,6 +24,7 @@ func main() { flag.BoolVar(&cfg.MockMRClient, "mock-mr-client", false, "Use mock Model Registry client") flag.BoolVar(&cfg.DevMode, "dev-mode", false, "Use development mode for access to local K8s cluster") flag.IntVar(&cfg.DevModePort, "dev-mode-port", getEnvAsInt("DEV_MODE_PORT", 8080), "Use port when in development mode") + flag.BoolVar(&cfg.StandaloneMode, "standalone-mode", false, "Use standalone mode for enabling endpoints in standalone mode") flag.Parse() logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) diff --git a/clients/ui/bff/internal/api/app.go b/clients/ui/bff/internal/api/app.go index 52425e67..c144c232 100644 --- a/clients/ui/bff/internal/api/app.go +++ b/clients/ui/bff/internal/api/app.go @@ -3,11 +3,12 @@ package api import ( "context" "fmt" + "log/slog" + "net/http" + "github.com/kubeflow/model-registry/ui/bff/internal/config" "github.com/kubeflow/model-registry/ui/bff/internal/integrations" "github.com/kubeflow/model-registry/ui/bff/internal/repositories" - "log/slog" - "net/http" "github.com/julienschmidt/httprouter" "github.com/kubeflow/model-registry/ui/bff/internal/mocks" @@ -110,8 +111,8 @@ func (app *App) Routes() http.Handler { router.GET(UserPath, app.UserHandler) // Perform SAR to Get List Services by Namspace router.GET(ModelRegistryListPath, app.AttachNamespace(app.PerformSARonGetListServicesByNamespace(app.ModelRegistryHandler))) - if app.config.DevMode { - router.GET(NamespaceListPath, app.AttachNamespace(app.GetNamespacesHandler)) + if app.config.StandaloneMode { + router.GET(NamespaceListPath, app.GetNamespacesHandler) } return app.RecoverPanic(app.enableCORS(app.InjectUserHeaders(router))) diff --git a/clients/ui/bff/internal/config/environment.go b/clients/ui/bff/internal/config/environment.go index f63dcce8..7b905e12 100644 --- a/clients/ui/bff/internal/config/environment.go +++ b/clients/ui/bff/internal/config/environment.go @@ -1,9 +1,10 @@ package config type EnvConfig struct { - Port int - MockK8Client bool - MockMRClient bool - DevMode bool - DevModePort int + Port int + MockK8Client bool + MockMRClient bool + DevMode bool + StandaloneMode bool + DevModePort int } diff --git a/clients/ui/frontend/.env.development b/clients/ui/frontend/.env.development new file mode 100644 index 00000000..7d7cc8e8 --- /dev/null +++ b/clients/ui/frontend/.env.development @@ -0,0 +1,3 @@ +APP_ENV=development +MOCK_AUTH=true +DEPLOYMENT_MODE=standalone \ No newline at end of file diff --git a/clients/ui/frontend/Dockerfile b/clients/ui/frontend/Dockerfile index c25a2b1c..448f724b 100644 --- a/clients/ui/frontend/Dockerfile +++ b/clients/ui/frontend/Dockerfile @@ -6,7 +6,7 @@ COPY . /usr/src/app RUN npm cache clean --force RUN npm ci --omit=optional -RUN npm run build +RUN npm run build:prod FROM nginxinc/nginx-unprivileged diff --git a/clients/ui/frontend/src/app/App.tsx b/clients/ui/frontend/src/app/App.tsx index 66fc6967..ed0785a6 100644 --- a/clients/ui/frontend/src/app/App.tsx +++ b/clients/ui/frontend/src/app/App.tsx @@ -13,8 +13,9 @@ import { } from '@patternfly/react-core'; import ToastNotifications from '~/shared/components/ToastNotifications'; import { useSettings } from '~/shared/hooks/useSettings'; -import { isMUITheme, Theme, AUTH_HEADER, DEV_MODE } from '~/shared/utilities/const'; +import { isMUITheme, Theme, AUTH_HEADER, MOCK_AUTH } from '~/shared/utilities/const'; import { logout } from '~/shared/utilities/appUtils'; +import { NamespaceSelectorContext } from '~/shared/context/NamespaceSelectorContext'; import NavSidebar from './NavSidebar'; import AppRoutes from './AppRoutes'; import { AppContext } from './AppContext'; @@ -29,6 +30,8 @@ const App: React.FC = () => { loadError: configError, } = useSettings(); + const { namespacesLoaded, namespacesLoadError } = React.useContext(NamespaceSelectorContext); + const username = userSettings?.userId; React.useEffect(() => { @@ -41,7 +44,7 @@ const App: React.FC = () => { }, []); React.useEffect(() => { - if (DEV_MODE && username) { + if (MOCK_AUTH && username) { localStorage.setItem(AUTH_HEADER, username); } else { localStorage.removeItem(AUTH_HEADER); @@ -59,8 +62,10 @@ const App: React.FC = () => { [configSettings, userSettings], ); + const error = configError || namespacesLoadError; + // We lack the critical data to startup the app - if (configError) { + if (error) { // There was an error fetching critical data return ( @@ -68,7 +73,11 @@ const App: React.FC = () => { -

{configError.message || 'Unknown error occurred during startup.'}

+

+ {configError?.message || + namespacesLoadError?.message || + 'Unknown error occurred during startup.'} +

Logging out and logging back in may solve the issue.

@@ -87,7 +96,8 @@ const App: React.FC = () => { } // Waiting on the API to finish - const loading = !configLoaded || !userSettings || !configSettings || !contextValue; + const loading = + !configLoaded || !userSettings || !configSettings || !contextValue || !namespacesLoaded; return loading ? ( diff --git a/clients/ui/frontend/src/app/AppRoutes.tsx b/clients/ui/frontend/src/app/AppRoutes.tsx index 9c858d56..1f0f6461 100644 --- a/clients/ui/frontend/src/app/AppRoutes.tsx +++ b/clients/ui/frontend/src/app/AppRoutes.tsx @@ -53,6 +53,7 @@ const AppRoutes: React.FC = () => { } /> } /> } /> + {/* TODO: [Conditional render] Follow up add testing and conditional rendering when in standalone mode*/} {clusterAdmin && ( } /> )} diff --git a/clients/ui/frontend/src/app/NavBar.tsx b/clients/ui/frontend/src/app/NavBar.tsx index 62876c85..b9e52df6 100644 --- a/clients/ui/frontend/src/app/NavBar.tsx +++ b/clients/ui/frontend/src/app/NavBar.tsx @@ -13,23 +13,25 @@ import { ToolbarGroup, ToolbarItem, } from '@patternfly/react-core'; -import { SimpleSelect, SimpleSelectOption } from '@patternfly/react-templates'; +import { SimpleSelect } from '@patternfly/react-templates'; +import { NamespaceSelectorContext } from '~/shared/context/NamespaceSelectorContext'; interface NavBarProps { username?: string; onLogout: () => void; } -const Options: SimpleSelectOption[] = [{ content: 'All Namespaces', value: 'All' }]; - const NavBar: React.FC = ({ username, onLogout }) => { - const [selected, setSelected] = React.useState('All'); + const { namespaces, preferredNamespace, updatePreferredNamespace } = + React.useContext(NamespaceSelectorContext); + const [userMenuOpen, setUserMenuOpen] = React.useState(false); - const initialOptions = React.useMemo( - () => Options.map((o) => ({ ...o, selected: o.value === selected })), - [selected], - ); + const options = namespaces.map((namespace) => ({ + content: namespace.name, + value: namespace.name, + selected: namespace.name === preferredNamespace?.name, + })); const handleLogout = () => { setUserMenuOpen(false); @@ -51,9 +53,10 @@ const NavBar: React.FC = ({ username, onLogout }) => { setSelected(String(selection))} + initialOptions={options} + onSelect={(_ev, selection) => { + updatePreferredNamespace({ name: String(selection) }); + }} /> diff --git a/clients/ui/frontend/src/app/context/ModelRegistryContext.tsx b/clients/ui/frontend/src/app/context/ModelRegistryContext.tsx index 6c107e27..28ce8290 100644 --- a/clients/ui/frontend/src/app/context/ModelRegistryContext.tsx +++ b/clients/ui/frontend/src/app/context/ModelRegistryContext.tsx @@ -1,6 +1,9 @@ import * as React from 'react'; import { BFF_API_VERSION } from '~/app/const'; -import useModelRegistryAPIState, { ModelRegistryAPIState } from './useModelRegistryAPIState'; +import useQueryParamNamespaces from '~/shared/hooks/useQueryParamNamespaces'; +import useModelRegistryAPIState, { + ModelRegistryAPIState, +} from '~/app/hooks/useModelRegistryAPIState'; export type ModelRegistryContextType = { apiState: ModelRegistryAPIState; @@ -26,7 +29,9 @@ export const ModelRegistryContextProvider: React.FC = ({ children }) => { - const [modelRegistries, isLoaded, error] = useModelRegistries(); + const queryParams = useQueryParamNamespaces(); + + const [modelRegistries, isLoaded, error] = useModelRegistries(queryParams); const [preferredModelRegistry, setPreferredModelRegistry] = React.useState(undefined); diff --git a/clients/ui/frontend/src/app/hooks/useModelRegistries.ts b/clients/ui/frontend/src/app/hooks/useModelRegistries.ts index 20fdec71..7db8f7c6 100644 --- a/clients/ui/frontend/src/app/hooks/useModelRegistries.ts +++ b/clients/ui/frontend/src/app/hooks/useModelRegistries.ts @@ -5,9 +5,15 @@ import useFetchState, { } from '~/shared/utilities/useFetchState'; import { ModelRegistry } from '~/app/types'; import { getListModelRegistries } from '~/shared/api/k8s'; +import { useDeepCompareMemoize } from '~/shared/utilities/useDeepCompareMemoize'; -const useModelRegistries = (): FetchState => { - const listModelRegistries = React.useMemo(() => getListModelRegistries(''), []); +const useModelRegistries = (queryParams: Record): FetchState => { + const paramsMemo = useDeepCompareMemoize(queryParams); + + const listModelRegistries = React.useMemo( + () => getListModelRegistries('', paramsMemo), + [paramsMemo], + ); const callback = React.useCallback>( (opts) => listModelRegistries(opts), [listModelRegistries], diff --git a/clients/ui/frontend/src/app/hooks/useModelRegistryAPI.ts b/clients/ui/frontend/src/app/hooks/useModelRegistryAPI.ts index 5a211568..9bbe0c78 100644 --- a/clients/ui/frontend/src/app/hooks/useModelRegistryAPI.ts +++ b/clients/ui/frontend/src/app/hooks/useModelRegistryAPI.ts @@ -1,5 +1,5 @@ import * as React from 'react'; -import { ModelRegistryAPIState } from '~/app/context/useModelRegistryAPIState'; +import { ModelRegistryAPIState } from '~/app/hooks/useModelRegistryAPIState'; import { ModelRegistryContext } from '~/app/context/ModelRegistryContext'; type UseModelRegistryAPI = ModelRegistryAPIState & { diff --git a/clients/ui/frontend/src/app/context/useModelRegistryAPIState.tsx b/clients/ui/frontend/src/app/hooks/useModelRegistryAPIState.tsx similarity index 53% rename from clients/ui/frontend/src/app/context/useModelRegistryAPIState.tsx rename to clients/ui/frontend/src/app/hooks/useModelRegistryAPIState.tsx index d51592d9..e7364211 100644 --- a/clients/ui/frontend/src/app/context/useModelRegistryAPIState.tsx +++ b/clients/ui/frontend/src/app/hooks/useModelRegistryAPIState.tsx @@ -25,27 +25,31 @@ export type ModelRegistryAPIState = APIState; const useModelRegistryAPIState = ( hostPath: string | null, + queryParameters?: Record, ): [apiState: ModelRegistryAPIState, refreshAPIState: () => void] => { const createAPI = React.useCallback( (path: string) => ({ - createRegisteredModel: createRegisteredModel(path), - createModelVersion: createModelVersion(path), - createModelVersionForRegisteredModel: createModelVersionForRegisteredModel(path), - createModelArtifact: createModelArtifact(path), - createModelArtifactForModelVersion: createModelArtifactForModelVersion(path), - getRegisteredModel: getRegisteredModel(path), - getModelVersion: getModelVersion(path), - getModelArtifact: getModelArtifact(path), - listModelArtifacts: getListModelArtifacts(path), - listModelVersions: getListModelVersions(path), - listRegisteredModels: getListRegisteredModels(path), - getModelVersionsByRegisteredModel: getModelVersionsByRegisteredModel(path), - getModelArtifactsByModelVersion: getModelArtifactsByModelVersion(path), - patchRegisteredModel: patchRegisteredModel(path), - patchModelVersion: patchModelVersion(path), - patchModelArtifact: patchModelArtifact(path), + createRegisteredModel: createRegisteredModel(path, queryParameters), + createModelVersion: createModelVersion(path, queryParameters), + createModelVersionForRegisteredModel: createModelVersionForRegisteredModel( + path, + queryParameters, + ), + createModelArtifact: createModelArtifact(path, queryParameters), + createModelArtifactForModelVersion: createModelArtifactForModelVersion(path, queryParameters), + getRegisteredModel: getRegisteredModel(path, queryParameters), + getModelVersion: getModelVersion(path, queryParameters), + getModelArtifact: getModelArtifact(path, queryParameters), + listModelArtifacts: getListModelArtifacts(path, queryParameters), + listModelVersions: getListModelVersions(path, queryParameters), + listRegisteredModels: getListRegisteredModels(path, queryParameters), + getModelVersionsByRegisteredModel: getModelVersionsByRegisteredModel(path, queryParameters), + getModelArtifactsByModelVersion: getModelArtifactsByModelVersion(path, queryParameters), + patchRegisteredModel: patchRegisteredModel(path, queryParameters), + patchModelVersion: patchModelVersion(path, queryParameters), + patchModelArtifact: patchModelArtifact(path, queryParameters), }), - [], + [queryParameters], ); return useAPIState(hostPath, createAPI); diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/useRegistrationCommonState.ts b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/useRegistrationCommonState.ts index fadde80c..8d3510d6 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/useRegistrationCommonState.ts +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/useRegistrationCommonState.ts @@ -1,6 +1,6 @@ import React from 'react'; import { ModelRegistryContext } from '~/app/context/ModelRegistryContext'; -import { ModelRegistryAPIState } from '~/app/context/useModelRegistryAPIState'; +import { ModelRegistryAPIState } from '~/app/hooks/useModelRegistryAPIState'; import useUser from '~/app/hooks/useUser'; type RegistrationCommonState = { diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/utils.ts b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/utils.ts index 801cc76c..22fef4d0 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/utils.ts +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisterModel/utils.ts @@ -5,7 +5,7 @@ import { ModelVersion, RegisteredModel, } from '~/app/types'; -import { ModelRegistryAPIState } from '~/app/context/useModelRegistryAPIState'; +import { ModelRegistryAPIState } from '~/app/hooks/useModelRegistryAPIState'; import { objectStorageFieldsToUri } from '~/app/pages/modelRegistry/screens/utils'; import { ModelLocationType, diff --git a/clients/ui/frontend/src/app/pages/settings/ModelRegistrySettings.tsx b/clients/ui/frontend/src/app/pages/settings/ModelRegistrySettings.tsx index 120ea680..6e320bb2 100644 --- a/clients/ui/frontend/src/app/pages/settings/ModelRegistrySettings.tsx +++ b/clients/ui/frontend/src/app/pages/settings/ModelRegistrySettings.tsx @@ -5,10 +5,13 @@ import ApplicationsPage from '~/shared/components/ApplicationsPage'; import useModelRegistries from '~/app/hooks/useModelRegistries'; import TitleWithIcon from '~/shared/components/design/TitleWithIcon'; import { ProjectObjectType } from '~/shared/components/design/utils'; +import useQueryParamNamespaces from '~/shared/hooks/useQueryParamNamespaces'; import ModelRegistriesTable from './ModelRegistriesTable'; const ModelRegistrySettings: React.FC = () => { - const [modelRegistries, loaded, loadError] = useModelRegistries(); + const queryParams = useQueryParamNamespaces(); + + const [modelRegistries, loaded, loadError] = useModelRegistries(queryParams); return ( <> - + + + diff --git a/clients/ui/frontend/src/shared/api/__tests__/service.spec.ts b/clients/ui/frontend/src/shared/api/__tests__/service.spec.ts index 07e1fbc8..4052e0c1 100644 --- a/clients/ui/frontend/src/shared/api/__tests__/service.spec.ts +++ b/clients/ui/frontend/src/shared/api/__tests__/service.spec.ts @@ -342,6 +342,7 @@ describe('patchRegisteredModel', () => { const mockData = { description: 'new test' }; const response = await patchRegisteredModel( `/api/${BFF_API_VERSION}/model_registry/model-registry-1/`, + {}, )(APIOptionsMock, mockData, '1'); expect(response).toEqual(mockRestResponse); expect(restPATCHMock).toHaveBeenCalledTimes(1); @@ -349,6 +350,7 @@ describe('patchRegisteredModel', () => { `/api/${BFF_API_VERSION}/model_registry/model-registry-1/`, `/registered_models/1`, {}, + {}, APIOptionsMock, ); expect(handleRestFailuresMock).toHaveBeenCalledTimes(1); @@ -361,6 +363,7 @@ describe('patchModelVersion', () => { const mockData = { description: 'new test' }; const response = await patchModelVersion( `/api/${BFF_API_VERSION}/model_registry/model-registry-1/`, + {}, )(APIOptionsMock, mockData, '1'); expect(response).toEqual(mockRestResponse); expect(restPATCHMock).toHaveBeenCalledTimes(1); @@ -368,6 +371,7 @@ describe('patchModelVersion', () => { `/api/${BFF_API_VERSION}/model_registry/model-registry-1/`, `/model_versions/1`, {}, + {}, APIOptionsMock, ); expect(handleRestFailuresMock).toHaveBeenCalledTimes(1); @@ -380,6 +384,7 @@ describe('patchModelArtifact', () => { const mockData = { description: 'new test' }; const response = await patchModelArtifact( `/api/${BFF_API_VERSION}/model_registry/model-registry-1/`, + {}, )(APIOptionsMock, mockData, '1'); expect(response).toEqual(mockRestResponse); expect(restPATCHMock).toHaveBeenCalledTimes(1); @@ -387,6 +392,7 @@ describe('patchModelArtifact', () => { `/api/${BFF_API_VERSION}/model_registry/model-registry-1/`, `/model_artifacts/1`, {}, + {}, APIOptionsMock, ); expect(handleRestFailuresMock).toHaveBeenCalledTimes(1); diff --git a/clients/ui/frontend/src/shared/api/apiUtils.ts b/clients/ui/frontend/src/shared/api/apiUtils.ts index 01d03161..19d831ff 100644 --- a/clients/ui/frontend/src/shared/api/apiUtils.ts +++ b/clients/ui/frontend/src/shared/api/apiUtils.ts @@ -1,7 +1,7 @@ import { APIOptions } from '~/shared/api/types'; import { EitherOrNone } from '~/shared/typeHelpers'; import { ModelRegistryBody } from '~/app/types'; -import { DEV_MODE, AUTH_HEADER } from '~/shared/utilities/const'; +import { AUTH_HEADER, MOCK_AUTH } from '~/shared/utilities/const'; export const mergeRequestInit = ( opts: APIOptions = {}, @@ -65,11 +65,14 @@ const callRestJSON = ( requestData = JSON.stringify(data); } + // Workaround if we wanna force in a call to add the AUTH_HEADER + const authHeader = Object.keys(otherOptions.headers || {}).some((key) => key === AUTH_HEADER); + return fetch(`${host}${path}${searchParams ? `?${searchParams}` : ''}`, { ...otherOptions, headers: { ...otherOptions.headers, - ...(DEV_MODE && { [AUTH_HEADER]: localStorage.getItem(AUTH_HEADER) }), + ...(MOCK_AUTH && !authHeader && { [AUTH_HEADER]: localStorage.getItem(AUTH_HEADER) }), ...(contentType && { 'Content-Type': contentType }), }, method, @@ -152,10 +155,12 @@ export const restPATCH = ( host: string, path: string, data: Record, + queryParams: Record = {}, options?: APIOptions, ): Promise => callRestJSON(host, path, mergeRequestInit(options, { method: 'PATCH' }), { data, + queryParams, parseJSON: options?.parseJSON, }); @@ -184,3 +189,8 @@ export const isModelRegistryResponse = (response: unknown): response is Model export const assembleModelRegistryBody = (data: T): ModelRegistryBody => ({ data, }); + +export const getNamespaceQueryParam = (): string | null => { + const params = new URLSearchParams(window.location.search); + return params.get('ns'); +}; diff --git a/clients/ui/frontend/src/shared/api/k8s.ts b/clients/ui/frontend/src/shared/api/k8s.ts index 6c483eae..1687740f 100644 --- a/clients/ui/frontend/src/shared/api/k8s.ts +++ b/clients/ui/frontend/src/shared/api/k8s.ts @@ -3,26 +3,38 @@ import { handleRestFailures } from '~/shared/api/errorUtils'; import { isModelRegistryResponse, restGET } from '~/shared/api/apiUtils'; import { ModelRegistry } from '~/app/types'; import { BFF_API_VERSION } from '~/app/const'; -import { UserSettings } from '~/shared/types'; +import { Namespace, UserSettings } from '~/shared/types'; export const getListModelRegistries = - (hostPath: string) => + (hostPath: string, queryParams: Record = {}) => (opts: APIOptions): Promise => - handleRestFailures(restGET(hostPath, `/api/${BFF_API_VERSION}/model_registry`, {}, opts)).then( + handleRestFailures( + restGET(hostPath, `/api/${BFF_API_VERSION}/model_registry`, queryParams, opts), + ).then((response) => { + if (isModelRegistryResponse(response)) { + return response.data; + } + throw new Error('Invalid response format'); + }); + +export const getUser = + (hostPath: string) => + (opts: APIOptions): Promise => + handleRestFailures(restGET(hostPath, `/api/${BFF_API_VERSION}/user`, {}, opts)).then( (response) => { - if (isModelRegistryResponse(response)) { + if (isModelRegistryResponse(response)) { return response.data; } throw new Error('Invalid response format'); }, ); -export const getUser = +export const getNamespaces = (hostPath: string) => - (opts: APIOptions): Promise => - handleRestFailures(restGET(hostPath, `/api/${BFF_API_VERSION}/user`, {}, opts)).then( + (opts: APIOptions): Promise => + handleRestFailures(restGET(hostPath, `/api/${BFF_API_VERSION}/namespaces`, {}, opts)).then( (response) => { - if (isModelRegistryResponse(response)) { + if (isModelRegistryResponse(response)) { return response.data; } throw new Error('Invalid response format'); diff --git a/clients/ui/frontend/src/shared/api/service.ts b/clients/ui/frontend/src/shared/api/service.ts index b62c7721..1e377235 100644 --- a/clients/ui/frontend/src/shared/api/service.ts +++ b/clients/ui/frontend/src/shared/api/service.ts @@ -20,10 +20,16 @@ import { APIOptions } from '~/shared/api/types'; import { handleRestFailures } from '~/shared/api/errorUtils'; export const createRegisteredModel = - (hostPath: string) => + (hostPath: string, queryParams: Record = {}) => (opts: APIOptions, data: CreateRegisteredModelData): Promise => handleRestFailures( - restCREATE(hostPath, `/registered_models`, assembleModelRegistryBody(data), {}, opts), + restCREATE( + hostPath, + `/registered_models`, + assembleModelRegistryBody(data), + queryParams, + opts, + ), ).then((response) => { if (isModelRegistryResponse(response)) { return response.data; @@ -32,10 +38,10 @@ export const createRegisteredModel = }); export const createModelVersion = - (hostPath: string) => + (hostPath: string, queryParams: Record = {}) => (opts: APIOptions, data: CreateModelVersionData): Promise => handleRestFailures( - restCREATE(hostPath, `/model_versions`, assembleModelRegistryBody(data), {}, opts), + restCREATE(hostPath, `/model_versions`, assembleModelRegistryBody(data), queryParams, opts), ).then((response) => { if (isModelRegistryResponse(response)) { return response.data; @@ -44,7 +50,7 @@ export const createModelVersion = }); export const createModelVersionForRegisteredModel = - (hostPath: string) => + (hostPath: string, queryParams: Record = {}) => ( opts: APIOptions, registeredModelId: string, @@ -55,7 +61,7 @@ export const createModelVersionForRegisteredModel = hostPath, `/registered_models/${registeredModelId}/versions`, assembleModelRegistryBody(data), - {}, + queryParams, opts, ), ).then((response) => { @@ -66,10 +72,10 @@ export const createModelVersionForRegisteredModel = }); export const createModelArtifact = - (hostPath: string) => + (hostPath: string, queryParams: Record = {}) => (opts: APIOptions, data: CreateModelArtifactData): Promise => handleRestFailures( - restCREATE(hostPath, `/model_artifacts`, assembleModelRegistryBody(data), {}, opts), + restCREATE(hostPath, `/model_artifacts`, assembleModelRegistryBody(data), queryParams, opts), ).then((response) => { if (isModelRegistryResponse(response)) { return response.data; @@ -78,7 +84,7 @@ export const createModelArtifact = }); export const createModelArtifactForModelVersion = - (hostPath: string) => + (hostPath: string, queryParams: Record = {}) => ( opts: APIOptions, modelVersionId: string, @@ -89,7 +95,7 @@ export const createModelArtifactForModelVersion = hostPath, `/model_versions/${modelVersionId}/artifacts`, assembleModelRegistryBody(data), - {}, + queryParams, opts, ), ).then((response) => { @@ -100,55 +106,57 @@ export const createModelArtifactForModelVersion = }); export const getRegisteredModel = - (hostPath: string) => + (hostPath: string, queryParams: Record = {}) => (opts: APIOptions, registeredModelId: string): Promise => - handleRestFailures(restGET(hostPath, `/registered_models/${registeredModelId}`, {}, opts)).then( - (response) => { - if (isModelRegistryResponse(response)) { - return response.data; - } - throw new Error('Invalid response format'); - }, - ); + handleRestFailures( + restGET(hostPath, `/registered_models/${registeredModelId}`, queryParams, opts), + ).then((response) => { + if (isModelRegistryResponse(response)) { + return response.data; + } + throw new Error('Invalid response format'); + }); export const getModelVersion = - (hostPath: string) => + (hostPath: string, queryParams: Record = {}) => (opts: APIOptions, modelversionId: string): Promise => - handleRestFailures(restGET(hostPath, `/model_versions/${modelversionId}`, {}, opts)).then( - (response) => { - if (isModelRegistryResponse(response)) { - return response.data; - } - throw new Error('Invalid response format'); - }, - ); + handleRestFailures( + restGET(hostPath, `/model_versions/${modelversionId}`, queryParams, opts), + ).then((response) => { + if (isModelRegistryResponse(response)) { + return response.data; + } + throw new Error('Invalid response format'); + }); export const getModelArtifact = - (hostPath: string) => + (hostPath: string, queryParams: Record = {}) => (opts: APIOptions, modelArtifactId: string): Promise => - handleRestFailures(restGET(hostPath, `/model_artifacts/${modelArtifactId}`, {}, opts)).then( + handleRestFailures( + restGET(hostPath, `/model_artifacts/${modelArtifactId}`, queryParams, opts), + ).then((response) => { + if (isModelRegistryResponse(response)) { + return response.data; + } + throw new Error('Invalid response format'); + }); + +export const getListModelArtifacts = + (hostPath: string, queryParams: Record = {}) => + (opts: APIOptions): Promise => + handleRestFailures(restGET(hostPath, `/model_artifacts`, queryParams, opts)).then( (response) => { - if (isModelRegistryResponse(response)) { + if (isModelRegistryResponse(response)) { return response.data; } throw new Error('Invalid response format'); }, ); -export const getListModelArtifacts = - (hostPath: string) => - (opts: APIOptions): Promise => - handleRestFailures(restGET(hostPath, `/model_artifacts`, {}, opts)).then((response) => { - if (isModelRegistryResponse(response)) { - return response.data; - } - throw new Error('Invalid response format'); - }); - export const getListModelVersions = - (hostPath: string) => + (hostPath: string, queryParams: Record = {}) => (opts: APIOptions): Promise => - handleRestFailures(restGET(hostPath, `/model_versions`, {}, opts)).then((response) => { + handleRestFailures(restGET(hostPath, `/model_versions`, queryParams, opts)).then((response) => { if (isModelRegistryResponse(response)) { return response.data; } @@ -156,20 +164,22 @@ export const getListModelVersions = }); export const getListRegisteredModels = - (hostPath: string) => + (hostPath: string, queryParams: Record = {}) => (opts: APIOptions): Promise => - handleRestFailures(restGET(hostPath, `/registered_models`, {}, opts)).then((response) => { - if (isModelRegistryResponse(response)) { - return response.data; - } - throw new Error('Invalid response format'); - }); + handleRestFailures(restGET(hostPath, `/registered_models`, queryParams, opts)).then( + (response) => { + if (isModelRegistryResponse(response)) { + return response.data; + } + throw new Error('Invalid response format'); + }, + ); export const getModelVersionsByRegisteredModel = - (hostPath: string) => + (hostPath: string, queryParams: Record = {}) => (opts: APIOptions, registeredmodelId: string): Promise => handleRestFailures( - restGET(hostPath, `/registered_models/${registeredmodelId}/versions`, {}, opts), + restGET(hostPath, `/registered_models/${registeredmodelId}/versions`, queryParams, opts), ).then((response) => { if (isModelRegistryResponse(response)) { return response.data; @@ -178,10 +188,10 @@ export const getModelVersionsByRegisteredModel = }); export const getModelArtifactsByModelVersion = - (hostPath: string) => + (hostPath: string, queryParams: Record = {}) => (opts: APIOptions, modelVersionId: string): Promise => handleRestFailures( - restGET(hostPath, `/model_versions/${modelVersionId}/artifacts`, {}, opts), + restGET(hostPath, `/model_versions/${modelVersionId}/artifacts`, queryParams, opts), ).then((response) => { if (isModelRegistryResponse(response)) { return response.data; @@ -190,7 +200,7 @@ export const getModelArtifactsByModelVersion = }); export const patchRegisteredModel = - (hostPath: string) => + (hostPath: string, queryParams: Record = {}) => ( opts: APIOptions, data: Partial, @@ -201,6 +211,7 @@ export const patchRegisteredModel = hostPath, `/registered_models/${registeredModelId}`, assembleModelRegistryBody(data), + queryParams, opts, ), ).then((response) => { @@ -211,13 +222,14 @@ export const patchRegisteredModel = }); export const patchModelVersion = - (hostPath: string) => + (hostPath: string, queryParams: Record = {}) => (opts: APIOptions, data: Partial, modelversionId: string): Promise => handleRestFailures( restPATCH( hostPath, `/model_versions/${modelversionId}`, assembleModelRegistryBody(data), + queryParams, opts, ), ).then((response) => { @@ -228,7 +240,7 @@ export const patchModelVersion = }); export const patchModelArtifact = - (hostPath: string) => + (hostPath: string, queryParams: Record = {}) => ( opts: APIOptions, data: Partial, @@ -239,6 +251,7 @@ export const patchModelArtifact = hostPath, `/model_artifacts/${modelartifactId}`, assembleModelRegistryBody(data), + queryParams, opts, ), ).then((response) => { diff --git a/clients/ui/frontend/src/shared/context/NamespaceSelectorContext.tsx b/clients/ui/frontend/src/shared/context/NamespaceSelectorContext.tsx new file mode 100644 index 00000000..372e9372 --- /dev/null +++ b/clients/ui/frontend/src/shared/context/NamespaceSelectorContext.tsx @@ -0,0 +1,59 @@ +import * as React from 'react'; +import useNamespaces from '~/shared/hooks/useNamespaces'; +import { Namespace } from '~/shared/types'; + +export type NamespaceSelectorContextType = { + namespacesLoaded: boolean; + namespacesLoadError?: Error; + namespaces: Namespace[]; + preferredNamespace: Namespace | undefined; + updatePreferredNamespace: (namespace: Namespace | undefined) => void; +}; + +type NamespaceSelectorContextProviderProps = { + children: React.ReactNode; +}; + +export const NamespaceSelectorContext = React.createContext({ + namespacesLoaded: false, + namespacesLoadError: undefined, + namespaces: [], + preferredNamespace: undefined, + updatePreferredNamespace: () => undefined, +}); + +export const NamespaceSelectorContextProvider: React.FC = ({ + children, + ...props +}) => ( + + {children} + +); + +const EnabledNamespaceSelectorContextProvider: React.FC = ({ + children, +}) => { + const [namespaces, isLoaded, error] = useNamespaces(); + const [preferredNamespace, setPreferredNamespace] = + React.useState(undefined); + + const firstNamespace = namespaces.length > 0 ? namespaces[0] : null; + + const contextValue = React.useMemo( + () => ({ + namespacesLoaded: isLoaded, + namespacesLoadError: error, + namespaces, + preferredNamespace: preferredNamespace ?? firstNamespace ?? undefined, + updatePreferredNamespace: setPreferredNamespace, + }), + [isLoaded, error, namespaces, preferredNamespace, firstNamespace], + ); + + return ( + + {children} + + ); +}; diff --git a/clients/ui/frontend/src/shared/hooks/useNamespaces.ts b/clients/ui/frontend/src/shared/hooks/useNamespaces.ts new file mode 100644 index 00000000..72a0e0f7 --- /dev/null +++ b/clients/ui/frontend/src/shared/hooks/useNamespaces.ts @@ -0,0 +1,28 @@ +import * as React from 'react'; +import useFetchState, { + FetchState, + FetchStateCallbackPromise, +} from '~/shared/utilities/useFetchState'; +import { Namespace } from '~/shared/types'; +import { AUTH_HEADER, isStandalone, MOCK_AUTH, USERNAME } from '~/shared/utilities/const'; +import { getNamespaces } from '~/shared/api/k8s'; + +const useNamespaces = (): FetchState => { + const listNamespaces = React.useMemo(() => getNamespaces(''), []); + const callback = React.useCallback>( + (opts) => { + if (!isStandalone()) { + return Promise.resolve([]); + } + const headers = MOCK_AUTH ? { [AUTH_HEADER]: USERNAME } : undefined; + return listNamespaces({ + ...opts, + headers, + }); + }, + [listNamespaces], + ); + return useFetchState(callback, [], { initialPromisePurity: true }); +}; + +export default useNamespaces; diff --git a/clients/ui/frontend/src/shared/hooks/useQueryParamNamespaces.ts b/clients/ui/frontend/src/shared/hooks/useQueryParamNamespaces.ts new file mode 100644 index 00000000..e24ce3de --- /dev/null +++ b/clients/ui/frontend/src/shared/hooks/useQueryParamNamespaces.ts @@ -0,0 +1,14 @@ +import React from 'react'; +import { NamespaceSelectorContext } from '~/shared/context/NamespaceSelectorContext'; +import { isStandalone } from '~/shared/utilities/const'; +import { getNamespaceQueryParam } from '~/shared/api/apiUtils'; +import { useDeepCompareMemoize } from '~/shared/utilities/useDeepCompareMemoize'; + +const useQueryParamNamespaces = (): Record => { + const { preferredNamespace: namespaceSelector } = React.useContext(NamespaceSelectorContext); + const namespace = isStandalone() ? namespaceSelector?.name : getNamespaceQueryParam(); + + return useDeepCompareMemoize({ namespace }); +}; + +export default useQueryParamNamespaces; diff --git a/clients/ui/frontend/src/shared/hooks/useSettings.tsx b/clients/ui/frontend/src/shared/hooks/useSettings.tsx index d81488c6..c7014efb 100644 --- a/clients/ui/frontend/src/shared/hooks/useSettings.tsx +++ b/clients/ui/frontend/src/shared/hooks/useSettings.tsx @@ -1,9 +1,9 @@ import * as React from 'react'; -import { USERNAME, POLL_INTERVAL, AUTH_HEADER, DEV_MODE } from '~/shared/utilities/const'; +import { USERNAME, POLL_INTERVAL, AUTH_HEADER, MOCK_AUTH } from '~/shared/utilities/const'; import { useDeepCompareMemoize } from '~/shared/utilities/useDeepCompareMemoize'; import { ConfigSettings, UserSettings } from '~/shared/types'; import useTimeBasedRefresh from '~/shared/hooks/useTimeBasedRefresh'; -import { getUser } from '~/shared/api/k8s'; +import { getNamespaces, getUser } from '~/shared/api/k8s'; export const useSettings = (): { configSettings: ConfigSettings | null; @@ -15,15 +15,16 @@ export const useSettings = (): { const [loadError, setLoadError] = React.useState(); const [config, setConfig] = React.useState(null); const [user, setUser] = React.useState(null); - const userSettings = React.useMemo(() => getUser(''), []); + const userRest = React.useMemo(() => getUser(''), []); + const namespaceRest = React.useMemo(() => getNamespaces(''), []); const setRefreshMarker = useTimeBasedRefresh(); React.useEffect(() => { let watchHandle: ReturnType; let cancelled = false; const watchConfig = () => { - const headers = DEV_MODE ? { [AUTH_HEADER]: USERNAME } : undefined; - Promise.all([fetchConfig(), userSettings({ headers })]) + const headers = MOCK_AUTH ? { [AUTH_HEADER]: USERNAME } : undefined; + Promise.all([fetchConfig(), userRest({ headers })]) .then(([fetchedConfig, fetchedUser]) => { if (cancelled) { return; @@ -56,12 +57,17 @@ export const useSettings = (): { cancelled = true; clearTimeout(watchHandle); }; - }, [setRefreshMarker, userSettings]); + }, [setRefreshMarker, userRest, namespaceRest]); const retConfig = useDeepCompareMemoize(config); const retUser = useDeepCompareMemoize(user); - return { configSettings: retConfig, userSettings: retUser, loaded, loadError }; + return { + configSettings: retConfig, + userSettings: retUser, + loaded, + loadError, + }; }; // Mock a settings config call diff --git a/clients/ui/frontend/src/shared/types.ts b/clients/ui/frontend/src/shared/types.ts index 31658843..56164eae 100644 --- a/clients/ui/frontend/src/shared/types.ts +++ b/clients/ui/frontend/src/shared/types.ts @@ -22,4 +22,8 @@ export type KeyValuePair = { value: string; }; +export type Namespace = { + name: string; +}; + export type UpdateObjectAtPropAndValue = (propKey: keyof T, propValue: ValueOf) => void; diff --git a/clients/ui/frontend/src/shared/utilities/const.ts b/clients/ui/frontend/src/shared/utilities/const.ts index 5fcb7e79..eef4905f 100644 --- a/clients/ui/frontend/src/shared/utilities/const.ts +++ b/clients/ui/frontend/src/shared/utilities/const.ts @@ -4,14 +4,23 @@ export enum Theme { // Future themes can be added here } +export enum DeploymentMode { + Standalone = 'standalone', + Integrated = 'integrated', +} + export const isMUITheme = (): boolean => STYLE_THEME === Theme.MUI; +export const isStandalone = (): boolean => DEPLOYMENT_MODE === DeploymentMode.Standalone; +export const isIntegrated = (): boolean => DEPLOYMENT_MODE === DeploymentMode.Integrated; const STYLE_THEME = process.env.STYLE_THEME || Theme.MUI; const DEV_MODE = process.env.APP_ENV === 'development'; +const MOCK_AUTH = process.env.MOCK_AUTH === 'true'; +const DEPLOYMENT_MODE = process.env.DEPLOYMENT_MODE || DeploymentMode.Integrated; const POLL_INTERVAL = process.env.POLL_INTERVAL ? parseInt(process.env.POLL_INTERVAL) : 30000; const AUTH_HEADER = process.env.AUTH_HEADER || 'kubeflow-userid'; const USERNAME = process.env.USERNAME || 'user@example.com'; const IMAGE_DIR = process.env.IMAGE_DIR || 'images'; const LOGO_LIGHT = process.env.LOGO || 'logo-light-theme.svg'; -export { POLL_INTERVAL, DEV_MODE, AUTH_HEADER, USERNAME, IMAGE_DIR, LOGO_LIGHT }; +export { POLL_INTERVAL, DEV_MODE, AUTH_HEADER, USERNAME, IMAGE_DIR, LOGO_LIGHT, MOCK_AUTH }; diff --git a/clients/ui/manifests/kubeflow/kustomization.yaml b/clients/ui/manifests/kubeflow/kustomization.yaml new file mode 100644 index 00000000..418e7642 --- /dev/null +++ b/clients/ui/manifests/kubeflow/kustomization.yaml @@ -0,0 +1,16 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +patchesJson6902: + - path: model-registry-ui-deployment.yaml + target: + group: apps + version: v1 + kind: Deployment + name: model-registry-ui-deployment + - path: deployment.yaml + target: + group: apps + version: v1 + kind: Deployment + name: model-registry-bff-deployment \ No newline at end of file diff --git a/clients/ui/manifests/kubeflow/model-registry-bff-deployment.yaml b/clients/ui/manifests/kubeflow/model-registry-bff-deployment.yaml new file mode 100644 index 00000000..b7216756 --- /dev/null +++ b/clients/ui/manifests/kubeflow/model-registry-bff-deployment.yaml @@ -0,0 +1,4 @@ +- op: add + path: /spec/template/spec/containers/0/args + value: + - "--standalone-mode=false" diff --git a/clients/ui/manifests/kubeflow/model-registry-ui-deployment.yaml b/clients/ui/manifests/kubeflow/model-registry-ui-deployment.yaml new file mode 100644 index 00000000..1959e4bb --- /dev/null +++ b/clients/ui/manifests/kubeflow/model-registry-ui-deployment.yaml @@ -0,0 +1,9 @@ +- op: add + path: /spec/template/spec/containers/0/env + value: + - name: API_URL + value: "http://model-registry-bff-service:4000" + - name: MOCK_AUTH + value: "false" + - name: DEPLOYMENT_MODE + value: "integrated" \ No newline at end of file diff --git a/clients/ui/manifests/user-rbac/kubeflow-dashboard-rbac.yaml b/clients/ui/manifests/standalone/kubeflow-dashboard-rbac.yaml similarity index 100% rename from clients/ui/manifests/user-rbac/kubeflow-dashboard-rbac.yaml rename to clients/ui/manifests/standalone/kubeflow-dashboard-rbac.yaml diff --git a/clients/ui/manifests/standalone/kustomization.yaml b/clients/ui/manifests/standalone/kustomization.yaml new file mode 100644 index 00000000..fda80e7d --- /dev/null +++ b/clients/ui/manifests/standalone/kustomization.yaml @@ -0,0 +1,19 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - kubeflow-dashboard-rbac.yaml + +patchesJson6902: + - path: model-registry-ui-deployment.yaml + target: + group: apps + version: v1 + kind: Deployment + name: model-registry-bff-deployment + - path: deployment.yaml + target: + group: apps + version: v1 + kind: Deployment + name: model-registry-bff-deployment \ No newline at end of file diff --git a/clients/ui/manifests/standalone/model-registry-bff-deployment.yaml b/clients/ui/manifests/standalone/model-registry-bff-deployment.yaml new file mode 100644 index 00000000..38b5569a --- /dev/null +++ b/clients/ui/manifests/standalone/model-registry-bff-deployment.yaml @@ -0,0 +1,4 @@ +- op: add + path: /spec/template/spec/containers/0/args + value: + - "--standalone-mode=true" diff --git a/clients/ui/manifests/standalone/model-registry-ui-deployment.yaml b/clients/ui/manifests/standalone/model-registry-ui-deployment.yaml new file mode 100644 index 00000000..5211d0b0 --- /dev/null +++ b/clients/ui/manifests/standalone/model-registry-ui-deployment.yaml @@ -0,0 +1,9 @@ +- op: add + path: /spec/template/spec/containers/0/env + value: + - name: API_URL + value: "http://model-registry-bff-service:4000" + - name: MOCK_AUTH + value: "true" + - name: DEPLOYMENT_MODE + value: "standalone" \ No newline at end of file diff --git a/clients/ui/manifests/user-rbac/admin-rbac.yaml b/clients/ui/manifests/user-rbac/admin-rbac.yaml deleted file mode 100644 index 592a58bf..00000000 --- a/clients/ui/manifests/user-rbac/admin-rbac.yaml +++ /dev/null @@ -1,18 +0,0 @@ -apiVersion: v1 -kind: ServiceAccount -metadata: - name: admin-user - namespace: kube-system ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRoleBinding -metadata: - name: admin-user -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: ClusterRole - name: cluster-admin -subjects: - - kind: ServiceAccount - name: admin-user - namespace: kube-system \ No newline at end of file diff --git a/clients/ui/manifests/user-rbac/kustomization.yaml b/clients/ui/manifests/user-rbac/kustomization.yaml deleted file mode 100644 index 3e513a32..00000000 --- a/clients/ui/manifests/user-rbac/kustomization.yaml +++ /dev/null @@ -1,6 +0,0 @@ -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization - -resources: - - admin-rbac.yaml - - kubeflow-dashboard-rbac.yaml \ No newline at end of file