diff --git a/.github/ISSUE_TEMPLATE/internal_tracker.yml b/.github/ISSUE_TEMPLATE/internal_tracker.yml new file mode 100644 index 0000000000..4345f6215c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/internal_tracker.yml @@ -0,0 +1,64 @@ +name: (Internal) Tracker Template +description: Intended to help with a template for tracking larger grouped items. +title: "[Tracker]: " +labels: ["tracker"] +body: + - type: textarea + id: description + attributes: + label: Description + description: A introductory description of the larger task + validations: + required: + true + - type: input + id: branch + attributes: + label: Target Branch + description: What is the feature branch to contain this effort? If not known at this time, replace with `TBD` + placeholder: f/ + validations: + required: true + - type: textarea + id: requirements + attributes: + label: Requirements + description: A series of requirements to consider this tracker complete. + placeholder: | + * P0: Show something + * P2: Allow users to change permissions + validations: + required: true + - type: textarea + id: ux-issues + attributes: + label: Itemized UX Issues + description: | + List the tickets that UX will work on. + + Tip: Using a bullet list will help display links to other tickets by unraveling the name and status of that ticket. + placeholder: | + * #1234 + * Design mocks - Ticket TBD + validations: + required: true + - type: textarea + id: dev-issues + attributes: + label: Itemized Dev Issues + description: | + List the tickets that Development will work on. If unknown at this time, add `TBD` + + Tip: Using a bullet list will help display links to other tickets by unraveling the name and status of that ticket. + placeholder: | + * #1234 + * Implement Table Page - Ticket TBD + validations: + required: true + - type: textarea + id: artifacts + attributes: + label: Related artifacts + description: Any additional artifacts that will help with the tracker goals + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/internal_ux.yml b/.github/ISSUE_TEMPLATE/internal_ux.yml new file mode 100644 index 0000000000..8cf69b0b96 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/internal_ux.yml @@ -0,0 +1,53 @@ +name: (Internal) UX Template +description: Intended to help ux create internal flows. +title: "[UX]: " +labels: ["kind/ux"] +body: + - type: textarea + id: description + attributes: + label: Description + description: A introductory description of the task + validations: + required: + true + - type: textarea + id: goals + attributes: + label: Goals + description: An itemized list of goals to complete for this ticket + placeholder: | + * Research... + * Design... + validations: + required: false + - type: textarea + id: output + attributes: + label: Expected Output + description: What would be considered the end result? + validations: + required: false + - type: textarea + id: related-issues + attributes: + label: Related Issues + description: | + Any related issues that might be useful to mention as it relates to this ticket's goals, expectations, or follow ups. + + Tip: Using a bullet list will help display links to other tickets by unraveling the name and status of that ticket. + placeholder: | + * #1234 + * Create figma designs - Ticket TBD + validations: + required: false + - type: textarea + id: artifacts + attributes: + label: Completed artifacts + description: | + Any artifacts you want to easily note as results of the effort. + + Typically this is left empty at the start. Also useful to include useful links or information you would like to share for additional context. + validations: + required: false diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 1e752b78d8..8f470dc456 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -28,7 +28,7 @@ Self checklist (all need to be checked): If you have UI changes: - [ ] Included any necessary screenshots or gifs if it was a UI change. -- [ ] Included tags to the UX team if it was a UI/UX change. +- [ ] Included tags to the UX team if it was a UI/UX change (find relevant UX in the [SMEs](https://github.com/opendatahub-io/odh-dashboard/tree/main/docs/smes.md) section). After the PR is posted & before it merges: - [ ] The developer has tested their solution on a cluster by using the image produced by the PR to `main` diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 81d7cdead6..97ad7b786d 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -12,7 +12,7 @@ jobs: steps: - uses: actions/checkout@v3 - name: Setup Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v3.7.0 + uses: actions/setup-node@v3.8.1 with: node-version: ${{ matrix.node-version }} - name: Node.js modules cache, repository diff --git a/OWNERS b/OWNERS index 6cf86d8d1c..bf019d3d5f 100644 --- a/OWNERS +++ b/OWNERS @@ -1,8 +1,17 @@ approvers: - andrewballantyne +- lucferbux +- alexcreasy +- christianvogt reviewers: -- DaoDaoNoCode -- lucferbux -- Gkrumbach07 - alexcreasy +- christianvogt +- uidoyen +- Gkrumbach07 +- lucferbux +- DaoDaoNoCode +- manaswinidas +- pnaik1 +- ppadti +- dpanshug diff --git a/backend/.eslintrc b/backend/.eslintrc index a0d326e003..c28a9daf75 100755 --- a/backend/.eslintrc +++ b/backend/.eslintrc @@ -35,6 +35,10 @@ "settings": { }, "rules": { + "no-restricted-properties": [ "error", { + "property": "toString", + "message": "e.toString() should be fastify.log.error(e, 'your string'). Other use-cases should avoid obj.toString() on principle. Craft the string you want instead." + }], "@typescript-eslint/explicit-function-return-type": "off", "@typescript-eslint/interface-name-prefix": "off", "@typescript-eslint/no-var-requires": "off", diff --git a/backend/src/plugins/kube.ts b/backend/src/plugins/kube.ts index ee1d78c87f..ed1c43eaa6 100644 --- a/backend/src/plugins/kube.ts +++ b/backend/src/plugins/kube.ts @@ -46,7 +46,10 @@ export default fp(async (fastify: FastifyInstance) => { ); clusterID = (clusterVersion.body as { spec: { clusterID: string } }).spec.clusterID; } catch (e) { - fastify.log.error(`Failed to retrieve cluster id: ${e.response?.body?.message || e.message}.`); + fastify.log.error( + e, + `Failed to retrieve cluster id: ${e.response?.body?.message || e.message}.`, + ); } let clusterBranding = 'okd'; try { diff --git a/backend/src/routes/api/cluster-settings/clusterSettingsUtils.ts b/backend/src/routes/api/cluster-settings/clusterSettingsUtils.ts index 57692c5ca0..4fab3ccd15 100644 --- a/backend/src/routes/api/cluster-settings/clusterSettingsUtils.ts +++ b/backend/src/routes/api/cluster-settings/clusterSettingsUtils.ts @@ -111,9 +111,7 @@ export const updateClusterSettings = async ( } return { success: true, error: null }; } catch (e) { - fastify.log.error( - 'Setting cluster settings error: ' + e.toString() + e.response?.body?.message, - ); + fastify.log.error(e, 'Setting cluster settings error: ' + e.response?.body?.message); if (e.response?.statusCode !== 404) { return { success: false, error: 'Unable to update cluster settings. ' + e.message }; } @@ -137,7 +135,7 @@ export const getClusterSettings = async ( clusterSettings.userTrackingEnabled = segmentEnabledRes.body.data.segmentKeyEnabled === 'true'; } catch (e) { - fastify.log.error('Error retrieving segment key enabled: ' + e.toString()); + fastify.log.error(e, 'Error retrieving segment key enabled.'); } } if (isJupyterEnabled) { @@ -165,7 +163,7 @@ export const getClusterSettings = async ( if (e.statusCode === 404) { fastify.log.warn('Notebook controller culling config not found, culling disabled...'); } else { - fastify.log.error('Error getting notebook controller culling settings: ' + e.toString()); + fastify.log.error(e, 'Error getting notebook controller culling settings.'); throw e; } }); @@ -175,7 +173,7 @@ export const getClusterSettings = async ( clusterSettings.pvcSize = pvcSize; clusterSettings.cullerTimeout = cullerTimeout; } catch (e) { - fastify.log.error('Error retrieving cluster settings: ' + e.toString()); + fastify.log.error(e, 'Error retrieving cluster settings.'); } } diff --git a/backend/src/routes/api/dev-impersonate/index.ts b/backend/src/routes/api/dev-impersonate/index.ts index d5f4a2e9f8..778add7672 100644 --- a/backend/src/routes/api/dev-impersonate/index.ts +++ b/backend/src/routes/api/dev-impersonate/index.ts @@ -23,6 +23,8 @@ export default async (fastify: KubeFastifyInstance): Promise => { url, { headers: { + // This usage of toString is fine for internal dev flows + // eslint-disable-next-line no-restricted-properties Authorization: `Basic ${Buffer.from( `${DEV_IMPERSONATE_USER}:${DEV_IMPERSONATE_PASSWORD}`, ).toString('base64')}`, diff --git a/backend/src/routes/api/groups-config/groupsConfigUtil.ts b/backend/src/routes/api/groups-config/groupsConfigUtil.ts index f5b082caae..488993efb8 100644 --- a/backend/src/routes/api/groups-config/groupsConfigUtil.ts +++ b/backend/src/routes/api/groups-config/groupsConfigUtil.ts @@ -25,7 +25,7 @@ export const getGroupsConfig = async (fastify: KubeFastifyInstance): Promise 0) { - fastify.log.error('Duplicate name unable to add notebook image'); + fastify.log.error('Duplicate name unable to add notebook image.'); return { success: false, error: 'Unable to add notebook image: ' + body.name }; } @@ -397,7 +397,7 @@ export const updateImage = async ( return { success: true, error: null }; } catch (e) { if (e.response?.statusCode !== 404) { - fastify.log.error('Unable to update notebook image: ' + e.toString()); + fastify.log.error(e, 'Unable to update notebook image.'); return { success: false, error: 'Unable to update notebook image: ' + e.message }; } } diff --git a/backend/src/routes/api/nb-events/eventUtils.ts b/backend/src/routes/api/nb-events/eventUtils.ts index 3c0cd98ec8..66e6bd9c15 100644 --- a/backend/src/routes/api/nb-events/eventUtils.ts +++ b/backend/src/routes/api/nb-events/eventUtils.ts @@ -4,7 +4,8 @@ import { KubeFastifyInstance } from '../../../types'; export const getNotebookEvents = async ( fastify: KubeFastifyInstance, namespace: string, - podUID: string, + notebookName: string, + podUID: string | undefined, ): Promise => { return fastify.kube.coreV1Api .listNamespacedEvent( @@ -12,7 +13,9 @@ export const getNotebookEvents = async ( undefined, undefined, undefined, - `involvedObject.kind=Pod,involvedObject.uid=${podUID}`, + podUID + ? `involvedObject.kind=Pod,involvedObject.uid=${podUID}` + : `involvedObject.kind=StatefulSet,involvedObject.name=${notebookName}`, ) .then((res) => { const body = res.body as V1EventList; diff --git a/backend/src/routes/api/nb-events/index.ts b/backend/src/routes/api/nb-events/index.ts index b66a670d1b..7c3b2bdc6a 100644 --- a/backend/src/routes/api/nb-events/index.ts +++ b/backend/src/routes/api/nb-events/index.ts @@ -3,24 +3,21 @@ import { getNotebookEvents } from './eventUtils'; import { secureRoute } from '../../../utils/route-security'; export default async (fastify: FastifyInstance): Promise => { - fastify.get( - '/:namespace/:podUID', - secureRoute(fastify)( - async ( - request: FastifyRequest<{ - Params: { - namespace: string; - podUID: string; - }; - Querystring: { - // TODO: Support server side filtering - from?: string; - }; - }>, - ) => { - const { namespace, podUID } = request.params; - return getNotebookEvents(fastify, namespace, podUID); - }, - ), + const routeHandler = secureRoute(fastify)( + async ( + request: FastifyRequest<{ + Params: { + namespace: string; + notebookName: string; + podUID: string | undefined; + }; + }>, + ) => { + const { namespace, notebookName, podUID } = request.params; + return getNotebookEvents(fastify, namespace, notebookName, podUID); + }, ); + + fastify.get('/:namespace/:notebookName', routeHandler); + fastify.get('/:namespace/:notebookName/:podUID', routeHandler); }; diff --git a/backend/src/routes/api/segment-key/segmentKeyUtils.ts b/backend/src/routes/api/segment-key/segmentKeyUtils.ts index b4c02729a9..8289a4c5c4 100644 --- a/backend/src/routes/api/segment-key/segmentKeyUtils.ts +++ b/backend/src/routes/api/segment-key/segmentKeyUtils.ts @@ -11,7 +11,7 @@ export const getSegmentKey = async (fastify: KubeFastifyInstance): Promise { +app.listen({ port: PORT, host: IP }, (err) => { if (err) { app.log.error(err); process.exit(1); // eslint-disable-line diff --git a/backend/src/types.ts b/backend/src/types.ts index 7a3a384729..41a9723c80 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -903,8 +903,10 @@ export type ServingRuntime = K8sResourceCommon & { image: string; name: string; resources: ContainerResources; + volumeMounts?: VolumeMount[]; }[]; supportedModelFormats: SupportedModelFormats[]; replicas: number; + volumes?: Volume[]; }; }; diff --git a/backend/src/utils/adminUtils.ts b/backend/src/utils/adminUtils.ts index da2a96d6d8..66daf92dc5 100644 --- a/backend/src/utils/adminUtils.ts +++ b/backend/src/utils/adminUtils.ts @@ -52,7 +52,7 @@ export const getGroupsConfig = async ( return await checkUserInGroups(fastify, customObjectApi, adminGroupsList, username); } } catch (e) { - fastify.log.error(e.toString()); + fastify.log.error(e, 'Error getting groups config'); return false; } }; @@ -84,7 +84,7 @@ export const isUserAllowed = async ( ); } } catch (e) { - fastify.log.error(e.toString()); + fastify.log.error(e, 'Error determining isUserAllowed.'); return false; } }; @@ -142,7 +142,7 @@ const checkUserInGroups = async ( return true; } } catch (e) { - fastify.log.error(e.toString()); + fastify.log.error(e, 'Error checking if user is in group.'); } } return false; diff --git a/backend/src/utils/prometheusUtils.ts b/backend/src/utils/prometheusUtils.ts index d90f37a20c..3a08e117b3 100644 --- a/backend/src/utils/prometheusUtils.ts +++ b/backend/src/utils/prometheusUtils.ts @@ -48,7 +48,7 @@ const callPrometheus = async ( fastify.log.info('Successful response from Prometheus.'); return { code: 200, response: parsedData }; } catch (e) { - const errorMessage = e.message || e.toString(); + const errorMessage = e.message || 'Unknown reason.'; fastify.log.error(`Failure parsing the response from Prometheus. ${errorMessage}`); if (errorMessage.includes('Unexpected token < in JSON')) { throw { code: 422, response: 'Unprocessable prometheus response' }; diff --git a/docs/architecture.md b/docs/architecture.md index 3bde9492ce..564a3af313 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -100,20 +100,26 @@ When building new client features, there are a few things worth noting about the Tests can be divided into the following categories: unit, integration, accessibility, and end to end testing. To keep organized of the different types of tests, there will be a test folder at the root of the frontend project with the following structure. +E2e and integration tests are located in a single root directory: ``` -/frontend/tests - /integration => ComponentName.stories.tsx, ComponentName.spec.ts - /unit => functionName.test.ts +/frontend/src/__tests__ /e2e => storyName.spec.ts + /integration => ComponentName.stories.tsx, ComponentName.spec.ts ``` Some nesting can be used to organize testing groups together. For example, the _projects_ page has screens for _details_, _projects_, and, _spawner_ which can be all grouped together under a projects folder. +Unit tests are co-located in a `__tests__` directory adjacent to the target source file they are testing. +``` +/frontend/src/**/__tests__ + /targetFile.spec.ts +``` + #### Testing Types ##### Unit Testing -Unit tests cover util functions and other non React based functions. These tests are stored in the `/unit `folder and can be organized into folders depending on their parent page and/or screen. Use Jest to test each function using `describe` to group together the utils file and the specific function. Then each test is described using `it`. Some functions are very basic and don't need a test. Use your best judgment if a test is needed. +Unit tests cover util functions and other non React based functions. Use Jest to test each function using `describe` to group together the utils file and the specific function. Then each test is described using `it`. _Example_ diff --git a/docs/releases.md b/docs/releases.md index fe1135e76f..73974b2f95 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -74,6 +74,7 @@ Once we reach a date in which we want to do a release (see other sections for mo - Focusing on the `odh-dashboard` folder, we'll need to copy some files over to track the latest changes of this release - Test the latest version of the quay image ([our quay repo](https://quay.io/repository/opendatahub/odh-dashboard?tab=tags)) on a cluster to make sure the pods can come up and the Dashboard is accessible - Create a PR to include the following: + - Switch to the `incubation` branch in `odh-dashboard` - First delete everything in the folder -- git will do the diff of what changed for us - Copy all the child folders in the [manifest folder](../manifests) - Exclude the `overlays` folder as this is for internal testing purposes diff --git a/docs/smes.md b/docs/smes.md new file mode 100644 index 0000000000..da59e636cb --- /dev/null +++ b/docs/smes.md @@ -0,0 +1,68 @@ +# Subject Matter Experts (SMEs) + +A given subject matter expert is not necessarily the most knowledgeable in the area, but they are the one who probably knows the most about it when it was originally done or has a responsibility to expand their knowledge to know about the area going forward. Contacting them first will help with delegation of responsibilities at the Dashboard level. + +This will detail out former (or current) feature leads, area leads (has a responsibility to understand the area), as well as any other notable position in relation to the area. If you need to talk to someone or ping someone for a review, this information should help you determine who. + +Below there will be some terms like “previous” and “backup”, these are for additional context. The way you can read each are as follows: +- **previous** – the initial SME in the area. If you need legacy context, this person may be able to help +- **backup** – a good person to lean on if there is a need for any 2nd opinions, for bouncing ideas off of, or any larger discussion about direction +- **and** – Ping both during conversations – could be onboarding, could be a need to share information, best get both people involved at the same time + +## General Dashboard ownership +- Infrastructure / direction + - Architect: `Andrew` ([andrewballantyne]) + - General UX: `Kyle` ([kywalker-rh]) + - App Text: `Katie` ([kaedward]) +- Testing (Integration, Unit, etc) + - Area lead: `Gage` ([Gkrumbach07]) +- Performance + - Area lead: `Lucas` ([lucferbux]) + +## Dashboard feature areas +- Data Science Projects + - Feature lead: `Andrew` ([andrewballantyne]) + - UX: `Kyle` ([kywalker-rh]) **and** `Kun` ([xianli123]) +- Data Science Pipelines + - Feature lead: `Andrew` ([andrewballantyne]) + - UX: `Yan` ([yannnz]) + - Previous: `Kyle` ([kywalker-rh]) +- Explainability, Bias + - Feature lead: `Alex` ([alexcreasy]) + - UX: `Vince` ([vconzola]) +- Model Serving (Custom runtimes, general Model Serving) + - Feature lead: `Lucas` ([lucferbux]) + - UX: `Vince` ([vconzola]) +- Model Serving - Performance metrics + - Feature lead: `Andrew` ([andrewballantyne]) + - UX: `Vince` ([vconzola]) +- BYON - Custom Notebook Images + - Feature lead: `Juntao` ([DaoDaoNoCode]) + - Backup: `Andrew` ([andrewballantyne]) + - UX: `Vince` ([vconzola]) +- Accelerators (Habana, GPU, etc) + - Feature lead: `Gage` ([Gkrumbach07]) + - Backup: `Andrew` ([andrewballantyne]) + - UX: `Yan` ([yannnz]) +- Model Registry + - Feature lead: TBD + - UX: `Sim` ([simrandhaliw]) **and** `Haley` ([yih-wang]) +- Edge + - Feature lead: TBD + - UX: `Vince` ([vconzola]) + + +[andrewballantyne]: https://github.com/andrewballantyne +[Gkrumbach07]: https://github.com/Gkrumbach07 +[lucferbux]: https://github.com/lucferbux +[alexcreasy]: https://github.com/alexcreasy +[DaoDaoNoCode]: https://github.com/DaoDaoNoCode + + +[kywalker-rh]: https://github.com/kywalker-rh +[kaedward]: https://github.com/kaedward +[xianli123]: https://github.com/xianli123 +[vconzola]: https://github.com/vconzola +[yannnz]: https://github.com/yannnz +[simrandhaliw]: https://github.com/simrandhaliw +[yih-wang]: https://github.com/yih-wang diff --git a/frontend/config/webpack.common.js b/frontend/config/webpack.common.js index 419040148e..c59b65211c 100644 --- a/frontend/config/webpack.common.js +++ b/frontend/config/webpack.common.js @@ -219,7 +219,9 @@ module.exports = (env) => { }, ], }), - new MonacoWebpackPlugin(), + new MonacoWebpackPlugin({ + languages: ['yaml'], + }), ], resolve: { extensions: ['.js', '.ts', '.tsx', '.jsx'], diff --git a/frontend/jest.config.js b/frontend/jest.config.js index 979a7d6349..68a37f4e48 100644 --- a/frontend/jest.config.js +++ b/frontend/jest.config.js @@ -2,8 +2,11 @@ // https://jestjs.io/docs/en/configuration.html module.exports = { - roots: ['/src/__tests__/unit'], - testMatch: ['**/?(*.)+(spec|test).ts?(x)'], + roots: ['/src/'], + testMatch: [ + '**/src/__tests__/unit/**/?(*.)+(spec|test).ts?(x)', + '**/__tests__/?(*.)+(spec|test).ts?(x)', + ], // Automatically clear mock calls and instances between every test clearMocks: true, @@ -23,7 +26,7 @@ module.exports = { testEnvironment: 'jest-environment-jsdom', // include projects from node_modules as required - transformIgnorePatterns: ['node_modules/(?!yaml)'], + transformIgnorePatterns: ['node_modules/(?!yaml|@openshift|lodash-es|uuid)'], // A list of paths to snapshot serializer modules Jest should use for snapshot testing snapshotSerializers: [], diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b95d315985..1e407212c1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -45,6 +45,7 @@ }, "devDependencies": { "@babel/core": "^7.21.0", + "@testing-library/react": "^14.0.0", "@types/dompurify": "^2.2.6", "@types/lodash-es": "^4.17.8", "@types/node": "^17.0.29", @@ -129,9 +130,9 @@ } }, "node_modules/@adobe/css-tools": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.2.0.tgz", - "integrity": "sha512-E09FiIft46CmH5Qnjb0wsW54/YQd69LsxeKUOWawmws1XWvyFGURnAChH0mlr7YPFR1ofwvUQfcL0J3lMxXqPA==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.3.1.tgz", + "integrity": "sha512-/62yikz7NLScCGAAST5SHdnjaDJQBDq0M2muyRTpf2VQhw6StBg2ALiu73zSJQ4fMVLA+0uBhBHAle7Wg+2kSg==", "optional": true }, "node_modules/@ampproject/remapping": { @@ -8826,6 +8827,113 @@ "node": ">=8" } }, + "node_modules/@testing-library/react": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-14.0.0.tgz", + "integrity": "sha512-S04gSNJbYE30TlIMLTzv6QCTzt9AqIF5y6s6SzVFILNcNvbV/jU96GeiTPillGQo+Ny64M/5PV7klNYYgv5Dfg==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@testing-library/dom": "^9.0.0", + "@types/react-dom": "^18.0.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/@testing-library/react/node_modules/@testing-library/dom": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.1.tgz", + "integrity": "sha512-0DGPd9AR3+iDTjGoMpxIkAsUihHZ3Ai6CneU6bRRrffXMgzCdlNk43jTrD2/5LT6CBb3MWTP8v510JzYtahD2w==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.1.3", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@testing-library/react/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@testing-library/react/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@testing-library/react/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/@testing-library/react/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/@testing-library/react/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@testing-library/react/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -8848,7 +8956,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.1.tgz", "integrity": "sha512-XTIieEY+gvJ39ChLcB4If5zHtPxt3Syj5rgZR+e1ctpmK8NjPf0zFqsz4JpLJT0xla9GFDKjy8Cpu331nrmE1Q==", - "optional": true + "devOptional": true }, "node_modules/@types/babel__core": { "version": "7.20.0", @@ -10666,7 +10774,7 @@ "version": "5.1.3", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", - "optional": true, + "devOptional": true, "dependencies": { "deep-equal": "^2.0.5" } @@ -10819,7 +10927,7 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==", - "optional": true, + "devOptional": true, "engines": { "node": ">= 0.4" }, @@ -13918,7 +14026,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.0.tgz", "integrity": "sha512-RdpzE0Hv4lhowpIUKKMJfeH6C1pXdtT1/it80ubgWqwI3qpuxUBpC1S4hnHg+zjnuOoDkzUtUCEEkG+XG5l3Mw==", - "optional": true, + "devOptional": true, "dependencies": { "call-bind": "^1.0.2", "es-get-iterator": "^1.1.2", @@ -14025,7 +14133,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.0.tgz", "integrity": "sha512-xvqAVKGfT1+UAvPwKTVw/njhdQ8ZhXK4lI0bCIuCMrp2up9nPnaDftrLtmpTazqd1o+UY4zgzU+avtMbDP+ldA==", - "optional": true, + "devOptional": true, "dependencies": { "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" @@ -14235,7 +14343,7 @@ "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "optional": true + "devOptional": true }, "node_modules/dom-converter": { "version": "0.2.0", @@ -14619,7 +14727,7 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", - "optional": true, + "devOptional": true, "dependencies": { "call-bind": "^1.0.2", "get-intrinsic": "^1.1.3", @@ -16254,7 +16362,7 @@ "version": "0.3.3", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", - "optional": true, + "devOptional": true, "dependencies": { "is-callable": "^1.1.3" } @@ -16569,7 +16677,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", - "optional": true, + "devOptional": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -16869,7 +16977,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", - "optional": true, + "devOptional": true, "dependencies": { "get-intrinsic": "^1.1.3" }, @@ -16981,7 +17089,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", - "optional": true, + "devOptional": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -16999,7 +17107,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz", "integrity": "sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==", - "optional": true, + "devOptional": true, "dependencies": { "get-intrinsic": "^1.1.1" }, @@ -17035,7 +17143,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", - "optional": true, + "devOptional": true, "dependencies": { "has-symbols": "^1.0.2" }, @@ -17763,7 +17871,7 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.5.tgz", "integrity": "sha512-Y+R5hJrzs52QCG2laLn4udYVnxsfny9CpOhNhUvk/SSSVyF6T27FzRbF0sroPidSu3X8oEAkOn2K804mjpt6UQ==", - "optional": true, + "devOptional": true, "dependencies": { "get-intrinsic": "^1.2.0", "has": "^1.0.3", @@ -17826,7 +17934,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==", - "optional": true, + "devOptional": true, "dependencies": { "call-bind": "^1.0.2", "has-tostringtag": "^1.0.0" @@ -17842,7 +17950,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.2.tgz", "integrity": "sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==", - "optional": true, + "devOptional": true, "dependencies": { "call-bind": "^1.0.2", "get-intrinsic": "^1.2.0", @@ -17862,7 +17970,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", - "optional": true, + "devOptional": true, "dependencies": { "has-bigints": "^1.0.1" }, @@ -17885,7 +17993,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", - "optional": true, + "devOptional": true, "dependencies": { "call-bind": "^1.0.2", "has-tostringtag": "^1.0.0" @@ -17901,7 +18009,7 @@ "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "optional": true, + "devOptional": true, "engines": { "node": ">= 0.4" }, @@ -17925,7 +18033,7 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", - "optional": true, + "devOptional": true, "dependencies": { "has-tostringtag": "^1.0.0" }, @@ -18081,7 +18189,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.2.tgz", "integrity": "sha512-cOZFQQozTha1f4MxLFzlgKYPTyj26picdZTx82hbc/Xf4K/tZOOXSCkMvU4pKioRXGDLJRn0GM7Upe7kR721yg==", - "optional": true, + "devOptional": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -18132,7 +18240,7 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==", - "optional": true, + "devOptional": true, "dependencies": { "has-tostringtag": "^1.0.0" }, @@ -18192,7 +18300,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", - "optional": true, + "devOptional": true, "dependencies": { "call-bind": "^1.0.2", "has-tostringtag": "^1.0.0" @@ -18208,7 +18316,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.2.tgz", "integrity": "sha512-+2cnTEZeY5z/iXGbLhPrOAaK/Mau5k5eXq9j14CpRTftq0pAJu2MwVRSZhyZWBzx3o6X795Lz6Bpb6R0GKf37g==", - "optional": true, + "devOptional": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -18217,7 +18325,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz", "integrity": "sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==", - "optional": true, + "devOptional": true, "dependencies": { "call-bind": "^1.0.2" }, @@ -18241,7 +18349,7 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", - "optional": true, + "devOptional": true, "dependencies": { "has-tostringtag": "^1.0.0" }, @@ -18256,7 +18364,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", - "optional": true, + "devOptional": true, "dependencies": { "has-symbols": "^1.0.2" }, @@ -18271,7 +18379,7 @@ "version": "1.1.10", "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.10.tgz", "integrity": "sha512-PJqgEHiWZvMpaFZ3uTc8kHPM4+4ADTlDniuQL7cU/UDA0Ql7F70yGfHph3cLNe+c9toaigv+DFzTJKhc2CtO6A==", - "optional": true, + "devOptional": true, "dependencies": { "available-typed-arrays": "^1.0.5", "call-bind": "^1.0.2", @@ -18320,7 +18428,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.1.tgz", "integrity": "sha512-NSBR4kH5oVj1Uwvv970ruUkCV7O1mzgVFO4/rev2cLRda9Tm9HrL70ZPut4rOHgY0FNrUu9BCbXA2sdQ+x0chA==", - "optional": true, + "devOptional": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -18341,7 +18449,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.2.tgz", "integrity": "sha512-t2yVvttHkQktwnNNmBQ98AhENLdPUTDTE21uPqAQ0ARwQfGeQKRVS0NNurH7bTf7RrvcVn1OOge45CnBeHCSmg==", - "optional": true, + "devOptional": true, "dependencies": { "call-bind": "^1.0.2", "get-intrinsic": "^1.1.1" @@ -18381,7 +18489,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "optional": true + "devOptional": true }, "node_modules/isexe": { "version": "2.0.0", @@ -23067,7 +23175,7 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", - "optional": true, + "devOptional": true, "bin": { "lz-string": "bin/bin.js" } @@ -24330,7 +24438,7 @@ "version": "1.1.5", "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.5.tgz", "integrity": "sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==", - "optional": true, + "devOptional": true, "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.1.3" @@ -24346,7 +24454,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "optional": true, + "devOptional": true, "engines": { "node": ">= 0.4" } @@ -24355,7 +24463,7 @@ "version": "4.1.4", "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.4.tgz", "integrity": "sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ==", - "optional": true, + "devOptional": true, "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.1.4", @@ -25364,7 +25472,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", - "optional": true, + "devOptional": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -25378,7 +25486,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "optional": true, + "devOptional": true, "engines": { "node": ">=10" }, @@ -25390,7 +25498,7 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "optional": true + "devOptional": true }, "node_modules/pretty-hrtime": { "version": "1.0.3", @@ -26323,7 +26431,7 @@ "version": "1.4.3", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.4.3.tgz", "integrity": "sha512-fjggEOO3slI6Wvgjwflkc4NFRCTZAu5CnNfBd5qOMYhWdn67nJBBu34/TkD++eeFmd8C9r9jfXJ27+nSiRkSUA==", - "optional": true, + "devOptional": true, "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.1.3", @@ -27590,7 +27698,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz", "integrity": "sha512-iCGQj+0l0HOdZ2AEeBADlsRC+vsnDsZsbdSiH1yNSjcfKM7fdpCMfqAL/dwF5BLiw/XhRft/Wax6zQbhq2BcjQ==", - "optional": true, + "devOptional": true, "dependencies": { "internal-slot": "^1.0.4" }, @@ -30533,7 +30641,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", - "optional": true, + "devOptional": true, "dependencies": { "is-bigint": "^1.0.1", "is-boolean-object": "^1.1.0", @@ -30549,7 +30657,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.1.tgz", "integrity": "sha512-W8xeTUwaln8i3K/cY1nGXzdnVZlidBcagyNFtBdD5kxnb4TvGKR7FfSIS3mYpwWS1QUCutfKz8IY8RjftB0+1A==", - "optional": true, + "devOptional": true, "dependencies": { "is-map": "^2.0.1", "is-set": "^2.0.1", @@ -30569,7 +30677,7 @@ "version": "1.1.9", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.9.tgz", "integrity": "sha512-w9c4xkx6mPidwp7180ckYWfMmvxpjlZuIudNtDf4N/tTAUB8VJbX25qZoAsrtGuYNnGw3pa0AXgbGKRB8/EceA==", - "optional": true, + "devOptional": true, "dependencies": { "available-typed-arrays": "^1.0.5", "call-bind": "^1.0.2", diff --git a/frontend/package.json b/frontend/package.json index 09d626ac4a..c0c0fce8bb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -77,6 +77,7 @@ }, "devDependencies": { "@babel/core": "^7.21.0", + "@testing-library/react": "^14.0.0", "@types/dompurify": "^2.2.6", "@types/lodash-es": "^4.17.8", "@types/node": "^17.0.29", diff --git a/frontend/src/__mocks__/mockInferenceServiceK8sResource.ts b/frontend/src/__mocks__/mockInferenceServiceK8sResource.ts index f47a9d78c5..723b5dc3e3 100644 --- a/frontend/src/__mocks__/mockInferenceServiceK8sResource.ts +++ b/frontend/src/__mocks__/mockInferenceServiceK8sResource.ts @@ -9,6 +9,28 @@ type MockResourceConfigType = { secretName?: string; }; +export const mockInferenceServicek8sError = () => ({ + kind: 'Status', + apiVersion: 'v1', + metadata: {}, + status: 'Failure', + message: + 'InferenceService.serving.kserve.io "trigger-error" is invalid: [metadata.name: Invalid value: "trigger-error": is invalid, metadata.labels: Invalid value: "trigger-error": must have proper format]', + reason: 'Invalid', + details: { + name: 'trigger-error', + group: 'serving.kserve.io', + kind: 'InferenceService', + causes: [ + { + reason: 'FieldValueInvalid', + message: 'Invalid value: "trigger-error": must have proper format', + field: 'metadata.name', + }, + ], + }, +}); + export const mockInferenceServiceK8sResource = ({ name = 'test-inference-service', namespace = 'test-project', diff --git a/frontend/src/__tests__/dockerRepositoryURL.spec.ts b/frontend/src/__tests__/dockerRepositoryURL.spec.ts deleted file mode 100644 index 3f2b316211..0000000000 --- a/frontend/src/__tests__/dockerRepositoryURL.spec.ts +++ /dev/null @@ -1,49 +0,0 @@ -// https://cloud.google.com/artifact-registry/docs/docker/names -// The full name for a container image is one of the following formats: -// LOCATION-docker.pkg.dev/PROJECT-ID/REPOSITORY/IMAGE -// LOCATION-docker.pkg.dev/PROJECT-ID/REPOSITORY/IMAGE:TAG -// LOCATION-docker.pkg.dev/PROJECT-ID/REPOSITORY/IMAGE@IMAGE-DIGEST - -import { REPOSITORY_URL_REGEX } from '~/utilities/const'; - -test('Invalid URL', () => { - const url = 'docker.io'; - const match = url.match(REPOSITORY_URL_REGEX); - expect(match?.[1]).toBe(''); -}); - -test('Docker container URL without tag', () => { - const url = 'docker.io/library/mysql'; - const match = url.match(REPOSITORY_URL_REGEX); - expect(match?.[1]).toBe('docker.io'); - expect(match?.[4]).toBe(undefined); -}); - -test('Docker container URL with tag', () => { - const url = 'docker.io/library/mysql:test-tag'; - const match = url.match(REPOSITORY_URL_REGEX); - expect(match?.[1]).toBe('docker.io'); - expect(match?.[4]).toBe('test-tag'); -}); - -test('OpenShift internal registry URL without tag', () => { - const url = 'image-registry.openshift-image-registry.svc:5000/opendatahub/s2i-minimal-notebook'; - const match = url.match(REPOSITORY_URL_REGEX); - expect(match?.[1]).toBe('image-registry.openshift-image-registry.svc:5000'); - expect(match?.[4]).toBe(undefined); -}); - -test('OpenShift internal registry URL with tag', () => { - const url = - 'image-registry.openshift-image-registry.svc:5000/opendatahub/s2i-minimal-notebook:v0.3.0-py36'; - const match = url.match(REPOSITORY_URL_REGEX); - expect(match?.[1]).toBe('image-registry.openshift-image-registry.svc:5000'); - expect(match?.[4]).toBe('v0.3.0-py36'); -}); - -test('Quay URL with port and tag', () => { - const url = 'quay.io:443/opendatahub/odh-dashboard:main-55e19fa'; - const match = url.match(REPOSITORY_URL_REGEX); - expect(match?.[1]).toBe('quay.io:443'); - expect(match?.[4]).toBe('main-55e19fa'); -}); diff --git a/frontend/src/__tests__/integration/pages/modelServing/ModelServingGlobal.spec.ts b/frontend/src/__tests__/integration/pages/modelServing/ModelServingGlobal.spec.ts index 9b21197786..dee2ce86ae 100644 --- a/frontend/src/__tests__/integration/pages/modelServing/ModelServingGlobal.spec.ts +++ b/frontend/src/__tests__/integration/pages/modelServing/ModelServingGlobal.spec.ts @@ -92,3 +92,46 @@ test('Create model', async ({ page }) => { await page.getByLabel('Path').fill('test-model/'); await expect(await page.getByRole('button', { name: 'Deploy' })).toBeEnabled(); }); + +test('Create model error', async ({ page }) => { + await page.goto( + './iframe.html?args=&id=tests-integration-pages-modelserving-modelservingglobal--deploy-model&viewMode=story', + ); + + // wait for page to load + await page.waitForSelector('text=Deploy model'); + + // test that you can not submit on empty + await expect(await page.getByRole('button', { name: 'Deploy' })).toBeDisabled(); + + // test filling in minimum required fields + await page.locator('#existing-project-selection').click(); + await page.getByRole('option', { name: 'Test Project' }).click(); + await page.getByLabel('Model Name *').fill('trigger-error'); + await page.locator('#inference-service-model-selection').click(); + await page.getByRole('option', { name: 'ovms' }).click(); + await expect(page.getByText('Model framework (name - version)')).toBeTruthy(); + await page.locator('#inference-service-framework-selection').click(); + await page.getByRole('option', { name: 'onnx - 1' }).click(); + await expect(await page.getByRole('button', { name: 'Deploy' })).toBeDisabled(); + await page + .getByRole('group', { name: 'Model location' }) + .getByRole('button', { name: 'Options menu' }) + .click(); + await page.getByRole('option', { name: 'Test Secret' }).click(); + await page.getByLabel('Path').fill('test-model/'); + await expect(await page.getByRole('button', { name: 'Deploy' })).toBeEnabled(); + await page.getByLabel('Path').fill('test-model/'); + await expect(await page.getByRole('button', { name: 'Deploy' })).toBeEnabled(); + + // Submit and check the invalid error message + await page.getByRole('button', { name: 'Deploy' }).click(); + await page.waitForSelector('text=Error creating model server'); + + // Close the modal + await page.getByRole('button', { name: 'Cancel' }).click(); + + // Check that the error message is gone + await page.getByRole('button', { name: 'Deploy model' }).click(); + expect(await page.isVisible('text=Error creating model server')).toBeFalsy(); +}); diff --git a/frontend/src/__tests__/integration/pages/modelServing/ModelServingGlobal.stories.tsx b/frontend/src/__tests__/integration/pages/modelServing/ModelServingGlobal.stories.tsx index 5861976db9..488b034124 100644 --- a/frontend/src/__tests__/integration/pages/modelServing/ModelServingGlobal.stories.tsx +++ b/frontend/src/__tests__/integration/pages/modelServing/ModelServingGlobal.stories.tsx @@ -8,7 +8,10 @@ import { Route, Routes } from 'react-router-dom'; import { mockK8sResourceList } from '~/__mocks__/mockK8sResourceList'; import { mockProjectK8sResource } from '~/__mocks__/mockProjectK8sResource'; import { mockServingRuntimeK8sResource } from '~/__mocks__/mockServingRuntimeK8sResource'; -import { mockInferenceServiceK8sResource } from '~/__mocks__/mockInferenceServiceK8sResource'; +import { + mockInferenceServiceK8sResource, + mockInferenceServicek8sError, +} from '~/__mocks__/mockInferenceServiceK8sResource'; import { mockSecretK8sResource } from '~/__mocks__/mockSecretK8sResource'; import ModelServingContextProvider from '~/pages/modelServing/ModelServingContext'; import ModelServingGlobal from '~/pages/modelServing/screens/global/ModelServingGlobal'; @@ -38,6 +41,15 @@ export default { rest.get('/api/k8s/apis/project.openshift.io/v1/projects', (req, res, ctx) => res(ctx.json(mockK8sResourceList([mockProjectK8sResource({})]))), ), + rest.post( + 'api/k8s/apis/serving.kserve.io/v1beta1/namespaces/test-project/inferenceservices/test', + (req, res, ctx) => res(ctx.json(mockInferenceServiceK8sResource({}))), + ), + rest.post( + 'api/k8s/apis/serving.kserve.io/v1beta1/namespaces/test-project/inferenceservices/trigger-error', + (req, res, ctx) => + res(ctx.status(422, 'Unprocessable Entity'), ctx.json(mockInferenceServicek8sError())), + ), ], }, }, diff --git a/frontend/src/__tests__/integration/pages/modelServing/ServingRuntimeList.spec.ts b/frontend/src/__tests__/integration/pages/modelServing/ServingRuntimeList.spec.ts index f49d3b8068..486bc8233c 100644 --- a/frontend/src/__tests__/integration/pages/modelServing/ServingRuntimeList.spec.ts +++ b/frontend/src/__tests__/integration/pages/modelServing/ServingRuntimeList.spec.ts @@ -45,17 +45,33 @@ test('Legacy Serving Runtime', async ({ page }) => { await page.waitForSelector('text=Add server'); // Check that the legacy serving runtime is shown with the default runtime name - expect(await page.getByText('ovms')).toBeTruthy(); + expect(page.getByText('ovms')).toBeTruthy(); // Check that the legacy serving runtime displays the correct Serving Runtime - expect(await page.getByText('OpenVINO Model Server')).toBeTruthy(); + expect(page.getByText('OpenVINO Model Server')).toBeTruthy(); // Check that the legacy serving runtime has tokens disabled - expect(await page.getByText('Tokens disabled')).toBeTruthy(); + expect(page.getByText('Tokens disabled')).toBeTruthy(); // Check that the serving runtime is shown with the default runtime name - expect(await page.getByText('OVMS Model Serving')).toBeTruthy(); + expect(page.getByText('OVMS Model Serving')).toBeTruthy(); // Check that the serving runtime displays the correct Serving Runtime - expect(await page.getByText('OpenVINO Serving Runtime (Supports GPUs)')).toBeTruthy(); + expect(page.getByText('OpenVINO Serving Runtime (Supports GPUs)')).toBeTruthy(); + + // Get the first and second row + const firstButton = page.getByRole('button', { name: 'ovms', exact: true }); + const secondButton = page.getByRole('button', { name: 'OVMS Model Serving', exact: true }); + const firstRow = page.getByRole('rowgroup').filter({ has: firstButton }); + const secondRow = page.getByRole('rowgroup').filter({ has: secondButton }); + + // Check that both of the rows are not expanded + await expect(firstRow).not.toHaveClass('pf-m-expanded'); + await expect(secondRow).not.toHaveClass('pf-m-expanded'); + + await firstButton.click(); + + // Check that the first row is expanded while the second is not + await expect(firstRow).toHaveClass('pf-m-expanded'); + await expect(secondRow).not.toHaveClass('pf-m-expanded'); }); diff --git a/frontend/src/__tests__/unit/testUtils/hooks.spec.ts b/frontend/src/__tests__/unit/testUtils/hooks.spec.ts new file mode 100644 index 0000000000..dac0f2897a --- /dev/null +++ b/frontend/src/__tests__/unit/testUtils/hooks.spec.ts @@ -0,0 +1,163 @@ +import * as React from 'react'; +import { expectHook, renderHook, standardUseFetchState, testHook } from './hooks'; + +const useSayHello = (who: string, showCount = false) => { + const countRef = React.useRef(0); + countRef.current++; + return `Hello ${who}!${showCount && countRef.current > 1 ? ` x${countRef.current}` : ''}`; +}; + +const useSayHelloDelayed = (who: string, delay = 0) => { + const [speech, setSpeech] = React.useState(''); + React.useEffect(() => { + const handle = setTimeout(() => setSpeech(`Hello ${who}!`), delay); + return () => clearTimeout(handle); + }, [who, delay]); + return speech; +}; + +describe('hook test utils', () => { + it('simple testHook', () => { + const renderResult = testHook((who: string) => `Hello ${who}!`, 'world'); + expectHook(renderResult).toBe('Hello world!').toHaveUpdateCount(1); + renderResult.rerender('world'); + expectHook(renderResult).toBe('Hello world!').toBeStable().toHaveUpdateCount(2); + }); + + it('use testHook for rendering', () => { + const renderResult = testHook(useSayHello, 'world'); + expectHook(renderResult) + .toHaveUpdateCount(1) + .toBe('Hello world!') + .toStrictEqual('Hello world!'); + + renderResult.rerender('world', false); + + expectHook(renderResult) + .toHaveUpdateCount(2) + .toBe('Hello world!') + .toStrictEqual('Hello world!') + .toBeStable(); + + renderResult.rerender('world', true); + + expectHook(renderResult) + .toHaveUpdateCount(3) + .toBe('Hello world! x3') + .toStrictEqual('Hello world! x3') + .toBeStable(false); + }); + + it('use renderHook for rendering', () => { + type Props = { + who: string; + showCount?: boolean; + }; + const renderResult = renderHook(({ who, showCount }: Props) => useSayHello(who, showCount), { + initialProps: { + who: 'world', + }, + }); + + expectHook(renderResult) + .toHaveUpdateCount(1) + .toBe('Hello world!') + .toStrictEqual('Hello world!'); + + renderResult.rerender({ + who: 'world', + }); + + expectHook(renderResult) + .toHaveUpdateCount(2) + .toBe('Hello world!') + .toStrictEqual('Hello world!') + .toBeStable(); + + renderResult.rerender({ who: 'world', showCount: true }); + + expectHook(renderResult) + .toHaveUpdateCount(3) + .toBe('Hello world! x3') + .toStrictEqual('Hello world! x3') + .toBeStable(false); + }); + + it('should use waitForNextUpdate for async update testing', async () => { + const renderResult = testHook(useSayHelloDelayed, 'world'); + expectHook(renderResult).toHaveUpdateCount(1).toBe(''); + + await renderResult.waitForNextUpdate(); + expectHook(renderResult).toHaveUpdateCount(2).toBe('Hello world!'); + }); + + it('should throw error if waitForNextUpdate times out', async () => { + const renderResult = renderHook(() => useSayHelloDelayed('', 20)); + + await expect(renderResult.waitForNextUpdate({ timeout: 10, interval: 5 })).rejects.toThrow(); + expectHook(renderResult).toHaveUpdateCount(1); + + // unmount to test waiting for an update that will never happen + renderResult.unmount(); + + await expect(renderResult.waitForNextUpdate({ timeout: 50, interval: 10 })).rejects.toThrow(); + + expectHook(renderResult).toHaveUpdateCount(1); + }); + + it('should not throw if waitForNextUpdate timeout is sufficient', async () => { + const renderResult = renderHook(() => useSayHelloDelayed('', 20)); + + await expect( + renderResult.waitForNextUpdate({ timeout: 50, interval: 10 }), + ).resolves.not.toThrow(); + + expectHook(renderResult).toHaveUpdateCount(2); + }); + + it('should assert stability of results using isStable', () => { + let testValue = 'test'; + const renderResult = renderHook(() => testValue); + expectHook(renderResult).toHaveUpdateCount(1); + + renderResult.rerender(); + expectHook(renderResult).toHaveUpdateCount(2).toBeStable(true); + + testValue = 'new'; + renderResult.rerender(); + expectHook(renderResult).toHaveUpdateCount(3).toBeStable(false); + + renderResult.rerender(); + expectHook(renderResult).toHaveUpdateCount(4).toBeStable(true); + }); + + it('should assert stability of results using isStableArray', () => { + let testValue = 'test'; + // explicitly returns a new array each render to show the difference between `isStable` and `isStableArray` + const renderResult = renderHook(() => [testValue]); + expectHook(renderResult).toHaveUpdateCount(1); + + renderResult.rerender(); + expectHook(renderResult).toHaveUpdateCount(2).toBeStable(false); + expectHook(renderResult).toHaveUpdateCount(2).toBeStable([true]); + + testValue = 'new'; + renderResult.rerender(); + expectHook(renderResult).toHaveUpdateCount(3).toBeStable(false); + expectHook(renderResult).toHaveUpdateCount(3).toBeStable([false]); + + renderResult.rerender(); + expectHook(renderResult).toHaveUpdateCount(4).toBeStable(false); + expectHook(renderResult).toHaveUpdateCount(4).toBeStable([true]); + }); + + it('standardUseFetchState should return an array matching the state of useFetchState', () => { + expect(['test', false, undefined, () => null]).toStrictEqual(standardUseFetchState('test')); + expect(['test', true, undefined, () => null]).toStrictEqual( + standardUseFetchState('test', true), + ); + expect(['test', false, new Error('error'), () => null]).toStrictEqual( + standardUseFetchState('test', false, new Error('error')), + ); + }); +}); diff --git a/frontend/src/__tests__/unit/testUtils/hooks.ts b/frontend/src/__tests__/unit/testUtils/hooks.ts new file mode 100644 index 0000000000..4f13d7ee67 --- /dev/null +++ b/frontend/src/__tests__/unit/testUtils/hooks.ts @@ -0,0 +1,253 @@ +import { + renderHook as renderHookRTL, + RenderHookOptions, + RenderHookResult, + waitFor, + waitForOptions, +} from '@testing-library/react'; +import { queries, Queries } from '@testing-library/dom'; + +/** + * Set of helper functions used to perform assertions on the hook result. + */ +export type RenderHookResultExpect = { + /** + * Check that a value is what you expect. It uses `Object.is` to check strict equality. + * Don't use `toBe` with floating-point numbers. + */ + toBe: (expected: Result) => RenderHookResultExpect; + + /** + * Check that the result has the same types as well as structure. + */ + toStrictEqual: (expected: Result) => RenderHookResultExpect; + + /** + * Check the stability of the result. + * If the expected value is a boolean array, uses `isStableArray` for comparison, otherwise uses `isStable`. + * + * Stability is checked against the previous update. + */ + toBeStable: (expected?: boolean | boolean[]) => RenderHookResultExpect; + + /** + * Check the update count is the expected number. + * Update count increases every time the hook is called. + */ + toHaveUpdateCount: (expected: number) => RenderHookResultExpect; +}; + +/** + * Extension of RTL RenderHookResult providing functions used query the current state of the result. + */ +export type RenderHookResultExt = RenderHookResult & { + /** + * Returns `true` if the previous result is equal to the current result. Uses `Object.is` for comparison. + */ + isStable: () => boolean; + + /** + * Returns `true` if the previous result array items are equal to the current result array items. Uses `Object.is` for comparison. + * The equality of the array instances is not checked. + */ + isStableArray: () => boolean[]; + + /** + * Get the update count for how many times the hook has been rendered. + * An update occurs initially on render, subsequently when re-rendered, and also whenever the hook itself triggers a re-render. + * eg. An `useEffect` triggering a state update. + */ + getUpdateCount: () => number; + + /** + * Returns a Promise that resolves the next time the hook renders, commonly when state is updated as the result of an asynchronous update. + * + * Since `waitForNextUpdate` works using interval checks (backed by `waitFor`), it's possible that multiple updates may occur while waiting. + */ + waitForNextUpdate: (options?: Pick) => Promise; +}; + +/** + * Helper function that wraps a render result and provides a small set of jest Matcher equivalent functions that act directly on the result. + * + * ``` + * expectHook(renderResult).toBeStable().toHaveUpdateCount(2); + * ``` + * Equivalent to: + * ``` + * expect(renderResult.isStable()).toBe(true); + * expect(renderResult.getUpdateCount()).toBe(2); + * ``` + * + * See `RenderHookResultExpect` + */ +export const expectHook = ( + renderResult: Pick< + RenderHookResultExt, + 'result' | 'getUpdateCount' | 'isStableArray' | 'isStable' + >, +): RenderHookResultExpect => { + const expectUtil: RenderHookResultExpect = { + toBe: (expected) => { + expect(renderResult.result.current).toBe(expected); + return expectUtil; + }, + + toStrictEqual: (expected) => { + expect(renderResult.result.current).toStrictEqual(expected); + return expectUtil; + }, + + toBeStable: (expected = true) => { + if (renderResult.getUpdateCount() > 1) { + if (Array.isArray(expected)) { + expect(renderResult.isStableArray()).toStrictEqual(expected); + } else { + expect(renderResult.isStable()).toBe(expected); + } + } else { + // eslint-disable-next-line no-console + console.warn( + 'expectHook#toBeStable cannot assert stability as the hook has not run at least 2 times.', + ); + } + return expectUtil; + }, + + toHaveUpdateCount: (expected) => { + expect(renderResult.getUpdateCount()).toBe(expected); + return expectUtil; + }, + }; + return expectUtil; +}; + +/** + * Wrapper on top of RTL `renderHook` returning a result that implements the `RenderHookResultExt` interface. + * + * `renderHook` provides full control over the rendering of your hook including the ability to wrap the test component. + * This is usually used to add context providers from `React.createContext` for the hook to access with `useContext`. + * `initialProps` and props subsequently set by `rerender` will be provided to the wrapper. + * + * ``` + * const renderResult = renderHook(({ who }: { who: string }) => useSayHello(who), { initialProps: { who: 'world' }}); + * expectHook(renderResult).toBe('Hello world!'); + * renderResult.rerender({ who: 'there' }); + * expectHook(renderResult).toBe('Hello there!'); + * ``` + */ +export const renderHook = < + Result, + Props, + Q extends Queries = typeof queries, + Container extends Element | DocumentFragment = HTMLElement, + BaseElement extends Element | DocumentFragment = Container, +>( + render: (initialProps: Props) => Result, + options?: RenderHookOptions, +): RenderHookResultExt => { + let updateCount = 0; + let prevResult: Result | undefined; + let currentResult: Result | undefined; + + const renderResult = renderHookRTL((props) => { + updateCount++; + prevResult = currentResult; + currentResult = render(props); + return currentResult; + }, options); + + const renderResultExt: RenderHookResultExt = { + ...renderResult, + + isStable: () => (updateCount > 1 ? Object.is(renderResult.result.current, prevResult) : false), + + isStableArray: () => { + // prefill return array with `false` + const stable: boolean[] = Array( + Math.max( + Array.isArray(prevResult) ? prevResult?.length : 0, + Array.isArray(renderResult.result.current) ? renderResult.result.current.length : 0, + ), + ).fill(false); + + if ( + updateCount > 1 && + Array.isArray(prevResult) && + Array.isArray(renderResult.result.current) + ) { + renderResult.result.current.forEach((v, i) => { + stable[i] = Object.is(v, (prevResult as unknown[])[i]); + }); + } + return stable; + }, + + getUpdateCount: () => updateCount, + + waitForNextUpdate: async (options) => { + const expected = updateCount; + try { + await waitFor(() => expect(updateCount).toBeGreaterThan(expected), options); + } catch { + throw new Error('waitForNextUpdate timed out'); + } + }, + }; + + return renderResultExt; +}; + +/** + * Lightweight API for testing a single hook. + * + * Prefer this method of testing over `renderHook` for simplicity. + * + * ``` + * const renderResult = testHook(useSayHello, 'world'); + * expectHook(renderResult).toBe('Hello world!'); + * renderResult.rerender('there'); + * expectHook(renderResult).toBe('Hello there!'); + * ``` + */ + +export const testHook = Result, P extends unknown[]>( + hook: (...params: P) => Result, + ...initialParams: Parameters +) => { + type Params = Parameters; + const renderResult = renderHook(({ $params }: { $params: Params }) => hook(...$params), { + initialProps: { + $params: initialParams, + }, + }); + + return { + ...renderResult, + + rerender: (...params: Params) => renderResult.rerender({ $params: params }), + }; +}; + +/** + * A helper function for asserting the return value of hooks based on `useFetchState`. + * + * eg. + * ``` + * expectHook(renderResult).isStrictEqual(standardUseFetchState('test value', true)) + * ``` + * is equivalent to: + * ``` + * expectHook(renderResult).isStrictEqual(['test value', true, undefined, expect.any(Function)]) + * ``` + */ +export const standardUseFetchState = ( + data: D, + loaded = false, + error?: Error, +): [ + data: D, + loaded: boolean, + loadError: Error | undefined, + refresh: () => Promise, +] => [data, loaded, error, expect.any(Function)]; diff --git a/frontend/src/api/k8s/events.ts b/frontend/src/api/k8s/events.ts index 8eb04396dd..4ca93854af 100644 --- a/frontend/src/api/k8s/events.ts +++ b/frontend/src/api/k8s/events.ts @@ -1,17 +1,20 @@ -import { k8sListResource } from '@openshift/dynamic-plugin-sdk-utils'; +import { k8sListResourceItems } from '@openshift/dynamic-plugin-sdk-utils'; import { EventKind } from '~/k8sTypes'; import { EventModel } from '~/api/models'; -export const getNotebookEvents = async (namespace: string, podUid: string): Promise => - k8sListResource({ +export const getNotebookEvents = async ( + namespace: string, + notebookName: string, + podUid: string | undefined, +): Promise => + k8sListResourceItems({ model: EventModel, queryOptions: { ns: namespace, queryParams: { - fieldSelector: `involvedObject.kind=Pod,involvedObject.uid=${podUid}`, + fieldSelector: podUid + ? `involvedObject.kind=Pod,involvedObject.uid=${podUid}` + : `involvedObject.kind=StatefulSet,involvedObject.name=${notebookName}`, }, }, - }).then( - // Filter the events by pods that have the same name as the notebook - (r) => r.items, - ); + }); diff --git a/frontend/src/api/k8s/notebooks.ts b/frontend/src/api/k8s/notebooks.ts index 8c1ddb8d80..d7a86ba07e 100644 --- a/frontend/src/api/k8s/notebooks.ts +++ b/frontend/src/api/k8s/notebooks.ts @@ -26,17 +26,7 @@ import { } from '~/concepts/pipelines/elyra/utils'; import { createRoleBinding } from '~/api'; import { Volume, VolumeMount } from '~/types'; -import { assemblePodSpecOptions } from './utils'; - -const getshmVolumeMount = (): VolumeMount => ({ - name: 'shm', - mountPath: '/dev/shm', -}); - -const getshmVolume = (): Volume => ({ - name: 'shm', - emptyDir: { medium: 'Memory' }, -}); +import { assemblePodSpecOptions, getshmVolume, getshmVolumeMount } from './utils'; const assembleNotebook = ( data: StartNotebookData, diff --git a/frontend/src/api/k8s/servingRuntimes.ts b/frontend/src/api/k8s/servingRuntimes.ts index 3842828d48..a8b4699cac 100644 --- a/frontend/src/api/k8s/servingRuntimes.ts +++ b/frontend/src/api/k8s/servingRuntimes.ts @@ -14,7 +14,7 @@ import { getModelServingRuntimeName } from '~/pages/modelServing/utils'; import { getDisplayNameFromK8sResource, translateDisplayNameForK8s } from '~/pages/projects/utils'; import { applyK8sAPIOptions } from '~/api/apiMergeUtils'; import { getModelServingProjects } from './projects'; -import { assemblePodSpecOptions } from './utils'; +import { assemblePodSpecOptions, getshmVolume, getshmVolumeMount } from './utils'; const assembleServingRuntime = ( data: CreatingServingRuntimeObject, @@ -79,12 +79,27 @@ const assembleServingRuntime = ( const { affinity, tolerations, resources } = assemblePodSpecOptions(resourceSettings, gpus); - updatedServingRuntime.spec.containers = servingRuntime.spec.containers.map((container) => ({ - ...container, - resources, - affinity, - tolerations, - })); + const volumes = updatedServingRuntime.spec.volumes || []; + if (!volumes.find((volume) => volume.name === 'shm')) { + volumes.push(getshmVolume('2Gi')); + } + + updatedServingRuntime.spec.volumes = volumes; + + updatedServingRuntime.spec.containers = servingRuntime.spec.containers.map((container) => { + const volumeMounts = container.volumeMounts || []; + if (!volumeMounts.find((volumeMount) => volumeMount.mountPath === '/dev/shm')) { + volumeMounts.push(getshmVolumeMount()); + } + + return { + ...container, + resources, + affinity, + tolerations, + volumeMounts, + }; + }); return updatedServingRuntime; }; diff --git a/frontend/src/api/k8s/utils.ts b/frontend/src/api/k8s/utils.ts index 920415d757..ce2867007c 100644 --- a/frontend/src/api/k8s/utils.ts +++ b/frontend/src/api/k8s/utils.ts @@ -4,6 +4,8 @@ import { PodToleration, TolerationSettings, ContainerResourceAttributes, + VolumeMount, + Volume, } from '~/types'; import { determineTolerations } from '~/utilities/tolerations'; @@ -54,3 +56,13 @@ export const assemblePodSpecOptions = ( const tolerations = determineTolerations(gpus > 0, tolerationSettings); return { affinity, tolerations, resources }; }; + +export const getshmVolumeMount = (): VolumeMount => ({ + name: 'shm', + mountPath: '/dev/shm', +}); + +export const getshmVolume = (sizeLimit?: string): Volume => ({ + name: 'shm', + emptyDir: { medium: 'Memory', ...(sizeLimit && { sizeLimit }) }, +}); diff --git a/frontend/src/api/models/k8s.ts b/frontend/src/api/models/k8s.ts index 7da19cbcf0..2fc56ea7b6 100644 --- a/frontend/src/api/models/k8s.ts +++ b/frontend/src/api/models/k8s.ts @@ -18,6 +18,13 @@ export const PodModel: K8sModelCommon = { plural: 'pods', }; +export const StatefulSetModel: K8sModelCommon = { + apiVersion: 'v1', + apiGroup: 'apps', + kind: 'StatefulSet', + plural: 'statefulsets', +}; + export const PVCModel: K8sModelCommon = { apiVersion: 'v1', kind: 'PersistentVolumeClaim', diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx index 89da2dde65..2d8e49dfdf 100644 --- a/frontend/src/app/App.tsx +++ b/frontend/src/app/App.tsx @@ -15,7 +15,7 @@ import ErrorBoundary from '~/components/error/ErrorBoundary'; import ToastNotifications from '~/components/ToastNotifications'; import { useWatchBuildStatus } from '~/utilities/useWatchBuildStatus'; import { useUser } from '~/redux/selectors'; -import { DASHBOARD_MAIN_CONTAINER_SELECTOR } from '~/utilities/const'; +import { DASHBOARD_MAIN_CONTAINER_ID } from '~/utilities/const'; import useDetectUser from '~/utilities/useDetectUser'; import ProjectsContextProvider from '~/concepts/projects/ProjectsContext'; import Header from './Header'; @@ -26,6 +26,7 @@ import { AppContext } from './AppContext'; import { useApplicationSettings } from './useApplicationSettings'; import TelemetrySetup from './TelemetrySetup'; import { logout } from './appUtils'; +import QuickStarts from './QuickStarts'; import './App.scss'; @@ -95,11 +96,13 @@ const App: React.FC = () => { sidebar={isAllowed ? : undefined} notificationDrawer={ setNotificationsOpen(false)} />} isNotificationDrawerExpanded={notificationsOpen} - mainContainerId={DASHBOARD_MAIN_CONTAINER_SELECTOR} + mainContainerId={DASHBOARD_MAIN_CONTAINER_ID} > - + + + diff --git a/frontend/src/components/FormGroupSettings.tsx b/frontend/src/components/FormGroupSettings.tsx deleted file mode 100644 index 26626614d2..0000000000 --- a/frontend/src/components/FormGroupSettings.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import * as React from 'react'; -import { - FormGroup, - Text, - HelperText, - HelperTextItem, - Alert, - AlertActionCloseButton, - Hint, - HintBody, -} from '@patternfly/react-core'; -import { GroupsConfigField, MenuItemStatus } from '~/pages/groupSettings/groupTypes'; -import { MultiSelection } from './MultiSelection'; - -type FormGroupSettingsProps = { - title: string; - body: string; - groupsField: GroupsConfigField; - items: MenuItemStatus[]; - handleMenuItemSelection: (newState: MenuItemStatus[], field: GroupsConfigField) => void; - handleClose: () => void; - error?: string; -}; - -export const FormGroupSettings: React.FC = ({ - title, - body, - groupsField, - items, - handleMenuItemSelection, - handleClose, - error, -}) => ( - - {body} - handleMenuItemSelection(newState, groupsField)} - /> - {!error && ( - <> - - - {'View, edit, or create groups in OpenShift under User Management'} - - - {groupsField === GroupsConfigField.ADMIN && ( - - - {'All cluster admins are automatically assigned as Data Science administrators.'} - - - )} - - )} - {error && ( - } - > -

