diff --git a/backend/package-lock.json b/backend/package-lock.json index d328f7831f..fd304a349c 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -2090,15 +2090,6 @@ "integrity": "sha512-BdZ5BCCvho3EIXw6wUCXHe7rS53AIDPLE+JzwgT+OsJk53oBfbSmZZ7CX4VaRoN78N+TJpFi9QPlfIVNmJYWxQ==", "dev": true }, - "node_modules/@types/minipass": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/@types/minipass/-/minipass-3.3.5.tgz", - "integrity": "sha512-M2BLHQdEmDmH671h0GIlOQQJrgezd1vNqq7PVj1VOsHZ2uQQb4iPiQIl0SlMdhxZPUsLIfEklmeEHXg8DJRewA==", - "deprecated": "This is a stub types definition. minipass provides its own type definitions, so you do not need this installed.", - "dependencies": { - "minipass": "*" - } - }, "node_modules/@types/node": { "version": "18.15.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.15.3.tgz", @@ -2150,12 +2141,12 @@ } }, "node_modules/@types/tar": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@types/tar/-/tar-4.0.5.tgz", - "integrity": "sha512-cgwPhNEabHaZcYIy5xeMtux2EmYBitfqEceBUi2t5+ETy4dW6kswt6WX4+HqLeiiKOo42EXbGiDmVJ2x+vi37Q==", + "version": "6.1.13", + "resolved": "https://registry.npmjs.org/@types/tar/-/tar-6.1.13.tgz", + "integrity": "sha512-IznnlmU5f4WcGTh2ltRu/Ijpmk8wiWXfF0VA4s+HPjHZgvFggk1YaIkbo5krX/zUCzWF8N/l4+W/LNxnvAJ8nw==", "dependencies": { - "@types/minipass": "*", - "@types/node": "*" + "@types/node": "*", + "minipass": "^4.0.0" } }, "node_modules/@types/tough-cookie": { @@ -5392,9 +5383,9 @@ "integrity": "sha512-cIusKBIt/R/oI6z/1nyfe2FvGKVTohVRfvkOhvx0nCEW+xf5NoCXjAHcWp93uOUBchzYcsvPlrapAdX1uW+YGg==" }, "node_modules/fast-xml-parser": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.4.0.tgz", - "integrity": "sha512-kLY3jFlwIYwBNDojclKsNAC12sfD6NwW74QB2CoNGPvtVxjliYehVunB3HYyNi+n4Tt1dAcgwYvmKF/Z18flqg==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.4.1.tgz", + "integrity": "sha512-xkjOecfnKGkSsOwtZ5Pz7Us/T6mrbPQrq0nh+aCO5V9nk5NLWmasAHumTKjiPJPWANe+kAZ84Jc8ooJkzZ88Sw==", "funding": [ { "type": "github", @@ -10768,13 +10759,13 @@ } }, "node_modules/tar": { - "version": "6.1.13", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.13.tgz", - "integrity": "sha512-jdIBIN6LTIe2jqzay/2vtYLlBHa3JF42ot3h1dW8Q0PaAG4v8rm0cvpVePtau5C6OKXGGcgO9q2AMNSWxiLqKw==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", - "minipass": "^4.0.0", + "minipass": "^5.0.0", "minizlib": "^2.1.1", "mkdirp": "^1.0.3", "yallist": "^4.0.0" @@ -10783,6 +10774,14 @@ "node": ">=10" } }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "engines": { + "node": ">=8" + } + }, "node_modules/tar/node_modules/mkdirp": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", diff --git a/backend/package.json b/backend/package.json index c4cbe037e4..d435186c3c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -86,6 +86,7 @@ }, "overrides": { "tough-cookie": "^4.1.3", - "ws": "^8.17.1" + "ws": "^8.17.1", + "@types/tar": "^6.1.13" } } diff --git a/backend/src/routes/api/connection-types/connectionTypeUtils.ts b/backend/src/routes/api/connection-types/connectionTypeUtils.ts index a3d2110ae8..2ad55a262f 100644 --- a/backend/src/routes/api/connection-types/connectionTypeUtils.ts +++ b/backend/src/routes/api/connection-types/connectionTypeUtils.ts @@ -128,9 +128,9 @@ export const patchConnectionType = async ( const { dashboardNamespace } = getNamespaces(fastify); if ( - (partialConfigMap.metadata.labels?.[KnownLabels.DASHBOARD_RESOURCE] && + (partialConfigMap.metadata?.labels?.[KnownLabels.DASHBOARD_RESOURCE] && partialConfigMap.metadata.labels[KnownLabels.DASHBOARD_RESOURCE] !== 'true') || - (partialConfigMap.metadata.labels?.[KnownLabels.CONNECTION_TYPE] && + (partialConfigMap.metadata?.labels?.[KnownLabels.CONNECTION_TYPE] && partialConfigMap.metadata.labels[KnownLabels.CONNECTION_TYPE] !== 'true') ) { const error = 'Unable to update connection type, incorrect labels.'; diff --git a/backend/src/routes/api/storage/index.ts b/backend/src/routes/api/storage/index.ts deleted file mode 100644 index 5cec68b016..0000000000 --- a/backend/src/routes/api/storage/index.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { FastifyReply } from 'fastify'; -import { getObjectSize, getObjectStream, setupMinioClient } from './storageUtils'; -import { getDashboardConfig } from '../../../utils/resourceUtils'; -import { KubeFastifyInstance, OauthFastifyRequest } from '../../../types'; - -export default async (fastify: KubeFastifyInstance): Promise => { - fastify.get( - '/:namespace/size', - async ( - request: OauthFastifyRequest<{ - Querystring: { key: string }; - Params: { namespace: string }; - }>, - reply: FastifyReply, - ) => { - try { - const dashConfig = getDashboardConfig(request); - if (dashConfig?.spec.dashboardConfig.disableS3Endpoint !== false) { - reply.code(404).send('Not found'); - return reply; - } - - const { namespace } = request.params; - const { key } = request.query; - - const { client, bucket } = await setupMinioClient(fastify, request, namespace); - - const size = await getObjectSize({ - client, - key, - bucket, - }); - - reply.send(size); - } catch (err) { - reply.code(500).send(err.message); - return reply; - } - }, - ); - - fastify.get( - '/:namespace', - async ( - request: OauthFastifyRequest<{ - Querystring: { key: string; peek?: number }; - Params: { namespace: string }; - }>, - reply: FastifyReply, - ) => { - try { - const dashConfig = getDashboardConfig(request); - if (dashConfig?.spec.dashboardConfig.disableS3Endpoint !== false) { - reply.code(404).send('Not found'); - return reply; - } - - const { namespace } = request.params; - const { key, peek } = request.query; - - const { client, bucket } = await setupMinioClient(fastify, request, namespace); - - const stream = await getObjectStream({ - client, - key, - bucket, - peek, - }); - - reply.type('text/plain'); - - await new Promise((resolve, reject) => { - stream.on('data', (chunk) => { - reply.raw.write(chunk); - }); - - stream.on('end', () => { - reply.raw.end(); - resolve(); - }); - - stream.on('error', (err) => { - fastify.log.error('Stream error:', err); - reply.raw.statusCode = 500; - reply.raw.end(err.message); - reject(err); - }); - }); - - return; - } catch (err) { - reply.code(500).send(err.message); - return reply; - } - }, - ); -}; diff --git a/backend/src/routes/api/storage/storageUtils.ts b/backend/src/routes/api/storage/storageUtils.ts deleted file mode 100644 index f6e40150c5..0000000000 --- a/backend/src/routes/api/storage/storageUtils.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { Client as MinioClient } from 'minio'; -import { - DSPipelineKind, - K8sResourceCommon, - K8sResourceListResult, - KubeFastifyInstance, - OauthFastifyRequest, - SecretKind, -} from '../../../types'; -import { Transform, TransformOptions } from 'stream'; -import { passThroughResource } from '../k8s/pass-through'; - -export interface PreviewStreamOptions extends TransformOptions { - peek: number; -} -const DataSciencePipelineApplicationModel = { - apiVersion: 'v1alpha1', - apiGroup: 'datasciencepipelinesapplications.opendatahub.io', - kind: 'DataSciencePipelinesApplication', - plural: 'datasciencepipelinesapplications', -}; - -/** - * Transform stream that only stream the first X number of bytes. - */ -export class PreviewStream extends Transform { - constructor({ peek, ...opts }: PreviewStreamOptions) { - // acts like passthrough - let transform: TransformOptions['transform'] = (chunk, _encoding, callback) => - callback(undefined, chunk); - // implements preview - peek must be positive number - if (peek && peek > 0) { - let size = 0; - transform = (chunk, _encoding, callback) => { - const delta = peek - size; - size += chunk.length; - if (size >= peek) { - callback(undefined, chunk.slice(0, delta)); - this.resume(); // do not handle any subsequent data - return; - } - callback(undefined, chunk); - }; - } - super({ ...opts, transform }); - } -} - -export async function getDspa( - fastify: KubeFastifyInstance, - request: OauthFastifyRequest, - namespace: string, -): Promise { - const kc = fastify.kube.config; - const cluster = kc.getCurrentCluster(); - - // retreive the gating resource by name and namespace - const dspaResponse = await passThroughResource>( - fastify, - request, - { - url: `${cluster.server}/apis/${DataSciencePipelineApplicationModel.apiGroup}/${DataSciencePipelineApplicationModel.apiVersion}/namespaces/${namespace}/${DataSciencePipelineApplicationModel.plural}`, - method: 'GET', - }, - ).catch((e) => { - throw `A ${e.statusCode} error occurred when trying to fetch dspa aws storage credentials: ${ - e.response?.body?.message || e?.response?.statusMessage - }`; - }); - - function isK8sResourceList( - data: any, - ): data is K8sResourceListResult { - return data && data.items !== undefined; - } - - if (!isK8sResourceList(dspaResponse)) { - throw `A ${dspaResponse.code} error occurred when trying to fetch dspa aws storage credentials: ${dspaResponse.message}`; - } - - const dspas = dspaResponse.items; - - if (!dspas || !dspas.length) { - throw 'No Data Science Pipeline Application found'; - } - - return dspas[0]; -} - -async function getDspaSecretKeys( - fastify: KubeFastifyInstance, - request: OauthFastifyRequest, - namespace: string, - dspa: DSPipelineKind, -): Promise<{ accessKey: string; secretKey: string }> { - try { - const kc = fastify.kube.config; - const cluster = kc.getCurrentCluster(); - - const secretResp = await passThroughResource(fastify, request, { - url: `${cluster.server}/api/v1/namespaces/${namespace}/secrets/${dspa.spec.objectStorage.externalStorage.s3CredentialsSecret.secretName}`, - method: 'GET', - }).catch((e) => { - throw `A ${e.statusCode} error occurred when trying to fetch secret for aws credentials: ${ - e.response?.body?.message || e?.response?.statusMessage - }`; - }); - - const secret = secretResp as SecretKind; - - const accessKey = atob( - secret.data[dspa.spec.objectStorage.externalStorage.s3CredentialsSecret.accessKey], - ); - const secretKey = atob( - secret.data[dspa.spec.objectStorage.externalStorage.s3CredentialsSecret.secretKey], - ); - - if (!accessKey || !secretKey) { - throw 'Access key or secret key is empty'; - } - - return { accessKey, secretKey }; - } catch (err) { - console.error('Unable to get dspa secret keys: ', err); - throw new Error('Unable to get dspa secret keys: ' + err); - } -} - -/** - * Create minio client with aws instance profile credentials if needed. - * @param config minio client options where `accessKey` and `secretKey` are optional. - */ -export async function setupMinioClient( - fastify: KubeFastifyInstance, - request: OauthFastifyRequest, - namespace: string, -): Promise<{ client: MinioClient; bucket: string }> { - try { - const dspa = await getDspa(fastify, request, namespace); - - // check if object storage connection is available - if ( - !dspa.status?.conditions?.find((c) => c.type === 'APIServerReady' && c.status === 'True') || - !dspa.status?.conditions?.find( - (c) => c.type === 'ObjectStoreAvailable' && c.status === 'True', - ) - ) { - throw 'Object store is not available'; - } - - const externalStorage = dspa.spec.objectStorage.externalStorage; - if (externalStorage) { - const { region, host: endPoint, bucket } = externalStorage; - const { accessKey, secretKey } = await getDspaSecretKeys(fastify, request, namespace, dspa); - return { - client: new MinioClient({ accessKey, secretKey, endPoint, region }), - bucket, - }; - } - } catch (err) { - console.error('Unable to create minio client: ', err); - throw new Error('Unable to create minio client: ' + err); - } -} - -/** MinioRequestConfig describes the info required to retrieve an artifact. */ -export interface MinioRequestConfig { - bucket: string; - key: string; - client: MinioClient; - peek?: number; -} - -/** - * Returns a stream from an object in a s3 compatible object store (e.g. minio). - * - * @param param.bucket Bucket name to retrieve the object from. - * @param param.key Key of the object to retrieve. - * @param param.client Minio client. - * @param param.peek Number of bytes to preview. - * - */ -export async function getObjectStream({ - key, - client, - bucket, - peek = 1e8, // 100mb -}: MinioRequestConfig): Promise { - const safePeek = Math.min(peek, 1e8); // 100mb - const stream = await client.getObject(bucket, key); - return stream.pipe(new PreviewStream({ peek: safePeek })); -} - -export async function getObjectSize({ bucket, key, client }: MinioRequestConfig): Promise { - const stat = await client.statObject(bucket, key); - return stat.size; -} diff --git a/backend/src/types.ts b/backend/src/types.ts index 28249c0e84..3f62b32c40 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -37,8 +37,6 @@ export type DashboardConfig = K8sResourceCommon & { disableModelMesh: boolean; disableAcceleratorProfiles: boolean; disablePipelineExperiments: boolean; - disableS3Endpoint: boolean; - disableArtifactsAPI: boolean; disableDistributedWorkloads: boolean; disableModelRegistry: boolean; disableConnectionTypes: boolean; diff --git a/backend/src/utils/constants.ts b/backend/src/utils/constants.ts index f5da341c67..0b301b144b 100644 --- a/backend/src/utils/constants.ts +++ b/backend/src/utils/constants.ts @@ -62,8 +62,6 @@ export const blankDashboardCR: DashboardConfig = { disableModelMesh: false, disableAcceleratorProfiles: false, disablePipelineExperiments: false, - disableS3Endpoint: true, - disableArtifactsAPI: true, disableDistributedWorkloads: false, disableModelRegistry: true, disableConnectionTypes: true, diff --git a/backend/src/utils/resourceUtils.ts b/backend/src/utils/resourceUtils.ts index 041beb1beb..1bec2d7b93 100644 --- a/backend/src/utils/resourceUtils.ts +++ b/backend/src/utils/resourceUtils.ts @@ -677,8 +677,11 @@ export const cleanupGPU = async (fastify: KubeFastifyInstance): Promise => ) { // if gpu detected on cluster, create our default migrated-gpu const acceleratorDetected = await getDetectedAccelerators(fastify); + const hasNvidiaNodes = Object.keys(acceleratorDetected.total).some( + (nodeKey) => nodeKey === 'nvidia.com/gpu', + ); - if (acceleratorDetected.configured) { + if (acceleratorDetected.configured && hasNvidiaNodes) { const payload: AcceleratorProfileKind = { kind: 'AcceleratorProfile', apiVersion: 'dashboard.opendatahub.io/v1', diff --git a/docs/dashboard-config.md b/docs/dashboard-config.md index c6dd9a404f..f98a6ef375 100644 --- a/docs/dashboard-config.md +++ b/docs/dashboard-config.md @@ -63,8 +63,6 @@ spec: disableBiasMetrics: false disablePerformanceMetrics: false disablePipelineExperiments: true - disableS3Endpoint: true - disableArtifactsAPI: true disableDistributedWorkloads: false disableConnectionTypes: false ``` @@ -161,8 +159,6 @@ spec: disableBiasMetrics: false disablePerformanceMetrics: false disablePipelineExperiments: false - disableS3Endpoint: true - disableArtifactsAPI: true notebookController: enabled: true gpuSetting: autodetect diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6088e382bd..7df5f1bf35 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -23,6 +23,7 @@ "@patternfly/react-log-viewer": "^5.2.0", "@patternfly/react-styles": "^5.3.1", "@patternfly/react-table": "^5.3.3", + "@patternfly/react-templates": "^1.0.4", "@patternfly/react-tokens": "^5.3.1", "@patternfly/react-topology": "^5.4.0-prerelease.10", "@patternfly/react-virtualized-extension": "^5.1.0", @@ -4026,9 +4027,9 @@ } }, "node_modules/@patternfly/react-core": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/@patternfly/react-core/-/react-core-5.3.3.tgz", - "integrity": "sha512-qq3j0M+Vi+Xmd+a/MhRhGgjdRh9Hnm79iA+L935HwMIVDcIWRYp6Isib/Ha4+Jk+f3Qdl0RT3dBDvr/4m6OpVQ==", + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/@patternfly/react-core/-/react-core-5.3.4.tgz", + "integrity": "sha512-zr2yeilIoFp8MFOo0vNgI8XuM+P2466zHvy4smyRNRH2/but2WObqx7Wu4ftd/eBMYdNqmTeuXe6JeqqRqnPMQ==", "dependencies": { "@patternfly/react-icons": "^5.3.2", "@patternfly/react-styles": "^5.3.1", @@ -4107,6 +4108,22 @@ "react-dom": "^17 || ^18" } }, + "node_modules/@patternfly/react-templates": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@patternfly/react-templates/-/react-templates-1.0.4.tgz", + "integrity": "sha512-SHhlgaPoY1eoCqr2xtift3pRldhVfs8x8zelTnhAnfrja5yVFJqTq/yediB0qm7OZ84wF2HIgSfuS0iM0/iG5A==", + "dependencies": { + "@patternfly/react-core": "^5.3.4", + "@patternfly/react-icons": "^5.3.2", + "@patternfly/react-styles": "^5.3.1", + "@patternfly/react-tokens": "^5.3.1", + "tslib": "^2.5.0" + }, + "peerDependencies": { + "react": "^17 || ^18", + "react-dom": "^17 || ^18" + } + }, "node_modules/@patternfly/react-tokens": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/@patternfly/react-tokens/-/react-tokens-5.3.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 2adb953921..c72ad99211 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -66,6 +66,7 @@ "@patternfly/react-log-viewer": "^5.2.0", "@patternfly/react-styles": "^5.3.1", "@patternfly/react-table": "^5.3.3", + "@patternfly/react-templates": "^1.0.4", "@patternfly/react-tokens": "^5.3.1", "@patternfly/react-topology": "^5.4.0-prerelease.10", "@patternfly/react-virtualized-extension": "^5.1.0", @@ -149,13 +150,22 @@ "@babel/preset-react": "^7.18.6", "@babel/preset-typescript": "^7.21.5", "@cypress/code-coverage": "^3.12.34", + "@jsdevtools/coverage-istanbul-loader": "^3.0.5", + "@testing-library/cypress": "^10.0.1", + "@testing-library/dom": "^9.3.4", + "@testing-library/jest-dom": "^6.3.0", + "@testing-library/react": "^14.0.0", + "@testing-library/user-event": "^14.5.2", + "@types/chai-subset": "^1.3.5", + "@types/jest": "^28.1.8", + "@typescript-eslint/eslint-plugin": "^7.1.1", + "@typescript-eslint/parser": "^7.1.1", "chai-subset": "^1.6.0", "cypress": "^13.10.0", "cypress-axe": "^1.5.0", "cypress-high-resolution": "^1.0.0", "cypress-mochawesome-reporter": "^3.8.2", "cypress-multi-reporters": "^1.6.4", - "@types/jest": "^28.1.8", "eslint": "^8.57.0", "eslint-config-prettier": "^8.6.0", "eslint-import-resolver-node": "^0.3.7", @@ -174,19 +184,10 @@ "jest": "^28.1.3", "jest-environment-jsdom": "^29.4.3", "junit-report-merger": "^7.0.0", - "@jsdevtools/coverage-istanbul-loader": "^3.0.5", "mocha-junit-reporter": "^2.2.1", "npm-run-all": "^4.1.5", "nyc": "^15.1.0", "serve": "^14.2.1", - "@testing-library/cypress": "^10.0.1", - "@testing-library/dom": "^9.3.4", - "@testing-library/jest-dom": "^6.3.0", - "@testing-library/react": "^14.0.0", - "@testing-library/user-event": "^14.5.2", - "@types/chai-subset": "^1.3.5", - "@typescript-eslint/eslint-plugin": "^7.1.1", - "@typescript-eslint/parser": "^7.1.1", "ts-jest": "^28.0.8", "wait-on": "^7.2.0" }, diff --git a/frontend/src/__mocks__/index.ts b/frontend/src/__mocks__/index.ts index b73f92d395..22f4990248 100644 --- a/frontend/src/__mocks__/index.ts +++ b/frontend/src/__mocks__/index.ts @@ -15,3 +15,11 @@ export * from './mockPipelineKF'; export * from './mockSecretK8sResource'; export * from './mockGoogleRpcStatusKF'; export * from './mockK8sStatus'; +export * from './mockComponents'; +export * from './mockRegisteredModel'; +export * from './mockModelVersion'; +export * from './mockModelVersionList'; +export * from './mockModelArtifactList'; +export * from './mockModelRegistryService'; +export * from './mockServingRuntimeK8sResource'; +export * from './mockInferenceServiceK8sResource'; diff --git a/frontend/src/__mocks__/mlmd/mockGetArtifacts.ts b/frontend/src/__mocks__/mlmd/mockGetArtifacts.ts index 0926c4a387..50a87c23d5 100644 --- a/frontend/src/__mocks__/mlmd/mockGetArtifacts.ts +++ b/frontend/src/__mocks__/mlmd/mockGetArtifacts.ts @@ -8,7 +8,7 @@ export const mockedArtifactsResponse: GetArtifactsResponse = { id: 1, typeId: 14, type: 'system.Metrics', - uri: 's3://scalar-metrics-uri', + uri: 's3://scalar-metrics-uri-scalar-metrics-uri', properties: {}, customProperties: { accuracy: { doubleValue: 92 }, @@ -331,6 +331,19 @@ export const mockedArtifactsResponse: GetArtifactsResponse = { createTimeSinceEpoch: 1611399342384, lastUpdateTimeSinceEpoch: 1611399342384, }, + { + id: 6, + typeId: 18, + type: 'system.HTML', + uri: 's3://html-metrics-uri', + properties: {}, + customProperties: { + display_name: { stringValue: 'html metrics' }, + }, + state: 2, + createTimeSinceEpoch: 1611399342384, + lastUpdateTimeSinceEpoch: 1611399342384, + }, ], }; diff --git a/frontend/src/__mocks__/mlmd/mockGetArtifactsByContext.ts b/frontend/src/__mocks__/mlmd/mockGetArtifactsByContext.ts index 2ea0c21489..001f9538ae 100644 --- a/frontend/src/__mocks__/mlmd/mockGetArtifactsByContext.ts +++ b/frontend/src/__mocks__/mlmd/mockGetArtifactsByContext.ts @@ -18,6 +18,22 @@ const mockedScalarMetricArtifact = (noMetrics?: boolean): Artifact => ({ lastUpdateTimeSinceEpoch: 1711765118976, }); +const mockedHtmlArtifact = (noMetrics?: boolean): Artifact => ({ + id: 18, + typeId: 20, + type: 'system.HTML', + uri: 's3://aballant-pipelines/metrics-visualization-pipeline/16dbff18-a3d5-4684-90ac-4e6198a9da0f/markdown-visualization/html_artifact', + properties: {}, + customProperties: noMetrics + ? {} + : { + displayName: { stringValue: 'html_artifact' }, + }, + state: 2, + createTimeSinceEpoch: 1712841455267, + lastUpdateTimeSinceEpoch: 1712841455267, +}); + const mockedConfusionMatrixArtifact = (noMetrics?: boolean): Artifact => ({ id: 8, typeId: 18, @@ -116,6 +132,7 @@ export const mockGetArtifactsByContext = (noMetrics?: boolean): GrpcResponse => mockedConfusionMatrixArtifact(noMetrics), mockedRocCurveArtifact(noMetrics), mockedMarkdownArtifact(noMetrics), + mockedHtmlArtifact(noMetrics), ], }).finish(); return createGrpcResponse(binary); diff --git a/frontend/src/__mocks__/mlmd/mockGetEventsByExecutionIDs.ts b/frontend/src/__mocks__/mlmd/mockGetEventsByExecutionIDs.ts index 49e19c7c99..4d6de25d92 100644 --- a/frontend/src/__mocks__/mlmd/mockGetEventsByExecutionIDs.ts +++ b/frontend/src/__mocks__/mlmd/mockGetEventsByExecutionIDs.ts @@ -55,6 +55,19 @@ const mockedEventsByExecutionIdsResponse: GetEventsByExecutionIDsResponse = { type: 4, millisecondsSinceEpoch: 1712899531648, }, + { + artifactId: 18, + executionId: 289, + path: { + steps: [ + { + key: 'html_artifact', + }, + ], + }, + type: 4, + millisecondsSinceEpoch: 1712899531648, + }, ], }; diff --git a/frontend/src/__mocks__/mockArtifactStorage.ts b/frontend/src/__mocks__/mockArtifactStorage.ts new file mode 100644 index 0000000000..aaef9d9c8f --- /dev/null +++ b/frontend/src/__mocks__/mockArtifactStorage.ts @@ -0,0 +1,33 @@ +/* eslint-disable camelcase */ +import { ArtifactStorage } from '~/concepts/pipelines/types'; + +type MockArtifactStorageType = { + namespace?: string; + artifactId?: string; + storage_path?: string; + uri?: string; + download_url?: string; + artifact_type?: string; + artifact_size?: string; +}; + +export const mockArtifactStorage = ({ + namespace = 'test', + artifactId = '1', + storage_path = 'metrics-visualization-pipeline/5e873c64-39fa-4dd4-83db-eff0cdd1e274/html-visualization/html_artifact', + uri = 's3://aballant-pipelines/metrics-visualization-pipeline/5e873c64-39fa-4dd4-83db-eff0cdd1e274/html-visualization/html_artifact', + download_url = 'https://test.s3.dualstack.us-east-1.amazonaws.com/metrics-visualization-pipeline/5e873c64-39fa-4dd4-83db-eff0cdd1e274/html-visualization/html_artifact?X-Amz-Algorithm=AWS4-HMAC-SHA256\u0026X-Amz-Credential=AKIAYQPE7PSILMBBLXMO%2F20240808%2Fus-east-1%2Fs3%2Faws4_request\u0026X-Amz-Date=20240808T070034Z\u0026X-Amz-Expires=15\u0026X-Amz-SignedHeaders=host\u0026response-content-disposition=attachment%3B%20filename%3D%22%22\u0026X-Amz-Signature=de39ee684dd606e75da3b07c1b9f0820f7442ea7a037ae1bffccea9e33610ea9', + artifact_type = 'system.Markdown', + artifact_size = '61', +}: MockArtifactStorageType): ArtifactStorage => ({ + artifact_id: artifactId, + storage_provider: 's3', + storage_path, + uri, + download_url, + namespace, + artifact_size, + artifact_type, + created_at: '2024-06-19T12:27:19.827Z', + last_updated_at: '2024-06-19T12:27:19.827Z', +}); diff --git a/frontend/src/__mocks__/mockDashboardConfig.ts b/frontend/src/__mocks__/mockDashboardConfig.ts index 805dc08630..3d6bd168c7 100644 --- a/frontend/src/__mocks__/mockDashboardConfig.ts +++ b/frontend/src/__mocks__/mockDashboardConfig.ts @@ -23,8 +23,6 @@ type MockDashboardConfigType = { disablePerformanceMetrics?: boolean; disableBiasMetrics?: boolean; disablePipelineExperiments?: boolean; - disableS3Endpoint?: boolean; - disableArtifactsAPI?: boolean; disableDistributedWorkloads?: boolean; disableModelRegistry?: boolean; disableConnectionTypes?: boolean; @@ -54,8 +52,6 @@ export const mockDashboardConfig = ({ disablePerformanceMetrics = false, disableBiasMetrics = false, disablePipelineExperiments = false, - disableS3Endpoint = true, - disableArtifactsAPI = true, disableDistributedWorkloads = false, disableModelRegistry = true, disableConnectionTypes = true, @@ -162,8 +158,6 @@ export const mockDashboardConfig = ({ disableModelMesh, disableAcceleratorProfiles, disablePipelineExperiments, - disableS3Endpoint, - disableArtifactsAPI, disableDistributedWorkloads, disableModelRegistry, disableConnectionTypes, diff --git a/frontend/src/__mocks__/mockModelArtifact.ts b/frontend/src/__mocks__/mockModelArtifact.ts index 3bf055eb33..162a3af85e 100644 --- a/frontend/src/__mocks__/mockModelArtifact.ts +++ b/frontend/src/__mocks__/mockModelArtifact.ts @@ -1,6 +1,6 @@ import { ModelArtifact } from '~/concepts/modelRegistry/types'; -export const mockModelArtifact = (): ModelArtifact => ({ +export const mockModelArtifact = (partial?: Partial): ModelArtifact => ({ createTimeSinceEpoch: '1712234877179', id: '1', lastUpdateTimeSinceEpoch: '1712234877179', @@ -8,6 +8,10 @@ export const mockModelArtifact = (): ModelArtifact => ({ description: 'Description of model version', artifactType: 'model-artifact', customProperties: {}, + storageKey: 'test storage key', storagePath: 'test path', - uri: 'https://huggingface.io/mnist.onnx', + uri: 's3://test-bucket/demo-models/test-path?endpoint=test-endpoint&defaultRegion=test-region', + modelFormatName: 'test model format', + modelFormatVersion: 'test version 1', + ...partial, }); diff --git a/frontend/src/__mocks__/mockModelArtifactList.ts b/frontend/src/__mocks__/mockModelArtifactList.ts index 6e4e3edcef..da368c1dad 100644 --- a/frontend/src/__mocks__/mockModelArtifactList.ts +++ b/frontend/src/__mocks__/mockModelArtifactList.ts @@ -2,8 +2,10 @@ import { ModelArtifactList } from '~/concepts/modelRegistry/types'; import { mockModelArtifact } from './mockModelArtifact'; -export const mockModelArtifactList = (): ModelArtifactList => ({ - items: [mockModelArtifact()], +export const mockModelArtifactList = ({ + items = [mockModelArtifact()], +}: Partial): ModelArtifactList => ({ + items, nextPageToken: '', pageSize: 0, size: 1, diff --git a/frontend/src/__mocks__/mockModelVersion.ts b/frontend/src/__mocks__/mockModelVersion.ts index 29396dab1f..3593a127e6 100644 --- a/frontend/src/__mocks__/mockModelVersion.ts +++ b/frontend/src/__mocks__/mockModelVersion.ts @@ -9,6 +9,7 @@ type MockModelVersionType = { labels?: string[]; state?: ModelState; description?: string; + createTimeSinceEpoch?: string; }; export const mockModelVersion = ({ @@ -19,9 +20,10 @@ export const mockModelVersion = ({ id = '1', state = ModelState.LIVE, description = 'Description of model version', + createTimeSinceEpoch = '1712234877179', }: MockModelVersionType): ModelVersion => ({ author, - createTimeSinceEpoch: '1712234877179', + createTimeSinceEpoch, customProperties: createModelRegistryLabelsObject(labels), id, lastUpdateTimeSinceEpoch: '1712234877179', diff --git a/frontend/src/__mocks__/mockPipelineVersionsProxy.ts b/frontend/src/__mocks__/mockPipelineVersionsProxy.ts index bc04835828..a534dc88e8 100644 --- a/frontend/src/__mocks__/mockPipelineVersionsProxy.ts +++ b/frontend/src/__mocks__/mockPipelineVersionsProxy.ts @@ -869,3 +869,289 @@ export const mockArgoWorkflowPipelineVersion = ({ }, }, }); +export const mockMetricsVisualizationVersion: PipelineVersionKFv2 = { + pipeline_id: 'metrics-pipeline', + pipeline_version_id: 'metrics-pipeline-version', + display_name: 'metrics visualization', + created_at: '2024-06-19T11:28:05Z', + pipeline_spec: { + components: { + 'comp-digit-classification': { + executorLabel: 'exec-digit-classification', + outputDefinitions: { + artifacts: { + metrics: { + artifactType: { + schemaTitle: ArtifactType.METRICS, + schemaVersion: '0.0.1', + }, + }, + }, + }, + }, + 'comp-html-visualization': { + executorLabel: 'exec-html-visualization', + outputDefinitions: { + artifacts: { + html_artifact: { + artifactType: { + schemaTitle: ArtifactType.HTML, + schemaVersion: '0.0.1', + }, + }, + }, + }, + }, + 'comp-iris-sgdclassifier': { + executorLabel: 'exec-iris-sgdclassifier', + inputDefinitions: { + parameters: { + test_samples_fraction: { + parameterType: InputDefinitionParameterType.DOUBLE, + }, + }, + }, + outputDefinitions: { + artifacts: { + metrics: { + artifactType: { + schemaTitle: ArtifactType.CLASSIFICATION_METRICS, + schemaVersion: '0.0.1', + }, + }, + }, + }, + }, + 'comp-markdown-visualization': { + executorLabel: 'exec-markdown-visualization', + outputDefinitions: { + artifacts: { + markdown_artifact: { + artifactType: { + schemaTitle: ArtifactType.MARKDOWN, + schemaVersion: '0.0.1', + }, + }, + }, + }, + }, + 'comp-wine-classification': { + executorLabel: 'exec-wine-classification', + outputDefinitions: { + artifacts: { + metrics: { + artifactType: { + schemaTitle: ArtifactType.CLASSIFICATION_METRICS, + schemaVersion: '0.0.1', + }, + }, + }, + }, + }, + }, + deploymentSpec: { + executors: { + 'exec-digit-classification': { + container: { + args: ['--executor_input', '{{$}}', '--function_to_execute', 'digit_classification'], + command: [ + 'sh', + '-c', + '\nif ! [ -x "$(command -v pip)" ]; then\n python3 -m ensurepip || python3 -m ensurepip --user || apt-get install python3-pip\nfi\n\nPIP_DISABLE_PIP_VERSION_CHECK=1 python3 -m pip install --quiet --no-warn-script-location \'kfp==2.7.0\' \'--no-deps\' \'typing-extensions\u003e=3.7.4,\u003c5; python_version\u003c"3.9"\' \u0026\u0026 python3 -m pip install --quiet --no-warn-script-location \'scikit-learn\' \u0026\u0026 "$0" "$@"\n', + 'sh', + '-ec', + 'program_path=$(mktemp -d)\n\nprintf "%s" "$0" \u003e "$program_path/ephemeral_component.py"\n_KFP_RUNTIME=true python3 -m kfp.dsl.executor_main --component_module_path "$program_path/ephemeral_component.py" "$@"\n', + "\nimport kfp\nfrom kfp import dsl\nfrom kfp.dsl import *\nfrom typing import *\n\ndef digit_classification(metrics: Output[Metrics]):\n from sklearn import model_selection\n from sklearn.linear_model import LogisticRegression\n from sklearn import datasets\n from sklearn.metrics import accuracy_score\n\n # Load digits dataset\n iris = datasets.load_iris()\n\n # # Create feature matrix\n X = iris.data\n\n # Create target vector\n y = iris.target\n\n #test size\n test_size = 0.33\n\n seed = 7\n #cross-validation settings\n kfold = model_selection.KFold(n_splits=10, random_state=seed, shuffle=True)\n\n #Model instance\n model = LogisticRegression()\n scoring = 'accuracy'\n results = model_selection.cross_val_score(model, X, y, cv=kfold, scoring=scoring)\n\n #split data\n X_train, X_test, y_train, y_test = model_selection.train_test_split(X, y, test_size=test_size, random_state=seed)\n #fit model\n model.fit(X_train, y_train)\n\n #accuracy on test set\n result = model.score(X_test, y_test)\n metrics.log_metric('accuracy', (result*100.0))\n\n", + ], + image: 'ubi8/python-39:latest', + }, + }, + 'exec-html-visualization': { + container: { + args: ['--executor_input', '{{$}}', '--function_to_execute', 'html_visualization'], + command: [ + 'sh', + '-c', + '\nif ! [ -x "$(command -v pip)" ]; then\n python3 -m ensurepip || python3 -m ensurepip --user || apt-get install python3-pip\nfi\n\nPIP_DISABLE_PIP_VERSION_CHECK=1 python3 -m pip install --quiet --no-warn-script-location \'kfp==2.7.0\' \'--no-deps\' \'typing-extensions\u003e=3.7.4,\u003c5; python_version\u003c"3.9"\' \u0026\u0026 "$0" "$@"\n', + 'sh', + '-ec', + 'program_path=$(mktemp -d)\n\nprintf "%s" "$0" \u003e "$program_path/ephemeral_component.py"\n_KFP_RUNTIME=true python3 -m kfp.dsl.executor_main --component_module_path "$program_path/ephemeral_component.py" "$@"\n', + "\nimport kfp\nfrom kfp import dsl\nfrom kfp.dsl import *\nfrom typing import *\n\ndef html_visualization(html_artifact: Output[HTML]):\n html_content = '\u003c!DOCTYPE html\u003e\u003chtml\u003e\u003cbody\u003e\u003ch1\u003eHello world\u003c/h1\u003e\u003c/body\u003e\u003c/html\u003e'\n with open(html_artifact.path, 'w') as f:\n f.write(html_content)\n\n", + ], + image: 'python:3.7', + }, + }, + 'exec-iris-sgdclassifier': { + container: { + args: ['--executor_input', '{{$}}', '--function_to_execute', 'iris_sgdclassifier'], + command: [ + 'sh', + '-c', + '\nif ! [ -x "$(command -v pip)" ]; then\n python3 -m ensurepip || python3 -m ensurepip --user || apt-get install python3-pip\nfi\n\nPIP_DISABLE_PIP_VERSION_CHECK=1 python3 -m pip install --quiet --no-warn-script-location \'kfp==2.7.0\' \'--no-deps\' \'typing-extensions\u003e=3.7.4,\u003c5; python_version\u003c"3.9"\' \u0026\u0026 python3 -m pip install --quiet --no-warn-script-location \'scikit-learn\' \u0026\u0026 "$0" "$@"\n', + 'sh', + '-ec', + 'program_path=$(mktemp -d)\n\nprintf "%s" "$0" \u003e "$program_path/ephemeral_component.py"\n_KFP_RUNTIME=true python3 -m kfp.dsl.executor_main --component_module_path "$program_path/ephemeral_component.py" "$@"\n', + "\nimport kfp\nfrom kfp import dsl\nfrom kfp.dsl import *\nfrom typing import *\n\ndef iris_sgdclassifier(test_samples_fraction: float, metrics: Output[ClassificationMetrics]):\n from sklearn import datasets, model_selection\n from sklearn.linear_model import SGDClassifier\n from sklearn.metrics import confusion_matrix\n\n iris_dataset = datasets.load_iris()\n train_x, test_x, train_y, test_y = model_selection.train_test_split(\n iris_dataset['data'], iris_dataset['target'], test_size=test_samples_fraction)\n\n\n classifier = SGDClassifier()\n classifier.fit(train_x, train_y)\n predictions = model_selection.cross_val_predict(classifier, train_x, train_y, cv=3)\n metrics.log_confusion_matrix(\n ['Setosa', 'Versicolour', 'Virginica'],\n confusion_matrix(train_y, predictions).tolist() # .tolist() to convert np array to list.\n )\n\n", + ], + image: 'ubi8/python-39:latest', + }, + }, + 'exec-markdown-visualization': { + container: { + args: ['--executor_input', '{{$}}', '--function_to_execute', 'markdown_visualization'], + command: [ + 'sh', + '-c', + '\nif ! [ -x "$(command -v pip)" ]; then\n python3 -m ensurepip || python3 -m ensurepip --user || apt-get install python3-pip\nfi\n\nPIP_DISABLE_PIP_VERSION_CHECK=1 python3 -m pip install --quiet --no-warn-script-location \'kfp==2.7.0\' \'--no-deps\' \'typing-extensions\u003e=3.7.4,\u003c5; python_version\u003c"3.9"\' \u0026\u0026 "$0" "$@"\n', + 'sh', + '-ec', + 'program_path=$(mktemp -d)\n\nprintf "%s" "$0" \u003e "$program_path/ephemeral_component.py"\n_KFP_RUNTIME=true python3 -m kfp.dsl.executor_main --component_module_path "$program_path/ephemeral_component.py" "$@"\n', + "\nimport kfp\nfrom kfp import dsl\nfrom kfp.dsl import *\nfrom typing import *\n\ndef markdown_visualization(markdown_artifact: Output[Markdown]):\n markdown_content = '## Hello world \\n\\n Markdown content'\n with open(markdown_artifact.path, 'w') as f:\n f.write(markdown_content)\n\n", + ], + image: 'python:3.7', + }, + }, + 'exec-wine-classification': { + container: { + args: ['--executor_input', '{{$}}', '--function_to_execute', 'wine_classification'], + command: [ + 'sh', + '-c', + '\nif ! [ -x "$(command -v pip)" ]; then\n python3 -m ensurepip || python3 -m ensurepip --user || apt-get install python3-pip\nfi\n\nPIP_DISABLE_PIP_VERSION_CHECK=1 python3 -m pip install --quiet --no-warn-script-location \'kfp==2.7.0\' \'--no-deps\' \'typing-extensions\u003e=3.7.4,\u003c5; python_version\u003c"3.9"\' \u0026\u0026 python3 -m pip install --quiet --no-warn-script-location \'scikit-learn\' \u0026\u0026 "$0" "$@"\n', + 'sh', + '-ec', + 'program_path=$(mktemp -d)\n\nprintf "%s" "$0" \u003e "$program_path/ephemeral_component.py"\n_KFP_RUNTIME=true python3 -m kfp.dsl.executor_main --component_module_path "$program_path/ephemeral_component.py" "$@"\n', + "\nimport kfp\nfrom kfp import dsl\nfrom kfp.dsl import *\nfrom typing import *\n\ndef wine_classification(metrics: Output[ClassificationMetrics]):\n from sklearn.ensemble import RandomForestClassifier\n from sklearn.metrics import roc_curve\n from sklearn.datasets import load_wine\n from sklearn.model_selection import train_test_split, cross_val_predict\n\n X, y = load_wine(return_X_y=True)\n # Binary classification problem for label 1.\n y = y == 1\n\n X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=42)\n rfc = RandomForestClassifier(n_estimators=10, random_state=42)\n rfc.fit(X_train, y_train)\n y_scores = cross_val_predict(rfc, X_train, y_train, cv=3, method='predict_proba')\n y_predict = cross_val_predict(rfc, X_train, y_train, cv=3, method='predict')\n fpr, tpr, thresholds = roc_curve(y_true=y_train, y_score=y_scores[:,1], pos_label=True)\n thresholds[0] = 2\n metrics.log_roc_curve(fpr, tpr, thresholds)\n\n", + ], + image: 'ubi8/python-39:latest', + }, + }, + }, + }, + pipelineInfo: { + name: 'metrics-visualization-pipeline', + }, + root: { + dag: { + outputs: { + artifacts: { + 'digit-classification-metrics': { + artifactSelectors: [ + { + outputArtifactKey: 'metrics', + producerSubtask: 'digit-classification', + }, + ], + }, + 'iris-sgdclassifier-metrics': { + artifactSelectors: [ + { + outputArtifactKey: 'metrics', + producerSubtask: 'iris-sgdclassifier', + }, + ], + }, + 'wine-classification-metrics': { + artifactSelectors: [ + { + outputArtifactKey: 'metrics', + producerSubtask: 'wine-classification', + }, + ], + }, + }, + }, + tasks: { + 'digit-classification': { + cachingOptions: { + enableCache: true, + }, + componentRef: { + name: 'comp-digit-classification', + }, + taskInfo: { + name: 'digit-classification', + }, + }, + 'html-visualization': { + cachingOptions: { + enableCache: true, + }, + componentRef: { + name: 'comp-html-visualization', + }, + taskInfo: { + name: 'html-visualization', + }, + }, + 'iris-sgdclassifier': { + cachingOptions: { + enableCache: true, + }, + componentRef: { + name: 'comp-iris-sgdclassifier', + }, + inputs: { + parameters: { + test_samples_fraction: { + runtimeValue: { + constant: '0.3', + }, + }, + }, + }, + taskInfo: { + name: 'iris-sgdclassifier', + }, + }, + 'markdown-visualization': { + cachingOptions: { + enableCache: true, + }, + componentRef: { + name: 'comp-markdown-visualization', + }, + taskInfo: { + name: 'markdown-visualization', + }, + }, + 'wine-classification': { + cachingOptions: { + enableCache: true, + }, + componentRef: { + name: 'comp-wine-classification', + }, + taskInfo: { + name: 'wine-classification', + }, + }, + }, + }, + outputDefinitions: { + artifacts: { + 'digit-classification-metrics': { + artifactType: { + schemaTitle: ArtifactType.METRICS, + schemaVersion: '0.0.1', + }, + }, + 'iris-sgdclassifier-metrics': { + artifactType: { + schemaTitle: ArtifactType.CLASSIFICATION_METRICS, + schemaVersion: '0.0.1', + }, + }, + 'wine-classification-metrics': { + artifactType: { + schemaTitle: ArtifactType.CLASSIFICATION_METRICS, + schemaVersion: '0.0.1', + }, + }, + }, + }, + }, + schemaVersion: '2.1.0', + sdkVersion: 'kfp-2.7.0', + }, +}; diff --git a/frontend/src/__mocks__/mockRegisteredModel.ts b/frontend/src/__mocks__/mockRegisteredModel.ts index 0ba8a8c70c..7a34f843d1 100644 --- a/frontend/src/__mocks__/mockRegisteredModel.ts +++ b/frontend/src/__mocks__/mockRegisteredModel.ts @@ -4,6 +4,7 @@ import { createModelRegistryLabelsObject } from './utils'; type MockRegisteredModelType = { id?: string; name?: string; + owner?: string; state?: ModelState; description?: string; labels?: string[]; @@ -11,6 +12,7 @@ type MockRegisteredModelType = { export const mockRegisteredModel = ({ name = 'test', + owner = 'Author 1', state = ModelState.LIVE, description = '', labels = [], @@ -23,5 +25,6 @@ export const mockRegisteredModel = ({ lastUpdateTimeSinceEpoch: '1710404288975', name, state, + owner, customProperties: createModelRegistryLabelsObject(labels), }); diff --git a/frontend/src/__mocks__/mockRunKF.ts b/frontend/src/__mocks__/mockRunKF.ts index 3c4b8642af..c50daa6fc9 100644 --- a/frontend/src/__mocks__/mockRunKF.ts +++ b/frontend/src/__mocks__/mockRunKF.ts @@ -177,3 +177,420 @@ export const buildMockRunKF = (run?: Partial): PipelineRunKFv2 ], ...run, }); + +export const mockMetricsVisualizationRun: PipelineRunKFv2 = { + experiment_id: '337b4750-40fa-4593-8c07-f80c542cbb7d', + run_id: 'test-metrics-pipeline-run', + display_name: 'test', + storage_state: StorageStateKF.AVAILABLE, + pipeline_version_reference: { + pipeline_id: 'metrics-pipeline', + pipeline_version_id: 'metrics-pipeline-version', + }, + service_account: 'pipeline-runner-dspa', + created_at: '2024-06-19T11:28:32Z', + scheduled_at: '2024-06-19T11:28:32Z', + finished_at: '2024-06-19T11:29:03Z', + state: RuntimeStateKF.SUCCEEDED, + run_details: { + task_details: [ + { + run_id: 'test-metrics-pipeline-run', + task_id: '0eca1834-a6cc-4dd5-b872-d3aa7b3ff6e8', + display_name: 'root-driver', + create_time: '2024-06-19T11:28:32Z', + start_time: '2024-06-19T11:28:32Z', + end_time: '2024-06-19T11:28:43Z', + state: RuntimeStateKF.SUCCEEDED, + state_history: [ + { + update_time: '2024-06-19T11:28:33Z', + state: RuntimeStateKF.PENDING, + }, + { + update_time: '2024-06-19T11:28:43Z', + state: RuntimeStateKF.RUNNING, + }, + { + update_time: '2024-06-19T11:28:54Z', + state: RuntimeStateKF.SUCCEEDED, + }, + ], + child_tasks: [ + { + pod_name: 'metrics-visualization-pipeline-wbfhf-2493393560', + }, + ], + }, + { + run_id: 'test-metrics-pipeline-run', + task_id: '19bd4da0-0550-4c94-b664-6c926dce8001', + display_name: 'iris-sgdclassifier', + create_time: '2024-06-19T11:28:32Z', + start_time: '2024-06-19T11:29:03Z', + end_time: '2024-06-19T11:29:03Z', + state: RuntimeStateKF.SUCCEEDED, + state_history: [ + { + update_time: '2024-06-19T11:29:04Z', + state: RuntimeStateKF.SUCCEEDED, + }, + ], + child_tasks: [ + { + pod_name: 'metrics-visualization-pipeline-wbfhf-4194317718', + }, + ], + }, + { + run_id: 'test-metrics-pipeline-run', + task_id: '37cd492c-a01d-4fa2-898b-7eb0e8b30419', + display_name: 'executor', + create_time: '2024-06-19T11:28:32Z', + start_time: '2024-06-19T11:29:03Z', + end_time: '2024-06-19T11:29:03Z', + state: RuntimeStateKF.SKIPPED, + state_history: [ + { + update_time: '2024-06-19T11:29:04Z', + state: RuntimeStateKF.SKIPPED, + }, + ], + }, + { + run_id: 'test-metrics-pipeline-run', + task_id: '3e119baf-3c52-450d-b4cc-2ab2bc4de10b', + display_name: 'digit-classification', + create_time: '2024-06-19T11:28:32Z', + start_time: '2024-06-19T11:29:03Z', + end_time: '2024-06-19T11:29:03Z', + state: RuntimeStateKF.SUCCEEDED, + state_history: [ + { + update_time: '2024-06-19T11:29:04Z', + state: RuntimeStateKF.SUCCEEDED, + }, + ], + child_tasks: [ + { + pod_name: 'metrics-visualization-pipeline-wbfhf-1101486161', + }, + ], + }, + { + run_id: 'test-metrics-pipeline-run', + task_id: '41252b5a-ac35-4a16-9450-6df59de90af1', + display_name: 'html-visualization-driver', + create_time: '2024-06-19T11:28:32Z', + start_time: '2024-06-19T11:28:53Z', + end_time: '2024-06-19T11:28:59Z', + state: RuntimeStateKF.SUCCEEDED, + state_history: [ + { + update_time: '2024-06-19T11:28:54Z', + state: RuntimeStateKF.PENDING, + }, + { + update_time: '2024-06-19T11:29:04Z', + state: RuntimeStateKF.SUCCEEDED, + }, + ], + child_tasks: [ + { + pod_name: 'metrics-visualization-pipeline-wbfhf-1024349010', + }, + ], + }, + { + run_id: 'test-metrics-pipeline-run', + task_id: '42a1b696-462e-4d04-a1d8-42a78f32a3d4', + display_name: 'iris-sgdclassifier-driver', + create_time: '2024-06-19T11:28:32Z', + start_time: '2024-06-19T11:28:53Z', + end_time: '2024-06-19T11:28:57Z', + state: RuntimeStateKF.SUCCEEDED, + state_history: [ + { + update_time: '2024-06-19T11:28:54Z', + state: RuntimeStateKF.PENDING, + }, + { + update_time: '2024-06-19T11:29:04Z', + state: RuntimeStateKF.SUCCEEDED, + }, + ], + child_tasks: [ + { + pod_name: 'metrics-visualization-pipeline-wbfhf-2388146295', + }, + ], + }, + { + run_id: 'test-metrics-pipeline-run', + task_id: '46877f5d-bc58-4a3f-bd16-142c89bb3472', + display_name: 'executor', + create_time: '2024-06-19T11:28:32Z', + start_time: '2024-06-19T11:29:03Z', + end_time: '2024-06-19T11:29:03Z', + state: RuntimeStateKF.SKIPPED, + state_history: [ + { + update_time: '2024-06-19T11:29:04Z', + state: RuntimeStateKF.SKIPPED, + }, + ], + }, + { + run_id: 'test-metrics-pipeline-run', + task_id: '7809de66-7d83-44f4-ac27-6c1a49a1fe9a', + display_name: 'executor', + create_time: '2024-06-19T11:28:32Z', + start_time: '2024-06-19T11:29:03Z', + end_time: '2024-06-19T11:29:03Z', + state: RuntimeStateKF.SKIPPED, + state_history: [ + { + update_time: '2024-06-19T11:29:04Z', + state: RuntimeStateKF.SKIPPED, + }, + ], + }, + { + run_id: 'test-metrics-pipeline-run', + task_id: '9d9b708d-7c6e-43a3-9d47-037ee35d07ec', + display_name: 'metrics-visualization-pipeline-wbfhf', + create_time: '2024-06-19T11:28:32Z', + start_time: '2024-06-19T11:28:32Z', + end_time: '2024-06-19T11:29:03Z', + state: RuntimeStateKF.SUCCEEDED, + state_history: [ + { + update_time: '2024-06-19T11:28:33Z', + state: RuntimeStateKF.RUNNING, + }, + { + update_time: '2024-06-19T11:29:04Z', + state: RuntimeStateKF.SUCCEEDED, + }, + ], + child_tasks: [ + { + pod_name: 'metrics-visualization-pipeline-wbfhf-2043659685', + }, + ], + }, + { + run_id: 'test-metrics-pipeline-run', + task_id: '9f3c7a93-3b9c-4830-9ec6-968a470c61ac', + display_name: 'wine-classification-driver', + create_time: '2024-06-19T11:28:32Z', + start_time: '2024-06-19T11:28:53Z', + end_time: '2024-06-19T11:28:59Z', + state: RuntimeStateKF.SUCCEEDED, + state_history: [ + { + update_time: '2024-06-19T11:28:54Z', + state: RuntimeStateKF.PENDING, + }, + { + update_time: '2024-06-19T11:29:04Z', + state: RuntimeStateKF.SUCCEEDED, + }, + ], + child_tasks: [ + { + pod_name: 'metrics-visualization-pipeline-wbfhf-232158710', + }, + ], + }, + { + run_id: 'test-metrics-pipeline-run', + task_id: 'a1920cb4-cf8c-4d2b-8ec5-b92ac29d732f', + display_name: 'markdown-visualization-driver', + create_time: '2024-06-19T11:28:32Z', + start_time: '2024-06-19T11:28:53Z', + end_time: '2024-06-19T11:28:59Z', + state: RuntimeStateKF.SUCCEEDED, + state_history: [ + { + update_time: '2024-06-19T11:28:54Z', + state: RuntimeStateKF.PENDING, + }, + { + update_time: '2024-06-19T11:29:04Z', + state: RuntimeStateKF.SUCCEEDED, + }, + ], + child_tasks: [ + { + pod_name: 'metrics-visualization-pipeline-wbfhf-2636276234', + }, + ], + }, + { + run_id: 'test-metrics-pipeline-run', + task_id: 'a2b45cc2-d999-4af3-b4f8-f7213f7c7b7d', + display_name: 'markdown-visualization', + create_time: '2024-06-19T11:28:32Z', + start_time: '2024-06-19T11:29:03Z', + end_time: '2024-06-19T11:29:03Z', + state: RuntimeStateKF.SUCCEEDED, + state_history: [ + { + update_time: '2024-06-19T11:29:04Z', + state: RuntimeStateKF.SUCCEEDED, + }, + ], + child_tasks: [ + { + pod_name: 'metrics-visualization-pipeline-wbfhf-522038993', + }, + ], + }, + { + run_id: 'test-metrics-pipeline-run', + task_id: 'cb3c1755-25ee-4772-bfda-60524dfeafea', + display_name: 'executor', + create_time: '2024-06-19T11:28:32Z', + start_time: '2024-06-19T11:29:03Z', + end_time: '2024-06-19T11:29:03Z', + state: RuntimeStateKF.SKIPPED, + state_history: [ + { + update_time: '2024-06-19T11:29:04Z', + state: RuntimeStateKF.SKIPPED, + }, + ], + }, + { + run_id: 'test-metrics-pipeline-run', + task_id: 'cff9af15-9a73-4cc7-acc1-41be78f6cf2f', + display_name: 'root', + create_time: '2024-06-19T11:28:32Z', + start_time: '2024-06-19T11:28:53Z', + end_time: '2024-06-19T11:29:03Z', + state: RuntimeStateKF.SUCCEEDED, + state_history: [ + { + update_time: '2024-06-19T11:28:54Z', + state: RuntimeStateKF.RUNNING, + }, + { + update_time: '2024-06-19T11:29:04Z', + state: RuntimeStateKF.SUCCEEDED, + }, + ], + child_tasks: [ + { + pod_name: 'metrics-visualization-pipeline-wbfhf-3563862527', + }, + { + pod_name: 'metrics-visualization-pipeline-wbfhf-1985932151', + }, + { + pod_name: 'metrics-visualization-pipeline-wbfhf-3374311824', + }, + { + pod_name: 'metrics-visualization-pipeline-wbfhf-3118730367', + }, + { + pod_name: 'metrics-visualization-pipeline-wbfhf-1337836723', + }, + ], + }, + { + run_id: 'test-metrics-pipeline-run', + task_id: 'd7851ef9-d74d-4c54-899d-5a2f7b787347', + display_name: 'html-visualization', + create_time: '2024-06-19T11:28:32Z', + start_time: '2024-06-19T11:29:03Z', + end_time: '2024-06-19T11:29:03Z', + state: RuntimeStateKF.SUCCEEDED, + state_history: [ + { + update_time: '2024-06-19T11:29:04Z', + state: RuntimeStateKF.SUCCEEDED, + }, + ], + child_tasks: [ + { + pod_name: 'metrics-visualization-pipeline-wbfhf-378883961', + }, + ], + }, + { + run_id: 'test-metrics-pipeline-run', + task_id: 'de0741cd-b2b6-4876-b819-31bcd7ead154', + display_name: 'digit-classification-driver', + create_time: '2024-06-19T11:28:32Z', + start_time: '2024-06-19T11:28:53Z', + end_time: '2024-06-19T11:28:57Z', + state: RuntimeStateKF.SUCCEEDED, + state_history: [ + { + update_time: '2024-06-19T11:28:54Z', + state: RuntimeStateKF.PENDING, + }, + { + update_time: '2024-06-19T11:29:04Z', + state: RuntimeStateKF.SUCCEEDED, + }, + ], + child_tasks: [ + { + pod_name: 'metrics-visualization-pipeline-wbfhf-1495298698', + }, + ], + }, + { + run_id: 'test-metrics-pipeline-run', + task_id: 'e1b44a5c-37c8-478b-a6bc-9f788d3c2027', + display_name: 'wine-classification', + create_time: '2024-06-19T11:28:32Z', + start_time: '2024-06-19T11:29:03Z', + end_time: '2024-06-19T11:29:03Z', + state: RuntimeStateKF.SUCCEEDED, + state_history: [ + { + update_time: '2024-06-19T11:29:04Z', + state: RuntimeStateKF.SUCCEEDED, + }, + ], + child_tasks: [ + { + pod_name: 'metrics-visualization-pipeline-wbfhf-1000874645', + }, + ], + }, + { + run_id: 'test-metrics-pipeline-run', + task_id: 'f06409c9-0305-4187-a878-ba4996ffc5ca', + display_name: 'executor', + create_time: '2024-06-19T11:28:32Z', + start_time: '2024-06-19T11:29:03Z', + end_time: '2024-06-19T11:29:03Z', + state: RuntimeStateKF.SKIPPED, + state_history: [ + { + update_time: '2024-06-19T11:29:04Z', + state: RuntimeStateKF.SKIPPED, + }, + ], + }, + ], + }, + state_history: [ + { + update_time: '2024-06-19T11:28:32Z', + state: RuntimeStateKF.PENDING, + }, + { + update_time: '2024-06-19T11:28:33Z', + state: RuntimeStateKF.RUNNING, + }, + { + update_time: '2024-06-19T11:29:04Z', + state: RuntimeStateKF.SUCCEEDED, + }, + ], +}; diff --git a/frontend/src/__mocks__/mockSecretK8sResource.ts b/frontend/src/__mocks__/mockSecretK8sResource.ts index 3a787ee31c..90baaae2a5 100644 --- a/frontend/src/__mocks__/mockSecretK8sResource.ts +++ b/frontend/src/__mocks__/mockSecretK8sResource.ts @@ -6,6 +6,8 @@ type MockResourceConfigType = { namespace?: string; displayName?: string; s3Bucket?: string; + endPoint?: string; + region?: string; uid?: string; }; @@ -13,7 +15,9 @@ export const mockSecretK8sResource = ({ name = 'test-secret', namespace = 'test-project', displayName = 'Test Secret', - s3Bucket = 'test-bucket', + s3Bucket = 'dGVzdC1idWNrZXQ=', + endPoint = 'aHR0cHM6Ly9zMy5hbWF6b25hd3MuY29tLw==', + region = 'dXMtZWFzdC0x', uid = genUID('secret'), }: MockResourceConfigType): SecretKind => ({ kind: 'Secret', @@ -35,9 +39,9 @@ export const mockSecretK8sResource = ({ }, data: { AWS_ACCESS_KEY_ID: 'c2RzZA==', - AWS_DEFAULT_REGION: 'dXMtZWFzdC0x', + AWS_DEFAULT_REGION: region, AWS_S3_BUCKET: s3Bucket, - AWS_S3_ENDPOINT: 'aHR0cHM6Ly9zMy5hbWF6b25hd3MuY29tLw==', + AWS_S3_ENDPOINT: endPoint, AWS_SECRET_ACCESS_KEY: 'c2RzZA==', }, type: 'Opaque', diff --git a/frontend/src/__tests__/cypress/cypress/pages/acceleratorProfile.ts b/frontend/src/__tests__/cypress/cypress/pages/acceleratorProfile.ts index 707c5ea0bf..ba103e5b35 100644 --- a/frontend/src/__tests__/cypress/cypress/pages/acceleratorProfile.ts +++ b/frontend/src/__tests__/cypress/cypress/pages/acceleratorProfile.ts @@ -170,19 +170,19 @@ class TolerationsModal extends Modal { } findOperatorOptionExist() { - return this.findTolerationOperatorSelect().findDropdownItem( + return this.findTolerationOperatorSelect().findSelectOption( 'Exists A toleration "matches" a taint if the keys are the same and the effects are the same. No value should be specified.', ); } findOperatorOptionEqual() { - return this.findTolerationOperatorSelect().findDropdownItem( + return this.findTolerationOperatorSelect().findSelectOption( 'Equal A toleration "matches" a taint if the keys are the same, the effects are the same, and the values are equal.', ); } findEffectOptionNoExecute() { - return this.findTolerationEffectSelect().findDropdownItem( + return this.findTolerationEffectSelect().findSelectOption( 'NoExecute Pods will be evicted from the node if they do not tolerate the taint.', ); } diff --git a/frontend/src/__tests__/cypress/cypress/pages/clusterStorage.ts b/frontend/src/__tests__/cypress/cypress/pages/clusterStorage.ts index c98adc0e50..a9611a8ba8 100644 --- a/frontend/src/__tests__/cypress/cypress/pages/clusterStorage.ts +++ b/frontend/src/__tests__/cypress/cypress/pages/clusterStorage.ts @@ -29,7 +29,7 @@ class ClusterStorageModal extends Modal { findWorkbenchConnectionSelect() { return this.find() .findByTestId('connect-existing-workbench-group') - .findByRole('button', { name: 'Options menu' }); + .findByRole('button', { name: 'Typeahead menu toggle' }); } findMountField() { diff --git a/frontend/src/__tests__/cypress/cypress/pages/components/TableToolbar.ts b/frontend/src/__tests__/cypress/cypress/pages/components/TableToolbar.ts index a005266550..53bfd3aded 100644 --- a/frontend/src/__tests__/cypress/cypress/pages/components/TableToolbar.ts +++ b/frontend/src/__tests__/cypress/cypress/pages/components/TableToolbar.ts @@ -6,7 +6,7 @@ export class TableToolbar extends Contextual { } findFilterMenuOption(id: string, name: string): Cypress.Chainable> { - return this.findToggleButton(id).parents().findByRole('menuitem', { name }); + return this.findToggleButton(id).parents().findByRole('option', { name }); } findSearchInput(): Cypress.Chainable> { diff --git a/frontend/src/__tests__/cypress/cypress/pages/connectionTypes.ts b/frontend/src/__tests__/cypress/cypress/pages/connectionTypes.ts new file mode 100644 index 0000000000..f6e964cd65 --- /dev/null +++ b/frontend/src/__tests__/cypress/cypress/pages/connectionTypes.ts @@ -0,0 +1,205 @@ +import { appChrome } from '~/__tests__/cypress/cypress/pages/appChrome'; +import { TableRow } from './components/table'; +import { TableToolbar } from './components/TableToolbar'; + +class CreateConnectionTypeTableRow extends TableRow { + findSectionHeading() { + return this.find().findByTestId('section-heading'); + } + + findName() { + return this.find().findByTestId('field-name'); + } + + findType() { + return this.find().findByTestId('field-type'); + } + + findDefault() { + return this.find().findByTestId('field-default'); + } + + findEnvVar() { + return this.find().findByTestId('field-env'); + } + + findRequired() { + return this.find().findByTestId('field-required'); + } + + dragToIndex(i: number) { + const dataTransfer = new DataTransfer(); + this.find().trigger('dragstart', { dataTransfer }); + createConnectionTypePage + .getFieldsTableRow(i) + .find() + .trigger('dragover', { dataTransfer }) + .trigger('drop', { dataTransfer }) + .trigger('dragend'); + } +} + +class CreateConnectionTypePage { + visitCreatePage() { + cy.visitWithLogin('/connectionTypes/create'); + cy.findAllByText('Create connection type').should('exist'); + } + + visitDuplicatePage(name = 'existing') { + cy.visitWithLogin(`/connectionTypes/duplicate/${name}`); + cy.findAllByText('Create connection type').should('exist'); + } + + visitEditPage(name = 'existing') { + cy.visitWithLogin(`/connectionTypes/edit/${name}`); + cy.findAllByText('Create connection type').should('exist'); + } + + findConnectionTypeName() { + return cy.findByTestId('connection-type-name'); + } + + findConnectionTypeDesc() { + return cy.findByTestId('connection-type-description'); + } + + findConnectionTypeEnableCheckbox() { + return cy.findByTestId('connection-type-enable'); + } + + findConnectionTypePreviewToggle() { + return cy.findByTestId('preview-drawer-toggle-button'); + } + + findFieldsTable() { + return cy.findByTestId('connection-type-fields-table'); + } + + findAllFieldsTableRows() { + return this.findFieldsTable().findAllByTestId('row'); + } + + getFieldsTableRow(index: number) { + return new CreateConnectionTypeTableRow(() => this.findAllFieldsTableRows().eq(index)); + } + + findSubmitButton() { + return cy.findByTestId('submit-button'); + } +} + +class ConnectionTypesTableToolbar extends TableToolbar {} +class ConnectionTypeRow extends TableRow { + findConnectionTypeName() { + return this.find().findByTestId('connection-type-name'); + } + + shouldHaveName(name: string) { + return this.findConnectionTypeName().should('have.text', name); + } + + findConnectionTypeDescription() { + return this.find().findByTestId('table-row-title-description'); + } + + findConnectionTypeCreator() { + return this.find().findByTestId('connection-type-creator'); + } + + shouldHaveDescription(description: string) { + return this.findConnectionTypeDescription().should('have.text', description); + } + + shouldHaveCreator(creator: string) { + return this.findConnectionTypeCreator().should('have.text', creator); + } + + shouldShowPreInstalledLabel() { + return this.find().findByTestId('connection-type-user-label').should('exist'); + } + + findEnabled() { + return this.find().pfSwitchValue('connection-type-enable-switch'); + } + + findEnableSwitch() { + return this.find().pfSwitch('connection-type-enable-switch'); + } + + findEnableSwitchInput() { + return this.find().findByTestId('connection-type-enable-switch'); + } + + shouldBeEnabled() { + this.findEnabled().should('be.checked'); + } + + shouldBeDisabled() { + this.findEnabled().should('not.be.checked'); + } + + findEnableStatus() { + return this.find().findByTestId('connection-type-enable-status'); + } +} + +class ConnectionTypesPage { + visit() { + cy.visitWithLogin('/connectionTypes'); + this.wait(); + } + + private wait() { + cy.findByTestId('app-page-title'); + cy.testA11y(); + } + + findNavItem() { + return appChrome.findNavItem('Connection types'); + } + + navigate() { + this.findNavItem().click(); + this.wait(); + } + + shouldHaveConnectionTypes() { + this.findTable().should('exist'); + return this; + } + + shouldReturnNotFound() { + cy.findByTestId('not-found-page').should('exist'); + return this; + } + + shouldBeEmpty() { + cy.findByTestId('connection-types-empty-state').should('exist'); + return this; + } + + findTable() { + return cy.findByTestId('connection-types-table'); + } + + getConnectionTypeRow(name: string) { + return new ConnectionTypeRow(() => + this.findTable().findAllByTestId(`table-row-title`).contains(name).parents('tr'), + ); + } + + findEmptyFilterResults() { + return cy.findByTestId('no-result-found-title'); + } + + findSortButton(name: string) { + return this.findTable().find('thead').findByRole('button', { name }); + } + + getTableToolbar() { + return new ConnectionTypesTableToolbar(() => cy.findByTestId('connection-types-table-toolbar')); + } +} + +export const connectionTypesPage = new ConnectionTypesPage(); +export const createConnectionTypePage = new CreateConnectionTypePage(); diff --git a/frontend/src/__tests__/cypress/cypress/pages/dataConnection.ts b/frontend/src/__tests__/cypress/cypress/pages/dataConnection.ts index 93266f4247..1dcba70dee 100644 --- a/frontend/src/__tests__/cypress/cypress/pages/dataConnection.ts +++ b/frontend/src/__tests__/cypress/cypress/pages/dataConnection.ts @@ -36,7 +36,7 @@ class DataConnectionModal extends Modal { findWorkbenchConnectionSelect() { return cy .findByTestId('connect-existing-workbench-group') - .findByRole('button', { name: 'Options menu' }); + .findByRole('button', { name: 'Notebook select' }); } findNotebookRestartAlert() { diff --git a/frontend/src/__tests__/cypress/cypress/pages/modelMetrics.ts b/frontend/src/__tests__/cypress/cypress/pages/modelMetrics.ts index 720ec3508c..9b1ea5c601 100644 --- a/frontend/src/__tests__/cypress/cypress/pages/modelMetrics.ts +++ b/frontend/src/__tests__/cypress/cypress/pages/modelMetrics.ts @@ -96,6 +96,10 @@ class ModelMetricsBias extends ModelMetricsGlobal { return cy.findByTestId('bias-metric-config-toolbar').find('#bias-metric-config-selector'); } + selectMetric(name: string) { + cy.findByRole('option', { name }).click(); + } + shouldNotBeConfigured() { cy.findByTestId('bias-metrics-empty-state').should('exist'); } diff --git a/frontend/src/__tests__/cypress/cypress/pages/modelRegistry.ts b/frontend/src/__tests__/cypress/cypress/pages/modelRegistry.ts index 88688563d9..1b9c2a1d9c 100644 --- a/frontend/src/__tests__/cypress/cypress/pages/modelRegistry.ts +++ b/frontend/src/__tests__/cypress/cypress/pages/modelRegistry.ts @@ -66,7 +66,7 @@ class ModelRegistry { private wait() { cy.findByTestId('app-page-title').should('exist'); - cy.findByTestId('app-page-title').contains('Registered models'); + cy.findByTestId('app-page-title').contains('Model registry'); cy.testA11y(); } @@ -79,6 +79,10 @@ class ModelRegistry { return this; } + findModelRegistryEmptyState() { + return cy.findByTestId('empty-model-registries-state'); + } + shouldregisteredModelsEmpty() { cy.findByTestId('empty-registered-models').should('exist'); } @@ -88,7 +92,7 @@ class ModelRegistry { } shouldModelRegistrySelectorExist() { - cy.get('#model-registry-selector-dropdown').should('exist'); + cy.findByTestId('model-registry-selector-dropdown').should('exist'); } shouldtableToolbarExist() { @@ -140,6 +144,10 @@ class ModelRegistry { return this.findTable().find('thead').findByRole('button', { name }); } + findModelRegistry() { + return cy.findByTestId('model-registry-selector-dropdown'); + } + findModelVersionsTableHeaderButton(name: string) { return this.findModelVersionsTable().find('thead').findByRole('button', { name }); } diff --git a/frontend/src/__tests__/cypress/cypress/pages/modelRegistry/modelVersionDeployModal.ts b/frontend/src/__tests__/cypress/cypress/pages/modelRegistry/modelVersionDeployModal.ts new file mode 100644 index 0000000000..383ab38c3c --- /dev/null +++ b/frontend/src/__tests__/cypress/cypress/pages/modelRegistry/modelVersionDeployModal.ts @@ -0,0 +1,17 @@ +import { Modal } from '~/__tests__/cypress/cypress/pages/components/Modal'; + +class ModelVersionDeployModal extends Modal { + constructor() { + super('Deploy model'); + } + + findProjectSelector() { + return cy.findByTestId('deploy-model-project-selector'); + } + + selectProjectByName(name: string) { + this.findProjectSelector().findDropdownItem(name).click(); + } +} + +export const modelVersionDeployModal = new ModelVersionDeployModal(); diff --git a/frontend/src/__tests__/cypress/cypress/pages/modelRegistry/modelVersionDetails.ts b/frontend/src/__tests__/cypress/cypress/pages/modelRegistry/modelVersionDetails.ts index 4b096b245e..39dd95c138 100644 --- a/frontend/src/__tests__/cypress/cypress/pages/modelRegistry/modelVersionDetails.ts +++ b/frontend/src/__tests__/cypress/cypress/pages/modelRegistry/modelVersionDetails.ts @@ -46,6 +46,14 @@ class ModelVersionDetails { findModelVersionDropdownItem(name: string) { return cy.findByTestId('model-version-selector-list').find('li').contains(name); } + + findDetailsTab() { + return cy.findByTestId('model-versions-details-tab'); + } + + findRegisteredDeploymentsTab() { + return cy.findByTestId('deployments-tab'); + } } export const modelVersionDetails = new ModelVersionDetails(); diff --git a/frontend/src/__tests__/cypress/cypress/pages/modelRegistry/registerModelPage.ts b/frontend/src/__tests__/cypress/cypress/pages/modelRegistry/registerModelPage.ts index d420cdff09..41f007d7e3 100644 --- a/frontend/src/__tests__/cypress/cypress/pages/modelRegistry/registerModelPage.ts +++ b/frontend/src/__tests__/cypress/cypress/pages/modelRegistry/registerModelPage.ts @@ -33,6 +33,26 @@ class RegisterModelPage { return cy.get(selector); } + findObjectStorageAutofillButton() { + return cy.findByTestId('object-storage-autofill-button'); + } + + findConnectionAutofillModal() { + return cy.findByTestId('connection-autofill-modal'); + } + + findProjectSelector() { + return this.findConnectionAutofillModal().findByTestId('project-selector-dropdown'); + } + + findConnectionSelector() { + return this.findConnectionAutofillModal().findByTestId('select-data-connection'); + } + + findAutofillButton() { + return cy.findByTestId('autofill-modal-button'); + } + findSubmitButton() { return cy.findByTestId('create-button'); } diff --git a/frontend/src/__tests__/cypress/cypress/pages/modelRegistry/registerVersionPage.ts b/frontend/src/__tests__/cypress/cypress/pages/modelRegistry/registerVersionPage.ts new file mode 100644 index 0000000000..d08eb3c7ea --- /dev/null +++ b/frontend/src/__tests__/cypress/cypress/pages/modelRegistry/registerVersionPage.ts @@ -0,0 +1,51 @@ +export enum FormFieldSelector { + REGISTERED_MODEL = '#registered-model-container .pf-m-typeahead', + VERSION_NAME = '#version-name', + VERSION_DESCRIPTION = '#version-description', + SOURCE_MODEL_FORMAT = '#source-model-format', + SOURCE_MODEL_FORMAT_VERSION = '#source-model-format-version', + LOCATION_TYPE_OBJECT_STORAGE = '#location-type-object-storage', + LOCATION_ENDPOINT = '#location-endpoint', + LOCATION_BUCKET = '#location-bucket', + LOCATION_REGION = '#location-region', + LOCATION_PATH = '#location-path', + LOCATION_TYPE_URI = '#location-type-uri', + LOCATION_URI = '#location-uri', +} + +class RegisterVersionPage { + visit(registeredModelId?: string) { + const preferredModelRegistry = 'modelregistry-sample'; + cy.visitWithLogin( + registeredModelId + ? `/modelRegistry/${preferredModelRegistry}/registeredModels/${registeredModelId}/registerVersion` + : `/modelRegistry/${preferredModelRegistry}/registerVersion`, + ); + this.wait(); + } + + private wait() { + const preferredModelRegistry = 'modelregistry-sample'; + cy.findByTestId('app-page-title').should('exist'); + cy.findByTestId('app-page-title').contains('Register new version'); + cy.findByText(`Model registry - ${preferredModelRegistry}`).should('exist'); + cy.testA11y(); + } + + findFormField(selector: FormFieldSelector) { + return cy.get(selector); + } + + selectRegisteredModel(name: string) { + this.findFormField(FormFieldSelector.REGISTERED_MODEL) + .findByRole('button', { name: 'Typeahead menu toggle' }) + .findSelectOption(name) + .click(); + } + + findSubmitButton() { + return cy.findByTestId('create-button'); + } +} + +export const registerVersionPage = new RegisterVersionPage(); diff --git a/frontend/src/__tests__/cypress/cypress/pages/modelRegistryPermissions.ts b/frontend/src/__tests__/cypress/cypress/pages/modelRegistryPermissions.ts index 37b8fc2208..701987f12e 100644 --- a/frontend/src/__tests__/cypress/cypress/pages/modelRegistryPermissions.ts +++ b/frontend/src/__tests__/cypress/cypress/pages/modelRegistryPermissions.ts @@ -71,7 +71,7 @@ class PermissionTable extends Contextual { } findNameSelect() { - return this.find().get(`[aria-label="Name selection"]`); + return this.find().get(`[aria-label="Type to filter"]`); } getTableRow(name: string) { diff --git a/frontend/src/__tests__/cypress/cypress/pages/modelRegistrySettings.ts b/frontend/src/__tests__/cypress/cypress/pages/modelRegistrySettings.ts index 12c5fcc3ba..225ca53da6 100644 --- a/frontend/src/__tests__/cypress/cypress/pages/modelRegistrySettings.ts +++ b/frontend/src/__tests__/cypress/cypress/pages/modelRegistrySettings.ts @@ -46,7 +46,7 @@ class ModelRegistrySettings { private findHeading() { cy.findByTestId('app-page-title').should('exist'); - cy.findByTestId('app-page-title').contains('Model Registry Settings'); + cy.findByTestId('app-page-title').contains('Model registry settings'); } findNavItem() { diff --git a/frontend/src/__tests__/cypress/cypress/pages/modelServing.ts b/frontend/src/__tests__/cypress/cypress/pages/modelServing.ts index 18bd61e95a..16566af7e3 100644 --- a/frontend/src/__tests__/cypress/cypress/pages/modelServing.ts +++ b/frontend/src/__tests__/cypress/cypress/pages/modelServing.ts @@ -79,8 +79,8 @@ class ModelServingGlobal { } class InferenceServiceModal extends Modal { - constructor() { - super('Deploy model'); + constructor(private edit = false) { + super(`${edit ? 'Edit' : 'Deploy'} model`); } findSubmitButton() { @@ -92,11 +92,11 @@ class InferenceServiceModal extends Modal { } findServingRuntimeSelect() { - return this.find().find('#inference-service-model-selection'); + return this.find().findByTestId('inference-service-model-selection'); } findModelFrameworkSelect() { - return this.find().find('#inference-service-framework-selection'); + return this.find().findByTestId('inference-service-framework-selection'); } findExistingDataConnectionOption() { @@ -121,6 +121,12 @@ class InferenceServiceModal extends Modal { .findByRole('button', { name: 'Options menu' }); } + selectExistingConnectionSelectOptionByResourceName(name: string) { + this.findExistingConnectionSelect() + .findSelectOptionByTestId(`inference-service-data-connection ${name}`) + .click(); + } + findLocationNameInput() { return this.find().findByTestId('field Name'); } @@ -141,6 +147,10 @@ class InferenceServiceModal extends Modal { return this.find().findByTestId('field AWS_S3_BUCKET'); } + findLocationRegionInput() { + return this.find().findByTestId('field AWS_DEFAULT_REGION'); + } + findLocationPathInput() { return this.find().findByTestId('folder-path'); } @@ -152,7 +162,7 @@ class InferenceServiceModal extends Modal { class ServingRuntimeModal extends Modal { constructor(private edit = false) { - super(edit ? 'Edit model server' : 'Add model server'); + super(`${edit ? 'Edit' : 'Add'} model server`); } findSubmitButton() { @@ -194,7 +204,7 @@ class ServingRuntimeModal extends Modal { findModelServerSizeSelect() { return this.find() .findByRole('group', { name: 'Compute resources per replica' }) - .findByRole('button', { name: 'Options menu' }); + .findByTestId('model-server-size-selection'); } findModelServerReplicasMinusButton() { @@ -212,7 +222,11 @@ class ServingRuntimeModal extends Modal { // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging interface KServeModal extends ServingRuntimeModal, InferenceServiceModal {} // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging -class KServeModal extends InferenceServiceModal {} +class KServeModal extends InferenceServiceModal { + constructor(private edit = false) { + super(edit); + } +} mixin(KServeModal, [ServingRuntimeModal, InferenceServiceModal]); class ModelServingRow extends TableRow { @@ -371,7 +385,9 @@ class ModelServingSection { export const modelServingGlobal = new ModelServingGlobal(); export const inferenceServiceModal = new InferenceServiceModal(); +export const inferenceServiceModalEdit = new InferenceServiceModal(true); export const modelServingSection = new ModelServingSection(); export const createServingRuntimeModal = new ServingRuntimeModal(false); export const editServingRuntimeModal = new ServingRuntimeModal(true); export const kserveModal = new KServeModal(); +export const kserveModalEdit = new KServeModal(true); diff --git a/frontend/src/__tests__/cypress/cypress/pages/pipelines/artifacts.ts b/frontend/src/__tests__/cypress/cypress/pages/pipelines/artifacts.ts index 7af3819d38..786e1a2f51 100644 --- a/frontend/src/__tests__/cypress/cypress/pages/pipelines/artifacts.ts +++ b/frontend/src/__tests__/cypress/cypress/pages/pipelines/artifacts.ts @@ -33,7 +33,7 @@ class ArtifactsGlobal { } selectFilterType(type: string) { - cy.findByTestId('artifact-type-filter-select').findByTestId(`dropdown-item ${type}`).click(); + cy.findByTestId('artifact-type-filter-select').findByTestId(type).click(); } } diff --git a/frontend/src/__tests__/cypress/cypress/pages/pipelines/compareRuns.ts b/frontend/src/__tests__/cypress/cypress/pages/pipelines/compareRuns.ts index 5dd47265f8..caf7b326cf 100644 --- a/frontend/src/__tests__/cypress/cypress/pages/pipelines/compareRuns.ts +++ b/frontend/src/__tests__/cypress/cypress/pages/pipelines/compareRuns.ts @@ -178,6 +178,13 @@ class CompareRunsArtifactSelect extends Contextual { findArtifactContent(index = 0) { return this.find().findByTestId(`pipeline-run-artifact-content-${index}`); } + + findIframeContent(index = 0) { + return this.findArtifactContent(index) + .findByTestId('markdown-compare') + .its('0.contentDocument') + .its('body'); + } } class ConfusionMatrixArtifactSelect extends CompareRunsArtifactSelect { diff --git a/frontend/src/__tests__/cypress/cypress/pages/pipelines/createRunPage.ts b/frontend/src/__tests__/cypress/cypress/pages/pipelines/createRunPage.ts index 469537949d..6cad297d8e 100644 --- a/frontend/src/__tests__/cypress/cypress/pages/pipelines/createRunPage.ts +++ b/frontend/src/__tests__/cypress/cypress/pages/pipelines/createRunPage.ts @@ -72,11 +72,11 @@ export class CreateRunPage { } findScheduledRunTypeSelectorPeriodic(): Cypress.Chainable> { - return this.find().findByRole('menuitem', { name: 'Periodic' }); + return this.find().findByRole('option', { name: 'Periodic' }); } findScheduledRunTypeSelectorCron(): Cypress.Chainable> { - return this.find().findByRole('menuitem', { name: 'Cron' }); + return this.find().findByRole('option', { name: 'Cron' }); } findScheduledRunRunEvery(): Cypress.Chainable> { diff --git a/frontend/src/__tests__/cypress/cypress/pages/pipelines/executions.ts b/frontend/src/__tests__/cypress/cypress/pages/pipelines/executions.ts index 060801986b..5cfe40599c 100644 --- a/frontend/src/__tests__/cypress/cypress/pages/pipelines/executions.ts +++ b/frontend/src/__tests__/cypress/cypress/pages/pipelines/executions.ts @@ -34,7 +34,7 @@ class ExecutionFilter { } findTypeSearchFilterItem(item: string) { - return this.find().findByTestId('filter-toolbar-text-field').findDropdownItem(item); + return this.find().findByTestId('filter-toolbar-text-field').findSelectOption(item); } } diff --git a/frontend/src/__tests__/cypress/cypress/pages/pipelines/pipelineFilterBar.ts b/frontend/src/__tests__/cypress/cypress/pages/pipelines/pipelineFilterBar.ts index a5512da8af..414a539402 100644 --- a/frontend/src/__tests__/cypress/cypress/pages/pipelines/pipelineFilterBar.ts +++ b/frontend/src/__tests__/cypress/cypress/pages/pipelines/pipelineFilterBar.ts @@ -52,7 +52,7 @@ class PipelineRunFilterBar extends PipelineFilterBar { } selectStatusByName(name: string) { - this.findStatusSelect().findDropdownItem(name).click(); + this.findStatusSelect().findSelectOption(name).click(); } selectPipelineVersionByName(name: string): void { diff --git a/frontend/src/__tests__/cypress/cypress/pages/pipelines/topology.ts b/frontend/src/__tests__/cypress/cypress/pages/pipelines/topology.ts index 28b8e1b812..651f1fc5f4 100644 --- a/frontend/src/__tests__/cypress/cypress/pages/pipelines/topology.ts +++ b/frontend/src/__tests__/cypress/cypress/pages/pipelines/topology.ts @@ -53,6 +53,12 @@ class PipelinesTopology { findTaskNode(name: string) { return cy.get(`[data-id="${name}"][data-kind="node"][data-type="DEFAULT_TASK_NODE"]`); } + + findArtifactNode(name: string) { + return cy.get( + `[data-id="GROUP.root.ARTIFACT.${name}"][data-kind="node"][data-type="ICON_TASK_NODE"]`, + ); + } } class PipelineRunRightDrawer extends Contextual { @@ -81,6 +87,24 @@ class PipelineRunRightDrawer extends Contextual { } } +class ArtifactRightDrawer extends Contextual { + findArtifactTitle() { + return this.find().findByTestId('artifact-task-name'); + } + + findArtifactType() { + return this.find().findByTestId('artifact-type'); + } + + findVisualizationTab() { + return this.find().findByRole('tab', { name: 'Visualization' }); + } + + findIframeContent() { + return this.find().findByTestId('artifact-visualization').its('0.contentDocument').its('body'); + } +} + class RunDetails extends PipelinesTopology { findGraphTab() { return cy.findByTestId('pipeline-run-tab-graph'); @@ -103,6 +127,12 @@ class RunDetails extends PipelinesTopology { cy.findByTestId('pipeline-run-drawer-right-content').parent(), ); } + + findArtifactRightDrawer() { + return new ArtifactRightDrawer(() => + cy.findByTestId('pipeline-run-drawer-right-content').parent(), + ); + } } class DetailsItem extends Contextual { @@ -323,6 +353,10 @@ class PipelineRunDetails extends RunDetails { return cy.findByTestId('Output-artifacts'); } + findArtifactItems(itemId: string) { + return cy.findByTestId(`${itemId}-item`); + } + findErrorState(id: string) { return cy.findByTestId(id); } diff --git a/frontend/src/__tests__/cypress/cypress/pages/servingRuntimes.ts b/frontend/src/__tests__/cypress/cypress/pages/servingRuntimes.ts index 8f1cc89aa7..2b1412e687 100644 --- a/frontend/src/__tests__/cypress/cypress/pages/servingRuntimes.ts +++ b/frontend/src/__tests__/cypress/cypress/pages/servingRuntimes.ts @@ -93,23 +93,23 @@ class ServingRuntimes { shouldDisplayServingRuntimeValues(values: string[]) { this.findSelectServingPlatformButton().click(); - values.forEach((value) => cy.findByRole('menuitem', { name: value }).should('exist')); + values.forEach((value) => cy.findByRole('option', { name: value }).should('exist')); return this; } shouldDisplayAPIProtocolValues(values: ServingRuntimeAPIProtocol[]) { this.findSelectAPIProtocolButton().click(); - values.forEach((value) => cy.findByRole('menuitem', { name: value }).should('exist')); + values.forEach((value) => cy.findByRole('option', { name: value }).should('exist')); return this; } selectPlatform(value: string) { this.findSelectServingPlatformButton().click(); - cy.findByRole('menuitem', { name: value }).click(); + cy.findByRole('option', { name: value }).click(); } selectAPIProtocol(value: string) { - cy.findByRole('menuitem', { name: value }).click(); + cy.findByRole('option', { name: value }).click(); } uploadYaml(filePath: string) { diff --git a/frontend/src/__tests__/cypress/cypress/pages/userManagement.ts b/frontend/src/__tests__/cypress/cypress/pages/userManagement.ts index 1dd1ef0041..232b4b772d 100644 --- a/frontend/src/__tests__/cypress/cypress/pages/userManagement.ts +++ b/frontend/src/__tests__/cypress/cypress/pages/userManagement.ts @@ -39,7 +39,7 @@ class GroupSettingSection extends Contextual { } findMultiGroupSelectButton() { - return this.find().findByRole('button', { name: 'Options menu' }); + return this.find().findByTestId('group-setting-select'); } selectMultiGroup(name: string) { diff --git a/frontend/src/__tests__/cypress/cypress/pages/workbench.ts b/frontend/src/__tests__/cypress/cypress/pages/workbench.ts index cd631d6cca..d1d3f13170 100644 --- a/frontend/src/__tests__/cypress/cypress/pages/workbench.ts +++ b/frontend/src/__tests__/cypress/cypress/pages/workbench.ts @@ -8,11 +8,10 @@ class StorageModal extends Modal { } selectExistingPersistentStorage(name: string) { - this.find() - .findByTestId('persistent-storage-group') - .findByRole('button', { name: 'Options menu' }) - .findSelectOption(name) + cy.findByTestId('persistent-storage-group') + .findByRole('button', { name: 'Typeahead menu toggle' }) .click(); + cy.findByTestId('persistent-storage-group').findByRole('option', { name }).click(); } findSubmitButton() { @@ -190,9 +189,9 @@ class CreateSpawnerPage { selectExistingPersistentStorage(name: string) { cy.findByTestId('persistent-storage-group') - .findByRole('button', { name: 'Options menu' }) - .findSelectOption(name) + .findByRole('button', { name: 'Typeahead menu toggle' }) .click(); + cy.get('[id="dashboard-page-main"]').findByRole('option', { name }).click(); } selectPVSize(name: string) { @@ -237,7 +236,7 @@ class CreateSpawnerPage { findNotebookImage(name: string) { return cy .findByTestId('workbench-image-stream-selection') - .findDropdownItemByTestId(`dropdown-item ${name}`) + .findDropdownItemByTestId(name) .scrollIntoView(); } @@ -271,9 +270,9 @@ class CreateSpawnerPage { selectExistingDataConnection(name: string) { cy.findByTestId('data-connection-group') - .findByRole('button', { name: 'Options menu' }) - .findSelectOption(name) + .findByRole('button', { name: 'Typeahead menu toggle' }) .click(); + cy.get('[id="dashboard-page-main"]').findByRole('option', { name }).click(); } findAwsNameInput() { diff --git a/frontend/src/__tests__/cypress/cypress/support/commands/application.ts b/frontend/src/__tests__/cypress/cypress/support/commands/application.ts index ffc713ebaf..ed3ca55880 100644 --- a/frontend/src/__tests__/cypress/cypress/support/commands/application.ts +++ b/frontend/src/__tests__/cypress/cypress/support/commands/application.ts @@ -53,6 +53,12 @@ declare global { * @param name the name of the option */ findSelectOption: (name: string | RegExp) => Cypress.Chainable; + /** + * Finds a patternfly select option by first opening the select menu if not already opened. + * + * @param testId the name of the option + */ + findSelectOptionByTestId: (testId: string) => Cypress.Chainable; /** * Shortcut to first clear the previous value and then type text into DOM element. @@ -199,6 +205,16 @@ Cypress.Commands.add('findSelectOption', { prevSubject: 'element' }, (subject, n }); }); +Cypress.Commands.add('findSelectOptionByTestId', { prevSubject: 'element' }, (subject, testId) => { + Cypress.log({ displayName: 'findSelectOptionByTestId', message: testId }); + return cy.wrap(subject).then(($el) => { + if ($el.find('[aria-expanded=false]').addBack().length) { + cy.wrap($el).click(); + } + return cy.wrap($el).parent().findByTestId(testId); + }); +}); + Cypress.Commands.add('fill', { prevSubject: 'optional' }, (subject, text, options) => { cy.wrap(subject).clear(); return cy.wrap(subject).type(text, options); diff --git a/frontend/src/__tests__/cypress/cypress/support/commands/odh.ts b/frontend/src/__tests__/cypress/cypress/support/commands/odh.ts index 51838eb9a3..f3bf316a76 100644 --- a/frontend/src/__tests__/cypress/cypress/support/commands/odh.ts +++ b/frontend/src/__tests__/cypress/cypress/support/commands/odh.ts @@ -50,6 +50,8 @@ import type { } from '~/concepts/pipelines/kfTypes'; import type { GrpcResponse } from '~/__mocks__/mlmd/utils'; import type { BuildMockPipelinveVersionsType } from '~/__mocks__'; +import type { ArtifactStorage } from '~/concepts/pipelines/types'; +import type { ConnectionTypeConfigMap } from '~/concepts/connectionTypes/types'; type SuccessErrorResponse = { success: boolean; @@ -567,20 +569,46 @@ declare global { response: OdhResponse<{ notebook: NotebookKind; isRunning: boolean }>, ) => Cypress.Chainable) & (( - type: 'GET /api/storage/:namespace', + type: 'GET /api/service/pipelines/:namespace/:serviceName/apis/v2beta1/artifacts/:artifactId', options: { - query: { key: string; peek?: number }; - path: { namespace: string }; + query: { view: string }; + path: { namespace: string; serviceName: string; artifactId: string }; }, - response: OdhResponse, + response: OdhResponse, + ) => Cypress.Chainable) & + (( + type: 'GET /api/connection-types', + response: OdhResponse, ) => Cypress.Chainable) & (( - type: 'GET /api/storage/:namespace/size', + type: 'PATCH /api/connection-types/:name', options: { - query: { key: string }; - path: { namespace: string }; + path: { name: string }; + }, + response: OdhResponse, + ) => Cypress.Chainable) & + (( + type: 'GET /api/connection-types/:name', + options: { + path: { name: string }; + }, + response: OdhResponse, + ) => Cypress.Chainable) & + (( + type: 'GET /api/connection-types', + response: ConnectionTypeConfigMap[], + ) => Cypress.Chainable) & + (( + type: 'DELETE /api/connection-types/:name', + options: { path: { name: string } }, + response: OdhResponse, + ) => Cypress.Chainable) & + (( + type: 'PATCH /api/connection-types/:name', + options: { + path: { name: string }; }, - response: OdhResponse, + response: SuccessErrorResponse, ) => Cypress.Chainable); } } diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/connectionTypes/connectionTypes.cy.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/connectionTypes/connectionTypes.cy.ts new file mode 100644 index 0000000000..ca65bb8111 --- /dev/null +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/connectionTypes/connectionTypes.cy.ts @@ -0,0 +1,108 @@ +import { pageNotfound } from '~/__tests__/cypress/cypress/pages/pageNotFound'; +import { + asProductAdminUser, + asProjectAdminUser, +} from '~/__tests__/cypress/cypress/utils/mockUsers'; +import { connectionTypesPage } from '~/__tests__/cypress/cypress/pages/connectionTypes'; +import { mockDashboardConfig } from '~/__mocks__'; +import { mockConnectionTypeConfigMap } from '~/__mocks__/mockConnectionType'; +import { deleteModal } from '~/__tests__/cypress/cypress/pages/components/DeleteModal'; + +it('Connection types should not be available for non product admins', () => { + asProjectAdminUser(); + cy.visitWithLogin('/connectionTypes'); + pageNotfound.findPage().should('exist'); + connectionTypesPage.findNavItem().should('not.exist'); +}); + +it('Connection types should be hidden by feature flag', () => { + asProductAdminUser(); + + cy.visitWithLogin('/connectionTypes'); + connectionTypesPage.shouldReturnNotFound(); + + cy.interceptOdh( + 'GET /api/config', + mockDashboardConfig({ + disableConnectionTypes: false, + }), + ); + + connectionTypesPage.visit(); +}); + +describe('Connection types', () => { + beforeEach(() => { + asProductAdminUser(); + + cy.interceptOdh( + 'GET /api/config', + mockDashboardConfig({ + disableConnectionTypes: false, + }), + ); + cy.interceptOdh('GET /api/connection-types', [ + mockConnectionTypeConfigMap({}), + mockConnectionTypeConfigMap({ + name: 'no-display-name', + displayName: '', + description: 'description 2', + enabled: false, + preInstalled: true, + }), + mockConnectionTypeConfigMap({ + name: 'test-2', + displayName: 'Test display name', + description: 'Test description', + }), + ]); + }); + + it('should show the empty state when there are no results', () => { + cy.interceptOdh('GET /api/connection-types', []); + connectionTypesPage.visit(); + connectionTypesPage.shouldBeEmpty(); + }); + + it('should show the correct column values', () => { + connectionTypesPage.visit(); + + const row = connectionTypesPage.getConnectionTypeRow('Test display name'); + row.shouldHaveDescription('Test description'); + row.shouldHaveCreator('dashboard-admin'); + row.shouldBeEnabled(); + + const row2 = connectionTypesPage.getConnectionTypeRow('no-display-name'); + row2.shouldHaveDescription('description 2'); + row2.shouldShowPreInstalledLabel(); + row2.shouldBeDisabled(); + }); + + it('should delete connection type', () => { + connectionTypesPage.visit(); + cy.interceptOdh( + 'DELETE /api/connection-types/:name', + { + path: { name: 'test-2' }, + }, + { success: true }, + ).as('delete'); + cy.interceptOdh('GET /api/connection-types', [ + mockConnectionTypeConfigMap({}), + mockConnectionTypeConfigMap({ + name: 'no-display-name', + displayName: '', + description: 'description 2', + username: 'Pre-installed', + enabled: false, + }), + ]); + + connectionTypesPage.shouldHaveConnectionTypes(); + connectionTypesPage.getConnectionTypeRow('Test display name').findKebabAction('Delete').click(); + deleteModal.findSubmitButton().should('be.disabled'); + deleteModal.findInput().fill('Test display name'); + deleteModal.findSubmitButton().should('be.enabled').click(); + cy.wait('@delete'); + }); +}); diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/connectionTypes/createConnectionType.cy.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/connectionTypes/createConnectionType.cy.ts new file mode 100644 index 0000000000..990de9d30f --- /dev/null +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/connectionTypes/createConnectionType.cy.ts @@ -0,0 +1,165 @@ +import { + mockConnectionTypeConfigMap, + mockConnectionTypeConfigMapObj, +} from '~/__mocks__/mockConnectionType'; +import { createConnectionTypePage } from '~/__tests__/cypress/cypress/pages/connectionTypes'; +import { asProductAdminUser } from '~/__tests__/cypress/cypress/utils/mockUsers'; +import { mockDashboardConfig } from '~/__mocks__'; +import type { ConnectionTypeField } from '~/concepts/connectionTypes/types'; +import { toConnectionTypeConfigMap } from '~/concepts/connectionTypes/utils'; + +describe('create', () => { + beforeEach(() => { + asProductAdminUser(); + + cy.interceptOdh( + 'GET /api/config', + mockDashboardConfig({ + disableConnectionTypes: false, + }), + ); + }); + + it('Display base page', () => { + createConnectionTypePage.visitCreatePage(); + + createConnectionTypePage.findConnectionTypeName().should('exist'); + createConnectionTypePage.findConnectionTypeDesc().should('exist'); + createConnectionTypePage.findConnectionTypeEnableCheckbox().should('exist'); + createConnectionTypePage.findConnectionTypePreviewToggle().should('exist'); + }); + + it('Allows create button with valid name', () => { + createConnectionTypePage.visitCreatePage(); + + createConnectionTypePage.findConnectionTypeName().should('have.value', ''); + createConnectionTypePage.findSubmitButton().should('be.disabled'); + + createConnectionTypePage.findConnectionTypeName().type('hello'); + createConnectionTypePage.findSubmitButton().should('be.enabled'); + }); +}); + +describe('duplicate', () => { + const existing = mockConnectionTypeConfigMapObj({ name: 'existing' }); + + beforeEach(() => { + asProductAdminUser(); + + cy.interceptOdh( + 'GET /api/config', + mockDashboardConfig({ + disableConnectionTypes: false, + }), + ); + cy.interceptOdh( + 'GET /api/connection-types/:name', + { path: { name: 'existing' } }, + mockConnectionTypeConfigMap({ name: 'existing' }), + ); + }); + + it('Prefill details from existing connection', () => { + createConnectionTypePage.visitDuplicatePage('existing'); + + createConnectionTypePage + .findConnectionTypeName() + .should( + 'have.value', + `Duplicate of ${existing.metadata.annotations?.['openshift.io/display-name']}`, + ); + createConnectionTypePage + .findConnectionTypeDesc() + .should('have.value', existing.metadata.annotations?.['openshift.io/description']); + createConnectionTypePage.findConnectionTypeEnableCheckbox().should('be.checked'); + }); + + it('Prefill fields table from existing connection', () => { + createConnectionTypePage.visitDuplicatePage('existing'); + + createConnectionTypePage + .findAllFieldsTableRows() + .should('have.length', existing.data?.fields?.length); + + // Row 0 - Section + const row0 = createConnectionTypePage.getFieldsTableRow(0); + row0.findName().should('contain.text', 'Short text'); + row0.findSectionHeading().should('exist'); + + // Row 1 - Short text field + const row1 = createConnectionTypePage.getFieldsTableRow(1); + row1.findName().should('contain.text', 'Short text 1'); + row1.findType().should('have.text', 'Short text'); + row1.findDefault().should('have.text', '-'); + row1.findRequired().not('be.checked'); + + // Row 2 - Short text field + const row2 = createConnectionTypePage.getFieldsTableRow(2); + row2.findName().should('contain.text', 'Short text 2'); + row2.findType().should('have.text', 'Short text'); + row2.findDefault().should('have.text', 'This is the default value'); + row2.findRequired().should('be.checked'); + }); +}); + +describe('edit', () => { + const existing = mockConnectionTypeConfigMapObj({ + name: 'existing', + fields: [ + { + type: 'section', + name: 'header1', + }, + { + type: 'short-text', + name: 'field1', + envVar: 'short-text-1', + required: false, + properties: {}, + }, + { + type: 'short-text', + name: 'field2', + envVar: 'short-text-2', + required: true, + properties: {}, + }, + ] as ConnectionTypeField[], + }); + + beforeEach(() => { + asProductAdminUser(); + + cy.interceptOdh( + 'GET /api/config', + mockDashboardConfig({ + disableConnectionTypes: false, + }), + ); + cy.interceptOdh( + 'GET /api/connection-types/:name', + { path: { name: 'existing' } }, + toConnectionTypeConfigMap(existing), + ); + }); + + it('Drag and drop field rows in table', () => { + createConnectionTypePage.visitEditPage('existing'); + + createConnectionTypePage.getFieldsTableRow(0).findName().should('contain.text', 'header1'); + createConnectionTypePage.getFieldsTableRow(1).findName().should('contain.text', 'field1'); + createConnectionTypePage.getFieldsTableRow(2).findName().should('contain.text', 'field2'); + + createConnectionTypePage.getFieldsTableRow(0).dragToIndex(2); + + createConnectionTypePage.getFieldsTableRow(0).findName().should('contain.text', 'field1'); + createConnectionTypePage.getFieldsTableRow(1).findName().should('contain.text', 'field2'); + createConnectionTypePage.getFieldsTableRow(2).findName().should('contain.text', 'header1'); + + createConnectionTypePage.getFieldsTableRow(1).dragToIndex(0); + + createConnectionTypePage.getFieldsTableRow(0).findName().should('contain.text', 'field2'); + createConnectionTypePage.getFieldsTableRow(1).findName().should('contain.text', 'field1'); + createConnectionTypePage.getFieldsTableRow(2).findName().should('contain.text', 'header1'); + }); +}); diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/modelRegistry.cy.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/modelRegistry.cy.ts index 0dbfe06f81..6f39297340 100644 --- a/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/modelRegistry.cy.ts +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/modelRegistry.cy.ts @@ -12,17 +12,19 @@ import { } from '~/__tests__/cypress/cypress/utils/models'; import { mockModelVersionList } from '~/__mocks__/mockModelVersionList'; import { mockModelVersion } from '~/__mocks__/mockModelVersion'; -import type { ModelVersion, RegisteredModel } from '~/concepts/modelRegistry/types'; import { mockRegisteredModel } from '~/__mocks__/mockRegisteredModel'; import { mockModelRegistryService } from '~/__mocks__/mockModelRegistryService'; import { asProjectEditUser } from '~/__tests__/cypress/cypress/utils/mockUsers'; import { mockSelfSubjectRulesReview } from '~/__mocks__/mockSelfSubjectRulesReview'; import { mockSelfSubjectAccessReview } from '~/__mocks__/mockSelfSubjectAccessReview'; +import type { ModelVersion, RegisteredModel } from '~/concepts/modelRegistry/types'; +import type { ServiceKind } from '~/k8sTypes'; const MODEL_REGISTRY_API_VERSION = 'v1alpha3'; type HandlersProps = { disableModelRegistryFeature?: boolean; + modelRegistries?: ServiceKind[]; registeredModels?: RegisteredModel[]; modelVersions?: ModelVersion[]; allowed?: boolean; @@ -30,6 +32,10 @@ type HandlersProps = { const initIntercepts = ({ disableModelRegistryFeature = false, + modelRegistries = [ + mockModelRegistryService({ name: 'modelregistry-sample' }), + mockModelRegistryService({ name: 'modelregistry-sample-2' }), + ], registeredModels = [ mockRegisteredModel({ name: 'Fraud detection model', @@ -76,13 +82,7 @@ const initIntercepts = ({ cy.interceptK8s('POST', SelfSubjectRulesReviewModel, mockSelfSubjectRulesReview()); - cy.interceptK8sList( - ServiceModel, - mockK8sResourceList([ - mockModelRegistryService({ name: 'modelregistry-sample' }), - mockModelRegistryService({ name: 'modelregistry-sample-2' }), - ]), - ); + cy.interceptK8sList(ServiceModel, mockK8sResourceList(modelRegistries)); cy.interceptK8s(ServiceModel, mockModelRegistryService({ name: 'modelregistry-sample' })); @@ -141,6 +141,17 @@ describe('Model Registry core', () => { modelRegistry.tabEnabled(); }); + it('Renders empty state with no model registries', () => { + initIntercepts({ + disableModelRegistryFeature: false, + modelRegistries: [], + }); + + modelRegistry.visit(); + modelRegistry.navigate(); + modelRegistry.findModelRegistryEmptyState().should('exist'); + }); + it('No registered models in the selected Model Registry', () => { initIntercepts({ disableModelRegistryFeature: false, diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/modelVersionArchive.cy.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/modelVersionArchive.cy.ts index ce1fc616a2..7435cf7d63 100644 --- a/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/modelVersionArchive.cy.ts +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/modelVersionArchive.cy.ts @@ -246,7 +246,7 @@ describe('Archiving version', () => { modelVersionArchive.visitModelVersionList(); const modelVersionRow = modelRegistry.getModelVersionRow('model version 3'); - modelVersionRow.findKebabAction('Archive version').click(); + modelVersionRow.findKebabAction('Archive model version').click(); archiveVersionModal.findArchiveButton().should('be.disabled'); archiveVersionModal.findModalTextInput().fill('model version 3'); archiveVersionModal.findArchiveButton().should('be.enabled').click(); diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/modelVersionDeploy.cy.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/modelVersionDeploy.cy.ts new file mode 100644 index 0000000000..0da372fceb --- /dev/null +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/modelVersionDeploy.cy.ts @@ -0,0 +1,344 @@ +/* eslint-disable camelcase */ +import { + mockDscStatus, + mockK8sResourceList, + mockProjectK8sResource, + mockSecretK8sResource, +} from '~/__mocks__'; +import { mockDashboardConfig } from '~/__mocks__/mockDashboardConfig'; +import { mockRegisteredModelList } from '~/__mocks__/mockRegisteredModelsList'; +import { + ProjectModel, + SecretModel, + ServiceModel, + ServingRuntimeModel, + TemplateModel, +} from '~/__tests__/cypress/cypress/utils/models'; +import { mockModelVersionList } from '~/__mocks__/mockModelVersionList'; +import { mockModelVersion } from '~/__mocks__/mockModelVersion'; +import type { ModelVersion } from '~/concepts/modelRegistry/types'; +import { ModelState } from '~/concepts/modelRegistry/types'; +import { mockRegisteredModel } from '~/__mocks__/mockRegisteredModel'; +import { modelRegistry } from '~/__tests__/cypress/cypress/pages/modelRegistry'; +import { mockModelRegistryService } from '~/__mocks__/mockModelRegistryService'; +import { modelVersionDeployModal } from '~/__tests__/cypress/cypress/pages/modelRegistry/modelVersionDeployModal'; +import { mockModelArtifactList } from '~/__mocks__/mockModelArtifactList'; +import { + mockInvalidTemplateK8sResource, + mockServingRuntimeTemplateK8sResource, +} from '~/__mocks__/mockServingRuntimeTemplateK8sResource'; +import { ServingRuntimePlatform } from '~/types'; +import { kserveModal } from '~/__tests__/cypress/cypress/pages/modelServing'; +import { mockModelArtifact } from '~/__mocks__/mockModelArtifact'; + +const MODEL_REGISTRY_API_VERSION = 'v1alpha3'; + +type HandlersProps = { + registeredModelsSize?: number; + modelVersions?: ModelVersion[]; + modelMeshInstalled?: boolean; + kServeInstalled?: boolean; +}; + +const registeredModelMocked = mockRegisteredModel({ name: 'test-1' }); +const modelVersionMocked = mockModelVersion({ + id: '1', + name: 'test model version', + state: ModelState.LIVE, +}); +const modelArtifactMocked = mockModelArtifact(); + +const initIntercepts = ({ + registeredModelsSize = 4, + modelVersions = [mockModelVersion({ id: '1', name: 'test model version' })], + modelMeshInstalled = true, + kServeInstalled = true, +}: HandlersProps) => { + cy.interceptOdh( + 'GET /api/config', + mockDashboardConfig({ + disableModelRegistry: false, + }), + ); + cy.interceptOdh( + 'GET /api/dsc/status', + mockDscStatus({ + installedComponents: { + kserve: kServeInstalled, + 'model-mesh': modelMeshInstalled, + 'model-registry-operator': true, + }, + }), + ); + + cy.interceptK8sList( + ServiceModel, + mockK8sResourceList([mockModelRegistryService({ name: 'modelregistry-sample' })]), + ); + + cy.interceptOdh( + 'GET /api/service/modelregistry/:serviceName/api/model_registry/:apiVersion/registered_models', + { path: { serviceName: 'modelregistry-sample', apiVersion: MODEL_REGISTRY_API_VERSION } }, + mockRegisteredModelList({ size: registeredModelsSize }), + ); + + cy.interceptOdh( + 'GET /api/service/modelregistry/:serviceName/api/model_registry/:apiVersion/registered_models/:registeredModelId/versions', + { + path: { + serviceName: 'modelregistry-sample', + apiVersion: MODEL_REGISTRY_API_VERSION, + registeredModelId: 1, + }, + }, + mockModelVersionList({ + items: modelVersions, + }), + ); + + cy.interceptOdh( + 'GET /api/service/modelregistry/:serviceName/api/model_registry/:apiVersion/registered_models/:registeredModelId', + { + path: { + serviceName: 'modelregistry-sample', + apiVersion: MODEL_REGISTRY_API_VERSION, + registeredModelId: 1, + }, + }, + registeredModelMocked, + ); + + cy.interceptOdh( + 'GET /api/service/modelregistry/:serviceName/api/model_registry/:apiVersion/model_versions/:modelVersionId', + { + path: { + serviceName: 'modelregistry-sample', + apiVersion: MODEL_REGISTRY_API_VERSION, + modelVersionId: 1, + }, + }, + modelVersionMocked, + ); + + cy.interceptK8sList( + ProjectModel, + mockK8sResourceList([ + mockProjectK8sResource({ + enableModelMesh: true, + k8sName: 'model-mesh-project', + displayName: 'Model mesh project', + }), + mockProjectK8sResource({ + enableModelMesh: false, + k8sName: 'kserve-project', + displayName: 'KServe project', + }), + mockProjectK8sResource({ k8sName: 'test-project', displayName: 'Test project' }), + ]), + ); + + cy.interceptOdh( + `GET /api/service/modelregistry/:serviceName/api/model_registry/:apiVersion/model_versions/:modelVersionId/artifacts`, + { + path: { + serviceName: 'modelregistry-sample', + apiVersion: MODEL_REGISTRY_API_VERSION, + modelVersionId: 1, + }, + }, + mockModelArtifactList({}), + ); + + cy.interceptK8sList( + TemplateModel, + mockK8sResourceList( + [ + mockServingRuntimeTemplateK8sResource({ + name: 'template-1', + displayName: 'Multi Platform', + platforms: [ServingRuntimePlatform.SINGLE, ServingRuntimePlatform.MULTI], + }), + mockServingRuntimeTemplateK8sResource({ + name: 'template-2', + displayName: 'Caikit', + platforms: [ServingRuntimePlatform.SINGLE], + }), + mockServingRuntimeTemplateK8sResource({ + name: 'template-3', + displayName: 'New OVMS Server', + platforms: [ServingRuntimePlatform.MULTI], + }), + mockServingRuntimeTemplateK8sResource({ + name: 'template-4', + displayName: 'Serving Runtime with No Annotations', + }), + mockInvalidTemplateK8sResource({}), + ], + { namespace: 'opendatahub' }, + ), + ); +}; + +describe('Deploy model version', () => { + it('Deploy model version on unsupported platform', () => { + initIntercepts({ kServeInstalled: false, modelMeshInstalled: false }); + cy.visit(`/modelRegistry/modelregistry-sample/registeredModels/1/versions`); + const modelVersionRow = modelRegistry.getModelVersionRow('test model version'); + modelVersionRow.findKebabAction('Deploy').click(); + modelVersionDeployModal.selectProjectByName('Model mesh project'); + cy.findByText('Multi-model platform is not installed').should('exist'); + modelVersionDeployModal.selectProjectByName('KServe project'); + cy.findByText('Single-model platform is not installed').should('exist'); + }); + + it('Deploy model version on a project which platform is not selected', () => { + initIntercepts({}); + cy.visit(`/modelRegistry/modelregistry-sample/registeredModels/1/versions`); + const modelVersionRow = modelRegistry.getModelVersionRow('test model version'); + modelVersionRow.findKebabAction('Deploy').click(); + modelVersionDeployModal.selectProjectByName('Test project'); + cy.findByText('Cannot deploy the model until you select a model serving platform').should( + 'exist', + ); + }); + + it('Deploy model version on a model mesh project that has no model servers', () => { + initIntercepts({}); + cy.visit(`/modelRegistry/modelregistry-sample/registeredModels/1/versions`); + const modelVersionRow = modelRegistry.getModelVersionRow('test model version'); + modelVersionRow.findKebabAction('Deploy').click(); + cy.interceptK8sList(ServingRuntimeModel, mockK8sResourceList([])); + modelVersionDeployModal.selectProjectByName('Model mesh project'); + cy.findByText('Cannot deploy the model until you configure a model server').should('exist'); + }); + + it('Pre-fill deployment information on KServe modal', () => { + initIntercepts({}); + cy.interceptK8sList( + SecretModel, + mockK8sResourceList([ + mockSecretK8sResource({ + name: 'test-secret-not-match', + displayName: 'Test Secret Not Match', + namespace: 'kserve-project', + s3Bucket: 'dGVzdC1idWNrZXQ=', + endPoint: 'dGVzdC1lbmRwb2ludC1ub3QtbWF0Y2g=', // endpoint not match + region: 'dGVzdC1yZWdpb24=', + }), + ]), + ); + cy.visit(`/modelRegistry/modelregistry-sample/registeredModels/1/versions`); + const modelVersionRow = modelRegistry.getModelVersionRow('test model version'); + modelVersionRow.findKebabAction('Deploy').click(); + modelVersionDeployModal.selectProjectByName('KServe project'); + + // Validate name input field + kserveModal + .findModelNameInput() + .should('contain.value', `${registeredModelMocked.name} - ${modelVersionMocked.name} - `); + + // Validate model framework section + kserveModal.findModelFrameworkSelect().should('be.disabled'); + cy.findByText('The format of the source model is').should('not.exist'); + kserveModal.findServingRuntimeTemplateDropdown().findSelectOption('Multi Platform').click(); + kserveModal.findModelFrameworkSelect().should('be.enabled'); + cy.findByText( + `The format of the source model is ${modelArtifactMocked.modelFormatName} - ${modelArtifactMocked.modelFormatVersion}`, + ).should('exist'); + + // Validate data connection section + cy.findByText( + "We've auto-switched to create a new data connection and pre-filled the details for you.", + ).should('exist'); + kserveModal.findNewDataConnectionOption().should('be.checked'); + kserveModal.findLocationNameInput().should('have.value', modelArtifactMocked.storageKey); + kserveModal.findLocationBucketInput().should('have.value', 'test-bucket'); + kserveModal.findLocationRegionInput().should('have.value', 'test-region'); + kserveModal.findLocationEndpointInput().should('have.value', 'test-endpoint'); + kserveModal.findLocationPathInput().should('have.value', 'demo-models/test-path'); + }); + + it('One match data connection on KServe modal', () => { + initIntercepts({}); + cy.interceptK8sList( + SecretModel, + mockK8sResourceList([ + mockSecretK8sResource({ + namespace: 'kserve-project', + s3Bucket: 'dGVzdC1idWNrZXQ=', + endPoint: 'dGVzdC1lbmRwb2ludA==', + region: 'dGVzdC1yZWdpb24=', + }), + mockSecretK8sResource({ + name: 'test-secret-not-match', + displayName: 'Test Secret Not Match', + namespace: 'kserve-project', + s3Bucket: 'dGVzdC1idWNrZXQ=', + endPoint: 'dGVzdC1lbmRwb2ludC1ub3QtbWF0Y2g=', // endpoint not match + region: 'dGVzdC1yZWdpb24=', + }), + ]), + ); + + cy.visit(`/modelRegistry/modelregistry-sample/registeredModels/1/versions`); + const modelVersionRow = modelRegistry.getModelVersionRow('test model version'); + modelVersionRow.findKebabAction('Deploy').click(); + modelVersionDeployModal.selectProjectByName('KServe project'); + + // Validate data connection section + kserveModal.findExistingDataConnectionOption().should('be.checked'); + kserveModal.findExistingConnectionSelect().should('contain.text', 'Test SecretRecommended'); + kserveModal.findLocationPathInput().should('have.value', 'demo-models/test-path'); + }); + + it('More than one match data connections on KServe modal', () => { + initIntercepts({}); + cy.interceptK8sList( + SecretModel, + mockK8sResourceList([ + mockSecretK8sResource({ + namespace: 'kserve-project', + s3Bucket: 'dGVzdC1idWNrZXQ=', + endPoint: 'dGVzdC1lbmRwb2ludA==', + region: 'dGVzdC1yZWdpb24=', + }), + mockSecretK8sResource({ + name: 'test-secret-2', + displayName: 'Test Secret 2', + namespace: 'kserve-project', + s3Bucket: 'dGVzdC1idWNrZXQ=', + endPoint: 'dGVzdC1lbmRwb2ludA==', + region: 'dGVzdC1yZWdpb24=', + }), + mockSecretK8sResource({ + name: 'test-secret-not-match', + displayName: 'Test Secret Not Match', + namespace: 'kserve-project', + s3Bucket: 'dGVzdC1idWNrZXQ=', + endPoint: 'dGVzdC1lbmRwb2ludC1ub3QtbWF0Y2g=', // endpoint not match + region: 'dGVzdC1yZWdpb24=', + }), + ]), + ); + + cy.visit(`/modelRegistry/modelregistry-sample/registeredModels/1/versions`); + const modelVersionRow = modelRegistry.getModelVersionRow('test model version'); + modelVersionRow.findKebabAction('Deploy').click(); + modelVersionDeployModal.selectProjectByName('KServe project'); + + // Validate data connection section + kserveModal.findExistingDataConnectionOption().should('be.checked'); + kserveModal.findExistingConnectionSelect().should('contain.text', 'Select...'); + kserveModal.findLocationPathInput().should('have.value', 'demo-models/test-path'); + + // Make sure recommended label is there + kserveModal.selectExistingConnectionSelectOptionByResourceName('test-secret'); + kserveModal.findExistingConnectionSelect().should('contain.text', 'Recommended'); + + kserveModal.selectExistingConnectionSelectOptionByResourceName('test-secret-2'); + kserveModal.findExistingConnectionSelect().should('contain.text', 'Recommended'); + + kserveModal.selectExistingConnectionSelectOptionByResourceName('test-secret-not-match'); + kserveModal.findExistingConnectionSelect().should('not.contain.text', 'Recommended'); + }); +}); diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/modelVersionDetails.cy.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/modelVersionDetails.cy.ts index ac956b5520..5241d3c532 100644 --- a/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/modelVersionDetails.cy.ts +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/modelVersionDetails.cy.ts @@ -1,14 +1,28 @@ /* eslint-disable camelcase */ -import { mockDashboardConfig, mockK8sResourceList } from '~/__mocks__'; -import { mockComponents } from '~/__mocks__/mockComponents'; -import { ServiceModel } from '~/__tests__/cypress/cypress/utils/models'; -import { modelVersionDetails } from '~/__tests__/cypress/cypress/pages/modelRegistry/modelVersionDetails'; -import { mockRegisteredModel } from '~/__mocks__/mockRegisteredModel'; -import { mockModelVersion } from '~/__mocks__/mockModelVersion'; -import { mockModelVersionList } from '~/__mocks__/mockModelVersionList'; -import { mockModelArtifactList } from '~/__mocks__/mockModelArtifactList'; +import { + mockDashboardConfig, + mockK8sResourceList, + mockComponents, + mockRegisteredModel, + mockModelVersion, + mockModelVersionList, + mockModelArtifactList, + mockModelRegistryService, + mockServingRuntimeK8sResource, + mockInferenceServiceK8sResource, + mockProjectK8sResource, +} from '~/__mocks__'; + +import { + InferenceServiceModel, + ProjectModel, + ServiceModel, + ServingRuntimeModel, +} from '~/__tests__/cypress/cypress/utils/models'; import { verifyRelativeURL } from '~/__tests__/cypress/cypress/utils/url'; -import { mockModelRegistryService } from '~/__mocks__/mockModelRegistryService'; +import { modelVersionDetails } from '~/__tests__/cypress/cypress/pages/modelRegistry/modelVersionDetails'; +import { InferenceServiceModelState } from '~/pages/modelServing/screens/types'; +import { modelServingGlobal } from '~/__tests__/cypress/cypress/pages/modelServing'; const MODEL_REGISTRY_API_VERSION = 'v1alpha3'; @@ -29,6 +43,8 @@ const initIntercepts = () => { ]), ); + cy.interceptK8sList(ProjectModel, mockK8sResourceList([mockProjectK8sResource({})])); + cy.interceptOdh( `GET /api/service/modelregistry/:serviceName/api/model_registry/:apiVersion/registered_models/:registeredModelId`, { @@ -111,46 +127,87 @@ const initIntercepts = () => { modelVersionId: 1, }, }, - mockModelArtifactList(), + mockModelArtifactList({}), ); }; describe('Model version details', () => { - beforeEach(() => { - initIntercepts(); - modelVersionDetails.visit(); - }); + describe('Details tab', () => { + beforeEach(() => { + initIntercepts(); + modelVersionDetails.visit(); + }); - it('Model version details page header', () => { - verifyRelativeURL('/modelRegistry/modelregistry-sample/registeredModels/1/versions/1/details'); - cy.findByTestId('app-page-title').should('have.text', 'Version 1'); - cy.findByTestId('breadcrumb-version-name').should('have.text', 'Version 1'); - }); + it('Model version details page header', () => { + verifyRelativeURL( + '/modelRegistry/modelregistry-sample/registeredModels/1/versions/1/details', + ); + cy.findByTestId('app-page-title').should('have.text', 'Version 1'); + cy.findByTestId('breadcrumb-version-name').should('have.text', 'Version 1'); + }); - it('Model version details tab', () => { - modelVersionDetails.findVersionId().contains('1'); - modelVersionDetails.findDescription().should('have.text', 'Description of model version'); - modelVersionDetails.findMoreLabelsButton().contains('6 more'); - modelVersionDetails.findMoreLabelsButton().click(); - modelVersionDetails.shouldContainsModalLabels([ - 'Testing label', - 'Financial', - 'Financial data', - 'Fraud detection', - 'Machine learning', - 'Next data to be overflow', - 'Label x', - 'Label y', - 'Label z', - ]); - modelVersionDetails.findStorageLocation().contains('https://huggingface.io/mnist.onnx'); + it('Model version details tab', () => { + modelVersionDetails.findVersionId().contains('1'); + modelVersionDetails.findDescription().should('have.text', 'Description of model version'); + modelVersionDetails.findMoreLabelsButton().contains('6 more'); + modelVersionDetails.findMoreLabelsButton().click(); + modelVersionDetails.shouldContainsModalLabels([ + 'Testing label', + 'Financial', + 'Financial data', + 'Fraud detection', + 'Machine learning', + 'Next data to be overflow', + 'Label x', + 'Label y', + 'Label z', + ]); + modelVersionDetails.findStorageLocation().contains('s3://test-bucket/demo-models/test-path'); + }); + + it('Switching model versions', () => { + modelVersionDetails.findVersionId().contains('1'); + modelVersionDetails.findModelVersionDropdownButton().click(); + modelVersionDetails.findModelVersionDropdownSearch().fill('Version 2'); + modelVersionDetails.findModelVersionDropdownItem('Version 2').click(); + modelVersionDetails.findVersionId().contains('2'); + }); }); - it('Switching model versions', () => { - modelVersionDetails.findVersionId().contains('1'); - modelVersionDetails.findModelVersionDropdownButton().click(); - modelVersionDetails.findModelVersionDropdownSearch().fill('Version 2'); - modelVersionDetails.findModelVersionDropdownItem('Version 2').click(); - modelVersionDetails.findVersionId().contains('2'); + describe('Registered deployments tab', () => { + beforeEach(() => { + initIntercepts(); + }); + + it('renders empty state when the version has no registered deployments', () => { + cy.interceptK8sList(InferenceServiceModel, mockK8sResourceList([])); + cy.interceptK8sList(ServingRuntimeModel, mockK8sResourceList([])); + + modelVersionDetails.visit(); + modelVersionDetails.findRegisteredDeploymentsTab().click(); + + cy.findByTestId('model-version-deployments-empty-state').should('exist'); + }); + + it('renders table with data', () => { + cy.interceptK8sList( + InferenceServiceModel, + mockK8sResourceList([ + mockInferenceServiceK8sResource({ + url: 'test-inference-status.url.com', + activeModelState: InferenceServiceModelState.LOADED, + }), + ]), + ); + cy.interceptK8sList( + ServingRuntimeModel, + mockK8sResourceList([mockServingRuntimeK8sResource({})]), + ); + + modelVersionDetails.visit(); + modelVersionDetails.findRegisteredDeploymentsTab().click(); + + modelServingGlobal.getModelRow('Test Inference Service').should('exist'); + }); }); }); diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/modelVersions.cy.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/modelVersions.cy.ts index 543d5051fb..b13d3f6f04 100644 --- a/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/modelVersions.cy.ts +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/modelVersions.cy.ts @@ -11,6 +11,7 @@ import { mockRegisteredModel } from '~/__mocks__/mockRegisteredModel'; import type { ModelVersion } from '~/concepts/modelRegistry/types'; import { mockModelVersion } from '~/__mocks__/mockModelVersion'; import { mockModelRegistryService } from '~/__mocks__/mockModelRegistryService'; +import type { ServiceKind } from '~/k8sTypes'; const MODEL_REGISTRY_API_VERSION = 'v1alpha3'; @@ -18,11 +19,16 @@ type HandlersProps = { disableModelRegistryFeature?: boolean; registeredModelsSize?: number; modelVersions?: ModelVersion[]; + modelRegistries?: ServiceKind[]; }; const initIntercepts = ({ disableModelRegistryFeature = false, registeredModelsSize = 4, + modelRegistries = [ + mockModelRegistryService({ name: 'modelregistry-sample' }), + mockModelRegistryService({ name: 'modelregistry-sample-2' }), + ], modelVersions = [ mockModelVersion({ author: 'Author 1', @@ -48,13 +54,7 @@ const initIntercepts = ({ }), ); - cy.interceptK8sList( - ServiceModel, - mockK8sResourceList([ - mockModelRegistryService({ name: 'modelregistry-sample' }), - mockModelRegistryService({ name: 'modelregistry-sample-2' }), - ]), - ); + cy.interceptK8sList(ServiceModel, mockK8sResourceList(modelRegistries)); cy.interceptOdh( `GET /api/service/modelregistry/:serviceName/api/model_registry/:apiVersion/registered_models`, @@ -103,9 +103,15 @@ describe('Model Versions', () => { it('Model versions table', () => { initIntercepts({ disableModelRegistryFeature: false, + modelRegistries: [ + mockModelRegistryService({ name: 'modelRegistry-1' }), + mockModelRegistryService({}), + ], }); modelRegistry.visit(); + modelRegistry.findModelRegistry().findSelectOption('modelregistry-sample').click(); + cy.reload(); const registeredModelRow = modelRegistry.getRow('Fraud detection model'); registeredModelRow.findName().contains('Fraud detection model').click(); verifyRelativeURL(`/modelRegistry/modelregistry-sample/registeredModels/1/versions`); @@ -140,11 +146,11 @@ describe('Model Versions', () => { modelRegistry.findModelVersionsTableHeaderButton('Version name').click(); modelRegistry.findModelVersionsTableHeaderButton('Version name').should(be.sortDescending); - // sort by model version owner - modelRegistry.findModelVersionsTableHeaderButton('Owner').click(); - modelRegistry.findModelVersionsTableHeaderButton('Owner').should(be.sortAscending); - modelRegistry.findModelVersionsTableHeaderButton('Owner').click(); - modelRegistry.findModelVersionsTableHeaderButton('Owner').should(be.sortDescending); + // sort by model version author + modelRegistry.findModelVersionsTableHeaderButton('Author').click(); + modelRegistry.findModelVersionsTableHeaderButton('Author').should(be.sortAscending); + modelRegistry.findModelVersionsTableHeaderButton('Author').click(); + modelRegistry.findModelVersionsTableHeaderButton('Author').should(be.sortDescending); // filtering by keyword modelRegistry.findModelVersionsTableSearch().type('new model version'); @@ -152,8 +158,8 @@ describe('Model Versions', () => { modelRegistry.findModelVersionsTableRows().contains('new model version'); modelRegistry.findModelVersionsTableSearch().focused().clear(); - // filtering by owner - modelRegistry.findModelVersionsTableFilter().findDropdownItem('Owner').click(); + // filtering by model version author + modelRegistry.findModelVersionsTableFilter().findSelectOption('Author').click(); modelRegistry.findModelVersionsTableSearch().type('Test author'); modelRegistry.findModelVersionsTableRows().should('have.length', 1); modelRegistry.findModelVersionsTableRows().contains('Test author'); diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/registerModel.cy.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/registerModel.cy.ts index 594f974120..92894b025a 100644 --- a/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/registerModel.cy.ts +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/registerModel.cy.ts @@ -1,7 +1,13 @@ -import { mockDashboardConfig, mockDscStatus, mockK8sResourceList } from '~/__mocks__'; +import { + mockDashboardConfig, + mockDscStatus, + mockK8sResourceList, + mockProjectK8sResource, + mockSecretK8sResource, +} from '~/__mocks__'; import { mockDsciStatus } from '~/__mocks__/mockDsciStatus'; import { StackCapability, StackComponent } from '~/concepts/areas/types'; -import { ServiceModel } from '~/__tests__/cypress/cypress/utils/models'; +import { ProjectModel, SecretModel, ServiceModel } from '~/__tests__/cypress/cypress/utils/models'; import { FormFieldSelector, registerModelPage, @@ -42,7 +48,13 @@ const initIntercepts = () => { requiredCapabilities: [StackCapability.SERVICE_MESH, StackCapability.SERVICE_MESH_AUTHZ], }), ); - + cy.interceptK8sList( + ProjectModel, + mockK8sResourceList([ + mockProjectK8sResource({ k8sName: 'test-project', displayName: 'Test Project' }), + mockProjectK8sResource({ k8sName: 'test-project-2', displayName: 'Test Project 2' }), + ]), + ); cy.interceptK8sList( ServiceModel, mockK8sResourceList([ @@ -93,6 +105,67 @@ describe('Register model page', () => { registerModelPage.visit(); }); + it('Has Object storage autofill button if Object storage is selected', () => { + registerModelPage.findFormField(FormFieldSelector.LOCATION_TYPE_OBJECT_STORAGE).click(); + registerModelPage + .findFormField(FormFieldSelector.LOCATION_TYPE_OBJECT_STORAGE) + .should('be.checked'); + registerModelPage.findObjectStorageAutofillButton().should('be.visible'); + }); + + it('Does not have Object storage autofill button if Object storage is not selected', () => { + registerModelPage.findFormField(FormFieldSelector.LOCATION_TYPE_URI).click(); + registerModelPage + .findFormField(FormFieldSelector.LOCATION_TYPE_OBJECT_STORAGE) + .should('not.be.checked'); + registerModelPage.findObjectStorageAutofillButton().should('not.exist'); + }); + + it('Can open Object storage autofill modal', () => { + registerModelPage.findConnectionAutofillModal().should('not.exist'); + registerModelPage.findFormField(FormFieldSelector.LOCATION_TYPE_OBJECT_STORAGE).click(); + registerModelPage.findObjectStorageAutofillButton().click(); + registerModelPage.findConnectionAutofillModal().should('exist'); + }); + + it('Project selection with no connections displays message stating no connections available', () => { + registerModelPage.findConnectionAutofillModal().should('not.exist'); + registerModelPage.findFormField(FormFieldSelector.LOCATION_TYPE_OBJECT_STORAGE).click(); + registerModelPage.findObjectStorageAutofillButton().click(); + registerModelPage + .findConnectionSelector() + .contains('Select a project to view its available data connections'); + registerModelPage.findProjectSelector().findDropdownItem('Test Project').click(); + registerModelPage.findConnectionSelector().contains('No available data connections'); + }); + + it('Project selection with connections displays connections and fills form', () => { + cy.interceptK8sList( + SecretModel, + mockK8sResourceList([mockSecretK8sResource({ s3Bucket: 'cmhvZHMtcHVibGlj' })]), + ); + registerModelPage.findConnectionAutofillModal().should('not.exist'); + registerModelPage.findFormField(FormFieldSelector.LOCATION_TYPE_OBJECT_STORAGE).click(); + registerModelPage.findObjectStorageAutofillButton().click(); + registerModelPage + .findConnectionSelector() + .contains('Select a project to view its available data connections'); + registerModelPage.findProjectSelector().findDropdownItem('Test Project').click(); + registerModelPage.findConnectionSelector().contains('Select data connection'); + registerModelPage.findConnectionSelector().findDropdownItem('Test Secret').click(); + registerModelPage.findAutofillButton().click(); + registerModelPage.findConnectionAutofillModal().should('not.exist'); + registerModelPage + .findFormField(FormFieldSelector.LOCATION_ENDPOINT) + .should('have.value', 'https://s3.amazonaws.com/'); + registerModelPage + .findFormField(FormFieldSelector.LOCATION_BUCKET) + .should('have.value', 'rhods-public'); + registerModelPage + .findFormField(FormFieldSelector.LOCATION_REGION) + .should('have.value', 'us-east-1'); + }); + it('Disables submit until required fields are filled in object storage mode', () => { registerModelPage.findSubmitButton().should('be.disabled'); registerModelPage.findFormField(FormFieldSelector.MODEL_NAME).type('Test model name'); diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/registerVersion.cy.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/registerVersion.cy.ts new file mode 100644 index 0000000000..494abadbd5 --- /dev/null +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/registerVersion.cy.ts @@ -0,0 +1,585 @@ +import { mockDashboardConfig, mockDscStatus, mockK8sResourceList } from '~/__mocks__'; +import { mockDsciStatus } from '~/__mocks__/mockDsciStatus'; +import { StackCapability, StackComponent } from '~/concepts/areas/types'; +import { ServiceModel } from '~/__tests__/cypress/cypress/utils/models'; +import { + FormFieldSelector, + registerVersionPage, +} from '~/__tests__/cypress/cypress/pages/modelRegistry/registerVersionPage'; +import { mockRegisteredModel } from '~/__mocks__/mockRegisteredModel'; +import { mockModelVersion } from '~/__mocks__/mockModelVersion'; +import { mockModelArtifact } from '~/__mocks__/mockModelArtifact'; +import { mockModelRegistryService } from '~/__mocks__/mockModelRegistryService'; +import { mockRegisteredModelList } from '~/__mocks__/mockRegisteredModelsList'; +import { mockModelVersionList } from '~/__mocks__/mockModelVersionList'; +import { mockModelArtifactList } from '~/__mocks__/mockModelArtifactList'; +import { + ModelArtifactState, + ModelState, + type ModelVersion, + type ModelArtifact, +} from '~/concepts/modelRegistry/types'; + +const MODEL_REGISTRY_API_VERSION = 'v1alpha3'; + +const initIntercepts = () => { + cy.interceptOdh( + 'GET /api/config', + mockDashboardConfig({ + disableModelRegistry: false, + }), + ); + cy.interceptOdh( + 'GET /api/dsc/status', + mockDscStatus({ + installedComponents: { + [StackComponent.MODEL_REGISTRY]: true, + [StackComponent.MODEL_MESH]: true, + }, + }), + ); + cy.interceptOdh( + 'GET /api/dsci/status', + mockDsciStatus({ + requiredCapabilities: [StackCapability.SERVICE_MESH, StackCapability.SERVICE_MESH_AUTHZ], + }), + ); + + cy.interceptK8sList( + ServiceModel, + mockK8sResourceList([ + mockModelRegistryService({ name: 'modelregistry-sample' }), + mockModelRegistryService({ name: 'modelregistry-sample-2' }), + ]), + ); + + cy.interceptOdh( + `GET /api/service/modelregistry/:serviceName/api/model_registry/:apiVersion/registered_models`, + { + path: { serviceName: 'modelregistry-sample', apiVersion: MODEL_REGISTRY_API_VERSION }, + }, + mockRegisteredModelList({ + items: [ + mockRegisteredModel({ id: '1', name: 'Test model 1' }), + mockRegisteredModel({ id: '2', name: 'Test model 2' }), + mockRegisteredModel({ id: '3', name: 'Test model 3 has version but is missing artifact' }), + mockRegisteredModel({ id: '4', name: 'Test model 4 is missing version and artifact' }), + ], + }), + ); + + cy.interceptOdh( + 'GET /api/service/modelregistry/:serviceName/api/model_registry/:apiVersion/registered_models/:registeredModelId/versions', + { + path: { + serviceName: 'modelregistry-sample', + apiVersion: MODEL_REGISTRY_API_VERSION, + registeredModelId: 1, + }, + }, + mockModelVersionList({ + items: [ + mockModelVersion({ + id: '1', + registeredModelId: '1', + name: 'Test older version for model 1', + createTimeSinceEpoch: '1712234877179', // Apr 04 2024 + }), + mockModelVersion({ + id: '2', + registeredModelId: '1', + name: 'Test latest version for model 1', + createTimeSinceEpoch: '1723659611927', // Aug 14 2024 + }), + ], + }), + ); + + cy.interceptOdh( + 'GET /api/service/modelregistry/:serviceName/api/model_registry/:apiVersion/registered_models/:registeredModelId/versions', + { + path: { + serviceName: 'modelregistry-sample', + apiVersion: MODEL_REGISTRY_API_VERSION, + registeredModelId: 2, + }, + }, + mockModelVersionList({ + items: [ + mockModelVersion({ + id: '3', + registeredModelId: '2', + name: 'Test older version for model 2', + createTimeSinceEpoch: '1712234877179', // Apr 04 2024 + }), + mockModelVersion({ + id: '4', + registeredModelId: '2', + name: 'Test latest version for model 2', + createTimeSinceEpoch: '1723659611927', // Aug 14 2024 + }), + ], + }), + ); + + cy.interceptOdh( + 'GET /api/service/modelregistry/:serviceName/api/model_registry/:apiVersion/registered_models/:registeredModelId/versions', + { + path: { + serviceName: 'modelregistry-sample', + apiVersion: MODEL_REGISTRY_API_VERSION, + registeredModelId: 3, + }, + }, + mockModelVersionList({ + items: [ + mockModelVersion({ + id: '5', + registeredModelId: '3', + name: 'Test version for model 3', + }), + ], + }), + ); + + cy.interceptOdh( + 'GET /api/service/modelregistry/:serviceName/api/model_registry/:apiVersion/registered_models/:registeredModelId/versions', + { + path: { + serviceName: 'modelregistry-sample', + apiVersion: MODEL_REGISTRY_API_VERSION, + registeredModelId: 4, + }, + }, + mockModelVersionList({ + items: [], // Model 4 has no versions + }), + ); + + // Model id 1's latest version is id 2 + cy.interceptOdh( + 'GET /api/service/modelregistry/:serviceName/api/model_registry/:apiVersion/model_versions/:modelVersionId/artifacts', + { + path: { + serviceName: 'modelregistry-sample', + apiVersion: MODEL_REGISTRY_API_VERSION, + modelVersionId: 2, + }, + }, + mockModelArtifactList({ + items: [ + mockModelArtifact({ + modelFormatName: 'test-version-id-2-format-name', + modelFormatVersion: 'test-version-id-2-format-version', + uri: 's3://test-bucket-version-id-2/demo-models/test-path?endpoint=test-endpoint-version-id-2&defaultRegion=test-region-version-id-2', + }), + ], + }), + ); + + // Model id 2's latest version is id 4 + cy.interceptOdh( + 'GET /api/service/modelregistry/:serviceName/api/model_registry/:apiVersion/model_versions/:modelVersionId/artifacts', + { + path: { + serviceName: 'modelregistry-sample', + apiVersion: MODEL_REGISTRY_API_VERSION, + modelVersionId: 4, + }, + }, + mockModelArtifactList({ + items: [ + mockModelArtifact({ + modelFormatName: 'test-version-id-4-format-name', + modelFormatVersion: 'test-version-id-4-format-version', + uri: 'oops-malformed-uri', + }), + ], + }), + ); + + // Model id 3's latest version is id 5 + cy.interceptOdh( + 'GET /api/service/modelregistry/:serviceName/api/model_registry/:apiVersion/model_versions/:modelVersionId/artifacts', + { + path: { + serviceName: 'modelregistry-sample', + apiVersion: MODEL_REGISTRY_API_VERSION, + modelVersionId: 5, + }, + }, + mockModelArtifactList({ + items: [], // Model 3 has no artifacts + }), + ); + + cy.interceptOdh( + 'POST /api/service/modelregistry/:serviceName/api/model_registry/:apiVersion/registered_models/:registeredModelId/versions', + { + path: { + serviceName: 'modelregistry-sample', + apiVersion: MODEL_REGISTRY_API_VERSION, + registeredModelId: 1, + }, + }, + mockModelVersion({ id: '6', name: 'Test version name' }), + ).as('createModelVersion'); + + cy.interceptOdh( + 'POST /api/service/modelregistry/:serviceName/api/model_registry/:apiVersion/model_versions/:modelVersionId/artifacts', + { + path: { + serviceName: 'modelregistry-sample', + apiVersion: MODEL_REGISTRY_API_VERSION, + modelVersionId: 6, + }, + }, + mockModelArtifact(), + ).as('createModelArtifact'); +}; + +describe('Register model page with no preselected model', () => { + beforeEach(() => { + initIntercepts(); + registerVersionPage.visit(); + }); + + it('Prefills version/artifact details when a model is selected', () => { + registerVersionPage.selectRegisteredModel('Test model 1'); + cy.findByText('Current version is Test latest version for model 1').should('exist'); + registerVersionPage + .findFormField(FormFieldSelector.SOURCE_MODEL_FORMAT) + .should('have.value', 'test-version-id-2-format-name'); + registerVersionPage + .findFormField(FormFieldSelector.SOURCE_MODEL_FORMAT_VERSION) + .should('have.value', 'test-version-id-2-format-version'); + registerVersionPage + .findFormField(FormFieldSelector.LOCATION_TYPE_OBJECT_STORAGE) + .should('be.checked'); + registerVersionPage + .findFormField(FormFieldSelector.LOCATION_ENDPOINT) + .should('have.value', 'test-endpoint-version-id-2'); + registerVersionPage + .findFormField(FormFieldSelector.LOCATION_BUCKET) + .should('have.value', 'test-bucket-version-id-2'); + registerVersionPage + .findFormField(FormFieldSelector.LOCATION_REGION) + .should('have.value', 'test-region-version-id-2'); + + // Test model 2 has an invalid artifact URI so its object fields are reset + registerVersionPage.selectRegisteredModel('Test model 2'); + cy.findByText('Current version is Test latest version for model 2').should('exist'); + registerVersionPage + .findFormField(FormFieldSelector.SOURCE_MODEL_FORMAT) + .should('have.value', 'test-version-id-4-format-name'); + registerVersionPage + .findFormField(FormFieldSelector.SOURCE_MODEL_FORMAT_VERSION) + .should('have.value', 'test-version-id-4-format-version'); + registerVersionPage + .findFormField(FormFieldSelector.LOCATION_TYPE_OBJECT_STORAGE) + .should('be.checked'); + registerVersionPage.findFormField(FormFieldSelector.LOCATION_ENDPOINT).should('have.value', ''); + registerVersionPage.findFormField(FormFieldSelector.LOCATION_BUCKET).should('have.value', ''); + registerVersionPage.findFormField(FormFieldSelector.LOCATION_REGION).should('have.value', ''); + + // Switching back should prefill them again + registerVersionPage.selectRegisteredModel('Test model 1'); + cy.findByText('Current version is Test latest version for model 1').should('exist'); + registerVersionPage + .findFormField(FormFieldSelector.SOURCE_MODEL_FORMAT) + .should('have.value', 'test-version-id-2-format-name'); + registerVersionPage + .findFormField(FormFieldSelector.SOURCE_MODEL_FORMAT_VERSION) + .should('have.value', 'test-version-id-2-format-version'); + registerVersionPage + .findFormField(FormFieldSelector.LOCATION_TYPE_OBJECT_STORAGE) + .should('be.checked'); + registerVersionPage + .findFormField(FormFieldSelector.LOCATION_ENDPOINT) + .should('have.value', 'test-endpoint-version-id-2'); + registerVersionPage + .findFormField(FormFieldSelector.LOCATION_BUCKET) + .should('have.value', 'test-bucket-version-id-2'); + registerVersionPage + .findFormField(FormFieldSelector.LOCATION_REGION) + .should('have.value', 'test-region-version-id-2'); + }); + + it('Clears prefilled details if switching to a model with missing artifact', () => { + registerVersionPage.selectRegisteredModel('Test model 1'); + registerVersionPage.selectRegisteredModel('Test model 3 has version but is missing artifact'); + registerVersionPage + .findFormField(FormFieldSelector.SOURCE_MODEL_FORMAT) + .should('have.value', ''); + registerVersionPage + .findFormField(FormFieldSelector.SOURCE_MODEL_FORMAT_VERSION) + .should('have.value', ''); + registerVersionPage + .findFormField(FormFieldSelector.LOCATION_TYPE_OBJECT_STORAGE) + .should('be.checked'); + registerVersionPage.findFormField(FormFieldSelector.LOCATION_ENDPOINT).should('have.value', ''); + registerVersionPage.findFormField(FormFieldSelector.LOCATION_BUCKET).should('have.value', ''); + registerVersionPage.findFormField(FormFieldSelector.LOCATION_REGION).should('have.value', ''); + }); + + it('Clears prefilled details if switching to a model with missing version', () => { + registerVersionPage.selectRegisteredModel('Test model 1'); + registerVersionPage.selectRegisteredModel('Test model 4 is missing version and artifact'); + registerVersionPage + .findFormField(FormFieldSelector.SOURCE_MODEL_FORMAT) + .should('have.value', ''); + registerVersionPage + .findFormField(FormFieldSelector.SOURCE_MODEL_FORMAT_VERSION) + .should('have.value', ''); + registerVersionPage + .findFormField(FormFieldSelector.LOCATION_TYPE_OBJECT_STORAGE) + .should('be.checked'); + registerVersionPage.findFormField(FormFieldSelector.LOCATION_ENDPOINT).should('have.value', ''); + registerVersionPage.findFormField(FormFieldSelector.LOCATION_BUCKET).should('have.value', ''); + registerVersionPage.findFormField(FormFieldSelector.LOCATION_REGION).should('have.value', ''); + }); + + it('Disables submit until required fields are filled in object storage mode', () => { + registerVersionPage.findSubmitButton().should('be.disabled'); + registerVersionPage.selectRegisteredModel('Test model 1'); + registerVersionPage.findFormField(FormFieldSelector.VERSION_NAME).type('Test version name'); + registerVersionPage + .findFormField(FormFieldSelector.LOCATION_PATH) + .type('demo-models/flan-t5-small-caikit'); + registerVersionPage.findSubmitButton().should('be.enabled'); + }); + + it('Creates expected resources on submit in object storage mode', () => { + registerVersionPage.selectRegisteredModel('Test model 1'); + registerVersionPage.findFormField(FormFieldSelector.VERSION_NAME).type('Test version name'); + registerVersionPage + .findFormField(FormFieldSelector.VERSION_DESCRIPTION) + .type('Test version description'); + registerVersionPage + .findFormField(FormFieldSelector.LOCATION_PATH) + .type('demo-models/flan-t5-small-caikit'); + + registerVersionPage.findSubmitButton().click(); + + cy.wait('@createModelVersion').then((interception) => { + expect(interception.request.body).to.containSubset({ + name: 'Test version name', + description: 'Test version description', + customProperties: {}, + state: ModelState.LIVE, + author: 'test-user', + registeredModelId: '1', + } satisfies Partial); + }); + cy.wait('@createModelArtifact').then((interception) => { + expect(interception.request.body).to.containSubset({ + name: 'Test model 1-Test version name-artifact', + description: 'Test version description', + customProperties: {}, + state: ModelArtifactState.LIVE, + author: 'test-user', + modelFormatName: 'test-version-id-2-format-name', + modelFormatVersion: 'test-version-id-2-format-version', + uri: 's3://test-bucket-version-id-2/demo-models/flan-t5-small-caikit?endpoint=test-endpoint-version-id-2&defaultRegion=test-region-version-id-2', + artifactType: 'model-artifact', + } satisfies Partial); + }); + + cy.url().should('include', '/modelRegistry/modelregistry-sample/registeredModels/1/versions'); + }); + + it('Disables submit until required fields are filled in URI mode', () => { + registerVersionPage.findSubmitButton().should('be.disabled'); + registerVersionPage.selectRegisteredModel('Test model 1'); + registerVersionPage.findFormField(FormFieldSelector.VERSION_NAME).type('Test version name'); + registerVersionPage.findFormField(FormFieldSelector.LOCATION_TYPE_URI).click(); + registerVersionPage + .findFormField(FormFieldSelector.LOCATION_URI) + .type( + 's3://test-bucket/demo-models/flan-t5-small-caikit?endpoint=http%3A%2F%2Fs3.amazonaws.com%2F&defaultRegion=us-east-1', + ); + registerVersionPage.findSubmitButton().should('be.enabled'); + }); + + it('Creates expected resources on submit in URI mode', () => { + registerVersionPage.selectRegisteredModel('Test model 1'); + registerVersionPage.findFormField(FormFieldSelector.VERSION_NAME).type('Test version name'); + registerVersionPage + .findFormField(FormFieldSelector.VERSION_DESCRIPTION) + .type('Test version description'); + registerVersionPage.findFormField(FormFieldSelector.LOCATION_TYPE_URI).click(); + registerVersionPage + .findFormField(FormFieldSelector.LOCATION_URI) + .type( + 's3://test-bucket/demo-models/flan-t5-small-caikit?endpoint=http%3A%2F%2Fs3.amazonaws.com%2F&defaultRegion=us-east-1', + ); + + registerVersionPage.findSubmitButton().click(); + + cy.wait('@createModelVersion').then((interception) => { + expect(interception.request.body).to.containSubset({ + name: 'Test version name', + description: 'Test version description', + customProperties: {}, + state: ModelState.LIVE, + author: 'test-user', + registeredModelId: '1', + } satisfies Partial); + }); + cy.wait('@createModelArtifact').then((interception) => { + expect(interception.request.body).to.containSubset({ + name: 'Test model 1-Test version name-artifact', + description: 'Test version description', + customProperties: {}, + state: ModelArtifactState.LIVE, + author: 'test-user', + modelFormatName: 'test-version-id-2-format-name', + modelFormatVersion: 'test-version-id-2-format-version', + uri: 's3://test-bucket/demo-models/flan-t5-small-caikit?endpoint=http%3A%2F%2Fs3.amazonaws.com%2F&defaultRegion=us-east-1', + artifactType: 'model-artifact', + } satisfies Partial); + }); + + cy.url().should('include', '/modelRegistry/modelregistry-sample/registeredModels/1/versions'); + }); +}); + +describe('Register model page with preselected model', () => { + beforeEach(() => { + initIntercepts(); + }); + + it('Prefills version/artifact details for the preselected model', () => { + registerVersionPage.visit('1'); + cy.findByText('Current version is Test latest version for model 1').should('exist'); + registerVersionPage + .findFormField(FormFieldSelector.SOURCE_MODEL_FORMAT) + .should('have.value', 'test-version-id-2-format-name'); + registerVersionPage + .findFormField(FormFieldSelector.SOURCE_MODEL_FORMAT_VERSION) + .should('have.value', 'test-version-id-2-format-version'); + registerVersionPage + .findFormField(FormFieldSelector.LOCATION_TYPE_OBJECT_STORAGE) + .should('be.checked'); + registerVersionPage + .findFormField(FormFieldSelector.LOCATION_ENDPOINT) + .should('have.value', 'test-endpoint-version-id-2'); + registerVersionPage + .findFormField(FormFieldSelector.LOCATION_BUCKET) + .should('have.value', 'test-bucket-version-id-2'); + registerVersionPage + .findFormField(FormFieldSelector.LOCATION_REGION) + .should('have.value', 'test-region-version-id-2'); + }); + + it('Does not prefill location fields if the URI on the artifact is malformed', () => { + registerVersionPage.visit('2'); + registerVersionPage.findFormField(FormFieldSelector.LOCATION_ENDPOINT).should('have.value', ''); + registerVersionPage.findFormField(FormFieldSelector.LOCATION_BUCKET).should('have.value', ''); + registerVersionPage.findFormField(FormFieldSelector.LOCATION_REGION).should('have.value', ''); + }); + + it('Disables submit until required fields are filled in object storage mode', () => { + registerVersionPage.visit('1'); + registerVersionPage.findSubmitButton().should('be.disabled'); + registerVersionPage.findFormField(FormFieldSelector.VERSION_NAME).type('Test version name'); + registerVersionPage + .findFormField(FormFieldSelector.LOCATION_PATH) + .type('demo-models/flan-t5-small-caikit'); + registerVersionPage.findSubmitButton().should('be.enabled'); + }); + + it('Creates expected resources in object storage mode', () => { + registerVersionPage.visit('1'); + registerVersionPage.findFormField(FormFieldSelector.VERSION_NAME).type('Test version name'); + registerVersionPage + .findFormField(FormFieldSelector.VERSION_DESCRIPTION) + .type('Test version description'); + registerVersionPage + .findFormField(FormFieldSelector.LOCATION_PATH) + .type('demo-models/flan-t5-small-caikit'); + + registerVersionPage.findSubmitButton().click(); + + cy.wait('@createModelVersion').then((interception) => { + expect(interception.request.body).to.containSubset({ + name: 'Test version name', + description: 'Test version description', + customProperties: {}, + state: ModelState.LIVE, + author: 'test-user', + registeredModelId: '1', + } satisfies Partial); + }); + cy.wait('@createModelArtifact').then((interception) => { + expect(interception.request.body).to.containSubset({ + name: 'Test model 1-Test version name-artifact', + description: 'Test version description', + customProperties: {}, + state: ModelArtifactState.LIVE, + author: 'test-user', + modelFormatName: 'test-version-id-2-format-name', + modelFormatVersion: 'test-version-id-2-format-version', + uri: 's3://test-bucket-version-id-2/demo-models/flan-t5-small-caikit?endpoint=test-endpoint-version-id-2&defaultRegion=test-region-version-id-2', + artifactType: 'model-artifact', + } satisfies Partial); + }); + + cy.url().should('include', '/modelRegistry/modelregistry-sample/registeredModels/1/versions'); + }); + + it('Disables submit until required fields are filled in URI mode', () => { + registerVersionPage.visit('1'); + registerVersionPage.findSubmitButton().should('be.disabled'); + registerVersionPage.findFormField(FormFieldSelector.VERSION_NAME).type('Test version name'); + registerVersionPage.findFormField(FormFieldSelector.LOCATION_TYPE_URI).click(); + registerVersionPage + .findFormField(FormFieldSelector.LOCATION_URI) + .type( + 's3://test-bucket/demo-models/flan-t5-small-caikit?endpoint=http%3A%2F%2Fs3.amazonaws.com%2F&defaultRegion=us-east-1', + ); + registerVersionPage.findSubmitButton().should('be.enabled'); + }); + + it('Creates expected resources in URI mode', () => { + registerVersionPage.visit('1'); + registerVersionPage.findFormField(FormFieldSelector.VERSION_NAME).type('Test version name'); + registerVersionPage + .findFormField(FormFieldSelector.VERSION_DESCRIPTION) + .type('Test version description'); + registerVersionPage.findFormField(FormFieldSelector.LOCATION_TYPE_URI).click(); + registerVersionPage + .findFormField(FormFieldSelector.LOCATION_URI) + .type( + 's3://test-bucket/demo-models/flan-t5-small-caikit?endpoint=http%3A%2F%2Fs3.amazonaws.com%2F&defaultRegion=us-east-1', + ); + + registerVersionPage.findSubmitButton().click(); + + cy.wait('@createModelVersion').then((interception) => { + expect(interception.request.body).to.containSubset({ + name: 'Test version name', + description: 'Test version description', + customProperties: {}, + state: ModelState.LIVE, + author: 'test-user', + registeredModelId: '1', + } satisfies Partial); + }); + cy.wait('@createModelArtifact').then((interception) => { + expect(interception.request.body).to.containSubset({ + name: 'Test model 1-Test version name-artifact', + description: 'Test version description', + customProperties: {}, + state: ModelArtifactState.LIVE, + author: 'test-user', + modelFormatName: 'test-version-id-2-format-name', + modelFormatVersion: 'test-version-id-2-format-version', + uri: 's3://test-bucket/demo-models/flan-t5-small-caikit?endpoint=http%3A%2F%2Fs3.amazonaws.com%2F&defaultRegion=us-east-1', + artifactType: 'model-artifact', + } satisfies Partial); + }); + + cy.url().should('include', '/modelRegistry/modelregistry-sample/registeredModels/1/versions'); + }); +}); diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/registeredModelArchive.cy.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/registeredModelArchive.cy.ts index 8feea1aab0..1c8d9acf98 100644 --- a/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/registeredModelArchive.cy.ts +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/registeredModelArchive.cy.ts @@ -171,6 +171,7 @@ describe('Restoring archive model', () => { description: '', externalID: '1234132asdfasdf', state: 'LIVE', + owner: 'Author 1', }); }); }); @@ -200,6 +201,7 @@ describe('Restoring archive model', () => { description: '', externalID: '1234132asdfasdf', state: 'LIVE', + owner: 'Author 1', }); }); }); @@ -233,6 +235,7 @@ describe('Archiving model', () => { description: '', externalID: '1234132asdfasdf', state: 'ARCHIVED', + owner: 'Author 1', }); }); }); @@ -266,6 +269,7 @@ describe('Archiving model', () => { description: '', externalID: '1234132asdfasdf', state: 'ARCHIVED', + owner: 'Author 1', }); }); }); diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/modelServing/modelMetrics.cy.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/modelServing/modelMetrics.cy.ts index c47e9c8e7d..091270adbe 100644 --- a/frontend/src/__tests__/cypress/cypress/tests/mocked/modelServing/modelMetrics.cy.ts +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/modelServing/modelMetrics.cy.ts @@ -203,21 +203,11 @@ describe('Model Metrics', () => { modelMetricsBias.visit('test-project', 'test-inference-service'); - modelMetricsBias - .findConfigSelector() - .findSelectOption('Loan Acceptance 4') - .click() - .type('{esc}'); - modelMetricsBias - .findConfigSelector() - .findSelectOption('Loan acceptance 2') - .click() - .type('{esc}'); - modelMetricsBias - .findConfigSelector() - .findSelectOption('Loan acceptance 2 STRICT') - .click() - .type('{esc}'); + modelMetricsBias.findConfigSelector().click(); + modelMetricsBias.selectMetric('Loan Acceptance 4'); + modelMetricsBias.selectMetric('Loan acceptance 2'); + modelMetricsBias.selectMetric('Loan acceptance 2 STRICT'); + modelMetricsBias.findConfigSelector().click(); modelMetricsBias .getMetricsChart('Statistical parity difference (SPD)', 'Loan acceptance') @@ -243,21 +233,11 @@ describe('Model Metrics', () => { modelMetricsBias.visit('test-project', 'test-inference-service'); - modelMetricsBias - .findConfigSelector() - .findSelectOption('Loan Acceptance 4') - .click() - .type('{esc}'); - modelMetricsBias - .findConfigSelector() - .findSelectOption('Loan acceptance 2') - .click() - .type('{esc}'); - modelMetricsBias - .findConfigSelector() - .findSelectOption('Loan acceptance 2 STRICT') - .click() - .type('{esc}'); + modelMetricsBias.findConfigSelector().click(); + modelMetricsBias.selectMetric('Loan Acceptance 4'); + modelMetricsBias.selectMetric('Loan acceptance 2'); + modelMetricsBias.selectMetric('Loan acceptance 2 STRICT'); + modelMetricsBias.findConfigSelector().click(); modelMetricsBias .getMetricsChart('Statistical parity difference (SPD)', 'Loan acceptance') diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/modelServing/modelServingGlobal.cy.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/modelServing/modelServingGlobal.cy.ts index 5551fd3079..2fc869b93f 100644 --- a/frontend/src/__tests__/cypress/cypress/tests/mocked/modelServing/modelServingGlobal.cy.ts +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/modelServing/modelServingGlobal.cy.ts @@ -13,6 +13,7 @@ import { import { deleteModal } from '~/__tests__/cypress/cypress/pages/components/DeleteModal'; import { inferenceServiceModal, + inferenceServiceModalEdit, modelServingGlobal, } from '~/__tests__/cypress/cypress/pages/modelServing'; import { @@ -263,50 +264,50 @@ describe('Model Serving Global', () => { modelServingGlobal.getModelRow('Test Inference Service').findKebabAction('Edit').click(); // test that you can not submit on empty - inferenceServiceModal.shouldBeOpen(); - inferenceServiceModal.findModelNameInput().clear(); - inferenceServiceModal.findLocationPathInput().clear(); - inferenceServiceModal.findSubmitButton().should('be.disabled'); + inferenceServiceModalEdit.shouldBeOpen(); + inferenceServiceModalEdit.findModelNameInput().clear(); + inferenceServiceModalEdit.findLocationPathInput().clear(); + inferenceServiceModalEdit.findSubmitButton().should('be.disabled'); // test with invalid path name - inferenceServiceModal.findLocationPathInput().type('/'); - inferenceServiceModal + inferenceServiceModalEdit.findLocationPathInput().type('/'); + inferenceServiceModalEdit .findLocationPathInputError() .should('be.visible') .contains('The path must not point to a root folder'); - inferenceServiceModal.findSubmitButton().should('be.disabled'); - inferenceServiceModal.findLocationPathInput().clear(); - inferenceServiceModal.findLocationPathInput().type('test//path'); - inferenceServiceModal + inferenceServiceModalEdit.findSubmitButton().should('be.disabled'); + inferenceServiceModalEdit.findLocationPathInput().clear(); + inferenceServiceModalEdit.findLocationPathInput().type('test//path'); + inferenceServiceModalEdit .findLocationPathInputError() .should('be.visible') .contains('Invalid path format'); - inferenceServiceModal.findSubmitButton().should('be.disabled'); - inferenceServiceModal.findLocationPathInput().clear(); + inferenceServiceModalEdit.findSubmitButton().should('be.disabled'); + inferenceServiceModalEdit.findLocationPathInput().clear(); // test that you can update the name to a different name - inferenceServiceModal.findModelNameInput().type('Updated Model Name'); - inferenceServiceModal.findLocationPathInput().type('test-model/'); - inferenceServiceModal.findSubmitButton().should('be.enabled'); + inferenceServiceModalEdit.findModelNameInput().type('Updated Model Name'); + inferenceServiceModalEdit.findLocationPathInput().type('test-model/'); + inferenceServiceModalEdit.findSubmitButton().should('be.enabled'); // test that user cant upload on an empty new secret - inferenceServiceModal.findNewDataConnectionOption().click(); - inferenceServiceModal.findLocationPathInput().clear(); - inferenceServiceModal.findSubmitButton().should('be.disabled'); - inferenceServiceModal.findLocationPathInput().type('/'); - inferenceServiceModal.findSubmitButton().should('be.disabled'); + inferenceServiceModalEdit.findNewDataConnectionOption().click(); + inferenceServiceModalEdit.findLocationPathInput().clear(); + inferenceServiceModalEdit.findSubmitButton().should('be.disabled'); + inferenceServiceModalEdit.findLocationPathInput().type('/'); + inferenceServiceModalEdit.findSubmitButton().should('be.disabled'); // test that adding required values validates submit - inferenceServiceModal.findLocationNameInput().type('Test Name'); - inferenceServiceModal.findLocationAccessKeyInput().type('test-key'); - inferenceServiceModal.findLocationSecretKeyInput().type('test-secret-key'); - inferenceServiceModal.findLocationEndpointInput().type('test-endpoint'); - inferenceServiceModal.findLocationBucketInput().type('test-bucket'); - inferenceServiceModal.findLocationPathInput().clear(); - inferenceServiceModal.findLocationPathInput().type('test-model/'); - inferenceServiceModal.findSubmitButton().should('be.enabled'); - - inferenceServiceModal.findSubmitButton().click(); + inferenceServiceModalEdit.findLocationNameInput().type('Test Name'); + inferenceServiceModalEdit.findLocationAccessKeyInput().type('test-key'); + inferenceServiceModalEdit.findLocationSecretKeyInput().type('test-secret-key'); + inferenceServiceModalEdit.findLocationEndpointInput().type('test-endpoint'); + inferenceServiceModalEdit.findLocationBucketInput().type('test-bucket'); + inferenceServiceModalEdit.findLocationPathInput().clear(); + inferenceServiceModalEdit.findLocationPathInput().type('test-model/'); + inferenceServiceModalEdit.findSubmitButton().should('be.enabled'); + + inferenceServiceModalEdit.findSubmitButton().click(); cy.wait('@editModel').then((interception) => { const servingRuntimeMock = mockServingRuntimeK8sResource({ displayName: 'test-model' }); diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/modelServing/servingRuntimeList.cy.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/modelServing/servingRuntimeList.cy.ts index 84a8a6aad8..7977d4ba5f 100644 --- a/frontend/src/__tests__/cypress/cypress/tests/mocked/modelServing/servingRuntimeList.cy.ts +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/modelServing/servingRuntimeList.cy.ts @@ -28,6 +28,7 @@ import { editServingRuntimeModal, inferenceServiceModal, kserveModal, + kserveModalEdit, modelServingSection, } from '~/__tests__/cypress/cypress/pages/modelServing'; import { projectDetails } from '~/__tests__/cypress/cypress/pages/projects'; @@ -533,7 +534,7 @@ describe('Serving Runtime List', () => { // test filling in minimum required fields kserveModal.findModelNameInput().type('Test Name'); - kserveModal.findServingRuntimeTemplateDropdown().findDropdownItem('Caikit').click(); + kserveModal.findServingRuntimeTemplateDropdown().findSelectOption('Caikit').click(); kserveModal.findModelFrameworkSelect().findSelectOption('onnx - 1').click(); kserveModal.findSubmitButton().should('be.disabled'); // check external route, token should be checked and no alert @@ -613,7 +614,7 @@ describe('Serving Runtime List', () => { // test filling in minimum required fields kserveModal.findModelNameInput().type('Test Name'); - kserveModal.findServingRuntimeTemplateDropdown().findDropdownItem('Caikit').click(); + kserveModal.findServingRuntimeTemplateDropdown().findSelectOption('Caikit').click(); kserveModal.findModelFrameworkSelect().findSelectOption('onnx - 1').click(); kserveModal.findSubmitButton().should('be.disabled'); // check external route, token should be checked and no alert @@ -674,7 +675,7 @@ describe('Serving Runtime List', () => { // test filling in minimum required fields kserveModal.findModelNameInput().type('Test Name'); - kserveModal.findServingRuntimeTemplateDropdown().findDropdownItem('Caikit').click(); + kserveModal.findServingRuntimeTemplateDropdown().findSelectOption('Caikit').click(); kserveModal.findModelFrameworkSelect().findSelectOption('onnx - 1').click(); kserveModal.findExistingConnectionSelect().findSelectOption('Test Secret').click(); kserveModal.findNewDataConnectionOption().click(); @@ -739,13 +740,13 @@ describe('Serving Runtime List', () => { // click on the toggle button and open edit model server modelServingSection.getKServeRow('Llama Service').find().findKebabAction('Edit').click(); - kserveModal.shouldBeOpen(); + kserveModalEdit.shouldBeOpen(); // Submit button should be enabled - kserveModal.findSubmitButton().should('be.enabled'); + kserveModalEdit.findSubmitButton().should('be.enabled'); // Should allow editing - kserveModal.findSubmitButton().click(); - kserveModal.shouldBeOpen(false); + kserveModalEdit.findSubmitButton().click(); + kserveModalEdit.shouldBeOpen(false); //dry run request cy.wait('@updateServingRuntime').then((interception) => { @@ -898,7 +899,7 @@ describe('Serving Runtime List', () => { // test filling in minimum required fields kserveModal.findModelNameInput().type('Test Name'); - kserveModal.findServingRuntimeTemplateDropdown().findDropdownItem('Caikit').click(); + kserveModal.findServingRuntimeTemplateDropdown().findSelectOption('Caikit').click(); kserveModal.findModelFrameworkSelect().findSelectOption('onnx - 1').click(); kserveModal.findSubmitButton().should('be.disabled'); kserveModal.findExistingConnectionSelect().findSelectOption('Test Secret').click(); @@ -994,7 +995,7 @@ describe('Serving Runtime List', () => { createServingRuntimeModal.findModelServerNameInput().type('Test Name'); createServingRuntimeModal .findServingRuntimeTemplateDropdown() - .findDropdownItem('New OVMS Server') + .findSelectOption('New OVMS Server') .click(); createServingRuntimeModal.findSubmitButton().should('be.enabled'); @@ -1156,7 +1157,7 @@ describe('Serving Runtime List', () => { createServingRuntimeModal.findModelServerNameInput().type('Test Name'); createServingRuntimeModal .findServingRuntimeTemplateDropdown() - .findDropdownItem('New OVMS Server') + .findSelectOption('New OVMS Server') .click(); createServingRuntimeModal.findSubmitButton().should('be.enabled'); @@ -1213,7 +1214,7 @@ describe('Serving Runtime List', () => { createServingRuntimeModal.findModelServerNameInput().type('Test Name'); createServingRuntimeModal .findServingRuntimeTemplateDropdown() - .findDropdownItem('New OVMS Server') + .findSelectOption('New OVMS Server') .click(); createServingRuntimeModal.findSubmitButton().should('be.enabled'); @@ -1266,7 +1267,7 @@ describe('Serving Runtime List', () => { createServingRuntimeModal.findModelServerNameInput().type('Test Name'); createServingRuntimeModal .findServingRuntimeTemplateDropdown() - .findDropdownItem('New OVMS Server') + .findSelectOption('New OVMS Server') .click(); createServingRuntimeModal.findSubmitButton().should('be.enabled'); @@ -1329,7 +1330,7 @@ describe('Serving Runtime List', () => { createServingRuntimeModal.findModelServerNameInput().type('Test Name'); createServingRuntimeModal .findServingRuntimeTemplateDropdown() - .findDropdownItem('New OVMS Server') + .findSelectOption('New OVMS Server') .click(); createServingRuntimeModal.findSubmitButton().should('be.enabled'); @@ -1405,7 +1406,7 @@ describe('Serving Runtime List', () => { createServingRuntimeModal.findModelServerNameInput().type('Test Name'); createServingRuntimeModal .findServingRuntimeTemplateDropdown() - .findDropdownItem('New OVMS Server') + .findSelectOption('New OVMS Server') .click(); createServingRuntimeModal.findSubmitButton().should('be.enabled'); @@ -1625,7 +1626,7 @@ describe('Serving Runtime List', () => { // test filling in minimum required fields kserveModal.findModelNameInput().type('Test Name'); - kserveModal.findServingRuntimeTemplateDropdown().findDropdownItem('Caikit').click(); + kserveModal.findServingRuntimeTemplateDropdown().findSelectOption('Caikit').click(); kserveModal.findModelFrameworkSelect().findSelectOption('onnx - 1').click(); kserveModal.findSubmitButton().should('be.disabled'); kserveModal.findExistingConnectionSelect().findSelectOption('Test Secret').click(); @@ -1658,7 +1659,7 @@ describe('Serving Runtime List', () => { // test filling in minimum required fields kserveModal.findModelNameInput().type('Test Name'); - kserveModal.findServingRuntimeTemplateDropdown().findDropdownItem('Caikit').click(); + kserveModal.findServingRuntimeTemplateDropdown().findSelectOption('Caikit').click(); kserveModal.findModelFrameworkSelect().findSelectOption('onnx - 1').click(); kserveModal.findSubmitButton().should('be.disabled'); kserveModal.findExistingConnectionSelect().findSelectOption('Test Secret').click(); @@ -1692,7 +1693,7 @@ describe('Serving Runtime List', () => { kserveModal.shouldBeOpen(); kserveModal.findModelNameInput().type('Test Name'); - kserveModal.findServingRuntimeTemplateDropdown().findDropdownItem('Caikit').click(); + kserveModal.findServingRuntimeTemplateDropdown().findSelectOption('Caikit').click(); kserveModal.findModelFrameworkSelect().findSelectOption('onnx - 1').click(); kserveModal.findNewDataConnectionOption().click(); @@ -1786,9 +1787,9 @@ describe('Serving Runtime List', () => { // click on the toggle button and open edit model server kserveRow.find().findKebabAction('Edit').click(); - kserveModal.shouldBeOpen(); + kserveModalEdit.shouldBeOpen(); - kserveModal.findModelServerSizeSelect().invoke('text').should('equal', 'Small'); + kserveModalEdit.findModelServerSizeSelect().invoke('text').should('equal', 'Small'); }); it('Check model size rendered with InferenceService size', () => { @@ -1836,9 +1837,9 @@ describe('Serving Runtime List', () => { // click on the toggle button and open edit model server kserveRow.find().findKebabAction('Edit').click(); - kserveModal.shouldBeOpen(); + kserveModalEdit.shouldBeOpen(); - kserveModal.findModelServerSizeSelect().invoke('text').should('equal', 'Small'); + kserveModalEdit.findModelServerSizeSelect().invoke('text').should('equal', 'Small'); }); it('Check model size rendered with InferenceService custom size', () => { @@ -1887,9 +1888,9 @@ describe('Serving Runtime List', () => { // click on the toggle button and open edit model server modelServingSection.getKServeRow('Llama Service').find().findKebabAction('Edit').click(); - kserveModal.shouldBeOpen(); + kserveModalEdit.shouldBeOpen(); - kserveModal.findModelServerSizeSelect().invoke('text').should('equal', 'Custom'); + kserveModalEdit.findModelServerSizeSelect().invoke('text').should('equal', 'Custom'); }); }); diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/pipelines/artifacts.cy.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/pipelines/artifacts.cy.ts index 309f174375..1c7f000e48 100644 --- a/frontend/src/__tests__/cypress/cypress/tests/mocked/pipelines/artifacts.cy.ts +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/pipelines/artifacts.cy.ts @@ -1,3 +1,4 @@ +/* eslint-disable camelcase */ import { artifactDetails, artifactsGlobal, @@ -8,10 +9,23 @@ import { mockGetArtifactsResponse, mockedArtifactsResponse, } from '~/__mocks__/mlmd/mockGetArtifacts'; +import { + buildMockPipelineV2, + mockMetricsVisualizationRun, + mockMetricsVisualizationVersion, +} from '~/__mocks__'; +import { pipelineRunDetails } from '~/__tests__/cypress/cypress/pages/pipelines'; +import { mockArtifactStorage } from '~/__mocks__/mockArtifactStorage'; import { configIntercept, dspaIntercepts, projectsIntercept } from './intercepts'; +import { initMlmdIntercepts } from './mlmdUtils'; const projectName = 'test-project-name'; +const mockPipeline = buildMockPipelineV2({ + pipeline_id: 'metrics-pipeline', + display_name: 'metrics-pipeline', +}); + describe('Artifacts', () => { beforeEach(() => { initIntercepts(); @@ -34,7 +48,7 @@ describe('Artifacts', () => { const scalarMetricsRow = artifactsTable.getRowByName('scalar metrics'); scalarMetricsRow.findId().should('have.text', '1'); scalarMetricsRow.findType().should('have.text', 'system.Metrics'); - scalarMetricsRow.findUri().should('have.text', 's3://scalar-metrics-uri'); + scalarMetricsRow.findUri().should('have.text', 's3://scalar-metrics-uri-scalar-metrics-uri'); scalarMetricsRow.findCreated().should('have.text', '23 Jan 2021'); const datasetRow = artifactsTable.getRowByName('dataset'); @@ -76,7 +90,7 @@ describe('Artifacts', () => { 3, ); artifactsGlobal.visit(projectName); - artifactsTable.findRows().should('have.length', 4); + artifactsTable.findRows().should('have.length', 5); }); it('name', () => { @@ -91,7 +105,7 @@ describe('Artifacts', () => { 1, ); artifactsGlobal.findFilterFieldInput().type('metrics'); - artifactsTable.findRows().should('have.length', 2); + artifactsTable.findRows().should('have.length', 3); artifactsTable.getRowByName('scalar metrics').find().should('be.visible'); artifactsTable.getRowByName('confidence metrics').find().should('be.visible'); }); @@ -144,7 +158,7 @@ describe('Artifacts', () => { artifactDetails .findDatasetItemByLabel('URI') .next() - .should('include.text', 's3://scalar-metrics-uri'); + .should('include.text', 's3://scalar-metrics-uri-scalar-metrics-uri'); artifactDetails.findCustomPropItemByLabel('accuracy').next().should('have.text', '92'); artifactDetails .findCustomPropItemByLabel('display_name') @@ -152,10 +166,120 @@ describe('Artifacts', () => { .should('have.text', 'scalar metrics'); }); }); + + describe('artifact in pipeline run details page', () => { + it('url is clickable', () => { + pipelineRunDetails.visit( + projectName, + mockPipeline.pipeline_id, + mockMetricsVisualizationVersion.pipeline_version_id, + mockMetricsVisualizationRun.run_id, + ); + + pipelineRunDetails.findTaskNode('markdown-visualization').click(); + + pipelineRunDetails + .findArtifactItems('markdown_artifact') + .should( + 'contain.text', + 's3://aballant-pipelines/metrics-visualization-pipeline/16dbff18-a3d5-4684-90ac-4e6198a9da0f/markdown-visualization/markdown_artifact', + ) + .click() + .then(() => + cy.get('a').each(($el) => { + cy.wrap($el).should('have.attr', 'href').and('not.be.empty'); + }), + ); + }); + }); + describe('Pipeline run visualization tab', () => { + beforeEach(() => { + cy.interceptOdh( + 'GET /api/service/pipelines/:namespace/:serviceName/apis/v2beta1/artifacts/:artifactId', + { + query: { view: 'DOWNLOAD' }, + path: { namespace: projectName, serviceName: 'dspa', artifactId: '18' }, + }, + mockArtifactStorage({ namespace: projectName, artifactId: '18' }), + ); + cy.intercept( + 'GET', + 'https://test.s3.dualstack.us-east-1.amazonaws.com/metrics-visualization-pipeline/5e873c64-39fa-4dd4-83db-eff0cdd1e274/html-visualization/html_artifact?X-Amz-Algorithm=AWS4-HMAC-SHA256\u0026X-Amz-Credential=AKIAYQPE7PSILMBBLXMO%2F20240808%2Fus-east-1%2Fs3%2Faws4_request\u0026X-Amz-Date=20240808T070034Z\u0026X-Amz-Expires=15\u0026X-Amz-SignedHeaders=host\u0026response-content-disposition=attachment%3B%20filename%3D%22%22\u0026X-Amz-Signature=de39ee684dd606e75da3b07c1b9f0820f7442ea7a037ae1bffccea9e33610ea9', + 'helloWorld', + ); + initMlmdIntercepts(projectName); + }); + + it('check for visualization', () => { + pipelineRunDetails.visit( + projectName, + mockPipeline.pipeline_id, + mockMetricsVisualizationVersion.pipeline_version_id, + mockMetricsVisualizationRun.run_id, + ); + pipelineRunDetails.findArtifactNode('html-visualization.html_artifact').click(); + const artifactDrawer = pipelineRunDetails.findArtifactRightDrawer(); + artifactDrawer.findVisualizationTab().click(); + artifactDrawer.findIframeContent().should('have.text', 'helloWorld'); + }); + }); }); export const initIntercepts = (): void => { configIntercept(); dspaIntercepts(projectName); projectsIntercept([{ k8sName: projectName, displayName: 'Test project' }]); + initMlmdIntercepts(projectName); + cy.interceptOdh( + 'GET /api/service/pipelines/:namespace/:serviceName/apis/v2beta1/pipelines/:pipelineId', + { + path: { + namespace: projectName, + serviceName: 'dspa', + pipelineId: mockPipeline.pipeline_id, + }, + }, + mockPipeline, + ); + cy.interceptOdh( + 'GET /api/service/pipelines/:namespace/:serviceName/apis/v2beta1/pipelines/:pipelineId/versions/:pipelineVersionId', + { + path: { + namespace: projectName, + serviceName: 'dspa', + pipelineId: mockPipeline.pipeline_id, + pipelineVersionId: 'metrics-pipeline-version', + }, + }, + mockMetricsVisualizationVersion, + ); + cy.interceptOdh( + 'GET /api/service/pipelines/:namespace/:serviceName/apis/v2beta1/runs/:runId', + { + path: { namespace: projectName, serviceName: 'dspa', runId: 'test-metrics-pipeline-run' }, + }, + mockMetricsVisualizationRun, + ); + cy.interceptOdh( + 'GET /api/service/pipelines/:namespace/:serviceName/apis/v2beta1/artifacts/:artifactId', + { + query: { view: 'DOWNLOAD' }, + path: { + namespace: projectName, + serviceName: 'dspa', + artifactId: '16', + }, + }, + mockArtifactStorage({ + namespace: projectName, + artifactId: '16', + storage_path: + 'iris-training-pipeline/caf9116b-501e-491c-88e3-7772ba2b3334/create-dataset/iris_dataset', + uri: 's3://aballant-pipelines/metrics-visualization-pipeline/16dbff18-a3d5-4684-90ac-4e6198a9da0f/markdown-visualization/markdown_artifact', + download_url: + 'http://test-bucket.s3.dualstack.ap-south.amazonaws.com/metrics-visualization-pipeline', + artifact_type: 'system.Dataset', + artifact_size: '5098', + }), + ); }; diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/pipelines/compareRuns.cy.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/pipelines/compareRuns.cy.ts index 3cbe29a453..8651dbc5ed 100644 --- a/frontend/src/__tests__/cypress/cypress/tests/mocked/pipelines/compareRuns.cy.ts +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/pipelines/compareRuns.cy.ts @@ -24,6 +24,7 @@ import { compareRunsMetricsContent, } from '~/__tests__/cypress/cypress/pages/pipelines/compareRuns'; import { mockCancelledGoogleRpcStatus } from '~/__mocks__/mockGoogleRpcStatusKF'; +import { mockArtifactStorage } from '~/__mocks__/mockArtifactStorage'; import { initMlmdIntercepts } from './mlmdUtils'; const projectName = 'test-project-name'; @@ -82,7 +83,7 @@ const mockRun3 = buildMockRunKF({ describe('Compare runs', () => { beforeEach(() => { - initIntercepts(); + initIntercepts({}); }); it('zero runs in url', () => { @@ -210,122 +211,161 @@ describe('Compare runs', () => { }); describe('Metrics', () => { - beforeEach(() => { - initIntercepts(); - compareRunsGlobal.visit(projectName, mockExperiment.experiment_id, [ - mockRun.run_id, - mockRun2.run_id, - mockRun3.run_id, - ]); + describe('Metrics', () => { + beforeEach(() => { + initIntercepts({}); + compareRunsGlobal.visit(projectName, mockExperiment.experiment_id, [ + mockRun.run_id, + mockRun2.run_id, + mockRun3.run_id, + ]); + }); + + it('shows empty state when the Runs list has no selections', () => { + compareRunsListTable.findSelectAllCheckbox().click(); // Uncheck all + compareRunsMetricsContent + .findScalarMetricsTabContent() + .findScalarMetricsEmptyState() + .should('exist'); + compareRunsMetricsContent.findConfusionMatrixTab().click(); + compareRunsMetricsContent + .findConfusionMatrixTabContent() + .findConfusionMatrixEmptyState() + .should('exist'); + compareRunsMetricsContent.findRocCurveTab().click(); + compareRunsMetricsContent.findRocCurveTabContent().findRocCurveEmptyState().should('exist'); + compareRunsMetricsContent.findMarkdownTab().click(); + compareRunsMetricsContent.findMarkdownTabContent().findMarkdownEmptyState().should('exist'); + }); + + it('displays scalar metrics table data based on selections from Run list', () => { + compareRunsMetricsContent + .findScalarMetricsTabContent() + .findScalarMetricsTable() + .should('exist'); + compareRunsMetricsContent + .findScalarMetricsTabContent() + .findScalarMetricsColumnByName('Run name') + .should('exist'); + compareRunsMetricsContent + .findScalarMetricsTabContent() + .findScalarMetricsColumnByName('Run 1') + .should('exist'); + compareRunsMetricsContent + .findScalarMetricsTabContent() + .findScalarMetricsColumnByName('Run 2') + .should('exist'); + + compareRunsMetricsContent + .findScalarMetricsTabContent() + .findScalarMetricsColumnByName('Execution name > Artifact name') + .should('exist'); + compareRunsMetricsContent + .findScalarMetricsTabContent() + .findScalarMetricsColumnByName('digit-classification > metrics') + .should('exist'); + + compareRunsMetricsContent + .findScalarMetricsTabContent() + .findScalarMetricName('accuracy') + .should('exist'); + compareRunsMetricsContent + .findScalarMetricsTabContent() + .findScalarMetricCell('accuracy', 1) + .should('contain.text', '92'); + compareRunsMetricsContent + .findScalarMetricsTabContent() + .findScalarMetricCell('accuracy', 2) + .should('contain.text', '92'); + + compareRunsMetricsContent + .findScalarMetricsTabContent() + .findScalarMetricName('displayName') + .should('exist'); + compareRunsMetricsContent + .findScalarMetricsTabContent() + .findScalarMetricCell('displayName', 1) + .should('contain.text', '"metrics"'); + compareRunsMetricsContent + .findScalarMetricsTabContent() + .findScalarMetricCell('displayName', 2) + .should('contain.text', '"metrics"'); + }); + + it('displays confusion matrix data based on selections from Run list', () => { + compareRunsMetricsContent.findConfusionMatrixTab().click(); + + const confusionMatrixCompare = compareRunsMetricsContent + .findConfusionMatrixTabContent() + .findConfusionMatrixSelect(mockRun.run_id); + + // check graph data + const graph = confusionMatrixCompare.findConfusionMatrixGraph(); + graph.checkLabels(['Setosa', 'Versicolour', 'Virginica']); + graph.checkCells([ + [38, 0, 0], + [2, 19, 9], + [1, 17, 19], + ]); + + // check expanded graph + confusionMatrixCompare.findExpandButton().click(); + compareRunsMetricsContent + .findConfusionMatrixTabContent() + .findExpandedConfusionMatrix() + .find() + .should('exist'); + }); + + it('displays ROC curve empty state when no artifacts are found', () => { + compareRunsMetricsContent.findRocCurveTab().click(); + const content = compareRunsMetricsContent.findRocCurveTabContent(); + content.findRocCurveSearchBar().type('invalid'); + content.findRocCurveTableEmptyState().should('exist'); + }); }); - - it('shows empty state when the Runs list has no selections', () => { - compareRunsListTable.findSelectAllCheckbox().click(); // Uncheck all - compareRunsMetricsContent - .findScalarMetricsTabContent() - .findScalarMetricsEmptyState() - .should('exist'); - compareRunsMetricsContent.findConfusionMatrixTab().click(); - compareRunsMetricsContent - .findConfusionMatrixTabContent() - .findConfusionMatrixEmptyState() - .should('exist'); - compareRunsMetricsContent.findRocCurveTab().click(); - compareRunsMetricsContent.findRocCurveTabContent().findRocCurveEmptyState().should('exist'); - compareRunsMetricsContent.findMarkdownTab().click(); - compareRunsMetricsContent.findMarkdownTabContent().findMarkdownEmptyState().should('exist'); - }); - - it('displays scalar metrics table data based on selections from Run list', () => { - compareRunsMetricsContent - .findScalarMetricsTabContent() - .findScalarMetricsTable() - .should('exist'); - compareRunsMetricsContent - .findScalarMetricsTabContent() - .findScalarMetricsColumnByName('Run name') - .should('exist'); - compareRunsMetricsContent - .findScalarMetricsTabContent() - .findScalarMetricsColumnByName('Run 1') - .should('exist'); - compareRunsMetricsContent - .findScalarMetricsTabContent() - .findScalarMetricsColumnByName('Run 2') - .should('exist'); - - compareRunsMetricsContent - .findScalarMetricsTabContent() - .findScalarMetricsColumnByName('Execution name > Artifact name') - .should('exist'); - compareRunsMetricsContent - .findScalarMetricsTabContent() - .findScalarMetricsColumnByName('digit-classification > metrics') - .should('exist'); - - compareRunsMetricsContent - .findScalarMetricsTabContent() - .findScalarMetricName('accuracy') - .should('exist'); - compareRunsMetricsContent - .findScalarMetricsTabContent() - .findScalarMetricCell('accuracy', 1) - .should('contain.text', '92'); - compareRunsMetricsContent - .findScalarMetricsTabContent() - .findScalarMetricCell('accuracy', 2) - .should('contain.text', '92'); - - compareRunsMetricsContent - .findScalarMetricsTabContent() - .findScalarMetricName('displayName') - .should('exist'); - compareRunsMetricsContent - .findScalarMetricsTabContent() - .findScalarMetricCell('displayName', 1) - .should('contain.text', '"metrics"'); - compareRunsMetricsContent - .findScalarMetricsTabContent() - .findScalarMetricCell('displayName', 2) - .should('contain.text', '"metrics"'); - }); - - it('displays confusion matrix data based on selections from Run list', () => { - compareRunsMetricsContent.findConfusionMatrixTab().click(); - - const confusionMatrixCompare = compareRunsMetricsContent - .findConfusionMatrixTabContent() - .findConfusionMatrixSelect(mockRun.run_id); - - // check graph data - const graph = confusionMatrixCompare.findConfusionMatrixGraph(); - graph.checkLabels(['Setosa', 'Versicolour', 'Virginica']); - graph.checkCells([ - [38, 0, 0], - [2, 19, 9], - [1, 17, 19], - ]); - - // check expanded graph - confusionMatrixCompare.findExpandButton().click(); - compareRunsMetricsContent - .findConfusionMatrixTabContent() - .findExpandedConfusionMatrix() - .find() - .should('exist'); - }); - - it('displays ROC curve empty state when no artifacts are found', () => { - compareRunsMetricsContent.findRocCurveTab().click(); - const content = compareRunsMetricsContent.findRocCurveTabContent(); - content.findRocCurveSearchBar().type('invalid'); - content.findRocCurveTableEmptyState().should('exist'); + describe('Metrics', () => { + beforeEach(() => { + initIntercepts({}); + compareRunsGlobal.visit(projectName, mockExperiment.experiment_id, [ + mockRun.run_id, + mockRun2.run_id, + mockRun3.run_id, + ]); + cy.interceptOdh( + 'GET /api/service/pipelines/:namespace/:serviceName/apis/v2beta1/artifacts/:artifactId', + { + query: { view: 'DOWNLOAD' }, + path: { namespace: projectName, serviceName: 'dspa', artifactId: '16' }, + }, + mockArtifactStorage({ namespace: projectName }), + ); + cy.intercept( + 'GET', + 'https://test.s3.dualstack.us-east-1.amazonaws.com/metrics-visualization-pipeline/5e873c64-39fa-4dd4-83db-eff0cdd1e274/html-visualization/html_artifact?X-Amz-Algorithm=AWS4-HMAC-SHA256\u0026X-Amz-Credential=AKIAYQPE7PSILMBBLXMO%2F20240808%2Fus-east-1%2Fs3%2Faws4_request\u0026X-Amz-Date=20240808T070034Z\u0026X-Amz-Expires=15\u0026X-Amz-SignedHeaders=host\u0026response-content-disposition=attachment%3B%20filename%3D%22%22\u0026X-Amz-Signature=de39ee684dd606e75da3b07c1b9f0820f7442ea7a037ae1bffccea9e33610ea9', + 'helloWorld', + ); + }); + + it('display markdown based on selections from Run list', () => { + compareRunsMetricsContent.findMarkdownTab().click(); + let markDown = compareRunsMetricsContent + .findMarkdownTabContent() + .findMarkdownSelect(mockRun.run_id); + markDown.findIframeContent().should('have.text', 'helloWorld'); + markDown = compareRunsMetricsContent + .findMarkdownTabContent() + .findMarkdownSelect(mockRun2.run_id); + markDown.findIframeContent().should('have.text', 'helloWorld'); + markDown.findExpandButton().click(); + compareRunsMetricsContent.findMarkdownTabContent().findExpandedMarkdown().should('exist'); + }); }); }); describe('No metrics', () => { beforeEach(() => { - initIntercepts(true); + initIntercepts({ noMetrics: true }); compareRunsGlobal.visit(projectName, mockExperiment.experiment_id, [ mockRun.run_id, mockRun2.run_id, @@ -374,10 +414,16 @@ describe('Compare runs', () => { }); }); -const initIntercepts = (noMetrics?: boolean) => { +type InterceptsType = { + noMetrics?: boolean; +}; + +const initIntercepts = ({ noMetrics }: InterceptsType) => { cy.interceptOdh( 'GET /api/config', - mockDashboardConfig({ disablePipelineExperiments: false, disableS3Endpoint: false }), + mockDashboardConfig({ + disablePipelineExperiments: false, + }), ); cy.interceptK8sList( DataSciencePipelineApplicationModel, diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/pipelines/pipelineCreateRuns.cy.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/pipelines/pipelineCreateRuns.cy.ts index 106b8c804d..118301d4dc 100644 --- a/frontend/src/__tests__/cypress/cypress/tests/mocked/pipelines/pipelineCreateRuns.cy.ts +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/pipelines/pipelineCreateRuns.cy.ts @@ -588,7 +588,7 @@ describe('Pipeline create runs', () => { it('creates a schedule with trigger type cron without whitespace', () => { // Fill out the form with a schedule and submit createScheduleRunCommonTest(); - createSchedulePage.findScheduledRunTypeSelector().findDropdownItem('Cron').click(); + createSchedulePage.findScheduledRunTypeSelector().findSelectOption('Cron').click(); createSchedulePage.findScheduledRunCron().fill('@every 5m'); createSchedulePage .mockCreateRecurringRun(projectName, mockPipelineVersion, createRecurringRunParams) @@ -623,7 +623,7 @@ describe('Pipeline create runs', () => { it('creates a schedule with trigger type cron with whitespace', () => { createScheduleRunCommonTest(); - createSchedulePage.findScheduledRunTypeSelector().findDropdownItem('Cron').click(); + createSchedulePage.findScheduledRunTypeSelector().findSelectOption('Cron').click(); createSchedulePage.findScheduledRunCron().fill('@every 5m '); createSchedulePage .mockCreateRecurringRun(projectName, mockPipelineVersion, createRecurringRunParams) diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/pipelines/pipelinesTopology.cy.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/pipelines/pipelinesTopology.cy.ts index 27cbf34b1d..4d2102f263 100644 --- a/frontend/src/__tests__/cypress/cypress/tests/mocked/pipelines/pipelinesTopology.cy.ts +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/pipelines/pipelinesTopology.cy.ts @@ -528,7 +528,9 @@ describe('Pipeline topology', () => { pipelineRecurringRunDetails.selectActionDropdownItem('Disable'); pipelineRecurringRunDetails.findActionsDropdown().click(); - cy.findByRole('menuitem', { name: 'Enable' }).should('be.visible'); + cy.get('[id="dashboard-page-main"]') + .findByRole('menuitem', { name: 'Enable' }) + .should('be.visible'); }); it('enables recurring run from action dropdown', () => { @@ -554,7 +556,9 @@ describe('Pipeline topology', () => { pipelineRecurringRunDetails.selectActionDropdownItem('Enable'); pipelineRecurringRunDetails.findActionsDropdown().click(); - cy.findByRole('menuitem', { name: 'Disable' }).should('be.visible'); + cy.get('[id="dashboard-page-main"]') + .findByRole('menuitem', { name: 'Disable' }) + .should('be.visible'); }); }); diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/projects/workbench.cy.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/projects/workbench.cy.ts index b8af0ed3c4..6c65571f19 100644 --- a/frontend/src/__tests__/cypress/cypress/tests/mocked/projects/workbench.cy.ts +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/projects/workbench.cy.ts @@ -542,11 +542,15 @@ describe('Workbench page', () => { }, ], }); + cy.interceptK8sList( + PVCModel, + mockK8sResourceList([mockPVCK8sResource({ name: 'test-notebook' })]), + ); editSpawnerPage.visit('test-notebook'); editSpawnerPage.findNameInput().should('have.value', 'Test Notebook'); editSpawnerPage.shouldHaveNotebookImageSelectInput('Test Image'); editSpawnerPage.shouldHaveContainerSizeInput('Small'); - editSpawnerPage.shouldHavePersistentStorage('test-notebook'); + editSpawnerPage.shouldHavePersistentStorage('Test Storage'); editSpawnerPage.findSubmitButton().should('be.enabled'); editSpawnerPage.findNameInput().fill('Updated Notebook'); diff --git a/frontend/src/api/k8s/inferenceServices.ts b/frontend/src/api/k8s/inferenceServices.ts index 735413c3b6..a9062393d2 100644 --- a/frontend/src/api/k8s/inferenceServices.ts +++ b/frontend/src/api/k8s/inferenceServices.ts @@ -91,6 +91,7 @@ export const assembleInferenceService = ( [KnownLabels.DASHBOARD_RESOURCE]: 'true', ...(!isModelMesh && !externalRoute && { 'networking.knative.dev/visibility': 'cluster-local' }), + ...data.labels, }, annotations: { 'openshift.io/display-name': data.name.trim(), diff --git a/frontend/src/app/AppRoutes.tsx b/frontend/src/app/AppRoutes.tsx index cc6ee4516d..aec4a09004 100644 --- a/frontend/src/app/AppRoutes.tsx +++ b/frontend/src/app/AppRoutes.tsx @@ -14,6 +14,7 @@ import { useCheckJupyterEnabled } from '~/utilities/notebookControllerUtils'; import { SupportedArea } from '~/concepts/areas'; import useIsAreaAvailable from '~/concepts/areas/useIsAreaAvailable'; import ModelRegistrySettingsRoutes from '~/pages/modelRegistrySettings/ModelRegistrySettingsRoutes'; +import ConnectionTypeRoutes from '~/pages/connectionTypes/ConnectionTypeRoutes'; const HomePage = React.lazy(() => import('../pages/home/Home')); @@ -69,6 +70,7 @@ const AppRoutes: React.FC = () => { const { isAdmin, isAllowed } = useUser(); const isJupyterEnabled = useCheckJupyterEnabled(); const isHomeAvailable = useIsAreaAvailable(SupportedArea.HOME).status; + const isConnectionTypesAvailable = useIsAreaAvailable(SupportedArea.CONNECTION_TYPES).status; if (!isAllowed) { return ( @@ -123,6 +125,9 @@ const AppRoutes: React.FC = () => { } /> } /> } /> + {isConnectionTypesAvailable ? ( + } /> + ) : null} } /> } /> diff --git a/frontend/src/components/DashboardDescriptionListGroup.tsx b/frontend/src/components/DashboardDescriptionListGroup.tsx index 9a461f8c6b..584d865c95 100644 --- a/frontend/src/components/DashboardDescriptionListGroup.tsx +++ b/frontend/src/components/DashboardDescriptionListGroup.tsx @@ -6,6 +6,8 @@ import { DescriptionListDescription, DescriptionListGroup, DescriptionListTerm, + Flex, + FlexItem, Split, SplitItem, } from '@patternfly/react-core'; @@ -25,6 +27,7 @@ type EditableProps = { export type DashboardDescriptionListGroupProps = { title: React.ReactNode; + tooltip?: React.ReactNode; action?: React.ReactNode; isEmpty?: boolean; contentWhenEmpty?: React.ReactNode; @@ -34,6 +37,7 @@ export type DashboardDescriptionListGroupProps = { const DashboardDescriptionListGroup: React.FC = (props) => { const { title, + tooltip, action, isEmpty, contentWhenEmpty, @@ -96,7 +100,15 @@ const DashboardDescriptionListGroup: React.FC ) : ( - {title} + + + {title} + {tooltip} + + )} {isEditing ? contentWhenEditing : isEmpty ? contentWhenEmpty : children} diff --git a/frontend/src/components/FilterToolbar.tsx b/frontend/src/components/FilterToolbar.tsx new file mode 100644 index 0000000000..697e84cde9 --- /dev/null +++ b/frontend/src/components/FilterToolbar.tsx @@ -0,0 +1,133 @@ +import * as React from 'react'; +import { + ToolbarFilter, + ToolbarGroup, + ToolbarItem, + ToolbarChip, + Tooltip, + Dropdown, + DropdownItem, + MenuToggle, + DropdownList, +} from '@patternfly/react-core'; +import { FilterIcon } from '@patternfly/react-icons'; + +type FilterOptionRenders = { + onChange: (value?: string, label?: string) => void; + value?: string; + label?: string; +}; + +export type ToolbarFilterProps = React.ComponentProps & { + children?: React.ReactNode; + filterOptions: { [key in T]?: string }; + filterOptionRenders: Record React.ReactNode>; + filterData: Record; + onFilterUpdate: (filterType: T, value?: string | { label: string; value: string }) => void; + onClearFilters: () => void; + testId?: string; +}; + +function FilterToolbar({ + filterOptions, + filterOptionRenders, + filterData, + onFilterUpdate, + onClearFilters, + children, + testId = 'filter-toolbar', + ...toolbarGroupProps +}: ToolbarFilterProps): React.JSX.Element { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const keys = Object.keys(filterOptions) as Array; + const [open, setOpen] = React.useState(false); + const [currentFilterType, setCurrentFilterType] = React.useState(keys[0]); + const isToolbarChip = (v: unknown): v is ToolbarChip & { key: T } => + !!v && Object.keys(v).every((k) => ['key', 'node'].includes(k)); + const filterItem = filterData[currentFilterType]; + + return ( + <> + + + setOpen(isOpenChange)} + shouldFocusToggleOnSelect + toggle={(toggleRef) => ( + setOpen(!open)} + isExpanded={open} + > + <> + {filterOptions[currentFilterType]} + + + )} + isOpen={open} + > + + {keys.map((filterKey) => ( + { + setOpen(false); + setCurrentFilterType(filterKey); + }} + > + {filterOptions[filterKey]} + + ))} + + + + ((filterKey) => { + const optionValue = filterOptions[filterKey]; + const data = filterData[filterKey]; + if (data) { + const dataValue: { label: string; value: string } | undefined = + typeof data === 'string' ? { label: data, value: data } : data; + return { + key: filterKey, + node: ( + + {optionValue}:{' '} + + {dataValue.label} + + + ), + }; + } + return null; + }) + .filter(isToolbarChip)} + deleteChip={(_, chip) => { + if (isToolbarChip(chip)) { + onFilterUpdate(chip.key, ''); + } + }} + deleteChipGroup={() => onClearFilters()} + > + {filterOptionRenders[currentFilterType]({ + onChange: (value, label) => + onFilterUpdate(currentFilterType, label && value ? { label, value } : value), + ...(typeof filterItem === 'string' ? { value: filterItem } : filterItem), + })} + + + {children} + + ); +} + +export default FilterToolbar; diff --git a/frontend/src/components/MultiSelection.tsx b/frontend/src/components/MultiSelection.tsx index 9cc8cb85fa..0f141bf77d 100644 --- a/frontend/src/components/MultiSelection.tsx +++ b/frontend/src/components/MultiSelection.tsx @@ -13,6 +13,8 @@ import { Button, HelperText, HelperTextItem, + SelectGroup, + Divider, } from '@patternfly/react-core'; import { TimesIcon } from '@patternfly/react-icons/dist/esm/icons/times-icon'; @@ -22,28 +24,94 @@ export type SelectionOptions = { selected?: boolean; }; +export type GroupSelectionOptions = { + id: number | string; + name: string; + values: SelectionOptions[]; +}; + type MultiSelectionProps = { - value: SelectionOptions[]; + id?: string; + value?: SelectionOptions[]; + groupedValues?: GroupSelectionOptions[]; setValue: (itemSelection: SelectionOptions[]) => void; + toggleId?: string; ariaLabel: string; + placeholder?: string; + isDisabled?: boolean; + selectionRequired?: boolean; + noSelectedOptionsMessage?: string; + toggleTestId?: string; }; -export const MultiSelection: React.FC = ({ value, setValue }) => { +export const MultiSelection: React.FC = ({ + value = [], + groupedValues = [], + setValue, + placeholder, + isDisabled, + ariaLabel = 'Options menu', + id, + toggleId, + toggleTestId, + selectionRequired, + noSelectedOptionsMessage = 'One or more options must be selected', +}) => { const [isOpen, setIsOpen] = React.useState(false); const [inputValue, setInputValue] = React.useState(''); const [focusedItemIndex, setFocusedItemIndex] = React.useState(null); const [activeItem, setActiveItem] = React.useState(null); const textInputRef = React.useRef(); - const selected = React.useMemo(() => value.filter((v) => v.selected), [value]); + + const selectGroups = React.useMemo(() => { + let counter = 0; + return groupedValues + .map((g) => { + const values = g.values.filter( + (v) => !inputValue || v.name.toLowerCase().includes(inputValue.toLowerCase()), + ); + return { + ...g, + values: values.map((v) => ({ ...v, index: counter++ })), + }; + }) + .filter((g) => g.values.length); + }, [inputValue, groupedValues]); + + const setOpen = (open: boolean) => { + setIsOpen(open); + if (!open) { + setInputValue(''); + } + }; + const groupOptions = selectGroups.reduce((acc, g) => { + acc.push(...g.values); + return acc; + }, []); + const selectOptions = React.useMemo( () => - value.filter((v) => !inputValue || v.name.toLowerCase().includes(inputValue.toLowerCase())), - [inputValue, value], + value + .filter((v) => !inputValue || v.name.toLowerCase().includes(inputValue.toLowerCase())) + .map((v, index) => ({ ...v, index: groupOptions.length + index })), + [groupOptions, inputValue, value], ); + const allOptions = React.useMemo(() => { + const options = []; + groupedValues.forEach((group) => options.push(...group.values)); + options.push(...value); + + return options; + }, [groupedValues, value]); + + const visibleOptions = [...groupOptions, ...selectOptions]; + + const selected = React.useMemo(() => allOptions.filter((v) => v.selected), [allOptions]); + React.useEffect(() => { if (inputValue) { - setIsOpen(true); + setOpen(true); } setFocusedItemIndex(null); setActiveItem(null); @@ -52,20 +120,23 @@ export const MultiSelection: React.FC = ({ value, setValue const handleMenuArrowKeys = (key: string) => { let indexToFocus; if (!isOpen) { - setIsOpen(true); + setOpen(true); + setFocusedItemIndex(0); return; } + const optionsLength = visibleOptions.length; + if (key === 'ArrowUp') { if (focusedItemIndex === null || focusedItemIndex === 0) { - indexToFocus = selectOptions.length - 1; + indexToFocus = optionsLength - 1; } else { indexToFocus = focusedItemIndex - 1; } } if (key === 'ArrowDown') { - if (focusedItemIndex === null || focusedItemIndex === selectOptions.length - 1) { + if (focusedItemIndex === null || focusedItemIndex === optionsLength - 1) { indexToFocus = 0; } else { indexToFocus = focusedItemIndex + 1; @@ -74,13 +145,13 @@ export const MultiSelection: React.FC = ({ value, setValue if (indexToFocus != null) { setFocusedItemIndex(indexToFocus); - const focusedItem = selectOptions[indexToFocus]; + const focusedItem = visibleOptions[indexToFocus]; setActiveItem(`select-multi-typeahead-${focusedItem.name.replace(' ', '-')}`); } }; const onInputKeyDown = (event: React.KeyboardEvent) => { - const focusedItem = focusedItemIndex !== null ? selectOptions[focusedItemIndex] : null; + const focusedItem = focusedItemIndex !== null ? visibleOptions[focusedItemIndex] : null; switch (event.key) { case 'Enter': if (isOpen && focusedItem) { @@ -92,7 +163,7 @@ export const MultiSelection: React.FC = ({ value, setValue break; case 'Tab': case 'Escape': - setIsOpen(false); + setOpen(false); setActiveItem(null); break; case 'ArrowUp': @@ -104,7 +175,7 @@ export const MultiSelection: React.FC = ({ value, setValue }; const onToggleClick = () => { - setIsOpen(!isOpen); + setOpen(!isOpen); setTimeout(() => textInputRef.current?.focus(), 100); }; const onTextInputChange = (_event: React.FormEvent, valueOfInput: string) => { @@ -113,22 +184,26 @@ export const MultiSelection: React.FC = ({ value, setValue const onSelect = (menuItem?: SelectionOptions) => { if (menuItem) { setValue( - selected.includes(menuItem) - ? value.map((option) => (option === menuItem ? { ...option, selected: false } : option)) - : value.map((option) => (option === menuItem ? { ...option, selected: true } : option)), + allOptions.map((option) => + option.id === menuItem.id ? { ...option, selected: !option.selected } : option, + ), ); } textInputRef.current?.focus(); }; - const noSelectedItems = value.filter((option) => option.selected).length === 0; + const noSelectedItems = allOptions.filter((option) => option.selected).length === 0; const toggle = (toggleRef: React.Ref) => ( @@ -144,6 +219,7 @@ export const MultiSelection: React.FC = ({ value, setValue role="combobox" isExpanded={isOpen} aria-controls="select-multi-typeahead-listbox" + placeholder={placeholder} > {selected.map((selection, index) => ( @@ -165,7 +241,7 @@ export const MultiSelection: React.FC = ({ value, setValue variant="plain" onClick={() => { setInputValue(''); - setValue(value.map((option) => ({ ...option, selected: false }))); + setValue(allOptions.map((option) => ({ ...option, selected: false }))); textInputRef.current?.focus(); }} aria-label="Clear input value" @@ -181,34 +257,63 @@ export const MultiSelection: React.FC = ({ value, setValue return ( <> - {noSelectedItems && ( - + {noSelectedItems && selectionRequired && ( + - One or more group must be selected + {noSelectedOptionsMessage} )} diff --git a/frontend/src/components/SimpleDropdownSelect.tsx b/frontend/src/components/SimpleDropdownSelect.tsx deleted file mode 100644 index 42716df6d9..0000000000 --- a/frontend/src/components/SimpleDropdownSelect.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import * as React from 'react'; -import { Truncate, Dropdown, MenuToggle, DropdownList, DropdownItem } from '@patternfly/react-core'; -import './SimpleDropdownSelect.scss'; - -export type SimpleDropdownOption = { - key: string; - label: string; - description?: React.ReactNode; - dropdownLabel?: React.ReactNode; - isPlaceholder?: boolean; -}; - -type SimpleDropdownProps = { - options: SimpleDropdownOption[]; - value: string; - placeholder?: string; - onChange: (key: string, isPlaceholder: boolean) => void; - isFullWidth?: boolean; - isDisabled?: boolean; - icon?: React.ReactNode; - dataTestId?: string; -} & Omit, 'isOpen' | 'toggle' | 'dropdownItems' | 'onChange'>; - -const SimpleDropdownSelect: React.FC = ({ - isDisabled, - onChange, - options, - placeholder = 'Select...', - value, - isFullWidth, - icon, - dataTestId, - ...props -}) => { - const [open, setOpen] = React.useState(false); - const selectedOption = options.find(({ key }) => key === value); - const selectedLabel = selectedOption?.label ?? placeholder; - - return ( - setOpen(false)} - onOpenChange={(isOpen: boolean) => setOpen(isOpen)} - toggle={(toggleRef) => ( - setOpen(!open)} - isExpanded={open} - > - - - )} - shouldFocusToggleOnSelect - > - - {options - .toSorted((a, b) => (a.isPlaceholder === b.isPlaceholder ? 0 : a.isPlaceholder ? -1 : 1)) - .map(({ key, dropdownLabel, label, description, isPlaceholder }) => ( - { - onChange(key, !!isPlaceholder); - setOpen(false); - }} - > - {dropdownLabel ?? label} - - ))} - - - ); -}; - -export default SimpleDropdownSelect; diff --git a/frontend/src/components/SimpleDropdownSelect.scss b/frontend/src/components/SimpleSelect.scss similarity index 100% rename from frontend/src/components/SimpleDropdownSelect.scss rename to frontend/src/components/SimpleSelect.scss diff --git a/frontend/src/components/SimpleSelect.tsx b/frontend/src/components/SimpleSelect.tsx new file mode 100644 index 0000000000..25904e3885 --- /dev/null +++ b/frontend/src/components/SimpleSelect.tsx @@ -0,0 +1,144 @@ +import * as React from 'react'; +import { + Truncate, + MenuToggle, + Select, + SelectList, + SelectOption, + SelectGroup, + Divider, +} from '@patternfly/react-core'; +import { MenuToggleProps } from '@patternfly/react-core/src/components/MenuToggle/MenuToggle'; + +import './SimpleSelect.scss'; + +export type SimpleSelectOption = { + key: string; + label: string; + description?: React.ReactNode; + dropdownLabel?: React.ReactNode; + isPlaceholder?: boolean; + isDisabled?: boolean; +}; + +export type SimpleGroupSelectOption = { + key: string; + label: string; + options: SimpleSelectOption[]; +}; + +type SimpleSelectProps = { + options?: SimpleSelectOption[]; + groupedOptions?: SimpleGroupSelectOption[]; + value?: string; + toggleLabel?: React.ReactNode; + placeholder?: string; + onChange: (key: string, isPlaceholder: boolean) => void; + isFullWidth?: boolean; + toggleProps?: MenuToggleProps; + isDisabled?: boolean; + icon?: React.ReactNode; + dataTestId?: string; +} & Omit< + React.ComponentProps, + 'isOpen' | 'toggle' | 'dropdownItems' | 'onChange' | 'selected' +>; + +const SimpleSelect: React.FC = ({ + isDisabled, + onChange, + options, + groupedOptions, + placeholder = 'Select...', + value, + toggleLabel, + isFullWidth, + icon, + dataTestId, + toggleProps, + ...props +}) => { + const [open, setOpen] = React.useState(false); + + const findOptionForKey = (key: string) => + options?.find((option) => option.key === key) || + groupedOptions + ?.reduce((acc, group) => [...acc, ...group.options], []) + .find((o) => o.key === key); + + const selectedOption = value ? findOptionForKey(value) : undefined; + const selectedLabel = selectedOption?.label ?? placeholder; + + return ( + + ); +}; + +export default SimpleSelect; diff --git a/frontend/src/components/TypeaheadSelect.tsx b/frontend/src/components/TypeaheadSelect.tsx new file mode 100644 index 0000000000..55a4b493b5 --- /dev/null +++ b/frontend/src/components/TypeaheadSelect.tsx @@ -0,0 +1,420 @@ +import React from 'react'; +import { + Select, + SelectOption, + SelectList, + SelectOptionProps, + MenuToggle, + MenuToggleElement, + TextInputGroup, + TextInputGroupMain, + TextInputGroupUtilities, + Button, + MenuToggleProps, + SelectProps, +} from '@patternfly/react-core'; +import { TimesIcon } from '@patternfly/react-icons'; + +export interface TypeaheadSelectOption extends Omit { + /** Content of the select option. */ + content: string | number; + /** Value of the select option. */ + value: string | number; + /** Indicator for option being selected */ + isSelected?: boolean; +} + +export interface TypeaheadSelectProps extends Omit { + /** Options of the select */ + selectOptions: TypeaheadSelectOption[]; + /** Callback triggered on selection. */ + onSelect?: ( + _event: + | React.MouseEvent + | React.KeyboardEvent + | undefined, + selection: string | number, + ) => void; + /** Callback triggered when the select opens or closes. */ + onToggle?: (nextIsOpen: boolean) => void; + /** Callback triggered when the text in the input field changes. */ + onInputChange?: (newValue: string) => void; + /** Function to return items matching the current filter value */ + filterFunction?: ( + filterValue: string, + options: TypeaheadSelectOption[], + ) => TypeaheadSelectOption[]; + /** Callback triggered when the clear button is selected */ + onClearSelection?: () => void; + /** Placeholder text for the select input. */ + placeholder?: string; + /** Flag to indicate if the typeahead select allows new items */ + isCreatable?: boolean; + /** Flag to indicate if create option should be at top of typeahead */ + isCreateOptionOnTop?: boolean; + /** Message to display to create a new option */ + createOptionMessage?: string | ((newValue: string) => string); + /** Message to display when no options are available. */ + noOptionsAvailableMessage?: string; + /** Message to display when no options match the filter. */ + noOptionsFoundMessage?: string | ((filter: string) => string); + /** Flag indicating the select should be disabled. */ + isDisabled?: boolean; + /** Width of the toggle. */ + toggleWidth?: string; + /** Additional props passed to the toggle. */ + toggleProps?: MenuToggleProps; +} + +const defaultNoOptionsFoundMessage = (filter: string) => `No results found for "${filter}"`; +const defaultCreateOptionMessage = (newValue: string) => `Create "${newValue}"`; + +const defaultFilterFunction = (filterValue: string, options: TypeaheadSelectOption[]) => + options.filter((o) => String(o.content).toLowerCase().includes(filterValue.toLowerCase())); + +const TypeaheadSelect: React.FunctionComponent = ({ + innerRef, + selectOptions, + onSelect, + onToggle, + onInputChange, + filterFunction = defaultFilterFunction, + onClearSelection, + placeholder = 'Select an option', + noOptionsAvailableMessage = 'No options are available', + noOptionsFoundMessage = defaultNoOptionsFoundMessage, + isCreatable = false, + isCreateOptionOnTop = false, + createOptionMessage = defaultCreateOptionMessage, + isDisabled, + toggleWidth, + toggleProps, + ...props +}: TypeaheadSelectProps) => { + const [isOpen, setIsOpen] = React.useState(false); + const [filterValue, setFilterValue] = React.useState(''); + const [isFiltering, setIsFiltering] = React.useState(false); + const [focusedItemIndex, setFocusedItemIndex] = React.useState(null); + const [activeItemId, setActiveItemId] = React.useState(null); + const textInputRef = React.useRef(); + + const NO_RESULTS = 'no results'; + + const selected = React.useMemo( + () => selectOptions.find((option) => option.value === props.selected || option.isSelected), + [props.selected, selectOptions], + ); + + const filteredSelections = React.useMemo(() => { + let newSelectOptions: TypeaheadSelectOption[] = selectOptions; + + // Filter menu items based on the text input value when one exists + if (isFiltering && filterValue) { + newSelectOptions = filterFunction(filterValue, selectOptions); + + if ( + isCreatable && + filterValue.trim() && + !newSelectOptions.find((o) => String(o.content).toLowerCase() === filterValue.toLowerCase()) + ) { + const createOption = { + content: + typeof createOptionMessage === 'string' + ? createOptionMessage + : createOptionMessage(filterValue), + value: filterValue, + }; + newSelectOptions = isCreateOptionOnTop + ? [createOption, ...newSelectOptions] + : [...newSelectOptions, createOption]; + } + + // When no options are found after filtering, display 'No results found' + if (!newSelectOptions.length) { + newSelectOptions = [ + { + isAriaDisabled: true, + content: + typeof noOptionsFoundMessage === 'string' + ? noOptionsFoundMessage + : noOptionsFoundMessage(filterValue), + value: NO_RESULTS, + }, + ]; + } + } + + // When no options are available, display 'No options available' + if (!newSelectOptions.length) { + newSelectOptions = [ + { + isAriaDisabled: true, + content: noOptionsAvailableMessage, + value: NO_RESULTS, + }, + ]; + } + + return newSelectOptions; + }, [ + isFiltering, + filterValue, + filterFunction, + selectOptions, + noOptionsFoundMessage, + isCreatable, + isCreateOptionOnTop, + createOptionMessage, + noOptionsAvailableMessage, + ]); + + React.useEffect(() => { + if (isFiltering) { + openMenu(); + } + // Don't update on openMenu changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isFiltering]); + + const setActiveAndFocusedItem = (itemIndex: number) => { + setFocusedItemIndex(itemIndex); + const focusedItem = selectOptions[itemIndex]; + setActiveItemId(String(focusedItem.value)); + }; + + const resetActiveAndFocusedItem = () => { + setFocusedItemIndex(null); + setActiveItemId(null); + }; + + const openMenu = () => { + if (!isOpen) { + if (onToggle) { + onToggle(true); + } + setIsOpen(true); + } + }; + + const closeMenu = () => { + if (onToggle) { + onToggle(false); + } + setIsOpen(false); + resetActiveAndFocusedItem(); + setIsFiltering(false); + setFilterValue(String(selected?.content ?? '')); + }; + + const onInputClick = () => { + if (!isOpen) { + openMenu(); + setTimeout(() => { + textInputRef.current?.focus(); + }, 100); + } else if (isFiltering) { + closeMenu(); + } + }; + + const selectOption = ( + _event: + | React.MouseEvent + | React.KeyboardEvent + | undefined, + option: TypeaheadSelectOption, + ) => { + if (onSelect) { + onSelect(_event, option.value); + } + closeMenu(); + }; + + const handleSelect = ( + _event: React.MouseEvent | undefined, + value: string | number | undefined, + ) => { + if (value && value !== NO_RESULTS) { + const optionToSelect = selectOptions.find((option) => option.value === value); + if (optionToSelect) { + selectOption(_event, optionToSelect); + } else if (isCreatable) { + selectOption(_event, { value, content: value }); + } + } + }; + + const onTextInputChange = (_event: React.FormEvent, value: string) => { + setFilterValue(value || ''); + setIsFiltering(true); + if (onInputChange) { + onInputChange(value); + } + + resetActiveAndFocusedItem(); + }; + + const handleMenuArrowKeys = (key: string) => { + let indexToFocus = 0; + + openMenu(); + + if (filteredSelections.every((option) => option.isDisabled)) { + return; + } + + if (key === 'ArrowUp') { + // When no index is set or at the first index, focus to the last, otherwise decrement focus index + if (focusedItemIndex === null || focusedItemIndex === 0) { + indexToFocus = filteredSelections.length - 1; + } else { + indexToFocus = focusedItemIndex - 1; + } + + // Skip disabled options + while (filteredSelections[indexToFocus].isDisabled) { + indexToFocus--; + if (indexToFocus === -1) { + indexToFocus = filteredSelections.length - 1; + } + } + } + + if (key === 'ArrowDown') { + // When no index is set or at the last index, focus to the first, otherwise increment focus index + if (focusedItemIndex === null || focusedItemIndex === filteredSelections.length - 1) { + indexToFocus = 0; + } else { + indexToFocus = focusedItemIndex + 1; + } + + // Skip disabled options + while (filteredSelections[indexToFocus].isDisabled) { + indexToFocus++; + if (indexToFocus === filteredSelections.length) { + indexToFocus = 0; + } + } + } + + setActiveAndFocusedItem(indexToFocus); + }; + + const onInputKeyDown = (event: React.KeyboardEvent) => { + const focusedItem = focusedItemIndex !== null ? filteredSelections[focusedItemIndex] : null; + + switch (event.key) { + case 'Enter': + if ( + isOpen && + focusedItem && + focusedItem.value !== NO_RESULTS && + !focusedItem.isAriaDisabled + ) { + selectOption(event, focusedItem); + } + + openMenu(); + + break; + case 'ArrowUp': + case 'ArrowDown': + event.preventDefault(); + handleMenuArrowKeys(event.key); + break; + } + }; + + const onToggleClick = () => { + if (!isOpen) { + openMenu(); + } else { + closeMenu(); + } + textInputRef.current?.focus(); + }; + + const onClearButtonClick = () => { + if (selected && onSelect) { + onSelect(undefined, selected.value); + } + setFilterValue(''); + if (onInputChange) { + onInputChange(''); + } + setIsFiltering(false); + resetActiveAndFocusedItem(); + textInputRef.current?.focus(); + if (onClearSelection) { + onClearSelection(); + } + }; + + const toggle = (toggleRef: React.Ref) => ( + + + + + + + + + + ); + + return ( + + ); +}; + +export default TypeaheadSelect; diff --git a/frontend/src/components/table/TableRowTitleDescription.tsx b/frontend/src/components/table/TableRowTitleDescription.tsx index e6db33d0c9..ae833e5220 100644 --- a/frontend/src/components/table/TableRowTitleDescription.tsx +++ b/frontend/src/components/table/TableRowTitleDescription.tsx @@ -26,7 +26,12 @@ const TableRowTitleDescription: React.FC = ({ descriptionNode = descriptionAsMarkdown ? ( ) : ( - {description} + + {description} + ); } diff --git a/frontend/src/concepts/analyticsTracking/segmentIOUtils.tsx b/frontend/src/concepts/analyticsTracking/segmentIOUtils.tsx index 080ebeb379..f55556ba6b 100644 --- a/frontend/src/concepts/analyticsTracking/segmentIOUtils.tsx +++ b/frontend/src/concepts/analyticsTracking/segmentIOUtils.tsx @@ -86,6 +86,13 @@ export const firePageEvent = (): void => { } }; +// Stuff that gets send over as traits on an identify call. Must not include (anonymous) user Id. +type IdentifyTraits = { + isAdmin: boolean; + canCreateProjects: boolean; + clusterID: string; +}; + /* * This fires a call to associate further processing with the passed (anonymous) userId * in the properties. @@ -94,8 +101,13 @@ export const fireIdentifyEvent = (properties: IdentifyEventProperties): void => const clusterID = window.clusterID ?? ''; if (DEV_MODE) { /* eslint-disable-next-line no-console */ - console.log(`Identify event triggered`); + console.log(`Identify event triggered: ${JSON.stringify(properties)}`); } else if (window.analytics) { - window.analytics.identify(properties.anonymousID, { clusterID }); + const traits: IdentifyTraits = { + clusterID, + isAdmin: properties.isAdmin, + canCreateProjects: properties.canCreateProjects, + }; + window.analytics.identify(properties.anonymousID, traits); } }; diff --git a/frontend/src/concepts/analyticsTracking/trackingProperties.ts b/frontend/src/concepts/analyticsTracking/trackingProperties.ts index 81c26f44e9..61a56add1e 100644 --- a/frontend/src/concepts/analyticsTracking/trackingProperties.ts +++ b/frontend/src/concepts/analyticsTracking/trackingProperties.ts @@ -3,7 +3,9 @@ export type ODHSegmentKey = { }; export type IdentifyEventProperties = { + isAdmin: boolean; anonymousID?: string; + canCreateProjects: boolean; }; export const enum TrackingOutcome { diff --git a/frontend/src/concepts/analyticsTracking/useSegmentTracking.ts b/frontend/src/concepts/analyticsTracking/useSegmentTracking.ts index d609a9a2f9..7c5473d832 100644 --- a/frontend/src/concepts/analyticsTracking/useSegmentTracking.ts +++ b/frontend/src/concepts/analyticsTracking/useSegmentTracking.ts @@ -2,6 +2,7 @@ import React from 'react'; import { useAppContext } from '~/app/AppContext'; import { useAppSelector } from '~/redux/hooks'; import { fireIdentifyEvent, firePageEvent } from '~/concepts/analyticsTracking/segmentIOUtils'; +import { useTrackUser } from '~/concepts/analyticsTracking/useTrackUser'; import { useWatchSegmentKey } from './useWatchSegmentKey'; import { initSegment } from './initSegment'; @@ -10,28 +11,28 @@ export const useSegmentTracking = (): void => { const { dashboardConfig } = useAppContext(); const username = useAppSelector((state) => state.user); const clusterID = useAppSelector((state) => state.clusterID); + const [userProps, uPropsLoaded] = useTrackUser(username); + const disableTrackingConfig = dashboardConfig.spec.dashboardConfig.disableTracking; React.useEffect(() => { - if (segmentKey && loaded && !loadError && username && clusterID) { - const computeUserId = async () => { - const anonymousIDBuffer = await crypto.subtle.digest( - 'SHA-1', - new TextEncoder().encode(username), - ); - const anonymousIDArray = Array.from(new Uint8Array(anonymousIDBuffer)); - return anonymousIDArray.map((b) => b.toString(16).padStart(2, '0')).join(''); - }; - + if (segmentKey && loaded && !loadError && username && clusterID && uPropsLoaded) { window.clusterID = clusterID; initSegment({ segmentKey, - enabled: !dashboardConfig.spec.dashboardConfig.disableTracking, + enabled: !disableTrackingConfig, }).then(() => { - computeUserId().then((userId) => { - fireIdentifyEvent({ anonymousID: userId }); - firePageEvent(); - }); + fireIdentifyEvent(userProps); + firePageEvent(); }); } - }, [clusterID, loadError, loaded, segmentKey, username, dashboardConfig]); + }, [ + clusterID, + loadError, + loaded, + segmentKey, + username, + disableTrackingConfig, + userProps, + uPropsLoaded, + ]); }; diff --git a/frontend/src/concepts/analyticsTracking/useTrackUser.ts b/frontend/src/concepts/analyticsTracking/useTrackUser.ts new file mode 100644 index 0000000000..27429837c4 --- /dev/null +++ b/frontend/src/concepts/analyticsTracking/useTrackUser.ts @@ -0,0 +1,46 @@ +import React from 'react'; +import { useUser } from '~/redux/selectors'; +import { useAccessReview } from '~/api'; +import { AccessReviewResourceAttributes } from '~/k8sTypes'; +import { IdentifyEventProperties } from '~/concepts/analyticsTracking/trackingProperties'; + +export const useTrackUser = (username?: string): [IdentifyEventProperties, boolean] => { + const { isAdmin } = useUser(); + const [anonymousId, setAnonymousId] = React.useState(undefined); + + const createReviewResource: AccessReviewResourceAttributes = { + group: 'project.openshift.io', + resource: 'projectrequests', + verb: 'create', + }; + const [allowCreate, acLoaded] = useAccessReview(createReviewResource); + + React.useEffect(() => { + const computeAnonymousUserId = async () => { + const anonymousIDBuffer = await crypto.subtle.digest( + 'SHA-1', + new TextEncoder().encode(username), + ); + const anonymousIDArray = Array.from(new Uint8Array(anonymousIDBuffer)); + const aId = anonymousIDArray.map((b) => b.toString(16).padStart(2, '0')).join(''); + return aId; + }; + + computeAnonymousUserId().then((val) => { + setAnonymousId(val); + }); + // compute anonymousId only once + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const props: IdentifyEventProperties = React.useMemo( + () => ({ + isAdmin, + canCreateProjects: allowCreate, + anonymousID: anonymousId, + }), + [isAdmin, allowCreate, anonymousId], + ); + + return [props, acLoaded && !!anonymousId]; +}; diff --git a/frontend/src/concepts/areas/const.ts b/frontend/src/concepts/areas/const.ts index d10e8ad62d..51f8aafb6c 100644 --- a/frontend/src/concepts/areas/const.ts +++ b/frontend/src/concepts/areas/const.ts @@ -25,8 +25,6 @@ export const allFeatureFlags: string[] = Object.keys({ disableModelMesh: false, disableAcceleratorProfiles: false, disablePipelineExperiments: false, - disableS3Endpoint: false, - disableArtifactsAPI: false, disableDistributedWorkloads: false, disableModelRegistry: false, disableConnectionTypes: false, @@ -46,6 +44,9 @@ export const SupportedAreasStateMap: SupportedAreasState = { featureFlags: ['disableCustomServingRuntimes'], reliantAreas: [SupportedArea.MODEL_SERVING], }, + [SupportedArea.CONNECTION_TYPES]: { + featureFlags: ['disableConnectionTypes'], + }, [SupportedArea.DS_PIPELINES]: { featureFlags: ['disablePipelines'], requiredComponents: [StackComponent.DS_PIPELINES], @@ -105,16 +106,6 @@ export const SupportedAreasStateMap: SupportedAreasState = { featureFlags: ['disablePipelineExperiments'], reliantAreas: [SupportedArea.DS_PIPELINES], }, - - [SupportedArea.ARTIFACT_API]: { - featureFlags: ['disableArtifactsAPI'], - reliantAreas: [SupportedArea.DS_PIPELINES], - }, - - [SupportedArea.S3_ENDPOINT]: { - featureFlags: ['disableS3Endpoint'], - reliantAreas: [SupportedArea.DS_PIPELINES], - }, [SupportedArea.DISTRIBUTED_WORKLOADS]: { featureFlags: ['disableDistributedWorkloads'], requiredComponents: [StackComponent.KUEUE], @@ -124,7 +115,4 @@ export const SupportedAreasStateMap: SupportedAreasState = { requiredComponents: [StackComponent.MODEL_REGISTRY], requiredCapabilities: [StackCapability.SERVICE_MESH, StackCapability.SERVICE_MESH_AUTHZ], }, - [SupportedArea.DATA_CONNECTIONS_TYPES]: { - featureFlags: ['disableConnectionTypes'], - }, }; diff --git a/frontend/src/concepts/areas/types.ts b/frontend/src/concepts/areas/types.ts index bd1b4c9c10..f0034fe283 100644 --- a/frontend/src/concepts/areas/types.ts +++ b/frontend/src/concepts/areas/types.ts @@ -32,14 +32,13 @@ export enum SupportedArea { /* Pipelines areas */ DS_PIPELINES = 'ds-pipelines', PIPELINE_EXPERIMENTS = 'pipeline-experiments', - S3_ENDPOINT = 's3-endpoint', - ARTIFACT_API = 's3-artifact-api', /* Admin areas */ BYON = 'bring-your-own-notebook', CLUSTER_SETTINGS = 'cluster-settings', USER_MANAGEMENT = 'user-management', ACCELERATOR_PROFILES = 'accelerator-profiles', + CONNECTION_TYPES = 'connections-types', /* DS Projects specific areas */ DS_PROJECTS_PERMISSIONS = 'ds-projects-permission', @@ -61,8 +60,6 @@ export enum SupportedArea { /* Model Registry areas */ MODEL_REGISTRY = 'model-registry', - - DATA_CONNECTIONS_TYPES = 'data-connections-types', } /** Components deployed by the Operator. Part of the DSC Status. */ diff --git a/frontend/src/concepts/connectionTypes/ConnectionTypePreviewDrawer.tsx b/frontend/src/concepts/connectionTypes/ConnectionTypePreviewDrawer.tsx index 65d9f8c5ee..38d00e074b 100644 --- a/frontend/src/concepts/connectionTypes/ConnectionTypePreviewDrawer.tsx +++ b/frontend/src/concepts/connectionTypes/ConnectionTypePreviewDrawer.tsx @@ -6,7 +6,6 @@ import { DrawerActions, DrawerCloseButton, DrawerContent, - DrawerContentBody, Title, DrawerPanelBody, Card, @@ -68,9 +67,7 @@ const ConnectionTypePreviewDrawer: React.FC = ({ children, isExpanded, on return ( - - {children} - + {children} ); }; diff --git a/frontend/src/concepts/connectionTypes/__tests__/utils.spec.ts b/frontend/src/concepts/connectionTypes/__tests__/utils.spec.ts index 3cabf41ca8..cf93a5b726 100644 --- a/frontend/src/concepts/connectionTypes/__tests__/utils.spec.ts +++ b/frontend/src/concepts/connectionTypes/__tests__/utils.spec.ts @@ -1,7 +1,8 @@ import { mockConnectionTypeConfigMapObj } from '~/__mocks__/mockConnectionType'; -import { DropdownField, HiddenField, TextField } from '~/concepts/connectionTypes/types'; +import { DropdownField, HiddenField, TextField, UriField } from '~/concepts/connectionTypes/types'; import { defaultValueToString, + fieldTypeToString, toConnectionTypeConfigMap, toConnectionTypeConfigMapObj, } from '~/concepts/connectionTypes/utils'; @@ -201,3 +202,26 @@ describe('defaultValueToString', () => { ).toBe('Two, Three'); }); }); + +describe('fieldTypeToString', () => { + it('should return default value as string', () => { + expect( + fieldTypeToString({ + type: 'text', + name: 'test', + envVar: 'test', + properties: {}, + } satisfies TextField), + ).toBe('Text'); + expect( + fieldTypeToString({ + type: 'uri', + name: 'test', + envVar: 'test', + properties: { + defaultValue: '', + }, + } satisfies UriField), + ).toBe('URI'); + }); +}); diff --git a/frontend/src/concepts/connectionTypes/createConnectionTypeUtils.ts b/frontend/src/concepts/connectionTypes/createConnectionTypeUtils.ts new file mode 100644 index 0000000000..06fd1320ed --- /dev/null +++ b/frontend/src/concepts/connectionTypes/createConnectionTypeUtils.ts @@ -0,0 +1,44 @@ +import { ConnectionTypeConfigMapObj, ConnectionTypeField } from '~/concepts/connectionTypes/types'; + +export const extractConnectionTypeFromMap = ( + configMap?: ConnectionTypeConfigMapObj, +): { + k8sName: string; + name: string; + description: string; + enabled: boolean; + username: string; + fields: ConnectionTypeField[]; +} => ({ + k8sName: configMap?.metadata.name ?? '', + name: configMap?.metadata.annotations?.['openshift.io/display-name'] ?? '', + description: configMap?.metadata.annotations?.['openshift.io/description'] ?? '', + enabled: configMap?.metadata.annotations?.['opendatahub.io/enabled'] === 'true', + username: configMap?.metadata.annotations?.['opendatahub.io/username'] ?? '', + fields: configMap?.data?.fields ?? [], +}); + +export const createConnectionTypeObj = ( + k8sName: string, + displayName: string, + description: string, + enabled: boolean, + username: string, + fields: ConnectionTypeField[], +): ConnectionTypeConfigMapObj => ({ + kind: 'ConfigMap', + apiVersion: 'v1', + metadata: { + name: k8sName, + annotations: { + 'openshift.io/display-name': displayName, + 'openshift.io/description': description, + 'opendatahub.io/enabled': enabled ? 'true' : 'false', + 'opendatahub.io/username': username, + }, + labels: { 'opendatahub.io/dashboard': 'true', 'opendatahub.io/connection-type': 'true' }, + }, + data: { + fields, + }, +}); diff --git a/frontend/src/concepts/connectionTypes/fields/BooleanFormField.tsx b/frontend/src/concepts/connectionTypes/fields/BooleanFormField.tsx index 2f60d3f762..28875c0ace 100644 --- a/frontend/src/concepts/connectionTypes/fields/BooleanFormField.tsx +++ b/frontend/src/concepts/connectionTypes/fields/BooleanFormField.tsx @@ -1,36 +1,36 @@ import * as React from 'react'; import { Checkbox } from '@patternfly/react-core'; import { BooleanField } from '~/concepts/connectionTypes/types'; -import DataFormFieldGroup from '~/concepts/connectionTypes/fields/DataFormFieldGroup'; +import { FieldProps } from '~/concepts/connectionTypes/fields/types'; -type Props = { - field: BooleanField; - isPreview?: boolean; - value?: boolean; - onChange?: (value: boolean) => void; +const BooleanFormField: React.FC> = ({ + id, + field, + mode, + onChange, + value, + 'data-testid': dataTestId, +}) => { + const isPreview = mode === 'preview'; + return ( + undefined + : (_e, v) => onChange(v) + } + /> + ); }; -const BooleanFormField: React.FC = ({ field, isPreview, onChange, value }) => ( - - {(id) => ( - undefined - : (_e, v) => onChange(v) - } - /> - )} - -); - export default BooleanFormField; diff --git a/frontend/src/concepts/connectionTypes/fields/ConnectionTypeDataFormField.tsx b/frontend/src/concepts/connectionTypes/fields/ConnectionTypeDataFormField.tsx index 7a13c20830..fec74a5a3f 100644 --- a/frontend/src/concepts/connectionTypes/fields/ConnectionTypeDataFormField.tsx +++ b/frontend/src/concepts/connectionTypes/fields/ConnectionTypeDataFormField.tsx @@ -8,54 +8,30 @@ import TextFormField from '~/concepts/connectionTypes/fields/TextFormField'; import ShortTextFormField from '~/concepts/connectionTypes/fields/ShortTextFormField'; import UriFormField from '~/concepts/connectionTypes/fields/UriFormField'; import { ConnectionTypeDataField, ConnectionTypeFieldType } from '~/concepts/connectionTypes/types'; - -type Props = { - field: T; - isPreview?: boolean; - onChange?: (field: T, value: unknown) => void; - value?: T['properties']['defaultValue']; +import { FieldProps } from '~/concepts/connectionTypes/fields/types'; + +const components = { + [ConnectionTypeFieldType.ShortText]: ShortTextFormField, + [ConnectionTypeFieldType.Text]: TextFormField, + [ConnectionTypeFieldType.URI]: UriFormField, + [ConnectionTypeFieldType.Hidden]: HiddenFormField, + [ConnectionTypeFieldType.File]: FileFormField, + [ConnectionTypeFieldType.Boolean]: BooleanFormField, + [ConnectionTypeFieldType.Numeric]: NumericFormField, + [ConnectionTypeFieldType.Dropdown]: DropdownFormField, }; -const ConnectionTypeDataFormField = ({ - field, - isPreview, - onChange, - value, -}: Props): React.ReactNode => { - const commonProps = { - isPreview, - onChange: onChange ? (v: unknown) => onChange(field, v) : undefined, - // even though the value is the type of the field default value, typescript cannot determine this here - // or when applied to the element itself within the switch statement - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions,@typescript-eslint/no-explicit-any - value: value as any, - }; - switch (field.type) { - case ConnectionTypeFieldType.ShortText: - return ; - - case ConnectionTypeFieldType.Text: - return ; - - case ConnectionTypeFieldType.URI: - return ; - - case ConnectionTypeFieldType.Hidden: - return ; - - case ConnectionTypeFieldType.File: - return ; - - case ConnectionTypeFieldType.Boolean: - return ; - - case ConnectionTypeFieldType.Numeric: - return ; - - case ConnectionTypeFieldType.Dropdown: - return ; - } - return null; +const ConnectionTypeDataFormField = ( + props: FieldProps, +): React.ReactNode => { + const Component = components[props.field.type]; + return ( + + ); }; export default ConnectionTypeDataFormField; diff --git a/frontend/src/concepts/connectionTypes/fields/ConnectionTypeFormFields.tsx b/frontend/src/concepts/connectionTypes/fields/ConnectionTypeFormFields.tsx index a155abbdb7..a05ba90d06 100644 --- a/frontend/src/concepts/connectionTypes/fields/ConnectionTypeFormFields.tsx +++ b/frontend/src/concepts/connectionTypes/fields/ConnectionTypeFormFields.tsx @@ -1,6 +1,7 @@ import { FormSection } from '@patternfly/react-core'; import * as React from 'react'; import ConnectionTypeDataFormField from '~/concepts/connectionTypes/fields/ConnectionTypeDataFormField'; +import DataFormFieldGroup from '~/concepts/connectionTypes/fields/DataFormFieldGroup'; import SectionFormField from '~/concepts/connectionTypes/fields/SectionFormField'; import { ConnectionTypeDataField, @@ -17,9 +18,6 @@ type Props = { type FieldGroup = { section: SectionField | undefined; fields: ConnectionTypeDataField[] }; -const createKey = (field: ConnectionTypeField) => - `${field.type}-${field.type === ConnectionTypeFieldType.Section ? field.name : field.envVar}`; - const ConnectionTypeFormFields: React.FC = ({ fields, isPreview, onChange }) => { const fieldGroups = React.useMemo( () => @@ -37,20 +35,24 @@ const ConnectionTypeFormFields: React.FC = ({ fields, isPreview, onChange ); const renderDataFields = (dataFields: ConnectionTypeDataField[]) => - dataFields.map((field) => ( - + dataFields.map((field, i) => ( + + {(id) => ( + onChange(field, v) : undefined} + /> + )} + )); return ( <> - {fieldGroups?.map((fieldGroup) => + {fieldGroups?.map((fieldGroup, i) => fieldGroup.section ? ( - + {renderDataFields(fieldGroup.fields)} ) : ( diff --git a/frontend/src/concepts/connectionTypes/fields/DataFormFieldGroup.tsx b/frontend/src/concepts/connectionTypes/fields/DataFormFieldGroup.tsx index 2fe67db210..7ab2dd984e 100644 --- a/frontend/src/concepts/connectionTypes/fields/DataFormFieldGroup.tsx +++ b/frontend/src/concepts/connectionTypes/fields/DataFormFieldGroup.tsx @@ -1,41 +1,26 @@ import * as React from 'react'; -import { FormGroup } from '@patternfly/react-core'; +import { FormGroup, GenerateId } from '@patternfly/react-core'; import { ConnectionTypeDataField } from '~/concepts/connectionTypes/types'; -import FormGroupText from '~/components/FormGroupText'; -import UnspecifiedValue from '~/concepts/connectionTypes/fields/UnspecifiedValue'; -import { defaultValueToString } from '~/concepts/connectionTypes/utils'; -type Props = { - field: T; - isPreview: boolean; +type Props = { + field: ConnectionTypeDataField; children: (id: string) => React.ReactNode; - renderDefaultValue?: boolean; }; -const DataFormFieldGroup = ({ - field, - isPreview, - children, - renderDefaultValue = true, -}: Props): React.ReactNode => { - const id = `${field.type}-${field.envVar}`; - return ( - - {field.properties.defaultReadOnly && renderDefaultValue ? ( - - {defaultValueToString(field) ?? (isPreview ? : '-')} - - ) : ( - children(id) - )} - - ); -}; +const DataFormFieldGroup: React.FC = ({ field, children }): React.ReactNode => ( + + {(id) => ( + + {children(id)} + + )} + +); export default DataFormFieldGroup; diff --git a/frontend/src/concepts/connectionTypes/fields/DefaultValueTextRenderer.tsx b/frontend/src/concepts/connectionTypes/fields/DefaultValueTextRenderer.tsx new file mode 100644 index 0000000000..7ffdc17d25 --- /dev/null +++ b/frontend/src/concepts/connectionTypes/fields/DefaultValueTextRenderer.tsx @@ -0,0 +1,24 @@ +import * as React from 'react'; +import FormGroupText from '~/components/FormGroupText'; +import { FieldMode } from '~/concepts/connectionTypes/fields/types'; +import UnspecifiedValue from '~/concepts/connectionTypes/fields/UnspecifiedValue'; +import { ConnectionTypeDataField } from '~/concepts/connectionTypes/types'; +import { defaultValueToString } from '~/concepts/connectionTypes/utils'; + +type Props = { + id: string; + field: ConnectionTypeDataField; + mode?: FieldMode; + children: React.ReactNode; +}; + +const DefaultValueTextRenderer: React.FC = ({ id, field, mode, children }) => + mode !== 'default' && field.properties.defaultReadOnly ? ( + + {defaultValueToString(field) ?? (mode === 'preview' ? : '-')} + + ) : ( + children + ); + +export default DefaultValueTextRenderer; diff --git a/frontend/src/concepts/connectionTypes/fields/DropdownFormField.tsx b/frontend/src/concepts/connectionTypes/fields/DropdownFormField.tsx index 5a6032d23c..c9e1de730f 100644 --- a/frontend/src/concepts/connectionTypes/fields/DropdownFormField.tsx +++ b/frontend/src/concepts/connectionTypes/fields/DropdownFormField.tsx @@ -1,87 +1,88 @@ import * as React from 'react'; import { Badge, MenuToggle, Select, SelectList, SelectOption } from '@patternfly/react-core'; import { DropdownField } from '~/concepts/connectionTypes/types'; -import DataFormFieldGroup from '~/concepts/connectionTypes/fields/DataFormFieldGroup'; +import { FieldProps } from '~/concepts/connectionTypes/fields/types'; +import DefaultValueTextRenderer from '~/concepts/connectionTypes/fields/DefaultValueTextRenderer'; -type Props = { - field: DropdownField; - isPreview?: boolean; - onChange?: (value: string[]) => void; - value?: string[]; -}; - -const DropdownFormField: React.FC = ({ field, isPreview, onChange, value }) => { +const DropdownFormField: React.FC> = ({ + id, + field, + mode, + onChange, + value, + 'data-testid': dataTestId, +}) => { + const isPreview = mode === 'preview'; const [isOpen, setIsOpen] = React.useState(false); const isMulti = field.properties.variant === 'multi'; const selected = isPreview ? field.properties.defaultValue : value; return ( - - {(id) => ( - { + if (isMulti) { + if (selected?.includes(String(v))) { + onChange(selected.filter((s) => s !== v)); } else { - onChange([String(v)]); - setIsOpen(false); + onChange([...(selected || []), String(v)]); } + } else { + onChange([String(v)]); + setIsOpen(false); } - } - onOpenChange={(open) => setIsOpen(open)} - toggle={(toggleRef) => ( - { - setIsOpen((open) => !open); - }} - isExpanded={isOpen} + } + } + onOpenChange={(open) => setIsOpen(open)} + toggle={(toggleRef) => ( + { + setIsOpen((open) => !open); + }} + isExpanded={isOpen} + > + {isMulti ? ( + <> + Count{' '} + + {(isPreview ? field.properties.defaultValue?.length : value?.length) ?? 0}{' '} + selected + + + ) : ( + (isPreview + ? field.properties.items?.find( + (i) => i.value === field.properties.defaultValue?.[0], + )?.label + : field.properties.items?.find((i) => value?.includes(i.value))?.label) || + 'Select a value' + )} + + )} + > + + {field.properties.items?.map((i) => ( + - {isMulti ? ( - <> - Count{' '} - - {(isPreview ? field.properties.defaultValue?.length : value?.length) ?? 0}{' '} - selected - - - ) : ( - (isPreview - ? field.properties.items?.find( - (i) => i.value === field.properties.defaultValue?.[0], - )?.label - : field.properties.items?.find((i) => value?.includes(i.value))?.label) || - 'Select a value' - )} - - )} - > - - {field.properties.items?.map((i) => ( - - {i.label} - - ))} - - - )} - + {i.label} + + ))} + + + ); }; diff --git a/frontend/src/concepts/connectionTypes/fields/FileFormField.tsx b/frontend/src/concepts/connectionTypes/fields/FileFormField.tsx index 2ffb22052b..340a876aea 100644 --- a/frontend/src/concepts/connectionTypes/fields/FileFormField.tsx +++ b/frontend/src/concepts/connectionTypes/fields/FileFormField.tsx @@ -1,48 +1,44 @@ import * as React from 'react'; import { FileUpload } from '@patternfly/react-core'; import { FileField } from '~/concepts/connectionTypes/types'; -import DataFormFieldGroup from '~/concepts/connectionTypes/fields/DataFormFieldGroup'; +import { FieldProps } from '~/concepts/connectionTypes/fields/types'; -type Props = { - field: FileField; - isPreview?: boolean; - value?: string; - onChange?: (value: string) => void; -}; - -const FileFormField: React.FC = ({ field, isPreview, onChange, value }) => { +const FileFormField: React.FC> = ({ + id, + field, + mode, + onChange, + value, + 'data-testid': dataTestId, +}) => { + const isPreview = mode === 'preview'; const [isLoading, setIsLoading] = React.useState(false); const [filename, setFilename] = React.useState(''); const readOnly = isPreview || field.properties.defaultReadOnly; return ( - - {(id) => ( - onChange(content)} - onFileInputChange={(_e, file) => setFilename(file.name)} - onReadStarted={() => { - setIsLoading(true); - }} - onReadFinished={() => { - setIsLoading(false); - }} - /> - )} - + onChange(content)} + onFileInputChange={(_e, file) => setFilename(file.name)} + onReadStarted={() => { + setIsLoading(true); + }} + onReadFinished={() => { + setIsLoading(false); + }} + /> ); }; diff --git a/frontend/src/concepts/connectionTypes/fields/HiddenFormField.tsx b/frontend/src/concepts/connectionTypes/fields/HiddenFormField.tsx index 169c51fbfc..8e1ea1ce87 100644 --- a/frontend/src/concepts/connectionTypes/fields/HiddenFormField.tsx +++ b/frontend/src/concepts/connectionTypes/fields/HiddenFormField.tsx @@ -1,31 +1,34 @@ import * as React from 'react'; import { HiddenField } from '~/concepts/connectionTypes/types'; import PasswordInput from '~/components/PasswordInput'; -import DataFormFieldGroup from '~/concepts/connectionTypes/fields/DataFormFieldGroup'; +import { FieldProps } from '~/concepts/connectionTypes/fields/types'; +import DefaultValueTextRenderer from '~/concepts/connectionTypes/fields/DefaultValueTextRenderer'; -type Props = { - field: HiddenField; - isPreview?: boolean; - value?: string; - onChange?: (value: string) => void; -}; - -const HiddenFormField: React.FC = ({ field, isPreview, onChange, value }) => ( - - {(id) => ( +const HiddenFormField: React.FC> = ({ + id, + field, + mode, + onChange, + value, + 'data-testid': dataTestId, +}) => { + const isPreview = mode === 'preview'; + return ( + onChange(v)} /> - )} - -); + + ); +}; export default HiddenFormField; diff --git a/frontend/src/concepts/connectionTypes/fields/NumericFormField.tsx b/frontend/src/concepts/connectionTypes/fields/NumericFormField.tsx index 54a1975b40..cc981ac188 100644 --- a/frontend/src/concepts/connectionTypes/fields/NumericFormField.tsx +++ b/frontend/src/concepts/connectionTypes/fields/NumericFormField.tsx @@ -1,23 +1,26 @@ import * as React from 'react'; import { NumericField } from '~/concepts/connectionTypes/types'; import NumberInputWrapper from '~/components/NumberInputWrapper'; -import DataFormFieldGroup from '~/concepts/connectionTypes/fields/DataFormFieldGroup'; +import { FieldProps } from '~/concepts/connectionTypes/fields/types'; +import DefaultValueTextRenderer from '~/concepts/connectionTypes/fields/DefaultValueTextRenderer'; -type Props = { - field: NumericField; - isPreview?: boolean; - value?: number; - onChange?: (value: number) => void; -}; - -const NumericFormField: React.FC = ({ field, isPreview, onChange, value }) => ( - - {(id) => ( +const NumericFormField: React.FC> = ({ + id, + field, + mode, + onChange, + value, + 'data-testid': dataTestId, +}) => { + const isPreview = mode === 'preview'; + return ( + = ({ field, isPreview, onChange, value } // NumberInput shows a disabled input if no onChange provided onChange={isPreview || !onChange ? () => undefined : onChange} /> - )} - -); + + ); +}; export default NumericFormField; diff --git a/frontend/src/concepts/connectionTypes/fields/SectionFormField.tsx b/frontend/src/concepts/connectionTypes/fields/SectionFormField.tsx index 5f8cac18b6..0dfdafe447 100644 --- a/frontend/src/concepts/connectionTypes/fields/SectionFormField.tsx +++ b/frontend/src/concepts/connectionTypes/fields/SectionFormField.tsx @@ -5,10 +5,15 @@ import FormSection from '~/components/pf-overrides/FormSection'; type Props = { field: SectionField; children?: React.ReactNode; + 'data-testid'?: string; }; -const SectionFormField: React.FC = ({ field: { name, description }, children }) => ( - +const SectionFormField: React.FC = ({ + field: { name, description }, + children, + 'data-testid': dataTestId, +}) => ( + {children} ); diff --git a/frontend/src/concepts/connectionTypes/fields/ShortTextFormField.tsx b/frontend/src/concepts/connectionTypes/fields/ShortTextFormField.tsx index 99a03f5c2d..d8a86d5214 100644 --- a/frontend/src/concepts/connectionTypes/fields/ShortTextFormField.tsx +++ b/frontend/src/concepts/connectionTypes/fields/ShortTextFormField.tsx @@ -1,29 +1,32 @@ import * as React from 'react'; import { TextInput } from '@patternfly/react-core'; import { ShortTextField } from '~/concepts/connectionTypes/types'; -import DataFormFieldGroup from '~/concepts/connectionTypes/fields/DataFormFieldGroup'; +import { FieldProps } from '~/concepts/connectionTypes/fields/types'; +import DefaultValueTextRenderer from '~/concepts/connectionTypes/fields/DefaultValueTextRenderer'; -type Props = { - field: ShortTextField; - isPreview?: boolean; - value?: string; - onChange?: (value: string) => void; -}; - -const ShortTextFormField: React.FC = ({ field, isPreview, onChange, value }) => ( - - {(id) => ( +const ShortTextFormField: React.FC> = ({ + id, + field, + mode, + onChange, + value, + 'data-testid': dataTestId, +}) => { + const isPreview = mode === 'preview'; + return ( + onChange(v)} /> - )} - -); + + ); +}; export default ShortTextFormField; diff --git a/frontend/src/concepts/connectionTypes/fields/TextFormField.tsx b/frontend/src/concepts/connectionTypes/fields/TextFormField.tsx index ac101ea88b..d2d21921fe 100644 --- a/frontend/src/concepts/connectionTypes/fields/TextFormField.tsx +++ b/frontend/src/concepts/connectionTypes/fields/TextFormField.tsx @@ -1,18 +1,13 @@ import * as React from 'react'; import { TextArea } from '@patternfly/react-core'; import { TextField } from '~/concepts/connectionTypes/types'; -import DataFormFieldGroup from '~/concepts/connectionTypes/fields/DataFormFieldGroup'; +import { FieldProps } from '~/concepts/connectionTypes/fields/types'; +import DefaultValueTextRenderer from '~/concepts/connectionTypes/fields/DefaultValueTextRenderer'; -type Props = { - field: TextField; - isPreview?: boolean; - value?: string; - onChange?: (value: string) => void; -}; - -const TextFormField: React.FC = ({ field, isPreview, onChange, value }) => ( - - {(id) => ( +const TextFormField: React.FC> = ({ id, field, mode, onChange, value }) => { + const isPreview = mode === 'preview'; + return ( +