From 8e676c48484b95e4d81c3a98446015a9dc8ded09 Mon Sep 17 00:00:00 2001 From: Dipanshu Gupta Date: Mon, 22 Jul 2024 15:27:16 +0530 Subject: [PATCH] Block users from importing argo workflows in pipeline import modal --- .../__mocks__/mockPipelineVersionsProxy.ts | 110 +++++++++- .../cypress/pages/pipelines/createRunPage.ts | 10 +- .../pages/pipelines/pipelineImportModal.ts | 4 + .../pipelines/pipelineVersionImportModal.ts | 4 + .../cypress/cypress/support/commands/odh.ts | 6 +- .../pipelines/argo-workflow-pipeline.yaml | 131 ++++++++++++ .../mocked/pipelines/pipelineCreateRuns.cy.ts | 27 +++ .../tests/mocked/pipelines/pipelines.cy.ts | 73 +++++++ .../apiHooks/useLatestPipelineVersion.ts | 3 +- .../src/concepts/pipelines/content/const.ts | 10 + .../pipelines/content/createRun/RunForm.tsx | 11 +- .../pipelines/content/createRun/utils.ts | 10 +- .../content/import/PipelineImportModal.tsx | 33 ++- .../import/PipelineVersionImportModal.tsx | 37 +++- .../content/import/__tests__/utils.spec.ts | 41 ++++ .../pipelines/content/import/utils.ts | 13 ++ .../PipelineVersionSelector.tsx | 20 +- .../pipeline/PipelineDetails.tsx | 198 ++++++++++-------- .../pipeline/PipelineDetailsActions.tsx | 10 + .../pipeline/PipelineNotSupported.tsx | 32 +++ .../PipelineRecurringRunDetails.tsx | 64 +++--- .../PipelineRecurringRunDetailsActions.tsx | 62 +++--- .../pipelineRun/PipelineRunDetails.tsx | 61 +++--- .../pipelineRun/PipelineRunDetailsActions.tsx | 153 +++++++------- .../tables/pipeline/PipelinesTableRow.tsx | 17 +- .../pipelines/content/tables/utils.ts | 9 +- .../__tests__/usePipelineTaskTopology.spec.ts | 14 +- .../topology/usePipelineTaskTopology.tsx | 52 +++-- 28 files changed, 914 insertions(+), 301 deletions(-) create mode 100644 frontend/src/__tests__/cypress/cypress/tests/mocked/pipelines/argo-workflow-pipeline.yaml create mode 100644 frontend/src/concepts/pipelines/content/const.ts create mode 100644 frontend/src/concepts/pipelines/content/import/__tests__/utils.spec.ts create mode 100644 frontend/src/concepts/pipelines/content/pipelinesDetails/pipeline/PipelineNotSupported.tsx diff --git a/frontend/src/__mocks__/mockPipelineVersionsProxy.ts b/frontend/src/__mocks__/mockPipelineVersionsProxy.ts index 0dff70165a..ad2f9a295c 100644 --- a/frontend/src/__mocks__/mockPipelineVersionsProxy.ts +++ b/frontend/src/__mocks__/mockPipelineVersionsProxy.ts @@ -1,13 +1,23 @@ /* eslint-disable camelcase */ +import { K8sResourceCommon } from '@openshift/dynamic-plugin-sdk-utils'; import { ArtifactType, InputDefinitionParameterType, + PipelineKFCallCommon, PipelineVersionKF, PipelineVersionKFv2, RelationshipKF, ResourceTypeKF, } from '~/concepts/pipelines/kfTypes'; +export type ArgoWorkflowVersion = Omit & { + pipeline_spec: K8sResourceCommon; +}; + +export type BuildMockPipelinveVersionsType = PipelineKFCallCommon<{ + pipeline_versions: (PipelineVersionKFv2 | ArgoWorkflowVersion)[]; +}>; + /** * @deprecated Use `mockPipelineVersionsListV2` instead. */ @@ -732,14 +742,10 @@ export const buildMockPipelineVersions = ( }); export const buildMockPipelineVersionsV2 = ( - pipeline_versions: PipelineVersionKFv2[] = mockPipelineVersionsListV2, + pipeline_versions: (PipelineVersionKFv2 | ArgoWorkflowVersion)[] = mockPipelineVersionsListV2, totalSize?: number, nextPageToken?: string, -): { - total_size?: number | undefined; - next_page_token?: string | undefined; - pipeline_versions: PipelineVersionKFv2[]; -} => ({ +): BuildMockPipelinveVersionsType => ({ pipeline_versions, total_size: totalSize || pipeline_versions.length, next_page_token: nextPageToken, @@ -764,3 +770,95 @@ export const mockPipelineVersionsListSearch = ( .slice(0, 10); return buildMockPipelineVersions(filteredVersions, filteredVersions.length); }; + +export const mockArgoWorkflowPipelineVersion = (): ArgoWorkflowVersion => ({ + pipeline_id: 'test-pipeline', + pipeline_version_id: 'test-pipeline-version', + display_name: 'argo unsupported', + created_at: '2024-07-12T11:34:36Z', + pipeline_spec: { + apiVersion: 'argoproj.io/v1alpha1', + kind: 'Workflow', + metadata: { + annotations: { + 'pipelines.kubeflow.org/kfp_sdk_version': '1.8.22', + 'pipelines.kubeflow.org/pipeline_compilation_time': '2023-09-26T08:36:45.160091', + 'pipelines.kubeflow.org/pipeline_spec': + '{"description": "A sample pipeline to generate Confusion Matrix for UI visualization.", "name": "confusion-matrix-pipeline"}', + }, + generateName: 'confusion-matrix-pipeline-', + labels: { + 'pipelines.kubeflow.org/kfp_sdk_version': '1.8.22', + }, + }, + spec: { + arguments: {}, + entrypoint: 'confusion-matrix-pipeline', + serviceAccountName: 'pipeline-runner', + templates: [ + { + dag: { + tasks: [ + { + arguments: {}, + name: 'confusion-visualization', + template: 'confusion-visualization', + }, + ], + }, + inputs: {}, + metadata: {}, + name: 'confusion-matrix-pipeline', + outputs: {}, + }, + { + container: { + args: [ + '--matrix-uri', + 'https://raw.githubusercontent.com/kubeflow/pipelines/master/samples/core/visualization/confusion_matrix.csv', + '----output-paths', + '/tmp/outputs/mlpipeline_ui_metadata/data', + ], + command: [ + 'sh', + '-ec', + 'program_path=$(mktemp)\nprintf "%s" "$0" > "$program_path"\npython3 -u "$program_path" "$@"\n', + "def confusion_visualization(matrix_uri = 'https://raw.githubusercontent.com/kubeflow/pipelines/master/samples/core/visualization/confusion_matrix.csv'):\n \"\"\"Provide confusion matrix csv file to visualize as metrics.\"\"\"\n import json\n\n metadata = {\n 'outputs' : [{\n 'type': 'confusion_matrix',\n 'format': 'csv',\n 'schema': [\n {'name': 'target', 'type': 'CATEGORY'},\n {'name': 'predicted', 'type': 'CATEGORY'},\n {'name': 'count', 'type': 'NUMBER'},\n ],\n 'source': matrix_uri,\n 'labels': ['rose', 'lily', 'iris'],\n }]\n }\n\n from collections import namedtuple\n visualization_output = namedtuple('VisualizationOutput', [\n 'mlpipeline_ui_metadata'])\n return visualization_output(json.dumps(metadata))\n\nimport argparse\n_parser = argparse.ArgumentParser(prog='Confusion visualization', description='Provide confusion matrix csv file to visualize as metrics.')\n_parser.add_argument(\"--matrix-uri\", dest=\"matrix_uri\", type=str, required=False, default=argparse.SUPPRESS)\n_parser.add_argument(\"----output-paths\", dest=\"_output_paths\", type=str, nargs=1)\n_parsed_args = vars(_parser.parse_args())\n_output_files = _parsed_args.pop(\"_output_paths\", [])\n\n_outputs = confusion_visualization(**_parsed_args)\n\n_output_serializers = [\n str,\n\n]\n\nimport os\nfor idx, output_file in enumerate(_output_files):\n try:\n os.makedirs(os.path.dirname(output_file))\n except OSError:\n pass\n with open(output_file, 'w') as f:\n f.write(_output_serializers[idx](_outputs[idx]))\n", + ], + image: 'python:3.7', + name: '', + resources: {}, + }, + inputs: {}, + metadata: { + annotations: { + 'pipelines.kubeflow.org/arguments.parameters': + '{"matrix_uri": "https://raw.githubusercontent.com/kubeflow/pipelines/master/samples/core/visualization/confusion_matrix.csv"}', + 'pipelines.kubeflow.org/component_ref': '{}', + 'pipelines.kubeflow.org/component_spec': + '{"description": "Provide confusion matrix csv file to visualize as metrics.", "implementation": {"container": {"args": [{"if": {"cond": {"isPresent": "matrix_uri"}, "then": ["--matrix-uri", {"inputValue": "matrix_uri"}]}}, "----output-paths", {"outputPath": "mlpipeline_ui_metadata"}], "command": ["sh", "-ec", "program_path=$(mktemp)\\nprintf \\"%s\\" \\"$0\\" > \\"$program_path\\"\\npython3 -u \\"$program_path\\" \\"$@\\"\\n", "def confusion_visualization(matrix_uri = \'https://raw.githubusercontent.com/kubeflow/pipelines/master/samples/core/visualization/confusion_matrix.csv\'):\\n \\"\\"\\"Provide confusion matrix csv file to visualize as metrics.\\"\\"\\"\\n import json\\n\\n metadata = {\\n \'outputs\' : [{\\n \'type\': \'confusion_matrix\',\\n \'format\': \'csv\',\\n \'schema\': [\\n {\'name\': \'target\', \'type\': \'CATEGORY\'},\\n {\'name\': \'predicted\', \'type\': \'CATEGORY\'},\\n {\'name\': \'count\', \'type\': \'NUMBER\'},\\n ],\\n \'source\': matrix_uri,\\n \'labels\': [\'rose\', \'lily\', \'iris\'],\\n }]\\n }\\n\\n from collections import namedtuple\\n visualization_output = namedtuple(\'VisualizationOutput\', [\\n \'mlpipeline_ui_metadata\'])\\n return visualization_output(json.dumps(metadata))\\n\\nimport argparse\\n_parser = argparse.ArgumentParser(prog=\'Confusion visualization\', description=\'Provide confusion matrix csv file to visualize as metrics.\')\\n_parser.add_argument(\\"--matrix-uri\\", dest=\\"matrix_uri\\", type=str, required=False, default=argparse.SUPPRESS)\\n_parser.add_argument(\\"----output-paths\\", dest=\\"_output_paths\\", type=str, nargs=1)\\n_parsed_args = vars(_parser.parse_args())\\n_output_files = _parsed_args.pop(\\"_output_paths\\", [])\\n\\n_outputs = confusion_visualization(**_parsed_args)\\n\\n_output_serializers = [\\n str,\\n\\n]\\n\\nimport os\\nfor idx, output_file in enumerate(_output_files):\\n try:\\n os.makedirs(os.path.dirname(output_file))\\n except OSError:\\n pass\\n with open(output_file, \'w\') as f:\\n f.write(_output_serializers[idx](_outputs[idx]))\\n"], "image": "python:3.7"}}, "inputs": [{"default": "https://raw.githubusercontent.com/kubeflow/pipelines/master/samples/core/visualization/confusion_matrix.csv", "name": "matrix_uri", "optional": true, "type": "String"}], "name": "Confusion visualization", "outputs": [{"name": "mlpipeline_ui_metadata", "type": "UI_metadata"}]}', + }, + labels: { + 'pipelines.kubeflow.org/enable_caching': 'true', + 'pipelines.kubeflow.org/kfp_sdk_version': '1.8.22', + 'pipelines.kubeflow.org/pipeline-sdk-type': 'kfp', + }, + }, + name: 'confusion-visualization', + outputs: { + artifacts: [ + { + name: 'mlpipeline-ui-metadata', + path: '/tmp/outputs/mlpipeline_ui_metadata/data', + }, + ], + }, + }, + ], + }, + status: { + finishedAt: null, + startedAt: null, + }, + }, +}); diff --git a/frontend/src/__tests__/cypress/cypress/pages/pipelines/createRunPage.ts b/frontend/src/__tests__/cypress/cypress/pages/pipelines/createRunPage.ts index f8ab655e83..76eac3f578 100644 --- a/frontend/src/__tests__/cypress/cypress/pages/pipelines/createRunPage.ts +++ b/frontend/src/__tests__/cypress/cypress/pages/pipelines/createRunPage.ts @@ -8,6 +8,7 @@ import type { } from '~/concepts/pipelines/kfTypes'; import { buildMockExperiments, buildMockRunKF } from '~/__mocks__'; import { buildMockPipelines } from '~/__mocks__/mockPipelinesProxy'; +import type { ArgoWorkflowVersion } from '~/__mocks__/mockPipelineVersionsProxy'; import { buildMockPipelineVersionsV2 } from '~/__mocks__/mockPipelineVersionsProxy'; import { Contextual } from '~/__tests__/cypress/cypress/pages/components/Contextual'; import { buildMockRecurringRunKF } from '~/__mocks__/mockRecurringRunKF'; @@ -59,6 +60,13 @@ export class CreateRunPage { return this.find().findByTestId('pipeline-version-toggle-button'); } + findPipelineVersionByName(name: string): Cypress.Chainable> { + return this.find() + .findByTestId('pipeline-version-selector-table-list') + .find('td') + .contains(name); + } + findScheduledRunTypeSelector(): Cypress.Chainable> { return this.find().findByTestId('triggerTypeSelector'); } @@ -181,7 +189,7 @@ export class CreateRunPage { mockGetPipelineVersions( namespace: string, - versions: PipelineVersionKFv2[], + versions: (PipelineVersionKFv2 | ArgoWorkflowVersion)[], pipelineId: string, ): Cypress.Chainable { return cy.interceptOdh( diff --git a/frontend/src/__tests__/cypress/cypress/pages/pipelines/pipelineImportModal.ts b/frontend/src/__tests__/cypress/cypress/pages/pipelines/pipelineImportModal.ts index 94ab0b49fe..e473300a81 100644 --- a/frontend/src/__tests__/cypress/cypress/pages/pipelines/pipelineImportModal.ts +++ b/frontend/src/__tests__/cypress/cypress/pages/pipelines/pipelineImportModal.ts @@ -55,6 +55,10 @@ class PipelineImportModal extends Modal { return this.find().findByTestId('pipeline-file-upload-error'); } + findImportModalError() { + return this.find().findByTestId('import-modal-error'); + } + mockCreatePipelineAndVersion(params: CreatePipelineAndVersionKFData, namespace: string) { return cy.interceptOdh( 'POST /api/service/pipelines/:namespace/:serviceName/apis/v2beta1/pipelines/create', diff --git a/frontend/src/__tests__/cypress/cypress/pages/pipelines/pipelineVersionImportModal.ts b/frontend/src/__tests__/cypress/cypress/pages/pipelines/pipelineVersionImportModal.ts index d773f169a0..4eb9f62e84 100644 --- a/frontend/src/__tests__/cypress/cypress/pages/pipelines/pipelineVersionImportModal.ts +++ b/frontend/src/__tests__/cypress/cypress/pages/pipelines/pipelineVersionImportModal.ts @@ -54,6 +54,10 @@ class PipelineImportModal extends Modal { return this.find().findByTestId('code-source-input'); } + findImportModalError() { + return this.find().findByTestId('import-modal-error'); + } + selectPipelineByName(name: string) { this.findPipelineSelect() .click() diff --git a/frontend/src/__tests__/cypress/cypress/support/commands/odh.ts b/frontend/src/__tests__/cypress/cypress/support/commands/odh.ts index ac3a75d20b..b970c8608c 100644 --- a/frontend/src/__tests__/cypress/cypress/support/commands/odh.ts +++ b/frontend/src/__tests__/cypress/cypress/support/commands/odh.ts @@ -40,7 +40,6 @@ import type { ListExperimentsResponseKF, ListPipelineRecurringRunsResourceKF, ListPipelineRunsResourceKF, - ListPipelineVersionsKF, ListPipelinesResponseKF, PipelineKFv2, PipelineRecurringRunKFv2, @@ -48,6 +47,7 @@ import type { PipelineVersionKFv2, } from '~/concepts/pipelines/kfTypes'; import type { GrpcResponse } from '~/__mocks__/mlmd/utils'; +import type { ArgoWorkflowVersion, BuildMockPipelinveVersionsType } from '~/__mocks__'; type SuccessErrorResponse = { success: boolean; @@ -346,7 +346,7 @@ declare global { pipelineVersionId: string; }; }, - response: OdhResponse, + response: OdhResponse, ) => Cypress.Chainable) & (( type: `DELETE /api/service/pipelines/:namespace/:serviceName/apis/v2beta1/pipelines/:pipelineId/versions/:pipelineVersionId`, @@ -390,7 +390,7 @@ declare global { (( type: `GET /api/service/pipelines/:namespace/:serviceName/apis/v2beta1/pipelines/:pipelineId/versions`, options: { path: { namespace: string; serviceName: string; pipelineId: string } }, - response: OdhResponse, + response: OdhResponse, ) => Cypress.Chainable) & (( type: `GET /api/service/pipelines/:namespace/:serviceName/apis/v2beta1/pipelines`, diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/pipelines/argo-workflow-pipeline.yaml b/frontend/src/__tests__/cypress/cypress/tests/mocked/pipelines/argo-workflow-pipeline.yaml new file mode 100644 index 0000000000..381283ef20 --- /dev/null +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/pipelines/argo-workflow-pipeline.yaml @@ -0,0 +1,131 @@ +--- +apiVersion: argoproj.io/v1alpha1 +kind: Workflow +metadata: + annotations: + pipelines.kubeflow.org/kfp_sdk_version: 1.8.22 + pipelines.kubeflow.org/pipeline_compilation_time: '2023-09-26T08:36:45.160091' + pipelines.kubeflow.org/pipeline_spec: '{"description": "A sample pipeline to generate + Confusion Matrix for UI visualization.", "name": "confusion-matrix-pipeline"}' + creationTimestamp: + generateName: confusion-matrix-pipeline- + labels: + pipelines.kubeflow.org/kfp_sdk_version: 1.8.22 +spec: + arguments: {} + entrypoint: confusion-matrix-pipeline + serviceAccountName: pipeline-runner + templates: + - dag: + tasks: + - arguments: {} + name: confusion-visualization + template: confusion-visualization + inputs: {} + metadata: {} + name: confusion-matrix-pipeline + outputs: {} + - container: + args: + - "--matrix-uri" + - https://raw.githubusercontent.com/kubeflow/pipelines/master/samples/core/visualization/confusion_matrix.csv + - "----output-paths" + - "/tmp/outputs/mlpipeline_ui_metadata/data" + command: + - sh + - "-ec" + - | + program_path=$(mktemp) + printf "%s" "$0" > "$program_path" + python3 -u "$program_path" "$@" + - | + def confusion_visualization(matrix_uri = 'https://raw.githubusercontent.com/kubeflow/pipelines/master/samples/core/visualization/confusion_matrix.csv'): + """Provide confusion matrix csv file to visualize as metrics.""" + import json + + metadata = { + 'outputs' : [{ + 'type': 'confusion_matrix', + 'format': 'csv', + 'schema': [ + {'name': 'target', 'type': 'CATEGORY'}, + {'name': 'predicted', 'type': 'CATEGORY'}, + {'name': 'count', 'type': 'NUMBER'}, + ], + 'source': matrix_uri, + 'labels': ['rose', 'lily', 'iris'], + }] + } + + from collections import namedtuple + visualization_output = namedtuple('VisualizationOutput', [ + 'mlpipeline_ui_metadata']) + return visualization_output(json.dumps(metadata)) + + import argparse + _parser = argparse.ArgumentParser(prog='Confusion visualization', description='Provide confusion matrix csv file to visualize as metrics.') + _parser.add_argument("--matrix-uri", dest="matrix_uri", type=str, required=False, default=argparse.SUPPRESS) + _parser.add_argument("----output-paths", dest="_output_paths", type=str, nargs=1) + _parsed_args = vars(_parser.parse_args()) + _output_files = _parsed_args.pop("_output_paths", []) + + _outputs = confusion_visualization(**_parsed_args) + + _output_serializers = [ + str, + + ] + + import os + for idx, output_file in enumerate(_output_files): + try: + os.makedirs(os.path.dirname(output_file)) + except OSError: + pass + with open(output_file, 'w') as f: + f.write(_output_serializers[idx](_outputs[idx])) + image: python:3.7 + name: '' + resources: {} + inputs: {} + metadata: + annotations: + pipelines.kubeflow.org/arguments.parameters: '{"matrix_uri": "https://raw.githubusercontent.com/kubeflow/pipelines/master/samples/core/visualization/confusion_matrix.csv"}' + pipelines.kubeflow.org/component_ref: "{}" + pipelines.kubeflow.org/component_spec: '{"description": "Provide confusion + matrix csv file to visualize as metrics.", "implementation": {"container": + {"args": [{"if": {"cond": {"isPresent": "matrix_uri"}, "then": ["--matrix-uri", + {"inputValue": "matrix_uri"}]}}, "----output-paths", {"outputPath": "mlpipeline_ui_metadata"}], + "command": ["sh", "-ec", "program_path=$(mktemp)\nprintf \"%s\" \"$0\" > + \"$program_path\"\npython3 -u \"$program_path\" \"$@\"\n", "def confusion_visualization(matrix_uri + = ''https://raw.githubusercontent.com/kubeflow/pipelines/master/samples/core/visualization/confusion_matrix.csv''):\n \"\"\"Provide + confusion matrix csv file to visualize as metrics.\"\"\"\n import json\n\n metadata + = {\n ''outputs'' : [{\n ''type'': ''confusion_matrix'',\n ''format'': + ''csv'',\n ''schema'': [\n {''name'': ''target'', ''type'': + ''CATEGORY''},\n {''name'': ''predicted'', ''type'': ''CATEGORY''},\n {''name'': + ''count'', ''type'': ''NUMBER''},\n ],\n ''source'': matrix_uri,\n ''labels'': + [''rose'', ''lily'', ''iris''],\n }]\n }\n\n from collections + import namedtuple\n visualization_output = namedtuple(''VisualizationOutput'', + [\n ''mlpipeline_ui_metadata''])\n return visualization_output(json.dumps(metadata))\n\nimport + argparse\n_parser = argparse.ArgumentParser(prog=''Confusion visualization'', + description=''Provide confusion matrix csv file to visualize as metrics.'')\n_parser.add_argument(\"--matrix-uri\", + dest=\"matrix_uri\", type=str, required=False, default=argparse.SUPPRESS)\n_parser.add_argument(\"----output-paths\", + dest=\"_output_paths\", type=str, nargs=1)\n_parsed_args = vars(_parser.parse_args())\n_output_files + = _parsed_args.pop(\"_output_paths\", [])\n\n_outputs = confusion_visualization(**_parsed_args)\n\n_output_serializers + = [\n str,\n\n]\n\nimport os\nfor idx, output_file in enumerate(_output_files):\n try:\n os.makedirs(os.path.dirname(output_file))\n except + OSError:\n pass\n with open(output_file, ''w'') as f:\n f.write(_output_serializers[idx](_outputs[idx]))\n"], + "image": "python:3.7"}}, "inputs": [{"default": "https://raw.githubusercontent.com/kubeflow/pipelines/master/samples/core/visualization/confusion_matrix.csv", + "name": "matrix_uri", "optional": true, "type": "String"}], "name": "Confusion + visualization", "outputs": [{"name": "mlpipeline_ui_metadata", "type": "UI_metadata"}]}' + labels: + pipelines.kubeflow.org/enable_caching: 'true' + pipelines.kubeflow.org/kfp_sdk_version: 1.8.22 + pipelines.kubeflow.org/pipeline-sdk-type: kfp + name: confusion-visualization + outputs: + artifacts: + - name: mlpipeline-ui-metadata + path: "/tmp/outputs/mlpipeline_ui_metadata/data" +status: + finishedAt: + startedAt: 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 d4cc80d008..e0784efcc5 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 @@ -7,6 +7,7 @@ import { buildMockPipelineVersionV2, buildMockRecurringRunKF, buildMockExperimentKF, + mockArgoWorkflowPipelineVersion, } from '~/__mocks__'; import { createRunPage, @@ -24,6 +25,7 @@ import { configIntercept, dspaIntercepts, projectsIntercept } from './intercepts const projectName = 'test-project-name'; const mockPipeline = buildMockPipelineV2(); const mockPipelineVersion = buildMockPipelineVersionV2({ pipeline_id: mockPipeline.pipeline_id }); +const mockArgoPipelineVersion = mockArgoWorkflowPipelineVersion(); const pipelineVersionRef = { pipeline_id: mockPipeline.pipeline_id, pipeline_version_id: mockPipelineVersion.pipeline_version_id, @@ -98,6 +100,31 @@ describe('Pipeline create runs', () => { ); }); + it('Unsupported pipeline should not be displayed', () => { + visitLegacyRunsPage(); + + // Mock experiments, pipelines & versions for form select dropdowns + createRunPage.mockGetExperiments(projectName, mockExperiments); + createRunPage.mockGetPipelines(projectName, [mockPipeline]); + createRunPage.mockGetPipelineVersions( + projectName, + [mockArgoPipelineVersion, mockPipelineVersion], + mockArgoPipelineVersion.pipeline_id, + ); + + // Navigate to the 'Create run' page + pipelineRunsGlobal.findCreateRunButton().click(); + verifyRelativeURL( + `/pipelines/${projectName}/${mockArgoPipelineVersion.pipeline_id}/${mockArgoPipelineVersion.pipeline_version_id}/runs/create`, + ); + createRunPage.find(); + + createRunPage.findPipelineSelect().should('not.be.disabled').click(); + createRunPage.selectPipelineByName('Test pipeline'); + createRunPage.findPipelineVersionSelect().should('not.be.disabled').click(); + createRunPage.findPipelineVersionByName('argo unsupported').should('not.exist'); + }); + it('creates an active run', () => { visitLegacyRunsPage(); diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/pipelines/pipelines.cy.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/pipelines/pipelines.cy.ts index f2a5b582a7..3eb26b2a08 100644 --- a/frontend/src/__tests__/cypress/cypress/tests/mocked/pipelines/pipelines.cy.ts +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/pipelines/pipelines.cy.ts @@ -38,6 +38,7 @@ const initialMockPipelineVersion = buildMockPipelineVersionV2({ pipeline_id: initialMockPipeline.pipeline_id, }); const pipelineYamlPath = './cypress/tests/mocked/pipelines/mock-upload-pipeline.yaml'; +const argoWorkflowPipeline = './cypress/tests/mocked/pipelines/argo-workflow-pipeline.yaml'; const tooLargePipelineYAMLPath = './cypress/tests/mocked/pipelines/not-a-pipeline-2-megabytes.yaml'; describe('Pipelines', () => { @@ -597,6 +598,24 @@ describe('Pipelines', () => { pipelineImportModal.findSubmitButton().should('be.enabled'); }); + it('imports fails with Argo workflow', () => { + initIntercepts({}); + pipelinesGlobal.visit(projectName); + + // Open the "Import pipeline" modal + pipelinesGlobal.findImportPipelineButton().click(); + + // Fill out the "Import pipeline" modal and submit + pipelineImportModal.shouldBeOpen(); + pipelineImportModal.fillPipelineName('New pipeline'); + pipelineImportModal.fillPipelineDescription('New pipeline description'); + pipelineImportModal.uploadPipelineYaml(argoWorkflowPipeline); + pipelineImportModal.submit(); + + pipelineImportModal.findImportModalError().should('exist'); + pipelineImportModal.findImportModalError().contains('Unsupported pipeline version'); + }); + it('imports a new pipeline by url', () => { initIntercepts({}); pipelinesGlobal.visit(projectName); @@ -718,6 +737,28 @@ describe('Pipelines', () => { .should('exist'); }); + it('uploads fails with argo workflow', () => { + initIntercepts({}); + pipelinesGlobal.visit(projectName); + + // Wait for the pipelines table to load + pipelinesTable.find(); + + // Open the "Upload new version" modal + pipelinesGlobal.findUploadVersionButton().click(); + + // Fill out the "Upload new version" modal and submit + pipelineVersionImportModal.shouldBeOpen(); + pipelineVersionImportModal.selectPipelineByName('Test pipeline'); + pipelineVersionImportModal.fillVersionName('Argo workflow version'); + pipelineVersionImportModal.fillVersionDescription('Argo workflow version description'); + pipelineVersionImportModal.uploadPipelineYaml(argoWorkflowPipeline); + pipelineVersionImportModal.submit(); + + pipelineVersionImportModal.findImportModalError().should('exist'); + pipelineVersionImportModal.findImportModalError().contains('Unsupported pipeline version'); + }); + it('imports a new pipeline version by url', () => { initIntercepts({}); pipelinesGlobal.visit(projectName); @@ -960,6 +1001,38 @@ describe('Pipelines', () => { runCreateRunPageNavTest(visitPipelineProjects); }); + it('run and schedule dropdown action should be disabled when pipeline is not supported', () => { + const mockPipelines: PipelineKFv2[] = [ + buildMockPipelineV2({ + display_name: 'Argo workflow', + pipeline_id: 'argo-workflow', + }), + + buildMockPipelineV2({ + display_name: 'Supported pipeline', + pipeline_id: 'supported-pipeline', + }), + ]; + + initIntercepts({ mockPipelines }); + pipelinesGlobal.visit(projectName); + + // Wait for the pipelines table to load + pipelinesTable.find(); + pipelinesTable + .getRowById('supported-pipeline') + .findKebabAction('Create run') + .should('have.attr', 'aria-disabled'); + pipelinesTable + .getRowById('argo-workflow') + .findKebabAction('Create run') + .should('have.attr', 'aria-disabled'); + pipelinesTable + .getRowById('argo-workflow') + .findKebabAction('Create schedule') + .should('have.attr', 'aria-disabled'); + }); + it('run and schedule dropdown action should be disabeld when pipeline has no versions', () => { initIntercepts({ hasNoPipelineVersions: true }); pipelinesGlobal.visit(projectName); diff --git a/frontend/src/concepts/pipelines/apiHooks/useLatestPipelineVersion.ts b/frontend/src/concepts/pipelines/apiHooks/useLatestPipelineVersion.ts index 4d609d2cd7..1338e1947a 100644 --- a/frontend/src/concepts/pipelines/apiHooks/useLatestPipelineVersion.ts +++ b/frontend/src/concepts/pipelines/apiHooks/useLatestPipelineVersion.ts @@ -10,7 +10,7 @@ import useFetchState, { /** * Based on the pipeline associated with the provided pipelineId, - * fetch the last created pipeline version associated with that pipeline. + * fetch the last created pipeline version associated with that pipeline */ export const useLatestPipelineVersion = ( pipelineId: string | undefined, @@ -27,7 +27,6 @@ export const useLatestPipelineVersion = ( const response = await api.listPipelineVersions({}, pipelineId, { sortField: 'created_at', sortDirection: 'desc', - pageSize: 1, }); return response.pipeline_versions?.[0] || null; diff --git a/frontend/src/concepts/pipelines/content/const.ts b/frontend/src/concepts/pipelines/content/const.ts new file mode 100644 index 0000000000..c0d9ca3b76 --- /dev/null +++ b/frontend/src/concepts/pipelines/content/const.ts @@ -0,0 +1,10 @@ +export const PIPELINE_IMPORT_ARGO_ERROR_TEXT = + 'The selected pipeline was created using the Kubeflow v1 SDK, which is not supported by this UI. Select a pipeline that was created or recompiled using the Kubeflow v2 SDK.'; + +export const PIPELINE_ARGO_ERROR = 'Unsupported pipeline version'; + +export const PIPELINE_CREATE_SCHEDULE_TOOLTIP_ARGO_ERROR = + 'Cannot create schedule for Kubeflow v1 SDK pipelines'; + +export const PIPELINE_CREATE_RUN_TOOLTIP_ARGO_ERROR = + 'Cannot create run for Kubeflow v1 SDK pipelines'; diff --git a/frontend/src/concepts/pipelines/content/createRun/RunForm.tsx b/frontend/src/concepts/pipelines/content/createRun/RunForm.tsx index 2ca75e042d..92a85c1f19 100644 --- a/frontend/src/concepts/pipelines/content/createRun/RunForm.tsx +++ b/frontend/src/concepts/pipelines/content/createRun/RunForm.tsx @@ -9,6 +9,7 @@ import { PipelineVersionKFv2, RuntimeConfigParameters } from '~/concepts/pipelin import ProjectAndExperimentSection from '~/concepts/pipelines/content/createRun/contentSections/ProjectAndExperimentSection'; import { getDisplayNameFromK8sResource } from '~/concepts/k8s/utils'; import { useLatestPipelineVersion } from '~/concepts/pipelines/apiHooks/useLatestPipelineVersion'; +import { isArgoWorkflow } from '~/concepts/pipelines/content/tables/utils'; import PipelineSection from './contentSections/PipelineSection'; import { RunTypeSection } from './contentSections/RunTypeSection'; import { CreateRunPageSections, RUN_NAME_CHARACTER_LIMIT, runPageSectionTitles } from './const'; @@ -24,7 +25,15 @@ const RunForm: React.FC = ({ data, onValueChange, isCloned }) => { const [latestVersion] = useLatestPipelineVersion(data.pipeline?.pipeline_id); // Use this state to avoid the pipeline version being set as the latest version at the initial load const [initialLoadedState, setInitialLoadedState] = React.useState(true); - const selectedVersion = data.version || latestVersion; + const selectedVersion = React.useMemo(() => { + const version = data.version || latestVersion; + if (isArgoWorkflow(version?.pipeline_spec)) { + onValueChange('version', null); + return null; + } + return version; + }, [data.version, latestVersion, onValueChange]); + const paramsRef = React.useRef(data.params); const isSchedule = data.runType.type === RunTypeOption.SCHEDULED; diff --git a/frontend/src/concepts/pipelines/content/createRun/utils.ts b/frontend/src/concepts/pipelines/content/createRun/utils.ts index 10f88ffabd..2cd8daa0cc 100644 --- a/frontend/src/concepts/pipelines/content/createRun/utils.ts +++ b/frontend/src/concepts/pipelines/content/createRun/utils.ts @@ -8,6 +8,7 @@ import { import { ParametersKF, PipelineVersionKFv2 } from '~/concepts/pipelines/kfTypes'; import { getCorePipelineSpec } from '~/concepts/pipelines/getCorePipelineSpec'; import { convertToDate } from '~/utilities/time'; +import { isArgoWorkflow } from '~/concepts/pipelines/content/tables/utils'; const runTypeSafeData = (runType: RunFormData['runType']): boolean => runType.type !== RunTypeOption.SCHEDULED || @@ -58,5 +59,10 @@ export const isFilledRunFormDataExperiment = (formData: RunFormData): formData i export const getInputDefinitionParams = ( version: PipelineVersionKFv2 | null | undefined, -): ParametersKF | undefined => - getCorePipelineSpec(version?.pipeline_spec)?.root.inputDefinitions?.parameters; +): ParametersKF | undefined => { + // Return undefined for Argo workflow versions as they don't have root.inputDefinitions + if (isArgoWorkflow(version?.pipeline_spec)) { + return undefined; + } + return getCorePipelineSpec(version?.pipeline_spec)?.root.inputDefinitions?.parameters; +}; diff --git a/frontend/src/concepts/pipelines/content/import/PipelineImportModal.tsx b/frontend/src/concepts/pipelines/content/import/PipelineImportModal.tsx index f366553447..9197941709 100644 --- a/frontend/src/concepts/pipelines/content/import/PipelineImportModal.tsx +++ b/frontend/src/concepts/pipelines/content/import/PipelineImportModal.tsx @@ -14,8 +14,12 @@ import { usePipelinesAPI } from '~/concepts/pipelines/context'; import { usePipelineImportModalData } from '~/concepts/pipelines/content/import/useImportModalData'; import { PipelineKFv2 } from '~/concepts/pipelines/kfTypes'; import { getDisplayNameFromK8sResource } from '~/concepts/k8s/utils'; +import { + PIPELINE_ARGO_ERROR, + PIPELINE_IMPORT_ARGO_ERROR_TEXT, +} from '~/concepts/pipelines/content/const'; import PipelineUploadRadio from './PipelineUploadRadio'; -import { PipelineUploadOption } from './utils'; +import { PipelineUploadOption, extractKindFromPipelineYAML } from './utils'; type PipelineImportModalProps = { isOpen: boolean; @@ -28,6 +32,7 @@ const PipelineImportModal: React.FC = ({ isOpen, onClo const [error, setError] = React.useState(); const [{ name, description, fileContents, pipelineUrl, uploadOption }, setData, resetData] = usePipelineImportModalData(); + const isArgoWorkflow = extractKindFromPipelineYAML(fileContents) === 'Workflow'; const isImportButtonDisabled = !apiAvailable || @@ -47,13 +52,18 @@ const PipelineImportModal: React.FC = ({ isOpen, onClo setError(undefined); if (uploadOption === PipelineUploadOption.FILE_UPLOAD) { - api - .uploadPipeline({}, name, description, fileContents) - .then((pipeline) => onBeforeClose(pipeline)) - .catch((e) => { - setImporting(false); - setError(e); - }); + if (isArgoWorkflow) { + setImporting(false); + setError(new Error(PIPELINE_IMPORT_ARGO_ERROR_TEXT)); + } else { + api + .uploadPipeline({}, name, description, fileContents) + .then((pipeline) => onBeforeClose(pipeline)) + .catch((e) => { + setImporting(false); + setError(e); + }); + } } else { api .createPipelineAndVersion( @@ -152,7 +162,12 @@ const PipelineImportModal: React.FC = ({ isOpen, onClo {error && ( - + {error.message} diff --git a/frontend/src/concepts/pipelines/content/import/PipelineVersionImportModal.tsx b/frontend/src/concepts/pipelines/content/import/PipelineVersionImportModal.tsx index fc361f54a4..ec1845aae2 100644 --- a/frontend/src/concepts/pipelines/content/import/PipelineVersionImportModal.tsx +++ b/frontend/src/concepts/pipelines/content/import/PipelineVersionImportModal.tsx @@ -15,7 +15,15 @@ import { usePipelineVersionImportModalData } from '~/concepts/pipelines/content/ import { PipelineKFv2, PipelineVersionKFv2 } from '~/concepts/pipelines/kfTypes'; import PipelineSelector from '~/concepts/pipelines/content/pipelineSelector/PipelineSelector'; import { getDisplayNameFromK8sResource } from '~/concepts/k8s/utils'; -import { PipelineUploadOption, generatePipelineVersionName } from './utils'; +import { + PIPELINE_ARGO_ERROR, + PIPELINE_IMPORT_ARGO_ERROR_TEXT, +} from '~/concepts/pipelines/content/const'; +import { + PipelineUploadOption, + extractKindFromPipelineYAML, + generatePipelineVersionName, +} from './utils'; import PipelineUploadRadio from './PipelineUploadRadio'; type PipelineVersionImportModalProps = { @@ -35,6 +43,7 @@ const PipelineVersionImportModal: React.FC = ({ setData, resetData, ] = usePipelineVersionImportModalData(existingPipeline); + const isArgoWorkflow = extractKindFromPipelineYAML(fileContents) === 'Workflow'; const pipelineId = pipeline?.pipeline_id || ''; const pipelineName = pipeline?.display_name || ''; @@ -61,13 +70,18 @@ const PipelineVersionImportModal: React.FC = ({ setError(undefined); if (uploadOption === PipelineUploadOption.FILE_UPLOAD) { - api - .uploadPipelineVersion({}, name, description, fileContents, pipelineId) - .then((pipelineVersion) => onBeforeClose(pipelineVersion, pipeline)) - .catch((e) => { - setImporting(false); - setError(e); - }); + if (isArgoWorkflow) { + setImporting(false); + setError(new Error(PIPELINE_IMPORT_ARGO_ERROR_TEXT)); + } else { + api + .uploadPipelineVersion({}, name, description, fileContents, pipelineId) + .then((pipelineVersion) => onBeforeClose(pipelineVersion, pipeline)) + .catch((e) => { + setImporting(false); + setError(e); + }); + } } else { api .createPipelineVersion({}, pipelineId, { @@ -167,7 +181,12 @@ const PipelineVersionImportModal: React.FC = ({ {error && ( - + {error.message} diff --git a/frontend/src/concepts/pipelines/content/import/__tests__/utils.spec.ts b/frontend/src/concepts/pipelines/content/import/__tests__/utils.spec.ts new file mode 100644 index 0000000000..85c5db1755 --- /dev/null +++ b/frontend/src/concepts/pipelines/content/import/__tests__/utils.spec.ts @@ -0,0 +1,41 @@ +import { extractKindFromPipelineYAML } from '~/concepts/pipelines/content/import/utils'; + +describe('extractKindFromPipelineYAML', () => { + test('should return kind when kind field is present', () => { + const yamlFile = ` + apiVersion: argoproj.io/v1alpha1 + kind: Workflow + metadata: + name: example-workflow + `; + const result = extractKindFromPipelineYAML(yamlFile); + expect(result).toBe('Workflow'); + }); + + test('should return undefined when kind field is not present', () => { + const yamlFile = ` + apiVersion: argoproj.io/v1alpha1 + metadata: + name: example-workflow + `; + const result = extractKindFromPipelineYAML(yamlFile); + expect(result).toBeUndefined(); + }); + + test('should handle kind field with extra spaces', () => { + const yamlFile = ` + apiVersion: argoproj.io/v1alpha1 + kind: Workflow + metadata: + name: example-workflow + `; + const result = extractKindFromPipelineYAML(yamlFile); + expect(result).toBe('Workflow'); + }); + + test('should return undefined for an empty YAML string', () => { + const yamlFile = ''; + const result = extractKindFromPipelineYAML(yamlFile); + expect(result).toBeUndefined(); + }); +}); diff --git a/frontend/src/concepts/pipelines/content/import/utils.ts b/frontend/src/concepts/pipelines/content/import/utils.ts index 42cb8b6f20..b3cc91a5dc 100644 --- a/frontend/src/concepts/pipelines/content/import/utils.ts +++ b/frontend/src/concepts/pipelines/content/import/utils.ts @@ -1,3 +1,4 @@ +import YAML from 'yaml'; import { PipelineKFv2 } from '~/concepts/pipelines/kfTypes'; export const generatePipelineVersionName = (pipeline?: PipelineKFv2 | null): string => @@ -10,3 +11,15 @@ export enum PipelineUploadOption { URL_IMPORT, FILE_UPLOAD, } + +// Utility function to extract Kind from Pipeline YAML +export const extractKindFromPipelineYAML = (yamlFile: string): string | undefined => { + try { + const parsedYaml = YAML.parse(yamlFile); + return parsedYaml && parsedYaml.kind ? parsedYaml.kind : undefined; + } catch (e) { + // eslint-disable-next-line no-console + console.error('Error parsing YAML file:', e); + return undefined; + } +}; diff --git a/frontend/src/concepts/pipelines/content/pipelineSelector/PipelineVersionSelector.tsx b/frontend/src/concepts/pipelines/content/pipelineSelector/PipelineVersionSelector.tsx index fead2b71a4..c70b3ff9bc 100644 --- a/frontend/src/concepts/pipelines/content/pipelineSelector/PipelineVersionSelector.tsx +++ b/frontend/src/concepts/pipelines/content/pipelineSelector/PipelineVersionSelector.tsx @@ -22,6 +22,7 @@ import { pipelineVersionSelectorColumns } from '~/concepts/pipelines/content/pip import PipelineViewMoreFooterRow from '~/concepts/pipelines/content/tables/PipelineViewMoreFooterRow'; import DashboardEmptyTableView from '~/concepts/dashboard/DashboardEmptyTableView'; import usePipelineVersionSelector from '~/concepts/pipelines/content/pipelineSelector/usePipelineVersionSelector'; +import { isArgoWorkflow } from '~/concepts/pipelines/content/tables/utils'; type PipelineVersionSelectorProps = { pipelineId?: string; @@ -51,6 +52,12 @@ const PipelineVersionSelector: React.FC = ({ data: versions, } = usePipelineVersionSelector(pipelineId); + const supportedVersions = React.useMemo( + () => versions.filter((v) => !isArgoWorkflow(v.pipeline_spec)), + [versions], + ); + const supportedVersionsSize = supportedVersions.length; + const menu = ( @@ -63,7 +70,7 @@ const PipelineVersionSelector: React.FC = ({ /> - {`Type a name to search your ${totalSize} versions.`} + {`Type a name to search your ${supportedVersionsSize} versions.`} @@ -82,7 +89,7 @@ const PipelineVersionSelector: React.FC = ({ borders={false} variant={TableVariant.compact} columns={pipelineVersionSelectorColumns} - data={versions} + data={supportedVersions} rowRenderer={(row) => ( = ({ ref={toggleRef} onClick={() => setOpen(!isOpen)} isExpanded={isOpen} - isDisabled={!pipelineId || totalSize === 0} + isDisabled={!pipelineId || totalSize === 0 || supportedVersionsSize === 0} isFullWidth data-testid="pipeline-version-toggle-button" > {!pipelineId ? 'Select a pipeline version' : initialLoaded - ? selection || (totalSize === 0 ? 'No versions available' : 'Select a pipeline version') + ? selection || + (totalSize === 0 + ? 'No versions available' + : supportedVersionsSize === 0 + ? 'No supported versions available' + : 'Select a pipeline version') : 'Loading pipeline versions'} } diff --git a/frontend/src/concepts/pipelines/content/pipelinesDetails/pipeline/PipelineDetails.tsx b/frontend/src/concepts/pipelines/content/pipelinesDetails/pipeline/PipelineDetails.tsx index 2825405bd0..d92c9f8421 100644 --- a/frontend/src/concepts/pipelines/content/pipelinesDetails/pipeline/PipelineDetails.tsx +++ b/frontend/src/concepts/pipelines/content/pipelinesDetails/pipeline/PipelineDetails.tsx @@ -26,10 +26,12 @@ import PipelineVersionSelector from '~/concepts/pipelines/content/pipelineSelect import DeletePipelinesModal from '~/concepts/pipelines/content/DeletePipelinesModal'; import { pipelineVersionDetailsRoute, pipelinesBaseRoute } from '~/routes'; import { getCorePipelineSpec } from '~/concepts/pipelines/getCorePipelineSpec'; +import { isArgoWorkflow } from '~/concepts/pipelines/content/tables/utils'; import PipelineDetailsActions from './PipelineDetailsActions'; import SelectedTaskDrawerContent from './SelectedTaskDrawerContent'; import PipelineNotFound from './PipelineNotFound'; import { PipelineSummaryDescriptionList } from './PipelineSummaryDescriptionList'; +import PipelineNotSupported from './PipelineNotSupported'; enum PipelineDetailsTab { GRAPH, @@ -52,12 +54,14 @@ const PipelineDetails: PipelineCoreDetailsPageComponent = ({ breadcrumbPath }) = usePipelineVersionById(pipelineId, pipelineVersionId); const [pipeline, isPipelineLoaded, pipelineLoadError] = usePipelineById(pipelineId); - const nodes = usePipelineTaskTopology(pipelineVersion?.pipeline_spec); + const { nodes, error: topologyError } = usePipelineTaskTopology(pipelineVersion?.pipeline_spec); + const selectedNode = React.useMemo(() => { + if (topologyError) { + return null; + } + return nodes.find((n) => n.id === selectedId); + }, [topologyError, selectedId, nodes]); - const selectedNode = React.useMemo( - () => nodes.find((n) => n.id === selectedId), - [selectedId, nodes], - ); const isLoaded = isPipelineVersionLoaded && isPipelineLoaded && !!pipelineVersion?.pipeline_spec; if (pipelineVersionLoadError || pipelineLoadError) { @@ -150,6 +154,7 @@ const PipelineDetails: PipelineCoreDetailsPageComponent = ({ breadcrumbPath }) = onDelete={() => setDeletionOpen(true)} pipeline={pipeline} pipelineVersion={pipelineVersion} + isPipelineSupported={!isArgoWorkflow(pipelineVersion.pipeline_spec)} /> )} @@ -157,98 +162,105 @@ const PipelineDetails: PipelineCoreDetailsPageComponent = ({ breadcrumbPath }) = ) } > - - + ) : ( + - - { - setActiveTabKey(tabIndex); - setSelectedId(null); - }} - aria-label="Pipeline Details tabs" - role="region" - > - Graph} - aria-label="Pipeline Graph Tab" - tabContentId={`tabContent-${PipelineDetailsTab.GRAPH}`} - /> - Summary} - aria-label="Pipeline Summary Tab" + + + { + setActiveTabKey(tabIndex); + setSelectedId(null); + }} + aria-label="Pipeline Details tabs" + role="region" > - - - - - Pipeline spec} - data-testid="pipeline-yaml-tab" - aria-label="Pipeline YAML Tab" - tabContentId={`tabContent-${PipelineDetailsTab.YAML}`} - /> - - - - - - - - + + + + + + + + + )} {pipeline && ( void; + isPipelineSupported: boolean; pipeline: PipelineKFv2 | null; pipelineVersion: PipelineVersionKFv2 | null; }; const PipelineDetailsActions: React.FC = ({ onDelete, + isPipelineSupported, pipeline, pipelineVersion, }) => { @@ -55,6 +61,8 @@ const PipelineDetailsActions: React.FC = ({ , , navigate( @@ -72,6 +80,8 @@ const PipelineDetailsActions: React.FC = ({ Create run , navigate( diff --git a/frontend/src/concepts/pipelines/content/pipelinesDetails/pipeline/PipelineNotSupported.tsx b/frontend/src/concepts/pipelines/content/pipelinesDetails/pipeline/PipelineNotSupported.tsx new file mode 100644 index 0000000000..793df3bc51 --- /dev/null +++ b/frontend/src/concepts/pipelines/content/pipelinesDetails/pipeline/PipelineNotSupported.tsx @@ -0,0 +1,32 @@ +import * as React from 'react'; +import { + EmptyState, + EmptyStateBody, + EmptyStateIcon, + EmptyStateHeader, +} from '@patternfly/react-core'; +import { ExclamationTriangleIcon } from '@patternfly/react-icons'; +import { PIPELINE_ARGO_ERROR } from '~/concepts/pipelines/content/const'; + +const PipelineNotSupported: React.FC = () => ( + + + } + headingLevel="h4" + /> + + The selected pipeline was created using the Kubeflow v1 SDK, which is not supported by this + UI. +
+ Select a pipeline that was created or recompiled using the Kubeflow v2 SDK. +
+
+); + +export default PipelineNotSupported; diff --git a/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRecurringRun/PipelineRecurringRunDetails.tsx b/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRecurringRun/PipelineRecurringRunDetails.tsx index dd30f483fc..ecfa3e09f6 100644 --- a/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRecurringRun/PipelineRecurringRunDetails.tsx +++ b/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRecurringRun/PipelineRecurringRunDetails.tsx @@ -23,6 +23,8 @@ import { PipelineRunType } from '~/pages/pipelines/global/runs'; import SelectedTaskDrawerContent from '~/concepts/pipelines/content/pipelinesDetails/pipeline/SelectedTaskDrawerContent'; import { PipelineRunDetailsTabs } from '~/concepts/pipelines/content/pipelinesDetails/pipelineRun/PipelineRunDetailsTabs'; import usePipelineRecurringRunById from '~/concepts/pipelines/apiHooks/usePipelineRecurringRunById'; +import PipelineNotSupported from '~/concepts/pipelines/content/pipelinesDetails/pipeline/PipelineNotSupported'; +import { isArgoWorkflow } from '~/concepts/pipelines/content/tables/utils'; import PipelineRecurringRunDetailsActions from './PipelineRecurringRunDetailsActions'; const PipelineRecurringRunDetails: PipelineCoreDetailsPageComponent = ({ @@ -40,14 +42,21 @@ const PipelineRecurringRunDetails: PipelineCoreDetailsPageComponent = ({ const [deleting, setDeleting] = React.useState(false); const [selectedId, setSelectedId] = React.useState(null); - const nodes = usePipelineTaskTopology(version?.pipeline_spec); + const { nodes, error: topologyError } = usePipelineTaskTopology(version?.pipeline_spec); - const selectedNode = React.useMemo( - () => nodes.find((n) => n.id === selectedId), - [selectedId, nodes], - ); + const selectedNode = React.useMemo(() => { + if (topologyError) { + return null; + } + return nodes.find((n) => n.id === selectedId); + }, [topologyError, selectedId, nodes]); - const getFirstNode = (firstId: string) => nodes.find((n) => n.id === firstId)?.data?.pipelineTask; + const getFirstNode = (firstId: string) => { + if (topologyError) { + return null; + } + return nodes.find((n) => n.id === firstId)?.data?.pipelineTask; + }; const loaded = versionLoaded && recurringRunLoaded; const error = versionError || recurringRunError; @@ -99,30 +108,35 @@ const PipelineRecurringRunDetails: PipelineCoreDetailsPageComponent = ({ setDeleting(true)} + isPipelineSupported={!isArgoWorkflow(version?.pipeline_spec)} /> ) } empty={false} > - { - const firstId = ids[0]; - if (ids.length === 0) { - setSelectedId(null); - } else if (getFirstNode(firstId)) { - setSelectedId(firstId); - } - }} - sidePanel={panelContent} - /> - } - /> + {topologyError ? ( + + ) : ( + { + const firstId = ids[0]; + if (ids.length === 0) { + setSelectedId(null); + } else if (getFirstNode(firstId)) { + setSelectedId(firstId); + } + }} + sidePanel={panelContent} + /> + } + /> + )} void; + isPipelineSupported: boolean; }; const PipelineRecurringRunDetailsActions: React.FC = ({ onDelete, recurringRun, + isPipelineSupported, }) => { const navigate = useNavigate(); const { experimentId, pipelineId, pipelineVersionId } = useParams(); @@ -82,34 +84,38 @@ const PipelineRecurringRunDetailsActions: React.FC, - tooltip: 'Updating status...', - })} - > - {updateStatusActionLabel} -
, - - navigate( - cloneRecurringRunRoute( - namespace, - recurringRun.recurring_run_id, - isExperimentsAvailable ? experimentId : undefined, - pipelineId, - pipelineVersionId, - ), - ) - } - > - Duplicate - , - , + ...(isPipelineSupported + ? [ + , + tooltip: 'Updating status...', + })} + > + {updateStatusActionLabel} + , + + navigate( + cloneRecurringRunRoute( + namespace, + recurringRun.recurring_run_id, + isExperimentsAvailable ? experimentId : undefined, + pipelineId, + pipelineVersionId, + ), + ) + } + > + Duplicate + , + , + ] + : []), onDelete()}> Delete , diff --git a/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRun/PipelineRunDetails.tsx b/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRun/PipelineRunDetails.tsx index 8462ba8d60..b2dd06a90a 100644 --- a/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRun/PipelineRunDetails.tsx +++ b/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRun/PipelineRunDetails.tsx @@ -30,6 +30,8 @@ import { useGetEventsByExecutionIds } from '~/concepts/pipelines/apiHooks/mlmd/u import { PipelineTopology } from '~/concepts/topology'; import { FetchState } from '~/utilities/useFetchState'; import { PipelineRunKFv2 } from '~/concepts/pipelines/kfTypes'; +import PipelineNotSupported from '~/concepts/pipelines/content/pipelinesDetails/pipeline/PipelineNotSupported'; +import { isArgoWorkflow } from '~/concepts/pipelines/content/tables/utils'; import { usePipelineRunArtifacts } from './artifacts'; import { PipelineRunDetailsTabs } from './PipelineRunDetailsTabs'; @@ -54,7 +56,7 @@ const PipelineRunDetails: React.FC< const [events] = useGetEventsByExecutionIds( React.useMemo(() => executions.map((execution) => execution.getId()), [executions]), ); - const nodes = usePipelineTaskTopology( + const { nodes, error: topologyError } = usePipelineTaskTopology( pipelineSpec, run?.run_details, executions, @@ -62,10 +64,12 @@ const PipelineRunDetails: React.FC< artifacts, ); - const selectedNode = React.useMemo( - () => nodes.find((n) => n.id === selectedId), - [selectedId, nodes], - ); + const selectedNode = React.useMemo(() => { + if (topologyError) { + return null; + } + return nodes.find((n) => n.id === selectedId); + }, [topologyError, selectedId, nodes]); const loaded = runLoaded && (versionLoaded || !!run?.pipeline_spec || !!versionError); const error = runError; @@ -136,31 +140,36 @@ const PipelineRunDetails: React.FC< run={run} onDelete={() => setDeleting(true)} onArchive={() => setArchiving(true)} + isPipelineSupported={!isArgoWorkflow(version?.pipeline_spec)} /> } empty={false} > - { - const firstId = ids[0]; - if (ids.length === 0) { - setSelectedId(null); - } else if (nodes.find((node) => node.id === firstId)) { - setSelectedId(firstId); - } - }} - sidePanel={panelContent} - /> - } - /> + {!versionError && topologyError ? ( + + ) : ( + { + const firstId = ids[0]; + if (ids.length === 0) { + setSelectedId(null); + } else if (nodes.find((node) => node.id === firstId)) { + setSelectedId(firstId); + } + }} + sidePanel={panelContent} + /> + } + /> + )} void; onDelete: () => void; + isPipelineSupported: boolean; }; const PipelineRunDetailsActions: React.FC = ({ onDelete, onArchive, run, + isPipelineSupported, }) => { const navigate = useNavigate(); const { namespace, api, refreshAllAPI } = usePipelinesAPI(); @@ -68,85 +70,92 @@ const PipelineRunDetailsActions: React.FC = ({ !run ? [] : [ - - api - .retryPipelineRun({}, run.run_id) - .catch((e) => notification.error('Unable to retry pipeline run', e.message)) - } - > - Retry - , - - navigate( - cloneRunRoute( - namespace, - run.run_id, - isExperimentsAvailable ? experimentId : undefined, - pipelineId, - pipelineVersionId, + ...(isPipelineSupported + ? [ + + api + .retryPipelineRun({}, run.run_id) + .catch((e) => + notification.error('Unable to retry pipeline run', e.message), + ) + } + > + Retry + , + + navigate( + cloneRunRoute( + namespace, + run.run_id, + isExperimentsAvailable ? experimentId : undefined, + pipelineId, + pipelineVersionId, + ), + ) + } + > + Duplicate + , + + api + .stopPipelineRun({}, run.run_id) + .catch((e) => + notification.error('Unable to stop pipeline run', e.message), + ) + } + > + Stop + , + isExperimentsAvailable && experimentId && isRunActive ? ( + + navigate( + experimentsCompareRunsRoute(namespace, run.experiment_id, [run.run_id]), + ) + } + > + Compare runs + + ) : ( + ), - ) - } - > - Duplicate - , - - api - .stopPipelineRun({}, run.run_id) - .catch((e) => notification.error('Unable to stop pipeline run', e.message)) - } - > - Stop - , - isExperimentsAvailable && experimentId && isRunActive ? ( - - navigate( - experimentsCompareRunsRoute(namespace, run.experiment_id, [run.run_id]), - ) - } - > - Compare runs - - ) : ( - - ), - !isRunActive ? ( - !isExperimentActive ? ( - - Archived runs cannot be restored until its associated experiment is - restored. - - } - > - {RestoreDropdownItem} - - ) : ( - RestoreDropdownItem - ) - ) : ( - - ), + !isRunActive ? ( + !isExperimentActive ? ( + + Archived runs cannot be restored until its associated experiment is + restored. + + } + > + {RestoreDropdownItem} + + ) : ( + RestoreDropdownItem + ) + ) : ( + + ), + , + ] + : []), !isRunActive ? ( - onDelete()}>Delete ) : ( - onArchive()}>Archive ), diff --git a/frontend/src/concepts/pipelines/content/tables/pipeline/PipelinesTableRow.tsx b/frontend/src/concepts/pipelines/content/tables/pipeline/PipelinesTableRow.tsx index b672c6c69c..07f2a79588 100644 --- a/frontend/src/concepts/pipelines/content/tables/pipeline/PipelinesTableRow.tsx +++ b/frontend/src/concepts/pipelines/content/tables/pipeline/PipelinesTableRow.tsx @@ -15,6 +15,11 @@ import { pipelineVersionCreateRecurringRunRoute, pipelineVersionDetailsRoute, } from '~/routes'; +import { isArgoWorkflow } from '~/concepts/pipelines/content/tables/utils'; +import { + PIPELINE_CREATE_RUN_TOOLTIP_ARGO_ERROR, + PIPELINE_CREATE_SCHEDULE_TOOLTIP_ARGO_ERROR, +} from '~/concepts/pipelines/content/const'; const DISABLE_TOOLTIP = 'All child pipeline versions must be deleted before deleting the parent pipeline'; @@ -65,6 +70,8 @@ const PipelinesTableRow: React.FC = ({ disableCheck(pipelineRef.current, disableDelete || loading); }, [disableDelete, loading, disableCheck]); + const isCreateDisabled = isArgoWorkflow(version?.pipeline_spec) || hasNoPipelineVersions; + return ( <> @@ -126,7 +133,6 @@ const PipelinesTableRow: React.FC = ({ }, { title: 'Create run', - isAriaDisabled: hasNoPipelineVersions, onClick: () => { navigate( pipelineVersionCreateRunRoute( @@ -136,10 +142,13 @@ const PipelinesTableRow: React.FC = ({ ), ); }, + isAriaDisabled: isCreateDisabled, + tooltipProps: isArgoWorkflow(version?.pipeline_spec) + ? { content: PIPELINE_CREATE_RUN_TOOLTIP_ARGO_ERROR } + : undefined, }, { title: 'Create schedule', - isAriaDisabled: hasNoPipelineVersions, onClick: () => { navigate( pipelineVersionCreateRecurringRunRoute( @@ -149,6 +158,10 @@ const PipelinesTableRow: React.FC = ({ ), ); }, + isAriaDisabled: isCreateDisabled, + tooltipProps: isArgoWorkflow(version?.pipeline_spec) + ? { content: PIPELINE_CREATE_SCHEDULE_TOOLTIP_ARGO_ERROR } + : undefined, }, { isSeparator: true, diff --git a/frontend/src/concepts/pipelines/content/tables/utils.ts b/frontend/src/concepts/pipelines/content/tables/utils.ts index c26a3698ef..50daa448be 100644 --- a/frontend/src/concepts/pipelines/content/tables/utils.ts +++ b/frontend/src/concepts/pipelines/content/tables/utils.ts @@ -1,4 +1,8 @@ -import { PipelineRecurringRunKFv2, PipelineRunKFv2 } from '~/concepts/pipelines/kfTypes'; +import { + PipelineRecurringRunKFv2, + PipelineRunKFv2, + PipelineSpecVariable, +} from '~/concepts/pipelines/kfTypes'; export const getRunDuration = (run: PipelineRunKFv2): number => { const finishedDate = new Date(run.finished_at); @@ -72,3 +76,6 @@ export const getPipelineRecurringRunExecutionCount = (resourceName: string): str const match = resourceName.match(regex); return match ? match[1] : null; }; + +export const isArgoWorkflow = (spec?: PipelineSpecVariable): boolean => + !!spec && 'kind' in spec && spec.kind === 'Workflow'; diff --git a/frontend/src/concepts/pipelines/topology/__tests__/usePipelineTaskTopology.spec.ts b/frontend/src/concepts/pipelines/topology/__tests__/usePipelineTaskTopology.spec.ts index edc1660cd8..e9eea4e584 100644 --- a/frontend/src/concepts/pipelines/topology/__tests__/usePipelineTaskTopology.spec.ts +++ b/frontend/src/concepts/pipelines/topology/__tests__/usePipelineTaskTopology.spec.ts @@ -4,20 +4,24 @@ import { usePipelineTaskTopology } from '~/concepts/pipelines/topology'; import { mockLargePipelineSpec } from '~/concepts/pipelines/topology/__tests__/mockPipelineSpec'; import { ICON_TASK_NODE_TYPE } from '~/concepts/topology/utils'; import { EXECUTION_TASK_NODE_TYPE } from '~/concepts/topology/const'; +import { PipelineNodeModelExpanded } from '~/concepts/topology/types'; describe('usePipelineTaskTopology', () => { beforeEach(() => { jest.spyOn(console, 'warn').mockImplementation(jest.fn()); }); + it('returns the correct number of nodes', () => { const renderResult = testHook(usePipelineTaskTopology)(mockLargePipelineSpec); - const nodes = renderResult.result.current; + const { nodes } = renderResult.result.current; + + const pipelineNodes = nodes as PipelineNodeModelExpanded[]; - const tasks = nodes.filter((n) => n.type === DEFAULT_TASK_NODE_TYPE); - const groups = nodes.filter((n) => n.type === EXECUTION_TASK_NODE_TYPE); - const artifactNodes = nodes.filter((n) => n.type === ICON_TASK_NODE_TYPE); + const tasks = pipelineNodes.filter((n) => n.type === DEFAULT_TASK_NODE_TYPE); + const groups = pipelineNodes.filter((n) => n.type === EXECUTION_TASK_NODE_TYPE); + const artifactNodes = pipelineNodes.filter((n) => n.type === ICON_TASK_NODE_TYPE); - expect(nodes).toHaveLength(107); + expect(pipelineNodes).toHaveLength(107); expect(tasks).toHaveLength(35); expect(groups).toHaveLength(5); expect(artifactNodes).toHaveLength(67); diff --git a/frontend/src/concepts/pipelines/topology/usePipelineTaskTopology.tsx b/frontend/src/concepts/pipelines/topology/usePipelineTaskTopology.tsx index 83f020872c..60455edbe5 100644 --- a/frontend/src/concepts/pipelines/topology/usePipelineTaskTopology.tsx +++ b/frontend/src/concepts/pipelines/topology/usePipelineTaskTopology.tsx @@ -241,27 +241,49 @@ const getNodesForTasks = ( return [nodes, children]; }; +type UsePipelineTaskTopology = { + nodes: PipelineNodeModelExpanded[]; + loaded: boolean; + error: Error | undefined; +}; + export const usePipelineTaskTopology = ( spec?: PipelineSpecVariable, runDetails?: RunDetailsKF, executions?: Execution[] | null, events?: Event[] | null, artifacts?: Artifact[], -): PipelineNodeModelExpanded[] => - React.useMemo(() => { +): UsePipelineTaskTopology => { + const [nodes, setNodes] = React.useState([]); + const [loaded, setLoaded] = React.useState(false); + const [error, setError] = React.useState(undefined); + + React.useEffect(() => { if (!spec) { - return []; + setNodes([]); + setLoaded(true); + setError(new Error('No spec found')); + return; } const pipelineSpec = spec.pipeline_spec ?? spec; - const { - components, - deploymentSpec: { executors }, - root: { - dag: { tasks }, - inputDefinitions, - }, - } = pipelineSpec; + let components, executors, tasks, inputDefinitions; + + try { + ({ + components, + deploymentSpec: { executors }, + root: { + dag: { tasks }, + inputDefinitions, + }, + } = pipelineSpec); + } catch (e) { + setNodes([]); + setLoaded(true); + setError(new Error('Error processing pipelineSpec')); + return; + } const outputEvents = parseEventsByType(events ?? [])[Event.Type.OUTPUT]; @@ -270,7 +292,7 @@ export const usePipelineTaskTopology = ( const executionLinkedArtifactMap = getExecutionLinkedArtifactMap(artifacts, events); // There are some duplicated nodes, remove them - return _.uniqBy( + const uniqueNodes = _.uniqBy( getNodesForTasks( 'root', spec, @@ -288,4 +310,10 @@ export const usePipelineTaskTopology = ( )[0], (node) => node.id, ); + setNodes(uniqueNodes); + setLoaded(true); + setError(undefined); }, [artifacts, events, executions, runDetails, spec]); + + return { nodes, loaded, error }; +};