{error}

-
- )} -
-); diff --git a/frontend/src/components/GenericSidebar.tsx b/frontend/src/components/GenericSidebar.tsx index 2a4a268bde..b6ab8ca090 100644 --- a/frontend/src/components/GenericSidebar.tsx +++ b/frontend/src/components/GenericSidebar.tsx @@ -6,7 +6,7 @@ import { SidebarContent, SidebarPanel, } from '@patternfly/react-core'; -import { DASHBOARD_MAIN_CONTAINER_SELECTOR } from '~/utilities/const'; +import { DASHBOARD_SCROLL_CONTAINER_SELECTOR } from '~/utilities/const'; type GenericSidebarProps = { sections: string[]; @@ -20,7 +20,7 @@ const GenericSidebar: React.FC = ({ children, sections, titles, - scrollableSelector = `#${DASHBOARD_MAIN_CONTAINER_SELECTOR}`, + scrollableSelector = DASHBOARD_SCROLL_CONTAINER_SELECTOR, maxWidth, }) => ( diff --git a/frontend/src/components/ScrollViewOnMount.tsx b/frontend/src/components/ScrollViewOnMount.tsx index 2067fa91e7..d35cf7c85e 100644 --- a/frontend/src/components/ScrollViewOnMount.tsx +++ b/frontend/src/components/ScrollViewOnMount.tsx @@ -1,13 +1,19 @@ import * as React from 'react'; -const ScrollViewOnMount: React.FC = () => ( -
{ - if (elm) { - elm.scrollIntoView(); - } - }} - /> -); +type ScrollViewOnMountProps = { + shouldScroll: boolean; +}; + +const ScrollViewOnMount: React.FC = ({ shouldScroll }) => { + const ref = React.useRef(null); + + React.useEffect(() => { + if (shouldScroll && ref.current) { + ref.current?.scrollIntoView(); + } + }, [shouldScroll]); + + return
; +}; export default ScrollViewOnMount; diff --git a/frontend/src/components/table/Table.tsx b/frontend/src/components/table/Table.tsx index cf406bb5e8..568a126791 100644 --- a/frontend/src/components/table/Table.tsx +++ b/frontend/src/components/table/Table.tsx @@ -111,7 +111,7 @@ const Table = ({ )} {caption && {caption}} - + {columns.map((col, i) => { if (col.field === CHECKBOX_FIELD_ID && selectAll) { diff --git a/frontend/src/concepts/pipelines/content/configurePipelinesServer/DatabaseConnectionInputField.tsx b/frontend/src/concepts/pipelines/content/configurePipelinesServer/DatabaseConnectionInputField.tsx deleted file mode 100644 index d18421c15b..0000000000 --- a/frontend/src/concepts/pipelines/content/configurePipelinesServer/DatabaseConnectionInputField.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import * as React from 'react'; -import { FormGroup, TextInput } from '@patternfly/react-core'; -import PasswordInput from '~/pages/projects/components/PasswordInput'; -import { DATABASE_CONNECTION_KEYS } from './const'; - -type DatabaseConnectionInputFieldProps = { - isPassword?: boolean; - isRequired: boolean; - onChange: (key: DATABASE_CONNECTION_KEYS, value: string) => void; - type: DATABASE_CONNECTION_KEYS; - value: string; -}; - -const DatabaseConnectionInputField: React.FC = ({ - isPassword, - isRequired, - onChange, - type, - value, -}) => { - const ComponentField = isPassword ? PasswordInput : TextInput; - - return ( - - onChange(type, value)} - /> - - ); -}; - -export default DatabaseConnectionInputField; diff --git a/frontend/src/concepts/pipelines/content/configurePipelinesServer/__tests__/utils.spec.ts b/frontend/src/concepts/pipelines/content/configurePipelinesServer/__tests__/utils.spec.ts new file mode 100644 index 0000000000..20fe6b4266 --- /dev/null +++ b/frontend/src/concepts/pipelines/content/configurePipelinesServer/__tests__/utils.spec.ts @@ -0,0 +1,130 @@ +import { AWS_KEYS } from '~/pages/projects/dataConnections/const'; +import { PipelineServerConfigType } from '~/concepts/pipelines/content/configurePipelinesServer/types'; +import { createDSPipelineResourceSpec } from '~/concepts/pipelines/content/configurePipelinesServer/utils'; + +describe('configure pipeline server utils', () => { + describe('createDSPipelineResourceSpec', () => { + const createPipelineServerConfig = () => + ({ + database: { + useDefault: true, + value: [], + }, + objectStorage: { + useExisting: true, + existingName: '', + existingValue: [], + }, + } as PipelineServerConfigType); + + type SecretsResponse = Parameters[1]; + + const createSecretsResponse = ( + databaseSecret?: SecretsResponse[0], + objectStorageSecret?: SecretsResponse[1], + ): SecretsResponse => [databaseSecret, objectStorageSecret ?? { secretName: '', awsData: [] }]; + + it('should create resource spec', () => { + const spec = createDSPipelineResourceSpec( + createPipelineServerConfig(), + createSecretsResponse(), + ); + expect(spec).toEqual({ + database: undefined, + objectStorage: { + externalStorage: { + bucket: '', + host: '', + s3CredentialsSecret: { + accessKey: 'AWS_ACCESS_KEY_ID', + secretKey: 'AWS_SECRET_ACCESS_KEY', + secretName: '', + }, + scheme: 'https', + }, + }, + }); + }); + + it('should parse S3 endpoint with scheme', () => { + const secretsResponse = createSecretsResponse(); + secretsResponse[1].awsData = [ + { key: AWS_KEYS.S3_ENDPOINT, value: 'http://s3.amazonaws.com' }, + ]; + const spec = createDSPipelineResourceSpec(createPipelineServerConfig(), secretsResponse); + expect(spec.objectStorage.externalStorage?.scheme).toBe('http'); + expect(spec.objectStorage.externalStorage?.host).toBe('s3.amazonaws.com'); + }); + + it('should parse S3 endpoint without scheme', () => { + const secretsResponse = createSecretsResponse(); + + secretsResponse[1].awsData = [{ key: AWS_KEYS.S3_ENDPOINT, value: 's3.amazonaws.com' }]; + const spec = createDSPipelineResourceSpec(createPipelineServerConfig(), secretsResponse); + expect(spec.objectStorage.externalStorage?.scheme).toBe('https'); + expect(spec.objectStorage.externalStorage?.host).toBe('s3.amazonaws.com'); + }); + + it('should include bucket', () => { + const secretsResponse = createSecretsResponse(); + secretsResponse[1].awsData = [{ key: AWS_KEYS.AWS_S3_BUCKET, value: 'my-bucket' }]; + const spec = createDSPipelineResourceSpec(createPipelineServerConfig(), secretsResponse); + expect(spec.objectStorage.externalStorage?.bucket).toBe('my-bucket'); + }); + + it('should create spec with database object', () => { + const config = createPipelineServerConfig(); + config.database.value = [ + { + key: 'Username', + value: 'test-user', + }, + { + key: 'Port', + value: '8080', + }, + { + key: 'Host', + value: 'test.host.com', + }, + { + key: 'Database', + value: 'db-name', + }, + ]; + const spec = createDSPipelineResourceSpec( + config, + createSecretsResponse({ + key: 'password-key', + name: 'password-name', + }), + ); + expect(spec).toEqual({ + objectStorage: { + externalStorage: { + bucket: '', + host: '', + s3CredentialsSecret: { + accessKey: 'AWS_ACCESS_KEY_ID', + secretKey: 'AWS_SECRET_ACCESS_KEY', + secretName: '', + }, + scheme: 'https', + }, + }, + database: { + externalDB: { + host: 'test.host.com', + passwordSecret: { + key: 'password-key', + name: 'password-name', + }, + pipelineDBName: 'db-name', + port: '8080', + username: 'test-user', + }, + }, + }); + }); + }); +}); diff --git a/frontend/src/concepts/pipelines/content/configurePipelinesServer/utils.ts b/frontend/src/concepts/pipelines/content/configurePipelinesServer/utils.ts index 06c5b00164..d4cb2f6553 100644 --- a/frontend/src/concepts/pipelines/content/configurePipelinesServer/utils.ts +++ b/frontend/src/concepts/pipelines/content/configurePipelinesServer/utils.ts @@ -116,22 +116,22 @@ const createSecrets = (config: PipelineServerConfigType, projectName: string) => .catch(reject); }); -export const configureDSPipelineResourceSpec = ( +export const createDSPipelineResourceSpec = ( config: PipelineServerConfigType, - projectName: string, -): Promise => - createSecrets(config, projectName).then(([databaseSecret, objectStorageSecret]) => { + [databaseSecret, objectStorageSecret]: SecretsResponse, +): DSPipelineKind['spec'] => { + { const awsRecord = dataEntryToRecord(objectStorageSecret.awsData); const databaseRecord = dataEntryToRecord(config.database.value); const [, externalStorageScheme, externalStorageHost] = - awsRecord.AWS_S3_ENDPOINT?.match(/^(\w+):\/\/(.*)/) ?? []; + awsRecord.AWS_S3_ENDPOINT?.match(/^(?:(\w+):\/\/)?(.*)/) ?? []; return { objectStorage: { externalStorage: { - host: externalStorageHost.replace(/\/$/, ''), - scheme: externalStorageScheme, + host: externalStorageHost?.replace(/\/$/, '') || '', + scheme: externalStorageScheme || 'https', bucket: awsRecord.AWS_S3_BUCKET || '', s3CredentialsSecret: { accessKey: AWS_KEYS.ACCESS_KEY_ID, @@ -155,4 +155,13 @@ export const configureDSPipelineResourceSpec = ( } : undefined, }; - }); + } +}; + +export const configureDSPipelineResourceSpec = ( + config: PipelineServerConfigType, + projectName: string, +): Promise => + createSecrets(config, projectName).then((secretsResponse) => + createDSPipelineResourceSpec(config, secretsResponse), + ); diff --git a/frontend/src/concepts/pipelines/content/pipelinesDetails/PipelineDetailsYAML.tsx b/frontend/src/concepts/pipelines/content/pipelinesDetails/PipelineDetailsYAML.tsx index 8f7f2bda8a..8df1ae1ab4 100644 --- a/frontend/src/concepts/pipelines/content/pipelinesDetails/PipelineDetailsYAML.tsx +++ b/frontend/src/concepts/pipelines/content/pipelinesDetails/PipelineDetailsYAML.tsx @@ -33,6 +33,7 @@ const PipelineDetailsYAML: React.FC = ({ filename, con isCopyEnabled isLanguageLabelVisible language={Language.yaml} + isReadOnly /> ); }; diff --git a/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRun/PipelineRunDrawerBottomTabs.tsx b/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRun/PipelineRunDrawerBottomTabs.tsx index b795747ee6..99c470cd79 100644 --- a/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRun/PipelineRunDrawerBottomTabs.tsx +++ b/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRun/PipelineRunDrawerBottomTabs.tsx @@ -4,9 +4,11 @@ import PipelineRunTabDetails from '~/concepts/pipelines/content/pipelinesDetails import PipelineDetailsYAML from '~/concepts/pipelines/content/pipelinesDetails/PipelineDetailsYAML'; import { PipelineRunKind } from '~/k8sTypes'; import { PipelineRunKF } from '~/concepts/pipelines/kfTypes'; +import PipelineRunTabParameters from './PipelineRunTabParameters'; export enum RunDetailsTabs { DETAILS = 'Details', + PARAMETERS = 'Input parameters', YAML = 'Run output', } @@ -51,6 +53,14 @@ export const PipelineRunDrawerBottomTabs: React.FC pipelineRunKF={pipelineRunDetails?.kf} /> + = ({ pipelineRunKF }) => { + if (!pipelineRunKF) { + return ( + + + + Loading + + + ); + } + + if ( + !pipelineRunKF?.pipeline_spec.parameters || + pipelineRunKF.pipeline_spec.parameters.length === 0 + ) { + return ( + + + No parameters + + This pipeline run does not have any parameters defined. + + ); + } + + const details: DetailItem[] = pipelineRunKF.pipeline_spec.parameters.map((param) => ({ + key: param.name, + value: param.value, + })); + + return <>{renderDetailItems(details)}; +}; + +export default PipelineRunTabParameters; diff --git a/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRun/utils.tsx b/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRun/utils.tsx index a1a90e5d15..b4b18c6cca 100644 --- a/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRun/utils.tsx +++ b/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRun/utils.tsx @@ -20,7 +20,9 @@ export const renderDetailItems = (details: DetailItem[], flexKey?: boolean): Rea {details.map((detail) => ( - {detail.key} + + {detail.key} + {detail.value} diff --git a/frontend/src/concepts/pipelines/content/pipelinesDetails/taskDetails/TaskDetailsPrintKeyValues.tsx b/frontend/src/concepts/pipelines/content/pipelinesDetails/taskDetails/TaskDetailsPrintKeyValues.tsx index de6b814cc6..1e53303134 100644 --- a/frontend/src/concepts/pipelines/content/pipelinesDetails/taskDetails/TaskDetailsPrintKeyValues.tsx +++ b/frontend/src/concepts/pipelines/content/pipelinesDetails/taskDetails/TaskDetailsPrintKeyValues.tsx @@ -9,7 +9,9 @@ const TaskDetailsPrintKeyValues: React.FC = ({ i {items.map((result, i) => ( - {result.name} + + {result.name} + {result.value} ))} diff --git a/frontend/src/concepts/pipelines/content/tables/renderUtils.tsx b/frontend/src/concepts/pipelines/content/tables/renderUtils.tsx index 4c13dd0ae7..bcb0249941 100644 --- a/frontend/src/concepts/pipelines/content/tables/renderUtils.tsx +++ b/frontend/src/concepts/pipelines/content/tables/renderUtils.tsx @@ -169,22 +169,20 @@ export const RunJobStatus: RunJobUtil<{ onToggle: (value: boolean) => Promise - - { - setIsChangingFlag(true); - setError(null); - onToggle(checked).catch((e) => { - setError(e); - setIsChangingFlag(false); - }); - }} - isChecked={isEnabled} - /> - + { + setIsChangingFlag(true); + setError(null); + onToggle(checked).catch((e) => { + setError(e); + setIsChangingFlag(false); + }); + }} + isChecked={isEnabled} + /> {isChangingFlag && } diff --git a/frontend/src/concepts/pipelines/topology/core/TaskEdge.tsx b/frontend/src/concepts/pipelines/topology/core/TaskEdge.tsx new file mode 100644 index 0000000000..aa243cdade --- /dev/null +++ b/frontend/src/concepts/pipelines/topology/core/TaskEdge.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; +import { css } from '@patternfly/react-styles'; +import styles from '@patternfly/react-styles/css/components/Topology/topology-components'; +import { + observer, + Edge, + integralShapePath, + DEFAULT_SPACER_NODE_TYPE, + ConnectorArrow, +} from '@patternfly/react-topology'; +interface TaskEdgeProps { + element: Edge; + className?: string; + nodeSeparation?: number; +} + +const TaskEdge: React.FunctionComponent = ({ + element, + className, + nodeSeparation, +}) => { + const startPoint = element.getStartPoint(); + const endPoint = element.getEndPoint(); + const groupClassName = css(styles.topologyEdge, className); + const startIndent: number = element.getData()?.indent || 0; + + return ( + + + + {element.getTarget().getType() !== DEFAULT_SPACER_NODE_TYPE ? ( + + ) : null} + + ); +}; + +export default observer(TaskEdge); diff --git a/frontend/src/concepts/pipelines/topology/core/factories.ts b/frontend/src/concepts/pipelines/topology/core/factories.ts index 90f4aca898..2156503a37 100644 --- a/frontend/src/concepts/pipelines/topology/core/factories.ts +++ b/frontend/src/concepts/pipelines/topology/core/factories.ts @@ -6,12 +6,11 @@ import { GraphComponent, ModelKind, SpacerNode, - TaskEdge, withPanZoom, withSelection, } from '@patternfly/react-topology'; import StandardTaskNode from '~/concepts/pipelines/topology/core/customNodes/StandardTaskNode'; - +import TaskEdge from './TaskEdge'; // Topology gap... their types have issues with Strict TS mode // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore diff --git a/frontend/src/concepts/secrets/apiHooks/useSecret.ts b/frontend/src/concepts/secrets/apiHooks/useSecret.ts deleted file mode 100644 index a4c0a79175..0000000000 --- a/frontend/src/concepts/secrets/apiHooks/useSecret.ts +++ /dev/null @@ -1,20 +0,0 @@ -import * as React from 'react'; -import useFetchState, { FetchStateCallbackPromise, NotReadyError } from '~/utilities/useFetchState'; -import { getSecret } from '~/api'; -import { SecretKind } from '~/k8sTypes'; - -const useSecret = (name: string | null, namespace: string) => { - const callback = React.useCallback>( - (opts) => { - if (!name) { - return Promise.reject(new NotReadyError('Secret name is missing')); - } - return getSecret(namespace, name, opts); - }, - [name, namespace], - ); - - return useFetchState(callback, null); -}; - -export default useSecret; diff --git a/frontend/src/k8sTypes.ts b/frontend/src/k8sTypes.ts index 05577c6861..d8ef48c74b 100644 --- a/frontend/src/k8sTypes.ts +++ b/frontend/src/k8sTypes.ts @@ -12,6 +12,7 @@ import { TolerationSettings, ImageStreamStatusTagItem, ImageStreamStatusTagCondition, + VolumeMount, } from './types'; import { ServingRuntimeSize } from './pages/modelServing/screens/types'; @@ -328,9 +329,11 @@ export type ServingRuntimeKind = K8sResourceCommon & { image: string; name: string; resources: ContainerResources; + volumeMounts?: VolumeMount[]; }[]; supportedModelFormats: SupportedModelFormats[]; replicas: number; + volumes?: Volume[]; }; }; diff --git a/frontend/src/pages/enabledApplications/EnabledApplications.tsx b/frontend/src/pages/enabledApplications/EnabledApplications.tsx index e112137016..8114c42d3b 100644 --- a/frontend/src/pages/enabledApplications/EnabledApplications.tsx +++ b/frontend/src/pages/enabledApplications/EnabledApplications.tsx @@ -5,7 +5,6 @@ import { useWatchComponents } from '~/utilities/useWatchComponents'; import { OdhApplication } from '~/types'; import ApplicationsPage from '~/pages/ApplicationsPage'; import OdhAppCard from '~/components/OdhAppCard'; -import QuickStarts from '~/app/QuickStarts'; import { fireTrackingEvent } from '~/utilities/segmentIOUtils'; const description = `Launch your enabled applications, view documentation, or get started with quick start instructions and tasks.`; @@ -74,13 +73,7 @@ const EnabledApplications: React.FC = () => { }, [components, loaded]); return ( - - - + ); }; diff --git a/frontend/src/pages/learningCenter/LearningCenter.tsx b/frontend/src/pages/learningCenter/LearningCenter.tsx index 17e5a7a8b9..5a778ccaaf 100644 --- a/frontend/src/pages/learningCenter/LearningCenter.tsx +++ b/frontend/src/pages/learningCenter/LearningCenter.tsx @@ -9,7 +9,6 @@ import { useWatchDocs } from '~/utilities/useWatchDocs'; import { useBrowserStorage } from '~/components/browserStorage'; import { useQueryParams } from '~/utilities/useQueryParams'; import ApplicationsPage from '~/pages/ApplicationsPage'; -import QuickStarts from '~/app/QuickStarts'; import { DOC_LINK, ODH_PRODUCT_NAME } from '~/utilities/const'; import { combineCategoryAnnotations } from '~/utilities/utils'; import { useDeepCompareMemoize } from '~/utilities/useDeepCompareMemoize'; @@ -230,10 +229,4 @@ export const LearningCenter: React.FC = () => { ); }; -const LearningCenterWrapper: React.FC = () => ( - - - -); - -export default LearningCenterWrapper; +export default LearningCenter; diff --git a/frontend/src/pages/modelServing/customServingRuntimes/CustomServingRuntimeAddTemplate.tsx b/frontend/src/pages/modelServing/customServingRuntimes/CustomServingRuntimeAddTemplate.tsx index bb42ff04a0..47fac284d0 100644 --- a/frontend/src/pages/modelServing/customServingRuntimes/CustomServingRuntimeAddTemplate.tsx +++ b/frontend/src/pages/modelServing/customServingRuntimes/CustomServingRuntimeAddTemplate.tsx @@ -21,7 +21,10 @@ import { createServingRuntimeTemplateBackend, updateServingRuntimeTemplateBackend, } from '~/services/templateService'; -import { getServingRuntimeDisplayNameFromTemplate } from './utils'; +import { + getServingRuntimeDisplayNameFromTemplate, + getServingRuntimeNameFromTemplate, +} from './utils'; import { CustomServingRuntimeContext } from './CustomServingRuntimeContext'; type CustomServingRuntimeAddTemplateProps = { @@ -33,13 +36,35 @@ const CustomServingRuntimeAddTemplate: React.FC { const { dashboardNamespace } = useDashboardNamespace(); const { refreshData } = React.useContext(CustomServingRuntimeContext); - const { state } = useLocation(); + const { state }: { state?: { template: TemplateKind } } = useLocation(); + + const copiedServingRuntimeString = React.useMemo( + () => + state + ? YAML.stringify({ + ...state.template.objects[0], + metadata: { + ...state.template.objects[0].metadata, + name: `${getServingRuntimeNameFromTemplate(state.template)}-copy`, + annotations: { + ...state.template.objects[0].metadata.annotations, + 'openshift.io/display-name': `Copy of ${getServingRuntimeDisplayNameFromTemplate( + state.template, + )}`, + 'openshift.io/description': + state.template.objects[0].metadata.annotations?.['openshift.io/description'], + }, + }, + }) + : '', + [state], + ); - const stringifiedTemplate = existingTemplate - ? YAML.stringify(existingTemplate.objects[0]) - : state - ? YAML.stringify(state.template) - : ''; + const stringifiedTemplate = React.useMemo( + () => + existingTemplate ? YAML.stringify(existingTemplate.objects[0]) : copiedServingRuntimeString, + [copiedServingRuntimeString, existingTemplate], + ); const [code, setCode] = React.useState(stringifiedTemplate); const [loading, setIsLoading] = React.useState(false); const [error, setError] = React.useState(undefined); @@ -108,7 +133,7 @@ const CustomServingRuntimeAddTemplate: React.FC