diff --git a/backend/src/routes/api/integrations/nim/index.ts b/backend/src/routes/api/integrations/nim/index.ts index 25237c60d7..4958fb0b5c 100644 --- a/backend/src/routes/api/integrations/nim/index.ts +++ b/backend/src/routes/api/integrations/nim/index.ts @@ -2,7 +2,15 @@ import { FastifyReply, FastifyRequest } from 'fastify'; import { secureAdminRoute } from '../../../../utils/route-security'; import { KubeFastifyInstance } from '../../../../types'; import { isString } from 'lodash'; -import { createNIMAccount, getNIMAccount, isAppEnabled, manageNIMSecret } from './nimUtils'; +import { VariablesValidationStatus } from '../../../../types'; +import { + createNIMAccount, + manageNIMSecret, + getNIMAccount, + apiKeyValidationStatus, + apiKeyValidationTimestamp, + isAppEnabled, +} from './nimUtils'; module.exports = async (fastify: KubeFastifyInstance) => { const PAGE_NOT_FOUND_MESSAGE = '404 page not found'; @@ -15,11 +23,27 @@ module.exports = async (fastify: KubeFastifyInstance) => { if (response) { // Installed const isEnabled = isAppEnabled(response); - reply.send({ isInstalled: true, isEnabled: isEnabled, canInstall: false, error: '' }); + const keyValidationStatus: string = apiKeyValidationStatus(response); + const keyValidationTimestamp: string = apiKeyValidationTimestamp(response); + reply.send({ + isInstalled: true, + isEnabled: isEnabled, + variablesValidationStatus: keyValidationStatus, + variablesValidationTimestamp: keyValidationTimestamp, + canInstall: !isEnabled, + error: '', + }); } else { // Not installed fastify.log.info(`NIM account does not exist`); - reply.send({ isInstalled: false, isEnabled: false, canInstall: true, error: '' }); + reply.send({ + isInstalled: false, + isEnabled: false, + variablesValidationStatus: VariablesValidationStatus.UNKNOWN, + variablesValidationTimestamp: '', + canInstall: true, + error: '', + }); } }) .catch((e) => { @@ -33,6 +57,8 @@ module.exports = async (fastify: KubeFastifyInstance) => { reply.send({ isInstalled: false, isEnabled: false, + variablesValidationStatus: VariablesValidationStatus.UNKNOWN, + variablesValidationTimestamp: '', canInstall: false, error: 'NIM not installed', }); @@ -41,7 +67,9 @@ module.exports = async (fastify: KubeFastifyInstance) => { fastify.log.error(`An unexpected error occurred: ${e.response.body?.message}`); reply.send({ isInstalled: false, - isAppEnabled: false, + isEnabled: false, + variablesValidationStatus: VariablesValidationStatus.UNKNOWN, + variablesValidationTimestamp: '', canInstall: false, error: 'An unexpected error occurred. Please try again later.', }); @@ -66,24 +94,18 @@ module.exports = async (fastify: KubeFastifyInstance) => { // Ensure the account exists try { const account = await getNIMAccount(fastify); - if (!account) { - const response = await createNIMAccount(fastify); - const isEnabled = isAppEnabled(response); - reply.send({ - isInstalled: true, - isEnabled: isEnabled, - canInstall: false, - error: '', - }); - } else { - const isEnabled = isAppEnabled(account); - reply.send({ - isInstalled: true, - isEnabled: isEnabled, - canInstall: false, - error: '', - }); - } + const nimAccount = !account ? await createNIMAccount(fastify) : account; + const isEnabled = isAppEnabled(nimAccount); + const keyValidationStatus: string = apiKeyValidationStatus(nimAccount); + const keyValidationTimeStamp: string = apiKeyValidationTimestamp(nimAccount); + reply.send({ + isInstalled: true, + isEnabled: isEnabled, + variablesValidationStatus: keyValidationStatus, + variablesValidationTimestamp: keyValidationTimeStamp, + canInstall: !isEnabled, + error: '', + }); } catch (accountError: any) { const message = `Failed to create or retrieve NIM account: ${accountError.response?.body?.message}`; fastify.log.error(message); diff --git a/backend/src/routes/api/integrations/nim/nimUtils.ts b/backend/src/routes/api/integrations/nim/nimUtils.ts index 84b1045da9..00638c484f 100644 --- a/backend/src/routes/api/integrations/nim/nimUtils.ts +++ b/backend/src/routes/api/integrations/nim/nimUtils.ts @@ -3,6 +3,18 @@ import { KubeFastifyInstance, NIMAccountKind, SecretKind } from '../../../../typ const NIM_SECRET_NAME = 'nvidia-nim-access'; const NIM_ACCOUNT_NAME = 'odh-nim-account'; +export const apiKeyValidationTimestamp = (app: NIMAccountKind): string => { + const conditions = app?.status?.conditions || []; + const apiKeyCondition = conditions.find((condition) => condition.type === 'APIKeyValidation'); + return apiKeyCondition?.lastTransitionTime || ''; +}; + +export const apiKeyValidationStatus = (app: NIMAccountKind): string => { + const conditions = app?.status?.conditions || []; + const apiKeyCondition = conditions.find((condition) => condition.type === 'APIKeyValidation'); + return apiKeyCondition?.status || 'Unknown'; +}; + export const isAppEnabled = (app: NIMAccountKind): boolean => { const conditions = app?.status?.conditions || []; return ( diff --git a/backend/src/types.ts b/backend/src/types.ts index a1cbf5a7cf..31937497c0 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -1249,6 +1249,12 @@ export enum ServiceAddressAnnotation { EXTERNAL_GRPC = 'routing.opendatahub.io/external-address-grpc', } +export enum VariablesValidationStatus { + UNKNOWN = 'Unknown', + FAILED = 'False', + SUCCESS = 'True', +} + export type NIMAccountKind = K8sResourceCommon & { metadata: { name: string; diff --git a/frontend/src/components/OdhAppCard.tsx b/frontend/src/components/OdhAppCard.tsx index 252cb9edcd..734de6facb 100644 --- a/frontend/src/components/OdhAppCard.tsx +++ b/frontend/src/components/OdhAppCard.tsx @@ -25,6 +25,7 @@ import { ODH_PRODUCT_NAME } from '~/utilities/const'; import { useAppContext } from '~/app/AppContext'; import { useAppDispatch } from '~/redux/hooks'; import { SupportedArea, useIsAreaAvailable } from '~/concepts/areas'; +import { isInternalRouteIntegrationsApp } from '~/utilities/utils'; import { useQuickStartCardSelected } from './useQuickStartCardSelected'; import SupportedAppTitle from './SupportedAppTitle'; import BrandImage from './BrandImage'; @@ -149,20 +150,24 @@ const OdhAppCard: React.FC = ({ odhApp }) => { setEnableOpen(true); }} > - here + here. - . To remove card click  - - . + {!isInternalRouteIntegrationsApp(odhApp.spec.internalRoute) ? ( + <> + To remove card click  + + . + + ) : null} ); diff --git a/frontend/src/pages/exploreApplication/EnableModal.tsx b/frontend/src/pages/exploreApplication/EnableModal.tsx index 903a599a62..cd43d7a50e 100644 --- a/frontend/src/pages/exploreApplication/EnableModal.tsx +++ b/frontend/src/pages/exploreApplication/EnableModal.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { Alert, Button, Form, FormAlert, Spinner, TextInputTypes } from '@patternfly/react-core'; import { Modal, ModalVariant } from '@patternfly/react-core/deprecated'; import { ExternalLinkAltIcon } from '@patternfly/react-icons'; +import { isEmpty, values } from 'lodash-es'; import { OdhApplication } from '~/types'; import { EnableApplicationStatus, useEnableApplication } from '~/utilities/useEnableApplication'; import { asEnumMember } from '~/utilities/utils'; @@ -18,6 +19,10 @@ const EnableModal: React.FC = ({ selectedApp, shown, onClose } const [postError, setPostError] = React.useState(''); const [validationInProgress, setValidationInProgress] = React.useState(false); const [enableValues, setEnableValues] = React.useState<{ [key: string]: string }>({}); + const isEnableValuesHasEmptyValue = React.useMemo( + () => isEmpty(enableValues) || values(enableValues).some((val) => isEmpty(val)), + [enableValues], + ); const [validationStatus, validationErrorMessage] = useEnableApplication( validationInProgress, selectedApp.metadata.name, @@ -44,6 +49,12 @@ const EnableModal: React.FC = ({ selectedApp, shown, onClose } setValidationInProgress(true); }; + const handleClose = React.useCallback(() => { + setEnableValues({}); + setPostError(''); + onClose(); + }, [onClose]); + React.useEffect(() => { if (validationInProgress && validationStatus === EnableApplicationStatus.SUCCESS) { setValidationInProgress(false); @@ -53,13 +64,19 @@ const EnableModal: React.FC = ({ selectedApp, shown, onClose } selectedApp.spec.shownOnEnabledPage = true; /* eslint-enable no-param-reassign */ - onClose(); + handleClose(); } if (validationInProgress && validationStatus === EnableApplicationStatus.FAILED) { setValidationInProgress(false); setPostError(validationErrorMessage); } - }, [onClose, selectedApp.spec, validationErrorMessage, validationInProgress, validationStatus]); + }, [ + handleClose, + selectedApp.spec, + validationErrorMessage, + validationInProgress, + validationStatus, + ]); React.useEffect(() => { if (shown) { @@ -71,13 +88,6 @@ const EnableModal: React.FC = ({ selectedApp, shown, onClose } // eslint-disable-next-line react-hooks/exhaustive-deps }, [shown]); - const handleClose = () => { - if (!validationInProgress) { - setEnableValues({}); - } - onClose(); - }; - if (!selectedApp.spec.enable || !shown) { return null; } @@ -97,7 +107,7 @@ const EnableModal: React.FC = ({ selectedApp, shown, onClose } key="confirm" variant="primary" onClick={onDoEnableApp} - isDisabled={validationInProgress} + isDisabled={validationInProgress || isEnableValuesHasEmptyValue} > {enable.actionLabel} , diff --git a/frontend/src/pages/exploreApplication/GetStartedPanel.tsx b/frontend/src/pages/exploreApplication/GetStartedPanel.tsx index a3deae8169..a7d86a0ea3 100644 --- a/frontend/src/pages/exploreApplication/GetStartedPanel.tsx +++ b/frontend/src/pages/exploreApplication/GetStartedPanel.tsx @@ -13,6 +13,7 @@ import { DrawerPanelBody, DrawerPanelContent, Tooltip, + Skeleton, Content, } from '@patternfly/react-core'; import { ExternalLinkAltIcon } from '@patternfly/react-icons'; @@ -38,7 +39,7 @@ type GetStartedPanelProps = { const GetStartedPanel: React.FC = ({ selectedApp, onClose, onEnable }) => { const { dashboardConfig } = useAppContext(); const { enablement } = dashboardConfig.spec.dashboardConfig; - const [{ isInstalled, canInstall, error }, loaded] = useIntegratedAppStatus(selectedApp); + const [{ isEnabled, canInstall, error }, loaded] = useIntegratedAppStatus(selectedApp); const { isAdmin } = useUser(); if (!selectedApp) { @@ -46,27 +47,32 @@ const GetStartedPanel: React.FC = ({ selectedApp, onClose, } const renderEnableButton = () => { - if (!selectedApp.spec.enable || selectedApp.spec.isEnabled || isInstalled || !isAdmin) { + if (!selectedApp.spec.enable || selectedApp.spec.isEnabled || isEnabled || !isAdmin) { return null; } + + if (!loaded && !error) { + return ; + } + const button = ( ); - if (enablement) { - return button; + + if (!enablement || !canInstall) { + return ( + + {button} + + ); } - return ( - - {button} - - ); + return button; }; return ( diff --git a/frontend/src/pages/exploreApplication/useIntegratedAppStatus.ts b/frontend/src/pages/exploreApplication/useIntegratedAppStatus.ts index 3fa6b13773..973b8d5f9f 100644 --- a/frontend/src/pages/exploreApplication/useIntegratedAppStatus.ts +++ b/frontend/src/pages/exploreApplication/useIntegratedAppStatus.ts @@ -1,10 +1,13 @@ import * as React from 'react'; -import { IntegrationAppStatus, OdhApplication } from '~/types'; +import { IntegrationAppStatus, OdhApplication, VariablesValidationStatus } from '~/types'; import useFetchState, { FetchState, NotReadyError } from '~/utilities/useFetchState'; import { getIntegrationAppEnablementStatus } from '~/services/integrationAppService'; import { isIntegrationApp } from '~/utilities/utils'; +import { useAppSelector } from '~/redux/hooks'; export const useIntegratedAppStatus = (app?: OdhApplication): FetchState => { + const forceUpdate = useAppSelector((state) => state.forceComponentsUpdate); + const callback = React.useCallback(() => { if (!app) { return Promise.reject(new NotReadyError('Need an app to check')); @@ -15,12 +18,14 @@ export const useIntegratedAppStatus = (app?: OdhApplication): FetchState({ status: EnableApplicationStatus.IDLE, error: '' }); + const [lastVariablesValidationTimestamp, setLastVariablesValidationTimestamp] = + React.useState(''); const dispatch = useAppDispatch(); const dispatchResults = React.useCallback( (error?: string) => { - if (!error) { - dispatch( - addNotification({ - status: AlertVariant.success, - title: `${appName} has been added to the Enabled page.`, - timestamp: new Date(), - }), - ); - dispatch(forceComponentsUpdate()); - return; - } dispatch( addNotification({ - status: AlertVariant.danger, - title: `Error attempting to validate ${appName}.`, + status: error ? AlertVariant.danger : AlertVariant.success, + title: error + ? `Error attempting to validate ${appName}` + : `${appName} has been added to the Enabled page.`, message: error, timestamp: new Date(), }), ); + + if (!error) { + dispatch(forceComponentsUpdate()); + } }, [appName, dispatch], ); @@ -63,22 +62,39 @@ export const useEnableApplication = ( React.useEffect(() => { let cancelled = false; let watchHandle: ReturnType; + if (enableStatus.status === EnableApplicationStatus.INPROGRESS) { + const shouldContinueWatching = (response: IntegrationAppStatus): boolean => { + if (!_.isEqual(response.variablesValidationTimestamp, lastVariablesValidationTimestamp)) { + return false; + } + return true; + }; + const watchStatus = () => { if (isInternalRouteIntegrationsApp(internalRoute)) { getIntegrationAppEnablementStatus(internalRoute) .then((response) => { - if (!response.isInstalled && response.canInstall) { + if (shouldContinueWatching(response)) { watchHandle = setTimeout(watchStatus, 10 * 1000); return; } - if (response.isInstalled) { - setEnableStatus({ - status: EnableApplicationStatus.SUCCESS, - error: '', - }); - dispatchResults(undefined); - } + setLastVariablesValidationTimestamp(response.variablesValidationTimestamp || ''); + setEnableStatus({ + status: + response.variablesValidationStatus === VariablesValidationStatus.SUCCESS + ? EnableApplicationStatus.SUCCESS + : EnableApplicationStatus.FAILED, + error: + response.variablesValidationStatus === VariablesValidationStatus.SUCCESS + ? '' + : 'Variables are not valid', + }); + dispatchResults( + response.variablesValidationStatus === VariablesValidationStatus.SUCCESS + ? undefined + : 'Variables are not valid', + ); }) .catch((e) => { if (!cancelled) { @@ -115,7 +131,13 @@ export const useEnableApplication = ( cancelled = true; clearTimeout(watchHandle); }; - }, [appId, dispatchResults, enableStatus.status, internalRoute]); + }, [ + appId, + dispatchResults, + enableStatus.status, + internalRoute, + lastVariablesValidationTimestamp, + ]); React.useEffect(() => { let closed = false; @@ -124,23 +146,38 @@ export const useEnableApplication = ( enableIntegrationApp(internalRoute, enableValues) .then((response) => { if (!closed) { - if (!response.isInstalled && response.canInstall) { + if (response.isInstalled && response.canInstall) { setEnableStatus({ status: EnableApplicationStatus.INPROGRESS, error: '' }); - return; - } + setLastVariablesValidationTimestamp(response.variablesValidationTimestamp || ''); - if (response.isInstalled) { - setEnableStatus({ - status: EnableApplicationStatus.SUCCESS, - error: response.error, - }); - dispatchResults(undefined); + if ( + response.variablesValidationTimestamp !== '' && + lastVariablesValidationTimestamp !== '' && + response.variablesValidationTimestamp !== lastVariablesValidationTimestamp && + response.variablesValidationStatus !== VariablesValidationStatus.UNKNOWN + ) { + setEnableStatus({ + status: + response.variablesValidationStatus === VariablesValidationStatus.SUCCESS + ? EnableApplicationStatus.SUCCESS + : EnableApplicationStatus.FAILED, + error: + response.variablesValidationStatus === VariablesValidationStatus.SUCCESS + ? '' + : 'Variables are not valid', + }); + dispatchResults( + response.variablesValidationStatus === VariablesValidationStatus.SUCCESS + ? undefined + : 'Variables are not valid', + ); + } } } }) .catch((e) => { if (!closed) { - setEnableStatus({ status: EnableApplicationStatus.FAILED, error: e.m }); + setEnableStatus({ status: EnableApplicationStatus.FAILED, error: e.message }); } dispatchResults(e.message); }); @@ -164,7 +201,7 @@ export const useEnableApplication = ( }) .catch((e) => { if (!closed) { - setEnableStatus({ status: EnableApplicationStatus.FAILED, error: e.m }); + setEnableStatus({ status: EnableApplicationStatus.FAILED, error: e.message }); } dispatchResults(e.message); }); @@ -174,6 +211,15 @@ export const useEnableApplication = ( return () => { closed = true; }; - }, [appId, appName, dispatch, dispatchResults, doEnable, enableValues, internalRoute]); + }, [ + appId, + appName, + dispatch, + dispatchResults, + doEnable, + enableValues, + internalRoute, + lastVariablesValidationTimestamp, + ]); return [enableStatus.status, enableStatus.error]; }; diff --git a/frontend/src/utilities/useWatchIntegrationComponents.tsx b/frontend/src/utilities/useWatchIntegrationComponents.tsx index f555efaf08..37142ec6b1 100644 --- a/frontend/src/utilities/useWatchIntegrationComponents.tsx +++ b/frontend/src/utilities/useWatchIntegrationComponents.tsx @@ -1,6 +1,11 @@ import * as React from 'react'; import { useAppSelector } from '~/redux/hooks'; -import { IntegrationAppStatus, OdhApplication, OdhIntegrationApplication } from '~/types'; +import { + IntegrationAppStatus, + OdhApplication, + OdhIntegrationApplication, + VariablesValidationStatus, +} from '~/types'; import { getIntegrationAppEnablementStatus } from '~/services/integrationAppService'; import { allSettledPromises } from '~/utilities/allSettledPromises'; import { POLL_INTERVAL } from './const'; @@ -29,7 +34,9 @@ export const useWatchIntegrationComponents = ( isInstalled: false, isEnabled: false, canInstall: false, - error: e.message ?? e.error, // might be an error from the server, might be an error in the network call itself + variablesValidationStatus: VariablesValidationStatus.UNKNOWN, + variablesValidationTimestamp: '', + error: e.message ?? e.error, } satisfies IntegrationAppStatus), );