diff --git a/backend/src/routes/api/validate-isv/index.ts b/backend/src/routes/api/validate-isv/index.ts index 68bb5c73fb..027c44fcb1 100644 --- a/backend/src/routes/api/validate-isv/index.ts +++ b/backend/src/routes/api/validate-isv/index.ts @@ -1,11 +1,11 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { DEV_MODE } from '../../../utils/constants'; import { addCORSHeader } from '../../../utils/responseUtils'; -import { createValidationJob } from './validateISV'; +import { runValidation } from './validateISV'; export default async (fastify: FastifyInstance): Promise => { fastify.get('/', async (request: FastifyRequest, reply: FastifyReply) => { - return createValidationJob(fastify, request) + return runValidation(fastify, request) .then((res) => { if (DEV_MODE) { addCORSHeader(request, reply); diff --git a/backend/src/routes/api/validate-isv/validateISV.ts b/backend/src/routes/api/validate-isv/validateISV.ts index 2cad2f5cb4..4fb149954c 100644 --- a/backend/src/routes/api/validate-isv/validateISV.ts +++ b/backend/src/routes/api/validate-isv/validateISV.ts @@ -5,6 +5,42 @@ import { FastifyRequest } from 'fastify'; import { KubeFastifyInstance, ODHApp } from '../../../types'; import { getApplicationDef } from '../../../utils/resourceUtils'; +const doSleep = (timeout: number) => { + return new Promise((resolve) => setTimeout(resolve, timeout)); +}; + +const waitOnDeletion = async (reader: () => Promise) => { + const MAX_TRIES = 25; + let tries = 0; + let deleted = false; + + while (!deleted && ++tries < MAX_TRIES) { + await reader() + .then(() => doSleep(1000)) + .catch(() => { + deleted = true; + }); + } +}; + +const waitOnCompletion = async (reader: () => Promise): Promise => { + const MAX_TRIES = 60; + let tries = 0; + let completionStatus; + + while (completionStatus === undefined && ++tries < MAX_TRIES) { + await reader() + .then((res) => { + completionStatus = res; + }) + .catch(async () => { + await doSleep(1000); + return; + }); + } + return completionStatus || false; +}; + export const createAccessSecret = async ( appDef: ODHApp, namespace: string, @@ -34,10 +70,10 @@ export const createAccessSecret = async ( }); }; -export const createValidationJob = async ( +export const runValidation = async ( fastify: KubeFastifyInstance, request: FastifyRequest, -): Promise<{ response: IncomingMessage; body: V1Secret }> => { +): Promise => { const namespace = fastify.kube.namespace; const query = request.query as { [key: string]: string }; const appName = query?.appName; @@ -57,43 +93,64 @@ export const createValidationJob = async ( error.message = 'Unable to validate the application.'; throw error; } + const jobName = `${cronjobName}-job-custom-run`; + + await createAccessSecret(appDef, namespace, stringData, coreV1Api); - return createAccessSecret(appDef, namespace, stringData, coreV1Api).then(() => { - return batchV1beta1Api.readNamespacedCronJob(cronjobName, namespace).then(async (res) => { - const cronJob = res.body; - const jobSpec = cronJob.spec.jobTemplate.spec; - const jobName = `${cronjobName}-job-custom-run`; - const job = { - apiVersion: 'batch/v1', - metadata: { - name: jobName, - namespace, - annotations: { - 'cronjob.kubernetes.io/instantiate': 'manual', - }, - }, - spec: jobSpec, - }; - // Flag the cronjob as no longer suspended - cronJob.spec.suspend = false; - await batchV1beta1Api.replaceNamespacedCronJob(cronjobName, namespace, cronJob).catch((e) => { - fastify.log.error(`failed to unsuspend cronjob: ${e.response.body.message}`); + const cronJob = await batchV1beta1Api + .readNamespacedCronJob(cronjobName, namespace) + .then((res) => res.body); + + // Flag the cronjob as no longer suspended + cronJob.spec.suspend = false; + await batchV1beta1Api.replaceNamespacedCronJob(cronjobName, namespace, cronJob).catch((e) => { + fastify.log.error(`failed to unsuspend cronjob: ${e.response.body.message}`); + }); + + // If there was a manual job already, delete it + await batchV1Api.deleteNamespacedJob(jobName, namespace).catch(() => { + return; + }); + + // wait for job to be deleted + await waitOnDeletion(() => { + return batchV1Api.readNamespacedJob(jobName, namespace).then(() => { + return; + }); + }); + + // Wait for previous config map to be deleted + if (cmName) { + await waitOnDeletion(() => { + return coreV1Api.readNamespacedConfigMap(cmName, namespace).then(() => { + return; }); + }); + } - // If there was a manual job already, delete it - // eslint-disable-next-line @typescript-eslint/no-empty-function - await batchV1Api.deleteNamespacedJob(jobName, namespace).catch(() => {}); + const job = { + apiVersion: 'batch/v1', + metadata: { + name: jobName, + namespace, + annotations: { + 'cronjob.kubernetes.io/instantiate': 'manual', + }, + }, + spec: cronJob.spec.jobTemplate.spec, + }; - // If there was a config map already, delete it - if (cmName) { - // eslint-disable-next-line @typescript-eslint/no-empty-function - await coreV1Api.deleteNamespacedConfigMap(cmName, namespace).catch(() => {}); - } + await batchV1Api.createNamespacedJob(namespace, job); - // Some delay to allow job to delete - return new Promise((resolve) => setTimeout(resolve, 1000)).then(() => - batchV1Api.createNamespacedJob(namespace, job), - ); + return await waitOnCompletion(() => { + return batchV1Api.readNamespacedJobStatus(jobName, namespace).then((res) => { + if (res.body.status.succeeded) { + return true; + } + if (res.body.status.failed) { + return false; + } + throw new Error(); }); }); }; diff --git a/backend/src/utils/componentUtils.ts b/backend/src/utils/componentUtils.ts index 2c7505ad44..c986d39cf3 100644 --- a/backend/src/utils/componentUtils.ts +++ b/backend/src/utils/componentUtils.ts @@ -1,6 +1,14 @@ +import { IncomingMessage } from 'http'; import { V1ConfigMap } from '@kubernetes/client-node/dist/gen/model/v1ConfigMap'; import { ODHApp, K8sResourceCommon, KubeFastifyInstance, RouteKind } from '../types'; +type RoutesResponse = { + body: { + items: RouteKind[]; + }; + response: IncomingMessage; +}; + const getURLForRoute = (route: RouteKind, routeSuffix: string): string => { const host = route?.spec?.host; if (!host) { @@ -21,14 +29,10 @@ export const getLink = async ( const customObjectsApi = fastify.kube.customObjectsApi; const routeNamespace = namespace || fastify.kube.namespace; try { - const res = await customObjectsApi.getNamespacedCustomObject( - 'route.openshift.io', - 'v1', - routeNamespace, - 'routes', - routeName, - ); - return getURLForRoute(res?.body as RouteKind, routeSuffix); + const route = await customObjectsApi + .getNamespacedCustomObject('route.openshift.io', 'v1', routeNamespace, 'routes', routeName) + .then((res) => res.body as RouteKind); + return getURLForRoute(route, routeSuffix); } catch (e) { fastify.log.error(`failed to get route ${routeName} in namespace ${namespace}`); return null; @@ -52,13 +56,10 @@ export const getServiceLink = async ( const customObjectsApi = fastify.kube.customObjectsApi; const { namespace } = service.metadata; try { - const res = await customObjectsApi.listNamespacedCustomObject( - 'route.openshift.io', - 'v1', - namespace, - 'routes', - ); - return getURLForRoute((res?.body as { items: RouteKind[] })?.items?.[0], routeSuffix); + const routes = await customObjectsApi + .listNamespacedCustomObject('route.openshift.io', 'v1', namespace, 'routes') + .then((res: RoutesResponse) => res.body?.items); + return getURLForRoute(routes?.[0], routeSuffix); } catch (e) { fastify.log.error(`failed to get route in namespace ${namespace}`); return null; @@ -77,7 +78,7 @@ export const getApplicationEnabledConfigMap = ( const coreV1Api = fastify.kube.coreV1Api; return coreV1Api .readNamespacedConfigMap(name, namespace) - .then((result: { body: V1ConfigMap }) => result.body) + .then((result) => result.body) .catch(() => null); }; diff --git a/frontend/.eslintrc b/frontend/.eslintrc index a91130b93f..7b9b711fa4 100755 --- a/frontend/.eslintrc +++ b/frontend/.eslintrc @@ -48,6 +48,7 @@ "@typescript-eslint/no-inferrable-types": "error", "react-hooks/exhaustive-deps": "warn", "react-hooks/rules-of-hooks": "error", + "react/display-name": "off", "import/extensions": "off", "import/no-unresolved": "off", "prettier/prettier": [ diff --git a/frontend/src/pages/enabledApplications/EnabledApplications.tsx b/frontend/src/pages/enabledApplications/EnabledApplications.tsx index 07a4f184bf..98ced1ca4e 100644 --- a/frontend/src/pages/enabledApplications/EnabledApplications.tsx +++ b/frontend/src/pages/enabledApplications/EnabledApplications.tsx @@ -1,6 +1,8 @@ -import React from 'react'; +import * as React from 'react'; +import * as _ from 'lodash'; import { Gallery, PageSection } from '@patternfly/react-core'; import { useWatchComponents } from '../../utilities/useWatchComponents'; +import { ODHApp } from '../../types'; import ApplicationsPage from '../ApplicationsPage'; import OdhAppCard from '../../components/OdhAppCard'; import QuickStarts from '../../app/QuickStarts'; @@ -10,31 +12,54 @@ import './EnabledApplications.scss'; const description = `Launch your enabled applications or get started with quick start instructions and tasks.`; +type EnabledApplicationsInnerProps = { + loaded: boolean; + loadError?: Error; + components: ODHApp[]; +}; +const EnabledApplicationsInner: React.FC = React.memo( + ({ loaded, loadError, components }) => { + const isEmpty = !components || components.length === 0; + return ( + + + {!isEmpty ? ( + + + {components.map((c) => ( + + ))} + + + ) : null} + + + ); + }, +); + const EnabledApplications: React.FC = () => { const { components, loaded, loadError } = useWatchComponents(true); - const isEmpty = !components || components.length === 0; + const sortedComponents = React.useMemo(() => { + return _.cloneDeep(components).sort((a, b) => + a.spec.displayName.localeCompare(b.spec.displayName), + ); + }, [components]); + return ( - - {!isEmpty ? ( - - - {components - .sort((a, b) => a.spec.displayName.localeCompare(b.spec.displayName)) - .map((c) => ( - - ))} - - - ) : null} - + /> ); }; diff --git a/frontend/src/pages/exploreApplication/EnableModal.scss b/frontend/src/pages/exploreApplication/EnableModal.scss new file mode 100644 index 0000000000..365ad0ec75 --- /dev/null +++ b/frontend/src/pages/exploreApplication/EnableModal.scss @@ -0,0 +1,26 @@ +.odh-enable-modal { + &__progress-title { + display: flex; + align-items: center; + > svg { + margin-right: var(--pf-global--spacer--sm); + } + } + .pf-c-alert.m-no-alert-icon { + .pf-c-alert__icon { + display: none; + } + } + &__variable-input { + max-width: calc(400px - var(--pf-global--spacer--md)); + margin-right: var(--pf-global--spacer--xl); + } + &__toggle-password-vis { + border: 1px solid var(--pf-global--BorderColor--300); + border-radius: 0; + color: var(--pf-global--Color--200) !important; + padding: 6px var(--pf-global--spacer--sm) 5px; + position: relative; + right: 33px; + } +} diff --git a/frontend/src/pages/exploreApplication/EnableModal.tsx b/frontend/src/pages/exploreApplication/EnableModal.tsx index e74ab4e257..b3cf0993c2 100644 --- a/frontend/src/pages/exploreApplication/EnableModal.tsx +++ b/frontend/src/pages/exploreApplication/EnableModal.tsx @@ -4,23 +4,31 @@ import { Button, Form, FormAlert, - FormGroup, Modal, ModalVariant, - TextInput, + Spinner, TextInputTypes, } from '@patternfly/react-core'; import { ODHApp } from '../../types'; import { postValidateIsv } from '../../services/validateIsvService'; +import EnableVariable from './EnableVariable'; + +import './EnableModal.scss'; type EnableModalProps = { selectedApp?: ODHApp; - onClose: () => void; + onClose: (success?: boolean) => void; }; const EnableModal: React.FC = ({ selectedApp, onClose }) => { const [postError, setPostError] = React.useState(false); + const [validationInProgress, setValidationInProgress] = React.useState(false); const [enableValues, setEnableValues] = React.useState<{ [key: string]: string }>({}); + const focusRef = (element: HTMLElement) => { + if (element) { + element.focus(); + } + }; if (!selectedApp) { return null; @@ -34,48 +42,41 @@ const EnableModal: React.FC = ({ selectedApp, onClose }) => { setEnableValues(updatedValues); }; - const renderAppVariables = () => { - if (!selectedApp.spec.enable?.variables) { - return null; - } - return Object.keys(selectedApp.spec.enable.variables).map((key) => ( - - updateEnableValue(key, value)} - /> - - )); - }; - const onDoEnableApp = () => { + setPostError(false); + setValidationInProgress(true); postValidateIsv(selectedApp.metadata.name, enableValues) - .then(() => { - setPostError(false); - onClose(); + .then((valid) => { + setValidationInProgress(false); + if (valid) { + onClose(true); + return; + } + setPostError(true); }) .catch(() => { + setValidationInProgress(false); setPostError(true); }); }; return ( + , - , ]} @@ -86,14 +87,43 @@ const EnableModal: React.FC = ({ selectedApp, onClose }) => { {postError ? ( + + ) : null} + {validationInProgress ? ( + + + Validating your entries + + } aria-live="polite" isInline /> ) : null} - {renderAppVariables()} + {selectedApp.spec.enable?.variables + ? Object.keys(selectedApp.spec.enable.variables).map((key, index) => ( + updateEnableValue(key, value)} + /> + )) + : null} ) : null} diff --git a/frontend/src/pages/exploreApplication/EnableVariable.tsx b/frontend/src/pages/exploreApplication/EnableVariable.tsx new file mode 100644 index 0000000000..7f39d00a20 --- /dev/null +++ b/frontend/src/pages/exploreApplication/EnableVariable.tsx @@ -0,0 +1,49 @@ +import * as React from 'react'; +import { + Button, + ButtonVariant, + FormGroup, + TextInput, + TextInputTypes, +} from '@patternfly/react-core'; +import { EyeIcon, EyeSlashIcon } from '@patternfly/react-icons'; + +type EnableVariableProps = { + label: string; + inputType: TextInputTypes; + helperText?: string; + validationInProgress: boolean; + updateValue: (value: string) => void; +}; + +const EnableVariable = React.forwardRef( + ({ label, inputType, helperText, validationInProgress, updateValue }, ref) => { + const [showPassword, setShowPassword] = React.useState(false); + + return ( + + updateValue(value)} + /> + {inputType === TextInputTypes.password ? ( + + ) : null} + + ); + }, +); + +export default EnableVariable; diff --git a/frontend/src/pages/exploreApplication/ExploreApplications.tsx b/frontend/src/pages/exploreApplication/ExploreApplications.tsx index 04b4a10720..73cdc325a2 100644 --- a/frontend/src/pages/exploreApplication/ExploreApplications.tsx +++ b/frontend/src/pages/exploreApplication/ExploreApplications.tsx @@ -1,4 +1,5 @@ -import React from 'react'; +import * as React from 'react'; +import * as _ from 'lodash'; import { Drawer, DrawerContent, @@ -17,6 +18,57 @@ import { useHistory } from 'react-router'; const description = `Add optional applications to your Red Hat OpenShift Data Science instance.`; +type ExploreApplicationsInnerProps = { + loaded: boolean; + isEmpty: boolean; + loadError?: Error; + exploreComponents: ODHApp[]; + selectedComponent?: ODHApp; + updateSelection: (selectedId?: string | null) => void; +}; + +const ExploreApplicationsInner: React.FC = React.memo( + ({ loaded, isEmpty, loadError, exploreComponents, selectedComponent, updateSelection }) => { + return ( + + {!isEmpty ? ( + + updateSelection()} + selectedApp={selectedComponent} + /> + } + > + + + + {exploreComponents.map((c) => ( + updateSelection(c.metadata.name)} + /> + ))} + + + + + + ) : null} + + ); + }, +); + const ExploreApplications: React.FC = () => { const { components, loaded, loadError } = useWatchComponents(false); const history = useHistory(); @@ -41,6 +93,12 @@ const ExploreApplications: React.FC = () => { [components], ); + const exploreComponents = React.useMemo(() => { + return _.cloneDeep(components).sort((a, b) => + a.spec.displayName.localeCompare(b.spec.displayName), + ); + }, [components]); + React.useEffect(() => { if (components?.length > 0) { updateSelection(selectedId); @@ -48,40 +106,14 @@ const ExploreApplications: React.FC = () => { }, [updateSelection, selectedId, components]); return ( - - {!isEmpty ? ( - - updateSelection()} selectedApp={selectedComponent} /> - } - > - - - - {components - .sort((a, b) => a.spec.displayName.localeCompare(b.spec.displayName)) - .map((c) => ( - updateSelection(c.metadata.name)} - /> - ))} - - - - - - ) : null} - + exploreComponents={exploreComponents} + selectedComponent={selectedComponent} + updateSelection={updateSelection} + /> ); }; diff --git a/frontend/src/pages/exploreApplication/GetStartedPanel.tsx b/frontend/src/pages/exploreApplication/GetStartedPanel.tsx index e8e8531b76..36720cc1bc 100644 --- a/frontend/src/pages/exploreApplication/GetStartedPanel.tsx +++ b/frontend/src/pages/exploreApplication/GetStartedPanel.tsx @@ -85,6 +85,13 @@ const GetStartedPanel: React.FC = ({ selectedApp, onClose return ; }; + const onEnableClose = (success?: boolean) => { + if (success) { + selectedApp.spec.isEnabled = true; + } + setEnableOpen(false); + }; + const onEnable = () => { setEnableOpen(true); }; @@ -131,9 +138,7 @@ const GetStartedPanel: React.FC = ({ selectedApp, onClose {renderMarkdownContents()} - {enableOpen ? ( - setEnableOpen(false)} selectedApp={selectedApp} /> - ) : null} + {enableOpen ? : null} ); }; diff --git a/frontend/src/pages/learningCenter/LearningCenter.tsx b/frontend/src/pages/learningCenter/LearningCenter.tsx index cc43f5f5f8..667300dab4 100644 --- a/frontend/src/pages/learningCenter/LearningCenter.tsx +++ b/frontend/src/pages/learningCenter/LearningCenter.tsx @@ -23,11 +23,62 @@ import { useWatchDocs } from '../../utilities/useWatchDocs'; const description = `Access all learning resources for Red Hat OpenShift Data Science and supported applications.`; -const LearningCenter: React.FC = () => { +type LearningCenterProps = { + loaded: boolean; + loadError?: Error; + docsLoadError?: Error; + docsLoaded: boolean; + filteredDocApps: ODHDoc[]; + docTypeCounts: Record; + totalCount: number; +}; + +const LearningCenter: React.FC = React.memo( + ({ + loaded, + loadError, + docsLoaded, + docsLoadError, + filteredDocApps, + docTypeCounts, + totalCount, + }) => { + return ( + + + + + {filteredDocApps.map((doc) => ( + + ))} + + + + ); + }, +); + +const LearningCenterInner: React.FC = () => { const { docs: odhDocs, loaded: docsLoaded, loadError: docsLoadError } = useWatchDocs(); const { components, loaded, loadError } = useWatchComponents(false); const qsContext = React.useContext(QuickStartContext); const [docApps, setDocApps] = React.useState([]); + const [docTypesCount, setDocTypesCount] = React.useState>({ + [ODHDocType.Documentation]: 0, + [ODHDocType.HowTo]: 0, + [ODHDocType.Tutorial]: 0, + [ODHDocType.QuickStart]: 0, + }); const [filteredDocApps, setFilteredDocApps] = React.useState([]); const queryParams = useQueryParams(); const searchQuery = queryParams.get(SEARCH_FILTER_KEY) || ''; @@ -103,10 +154,7 @@ const LearningCenter: React.FC = () => { return sortVal; }), ); - }, [docApps, searchQuery, typeFilters, sortOrder, sortType]); - - const docTypesCount = React.useMemo(() => { - return docApps.reduce( + const docCounts = docApps.reduce( (acc, docApp) => { if (acc[docApp.metadata.type] !== undefined) { acc[docApp.metadata.type]++; @@ -120,35 +168,25 @@ const LearningCenter: React.FC = () => { [ODHDocType.QuickStart]: 0, }, ); - }, [docApps]); + setDocTypesCount(docCounts); + }, [docApps, searchQuery, typeFilters, sortOrder, sortType]); return ( - - - - - {filteredDocApps.map((doc) => ( - - ))} - - - + ); }; const LearningCenterWrapper: React.FC = () => ( - + ); diff --git a/frontend/src/services/validateIsvService.ts b/frontend/src/services/validateIsvService.ts index 39b0729d07..bcad19f8b8 100644 --- a/frontend/src/services/validateIsvService.ts +++ b/frontend/src/services/validateIsvService.ts @@ -14,8 +14,8 @@ export const postValidateIsv = ( const options = { params: searchParams }; return axios .get(url, options) - .then(() => { - return true; + .then((res) => { + return res.data; }) .catch((e) => { throw new Error(e.response.data.message);