diff --git a/frontend/packages/dev-console/src/components/import/pipeline/PipelineTemplate.tsx b/frontend/packages/dev-console/src/components/import/pipeline/PipelineTemplate.tsx index f720d7ba536..81c989db860 100644 --- a/frontend/packages/dev-console/src/components/import/pipeline/PipelineTemplate.tsx +++ b/frontend/packages/dev-console/src/components/import/pipeline/PipelineTemplate.tsx @@ -6,7 +6,7 @@ import { Alert, Expandable } from '@patternfly/react-core'; import { CheckboxField } from '@console/shared'; import { CLUSTER_PIPELINE_NS } from '../../../const'; import { PipelineModel } from '../../../models'; -import { PipelineVisualization } from '../../pipelines/detail-page-tabs/pipeline-details/PipelineVisualization'; +import PipelineVisualization from '../../pipelines/detail-page-tabs/pipeline-details/PipelineVisualization'; const MISSING_DOCKERFILE_LABEL_TEXT = 'The pipeline template for Dockerfiles is not available at this time.'; diff --git a/frontend/packages/dev-console/src/components/pipelineruns/detail-page-tabs/PipelineRunDetails.tsx b/frontend/packages/dev-console/src/components/pipelineruns/detail-page-tabs/PipelineRunDetails.tsx index 1491eabd5fd..7e27050aa22 100644 --- a/frontend/packages/dev-console/src/components/pipelineruns/detail-page-tabs/PipelineRunDetails.tsx +++ b/frontend/packages/dev-console/src/components/pipelineruns/detail-page-tabs/PipelineRunDetails.tsx @@ -1,10 +1,10 @@ import * as React from 'react'; import { SectionHeading, ResourceSummary } from '@console/internal/components/utils'; -import { K8sResourceKind } from '@console/internal/module/k8s'; -import { PipelineRunVisualization } from './PipelineRunVisualization'; +import PipelineRunVisualization from './PipelineRunVisualization'; +import { PipelineRun } from '../../../utils/pipeline-augment'; export interface PipelineRunDetailsProps { - obj: K8sResourceKind; + obj: PipelineRun; } export const PipelineRunDetails: React.FC = ({ obj: pipelineRun }) => { diff --git a/frontend/packages/dev-console/src/components/pipelineruns/detail-page-tabs/PipelineRunVisualization.tsx b/frontend/packages/dev-console/src/components/pipelineruns/detail-page-tabs/PipelineRunVisualization.tsx index 6b79579f29d..cc730387b91 100644 --- a/frontend/packages/dev-console/src/components/pipelineruns/detail-page-tabs/PipelineRunVisualization.tsx +++ b/frontend/packages/dev-console/src/components/pipelineruns/detail-page-tabs/PipelineRunVisualization.tsx @@ -1,57 +1,35 @@ import * as React from 'react'; -import { K8sResourceKind, k8sGet } from '@console/internal/module/k8s'; -import { getPipelineTasks } from '../../../utils/pipeline-utils'; +import { Alert } from '@patternfly/react-core'; +import { k8sGet } from '@console/internal/module/k8s'; import { PipelineModel } from '../../../models'; -import { pipelineRunFilterReducer } from '../../../utils/pipeline-filter-reducer'; -import { PipelineVisualizationGraph } from '../../pipelines/detail-page-tabs/pipeline-details/PipelineVisualizationGraph'; +import PipelineVisualization from '../../pipelines/detail-page-tabs/pipeline-details/PipelineVisualization'; +import { Pipeline, PipelineRun } from '../../../utils/pipeline-augment'; -export interface PipelineRunVisualizationProps { - pipelineRun: K8sResourceKind; -} +type PipelineRunVisualizationProps = { + pipelineRun: PipelineRun; +}; -export interface PipelineVisualizationRunState { - pipeline: K8sResourceKind; - errorCode?: number; -} +const PipelineRunVisualization: React.FC = ({ pipelineRun }) => { + const [errorMessage, setErrorMessage] = React.useState(null); + const [pipeline, setPipeline] = React.useState(null); -export class PipelineRunVisualization extends React.Component< - PipelineRunVisualizationProps, - PipelineVisualizationRunState -> { - constructor(props) { - super(props); - this.state = { - pipeline: { apiVersion: '', metadata: {}, kind: 'PipelineRun' }, - errorCode: null, - }; - } + React.useEffect(() => { + k8sGet(PipelineModel, pipelineRun.spec.pipelineRef.name, pipelineRun.metadata.namespace) + .then((res: Pipeline) => setPipeline(res)) + .catch((error) => + setErrorMessage(error?.message || 'Could not load visualization at this time.'), + ); + }, [pipelineRun, setPipeline]); - componentDidMount() { - k8sGet( - PipelineModel, - this.props.pipelineRun.spec.pipelineRef.name, - this.props.pipelineRun.metadata.namespace, - ) - .then((res) => { - this.setState({ - pipeline: res, - }); - }) - .catch((error) => this.setState({ errorCode: error.response.status })); + if (errorMessage) { + return ; } - render() { - const { pipelineRun } = this.props; - if (this.state.errorCode === 404) { - return null; - } - return ( - - ); + if (!pipeline || !pipelineRun) { + return null; } -} + + return ; +}; + +export default PipelineRunVisualization; diff --git a/frontend/packages/dev-console/src/components/pipelines/PipelineDetailsPage.tsx b/frontend/packages/dev-console/src/components/pipelines/PipelineDetailsPage.tsx index a95bd2a9bb1..f01cc079075 100644 --- a/frontend/packages/dev-console/src/components/pipelines/PipelineDetailsPage.tsx +++ b/frontend/packages/dev-console/src/components/pipelines/PipelineDetailsPage.tsx @@ -12,8 +12,8 @@ import { getLatestRun } from '../../utils/pipeline-augment'; import { PipelineRunModel, PipelineModel } from '../../models'; import { PipelineDetails, - PipelineParameters, - PipelineResources, + PipelineParametersForm, + PipelineResourcesForm, PipelineRuns, } from './detail-page-tabs'; import PipelineForm from './pipeline-form/PipelineForm'; @@ -76,7 +76,7 @@ class PipelineDetailsPage extends React.Component ( @@ -87,7 +87,7 @@ class PipelineDetailsPage extends React.Component ( diff --git a/frontend/packages/dev-console/src/components/pipelines/PipelinesResourceList.tsx b/frontend/packages/dev-console/src/components/pipelines/PipelinesResourceList.tsx index e696c0e240b..9ce3a6613b8 100644 --- a/frontend/packages/dev-console/src/components/pipelines/PipelinesResourceList.tsx +++ b/frontend/packages/dev-console/src/components/pipelines/PipelinesResourceList.tsx @@ -32,7 +32,7 @@ const PipelinesResourceList: React.FC = (props) => { createProps={{ to: `/k8s/${namespace ? `ns/${namespace}` : 'cluster'}/${referenceForModel( PipelineModel, - )}/~new`, + )}/~new/builder`, }} filterLabel="by name" textFilter="name" diff --git a/frontend/packages/dev-console/src/components/pipelines/const.ts b/frontend/packages/dev-console/src/components/pipelines/const.ts new file mode 100644 index 00000000000..d082278f866 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/const.ts @@ -0,0 +1,7 @@ +export enum PipelineResourceType { + '' = 'Select resource type', + git = 'Git', + image = 'Image', + cluster = 'Cluster', + storage = 'Storage', +} diff --git a/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/PipelineParameters.tsx b/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/PipelineParameters.tsx index c532a2144ff..397b18db5a4 100644 --- a/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/PipelineParameters.tsx +++ b/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/PipelineParameters.tsx @@ -1,67 +1,43 @@ import * as React from 'react'; -import * as _ from 'lodash'; -import { Form, TextInputTypes } from '@patternfly/react-core'; -import { FormikProps, FormikValues } from 'formik'; -import { useAccessReview } from '@console/internal/components/utils'; -import { getActiveNamespace } from '@console/internal/actions/ui'; -import { MultiColumnField, InputField, FormFooter } from '@console/shared'; +import { TextInputTypes } from '@patternfly/react-core'; +import { MultiColumnField, InputField } from '@console/shared'; + +type PipelineParametersProps = { + addLabel?: string; + fieldName: string; + isReadOnly?: boolean; +}; + +const PipelineParameters: React.FC = (props) => { + const { addLabel = 'Add Pipeline Params', fieldName, isReadOnly = false } = props; -const PipelineParameters: React.FC> = ({ - handleSubmit, - handleReset, - isSubmitting, - status, - errors, - dirty, -}) => { - const pipelineParameterAccess = useAccessReview({ - group: 'tekton.dev', - resource: 'pipelines', - namespace: getActiveNamespace(), - verb: 'update', - }); return ( -
-
- - - - - -
- {pipelineParameterAccess && ( - - )} -
-
+ + + + + ); }; diff --git a/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/PipelineParametersForm.tsx b/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/PipelineParametersForm.tsx new file mode 100644 index 00000000000..d7158a2c348 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/PipelineParametersForm.tsx @@ -0,0 +1,44 @@ +import * as React from 'react'; +import * as _ from 'lodash'; +import { Form } from '@patternfly/react-core'; +import { FormikProps, FormikValues } from 'formik'; +import { useAccessReview } from '@console/internal/components/utils'; +import { getActiveNamespace } from '@console/internal/actions/ui'; +import { FormFooter } from '@console/shared'; +import PipelineParameters from './PipelineParameters'; + +const PipelineParametersForm: React.FC> = ({ + handleSubmit, + handleReset, + isSubmitting, + status, + errors, + dirty, +}) => { + const pipelineParameterAccess = useAccessReview({ + group: 'tekton.dev', + resource: 'pipelines', + namespace: getActiveNamespace(), + verb: 'update', + }); + return ( +
+
+ +
+ {pipelineParameterAccess && ( + + )} +
+
+ ); +}; + +export default PipelineParametersForm; diff --git a/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/PipelineResources.tsx b/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/PipelineResources.tsx index 8deabdef94f..4ed27b5fd12 100644 --- a/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/PipelineResources.tsx +++ b/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/PipelineResources.tsx @@ -1,69 +1,33 @@ import * as React from 'react'; -import * as _ from 'lodash'; -import { Form, TextInputTypes } from '@patternfly/react-core'; -import { FormikProps, FormikValues } from 'formik'; -import { useAccessReview } from '@console/internal/components/utils'; -import { getActiveNamespace } from '@console/internal/actions/ui'; -import { MultiColumnField, InputField, DropdownField, FormFooter } from '@console/shared'; +import { TextInputTypes } from '@patternfly/react-core'; +import { MultiColumnField, InputField, DropdownField } from '@console/shared'; +import { PipelineResourceType } from '../const'; -enum resourceTypes { - '' = 'Select resource type', - git = 'Git', - image = 'Image', - cluster = 'Cluster', - storage = 'Storage', -} +type PipelineResourcesParam = { + addLabel?: string; + fieldName: string; + isReadOnly?: boolean; +}; + +const PipelineResources: React.FC = (props) => { + const { addLabel = 'Add Pipeline Resources', fieldName, isReadOnly = false } = props; -const PipelineResources: React.FC> = ({ - handleSubmit, - handleReset, - isSubmitting, - status, - errors, - dirty, -}) => { - const pipelineResourceAccess = useAccessReview({ - group: 'tekton.dev', - resource: 'pipelines', - namespace: getActiveNamespace(), - verb: 'update', - }); return ( -
-
- - - - -
- {pipelineResourceAccess && ( - - )} -
-
+ + + + ); }; diff --git a/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/PipelineResourcesForm.tsx b/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/PipelineResourcesForm.tsx new file mode 100644 index 00000000000..fffb4010280 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/PipelineResourcesForm.tsx @@ -0,0 +1,44 @@ +import * as React from 'react'; +import * as _ from 'lodash'; +import { Form } from '@patternfly/react-core'; +import { FormikProps, FormikValues } from 'formik'; +import { useAccessReview } from '@console/internal/components/utils'; +import { getActiveNamespace } from '@console/internal/actions/ui'; +import { FormFooter } from '@console/shared'; +import PipelineResources from './PipelineResources'; + +const PipelineResourcesForm: React.FC> = ({ + handleSubmit, + handleReset, + isSubmitting, + status, + errors, + dirty, +}) => { + const pipelineResourceAccess = useAccessReview({ + group: 'tekton.dev', + resource: 'pipelines', + namespace: getActiveNamespace(), + verb: 'update', + }); + return ( +
+
+ +
+ {pipelineResourceAccess && ( + + )} +
+
+ ); +}; + +export default PipelineResourcesForm; diff --git a/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/index.ts b/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/index.ts index 90664a10c77..6c027dfafbd 100644 --- a/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/index.ts +++ b/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/index.ts @@ -1,4 +1,6 @@ export { default as PipelineDetails } from './pipeline-details/PipelineDetails'; export { default as PipelineParameters } from './PipelineParameters'; +export { default as PipelineParametersForm } from './PipelineParametersForm'; export { default as PipelineResources } from './PipelineResources'; +export { default as PipelineResourcesForm } from './PipelineResourcesForm'; export { default as PipelineRuns } from './PipelineRuns'; diff --git a/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/PipelineDetails.tsx b/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/PipelineDetails.tsx index 8e556c1221f..827dd8e896c 100644 --- a/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/PipelineDetails.tsx +++ b/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/PipelineDetails.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { SectionHeading, ResourceSummary, ResourceLink } from '@console/internal/components/utils'; import { referenceForModel } from '@console/internal/module/k8s'; import { Pipeline, getResourceModelFromTask } from '../../../../utils/pipeline-augment'; -import { PipelineVisualization } from './PipelineVisualization'; +import PipelineVisualization from './PipelineVisualization'; interface PipelineDetailsProps { obj: Pipeline; diff --git a/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/PipelineVisualization.tsx b/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/PipelineVisualization.tsx index 82cd5824dc3..a4a5a1295f8 100644 --- a/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/PipelineVisualization.tsx +++ b/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/PipelineVisualization.tsx @@ -1,15 +1,37 @@ import * as React from 'react'; -import { K8sResourceKind } from '@console/internal/module/k8s'; -import { getPipelineTasks } from '../../../../utils/pipeline-utils'; -import { PipelineVisualizationGraph } from './PipelineVisualizationGraph'; +import { Alert } from '@patternfly/react-core'; +import { Pipeline, PipelineRun } from '../../../../utils/pipeline-augment'; +import PipelineTopologyGraph from '../../pipeline-topology/PipelineTopologyGraph'; +import { getTopologyNodesEdges } from '../../pipeline-topology/utils'; +import { PipelineLayout } from '../../pipeline-topology/const'; -export interface PipelineVisualizationProps { - pipeline?: K8sResourceKind; +interface PipelineTopologyVisualizationProps { + pipeline: Pipeline; + pipelineRun?: PipelineRun; } -export const PipelineVisualization: React.FC = ({ pipeline }) => ( - -); +const PipelineVisualization: React.FC = ({ + pipeline, + pipelineRun, +}) => { + const { nodes, edges } = getTopologyNodesEdges(pipeline, pipelineRun); + + if (nodes.length === 0 && edges.length === 0) { + // Nothing to render + // TODO: Confirm wording with UX; ODC-1860 + return ; + } + + return ( +
+ +
+ ); +}; + +export default PipelineVisualization; diff --git a/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/PipelineVisualizationGraph.scss b/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/PipelineVisualizationGraph.scss deleted file mode 100644 index 5816c756eca..00000000000 --- a/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/PipelineVisualizationGraph.scss +++ /dev/null @@ -1,176 +0,0 @@ -$border-color: var(--pf-global--BorderColor--light-100); -$gutter: 1.7em; - -.odc-pipeline-vis-graph { - margin: var(--pf-global--spacer--md) 0; - overflow: auto; - position: relative; - -webkit-overflow-scrolling: touch; - font-size: 12px; - - // reset - &__stage-column { - list-style: none; - padding: 0; - } - - &__stages { - display: inline-flex; - background: var(--pf-global--BackgroundColor--300); - border-radius: 20px; - padding: 20px; - } - - &__stage { - margin: 0 16px; - - &:not(:first-child) { - &:not(.is-parallel) { - & .odc-pipeline-vis-task:first-child { - &::before { - left: 0px; - width: 33px; - transform: translateX(-100%); - } - } - } - &.is-parallel { - & .odc-pipeline-vis-task { - &:first-child { - &::before { - content: ''; - position: absolute; - top: 1.25em; - border-top: 1px solid $border-color; - width: $gutter; - height: 0; - } - &::before { - left: 0; - width: 33px; - transform: translateX(-100%); - } - } - } - } - & .odc-pipeline-vis-task:first-child { - &::before { - content: ''; - position: absolute; - top: 1.25em; - - border-top: 1px solid $border-color; - width: $gutter; - height: 0; - } - } - & .odc-pipeline-vis-task { - &__connector { - display: none; - &::after { - content: ''; - width: 0.67em; - height: 1.2em; - position: absolute; - top: -2.75em; - border-top: 1px solid $border-color; - left: -17px; - border-right: 1px solid var(--pf-global--BorderColor--light-100); - border-radius: 0 20px; - } - } - &:not(:first-child) { - //connect each task - &::before { - content: ''; - border-radius: 0 0 0 1.33em; - top: -4.33em; - position: absolute; - border-bottom: 1px solid var(--pf-global--BorderColor--light-100); - width: 10px; - height: 5.75em; - } - // Left connecting lines - &::before { - left: -10px; - border-left: 1px solid $border-color; - border-radius: 0 0 0 12px; - } - } - &:nth-child(2) { - .odc-pipeline-vis-task__connector { - display: block; - } - &::before { - top: -1.9em; - height: 3.3em; - } - } - } - } - &:not(:last-child) { - &.is-parallel { - & .odc-pipeline-vis-task { - &:first-child { - &::after { - right: 0; - transform: translateX(100%); - } - } - } - } - & .odc-pipeline-vis-task { - &__connector { - display: none; - &::before { - content: ''; - width: 0.67em; - height: 1.2em; - position: absolute; - top: -2.75em; - border-top: 1px solid $border-color; - right: -17px; - border-left: 1px solid var(--pf-global--BorderColor--light-100); - border-radius: 20px 0 0; - } - } - //connect each task - &:not(:first-child) { - &::after { - content: ''; - border-radius: 0 0 0 1.33em; - top: -4.33em; - position: absolute; - border-bottom: 1px solid var(--pf-global--BorderColor--light-100); - width: 10px; - height: 5.75em; - } - - // Right connecting lines - &::after { - right: -10px; - border-right: 1px solid $border-color; - border-radius: 0 0 12px; - } - } - &:nth-child(2) { - .odc-pipeline-vis-task__connector { - display: block; - } - &::after { - top: -1.9em; - height: 3.3em; - } - } - } - } - &:last-child { - & .odc-pipeline-vis-task:first-child { - &::after { - content: ''; - width: $gutter / 2 + 0.05; - } - } - } - } -} diff --git a/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/PipelineVisualizationGraph.tsx b/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/PipelineVisualizationGraph.tsx deleted file mode 100644 index cc8d0048462..00000000000 --- a/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/PipelineVisualizationGraph.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import * as React from 'react'; -import * as cx from 'classnames'; -import { PipelineVisualizationTaskItem } from '../../../../utils/pipeline-utils'; -import { PipelineVisualizationTask } from './PipelineVisualizationTask'; - -import './PipelineVisualizationGraph.scss'; - -export interface PipelineVisualizationGraphProps { - pipelineRun?: string; - graph: PipelineVisualizationTaskItem[][]; - namespace: string; - runStatus?: string; -} - -export const PipelineVisualizationGraph: React.FC = ({ - pipelineRun, - graph, - namespace, - runStatus, -}) => { - return ( -
-
- {graph.map((stage) => { - return ( -
1 })} - key={stage.map((t) => `${t.taskRef.name}-${t.name}`).join(',')} - > -
    - {stage.map((task) => { - return ( - - ); - })} -
-
- ); - })} -
-
- ); -}; diff --git a/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/PipelineVisualizationTask.scss b/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/PipelineVisualizationTask.scss index 99b93b06971..6608e88e4da 100644 --- a/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/PipelineVisualizationTask.scss +++ b/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/PipelineVisualizationTask.scss @@ -2,7 +2,6 @@ $border-color: var(--pf-global--BorderColor--light-100); .odc-pipeline-vis-task { position: relative; width: 10em; - margin-top: 1.5em; &__content { width: inherit; diff --git a/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/PipelineVisualizationTask.tsx b/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/PipelineVisualizationTask.tsx index ba416b30d93..2ca0aa2ef86 100644 --- a/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/PipelineVisualizationTask.tsx +++ b/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/PipelineVisualizationTask.tsx @@ -15,7 +15,7 @@ import { createStepStatus, StepStatus, TaskStatus } from './pipeline-step-utils' import './PipelineVisualizationTask.scss'; interface TaskProps { - pipelineRun?: string; + pipelineRunName?: string; name: string; loaded?: boolean; task?: { @@ -27,7 +27,7 @@ interface TaskProps { } interface PipelineVisualizationTaskProp { - pipelineRun?: string; + pipelineRunName?: string; namespace: string; task: { name?: string; @@ -42,7 +42,7 @@ interface PipelineVisualizationTaskProp { } export const PipelineVisualizationTask: React.FC = ({ - pipelineRun, + pipelineRunName, task, namespace, pipelineRunStatus, @@ -82,7 +82,7 @@ export const PipelineVisualizationTask: React.FC return ( ); }; const TaskComponent: React.FC = ({ - pipelineRun, + pipelineRunName, namespace, task, status, @@ -104,8 +104,8 @@ const TaskComponent: React.FC = ({ const stepStatusList: StepStatus[] = stepList.map((step) => createStepStatus(step, status)); const showStatusState: boolean = isPipelineRun && !!status && !!status.reason; const visualName = name || _.get(task, ['metadata', 'name'], ''); - const path = pipelineRun - ? `${resourcePathFromModel(PipelineRunModel, pipelineRun, namespace)}/logs/${name}` + const path = pipelineRunName + ? `${resourcePathFromModel(PipelineRunModel, pipelineRunName, namespace)}/logs/${name}` : undefined; const visTask = ( <> @@ -142,8 +142,8 @@ const TaskComponent: React.FC = ({ ); return ( -
  • +
    {path ? {visTask} : visTask} -
  • + ); }; diff --git a/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/__tests__/PipelineVisualizationGraph.spec.tsx b/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/__tests__/PipelineVisualizationGraph.spec.tsx deleted file mode 100644 index 4e2d765ed26..00000000000 --- a/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/__tests__/PipelineVisualizationGraph.spec.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import * as React from 'react'; -import { shallow, ShallowWrapper } from 'enzyme'; -import * as Renderer from 'react-test-renderer'; -import { getPipelineTasks } from '../../../../../utils/pipeline-utils'; -import { PipelineVisualizationGraph } from '../PipelineVisualizationGraph'; -import { PipelineVisualizationProps } from '../PipelineVisualization'; -import { mockPipelineGraph } from './pipeline-visualization-test-data'; -import { mockPipeline } from './pipeline-mock'; -import { mockPipelineRun } from './pipelinerun-mock'; - -jest.mock('react-dom', () => ({ - findDOMNode: () => ({}), - createPortal: (node) => node, -})); - -jest.mock('../PipelineVisualizationTask'); -describe('PipelineVisualizationGraph', () => { - const props = { - namespace: 'test', - graph: mockPipelineGraph, - }; - let wrapper: ShallowWrapper; - beforeEach(() => { - wrapper = shallow(); - }); - - it('renders a Pipeline visualization graph', () => { - expect(wrapper.exists()).toBeTruthy(); - }); - - it('should contain right number of stages', () => { - const noOfStages = wrapper.find('.odc-pipeline-vis-graph__stage-column').length; - - expect(noOfStages).toEqual(mockPipelineGraph.length); - }); - - it('should match the previous pipeline snapshot', () => { - const tree = Renderer.create().toJSON(); - expect(tree).toMatchSnapshot(); - }); - - it('should match the previous pipelineRun snapshot', () => { - const graph = getPipelineTasks(mockPipeline, mockPipelineRun); - const tree = Renderer.create( - , - ).toJSON(); - expect(tree).toMatchSnapshot(); - }); -}); diff --git a/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/__tests__/__snapshots__/PipelineVisualizationGraph.spec.tsx.snap b/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/__tests__/__snapshots__/PipelineVisualizationGraph.spec.tsx.snap deleted file mode 100644 index 205686c366c..00000000000 --- a/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/__tests__/__snapshots__/PipelineVisualizationGraph.spec.tsx.snap +++ /dev/null @@ -1,135 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`PipelineVisualizationGraph should match the previous pipeline snapshot 1`] = ` -
    -
    -
    -
      -
    • - - start-app - -
    • -
    -
    -
    -
      -
    • - - test-app-1 - -
    • -
    • - - test-app-2 - -
    • -
    -
    -
    -
      -
    • - - build-image-1 - -
    • -
    • - - build-image-2 - -
    • -
    -
    -
    -
      -
    • - - deploy - -
    • -
    -
    -
    -
    -`; - -exports[`PipelineVisualizationGraph should match the previous pipelineRun snapshot 1`] = ` -
    -
    -
    -
      -
    • - - build-skaffold-web - - - Succeeded - - - 32s - -
    • -
    • - - deploy-web - - - Succeeded - - - 32s - -
    • -
    -
    -
    -
    -`; diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/PipelineBuilderForm.scss b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/PipelineBuilderForm.scss new file mode 100644 index 00000000000..f057050d69f --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/PipelineBuilderForm.scss @@ -0,0 +1,14 @@ +.odc-pipeline-builder-form { + height: 100%; + overflow: hidden; + + &__content { + height: 100%; + overflow: auto; + padding: 30px; + } + + &__short-section { + max-width: 400px; + } +} diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/PipelineBuilderForm.tsx b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/PipelineBuilderForm.tsx new file mode 100644 index 00000000000..934f243c77e --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/PipelineBuilderForm.tsx @@ -0,0 +1,137 @@ +import * as React from 'react'; +import * as _ from 'lodash'; +import { FormikProps, FormikValues } from 'formik'; +import { Form, ActionGroup, Button, ButtonVariant, TextInputTypes } from '@patternfly/react-core'; +import { ButtonBar } from '@console/internal/components/utils'; +import { InputField } from '@console/shared'; +import { PipelineTask } from '../../../utils/pipeline-augment'; +import { PipelineParameters, PipelineResources } from '../detail-page-tabs'; +import { TASK_INCOMPLETE_ERROR_MESSAGE } from './const'; +import PipelineBuilderVisualization from './PipelineBuilderVisualization'; +import Sidebar from './task-sidebar/Sidebar'; +import TaskSidebar from './task-sidebar/TaskSidebar'; +import { SelectedBuilderTask, TaskErrorMap } from './types'; + +import './PipelineBuilderForm.scss'; + +type PipelineBuilderFormProps = FormikProps & { + namespace: string; +}; + +const pruneErrors = (taskErrors: TaskErrorMap): TaskErrorMap => { + return Object.keys(taskErrors).reduce((newTaskMap, taskName) => { + const taskValue = taskErrors[taskName]; + + if ( + !taskValue.inputResourceCount && + !taskValue.outputResourceCount && + !taskValue.paramsMissingDefaults + ) { + return newTaskMap; + } + + return { + ...newTaskMap, + [taskName]: taskValue, + }; + }, {} as TaskErrorMap); +}; + +const PipelineBuilderForm: React.FC = (props) => { + const [selectedTask, setSelectedTask] = React.useState(null); + const [taskErrors, setTaskErrors] = React.useState({}); + + const { + status, + isSubmitting, + dirty, + handleReset, + handleSubmit, + errors, + namespace, + setFieldValue, + values, + } = props; + + return ( +
    +
    +
    + +
    + +
    +

    Tasks

    + { + setSelectedTask({ + taskIndex: values.tasks.findIndex(({ name }) => name === task.name), + resource, + }); + }} + onSetError={( + taskName, + inputResourceCount, + outputResourceCount, + paramsMissingDefaults, + ) => { + setTaskErrors({ + ...taskErrors, + [taskName]: { + inputResourceCount, + outputResourceCount, + paramsMissingDefaults, + message: TASK_INCOMPLETE_ERROR_MESSAGE, + }, + }); + }} + onUpdateTasks={(updatedTasks: PipelineTask[]) => setFieldValue('tasks', updatedTasks)} + pipelineTasks={values.tasks} + /> +
    + +
    +

    Parameters

    + +
    + +
    +

    Resources

    + +
    + + + + + + + +
    + setSelectedTask(null)}> + {() => ( + setTaskErrors(pruneErrors(newTaskErrors))} + setFieldValue={setFieldValue} + selectedPipelineTaskIndex={selectedTask.taskIndex} + taskResource={selectedTask.resource} + /> + )} + +
    + ); +}; + +export default PipelineBuilderForm; diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/PipelineBuilderPage.tsx b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/PipelineBuilderPage.tsx new file mode 100644 index 00000000000..7657cabe4b5 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/PipelineBuilderPage.tsx @@ -0,0 +1,67 @@ +import * as React from 'react'; +import { RouteComponentProps } from 'react-router-dom'; +import { Helmet } from 'react-helmet'; +import { Formik, FormikBag } from 'formik'; +import { Button } from '@patternfly/react-core'; +import { history, PageHeading } from '@console/internal/components/utils'; +import { k8sCreate } from '@console/internal/module/k8s'; +import { getBadgeFromType } from '@console/shared/src'; +import { PipelineModel } from '../../../models'; +import PipelineBuilderForm from './PipelineBuilderForm'; +import { PipelineBuilderFormValues, PipelineBuilderFormikValues } from './types'; +import { convertBuilderFormToPipeline, getPipelineURL } from './utils'; +import { validationSchema } from './validation-utils'; + +type PipelineBuilderPageProps = RouteComponentProps<{ ns?: string }>; + +const PipelineBuilderPage: React.FC = ({ match }) => { + const { + params: { ns }, + } = match; + + const initialValues: PipelineBuilderFormValues = { + name: 'new-pipeline', + params: [], + resources: [], + tasks: [], + }; + + const handleSubmit = ( + values: PipelineBuilderFormikValues, + actions: FormikBag, + ) => { + return k8sCreate(PipelineModel, convertBuilderFormToPipeline(values, ns)) + .then(() => { + actions.setSubmitting(false); + history.push(`${getPipelineURL(ns)}/${values.name}`); + }) + .catch((e) => { + actions.setStatus({ submitError: e.message }); + }); + }; + + return ( + <> + + Pipeline Builder + + +
    +
    + +
    +
    + } + /> + + ); +}; + +export default PipelineBuilderPage; diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/PipelineBuilderVisualization.tsx b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/PipelineBuilderVisualization.tsx new file mode 100644 index 00000000000..9064c828f78 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/PipelineBuilderVisualization.tsx @@ -0,0 +1,70 @@ +import * as React from 'react'; +import { Alert } from '@patternfly/react-core'; +import { LoadingBox } from '@console/internal/components/utils'; +import { PipelineTask } from '../../../utils/pipeline-augment'; +import { PipelineLayout } from '../pipeline-topology/const'; +import PipelineTopologyGraph from '../pipeline-topology/PipelineTopologyGraph'; +import { getEdgesFromNodes } from '../pipeline-topology/utils'; +import { useNodes } from './hooks'; +import { + SelectTaskCallback, + SetTaskErrorCallback, + TaskErrorMap, + UpdateTaskCallback, +} from './types'; + +type PipelineBuilderVisualizationProps = { + namespace: string; + onSetError: SetTaskErrorCallback; + onTaskSelection: SelectTaskCallback; + onUpdateTasks: UpdateTaskCallback; + pipelineTasks: PipelineTask[]; + tasksInError: TaskErrorMap; +}; + +const PipelineBuilderVisualization: React.FC = ({ + namespace, + onSetError, + onTaskSelection, + onUpdateTasks, + pipelineTasks, + tasksInError, +}) => { + const { tasksLoaded, tasksCount, nodes, loadingTasksError } = useNodes( + namespace, + onSetError, + onTaskSelection, + onUpdateTasks, + pipelineTasks, + tasksInError, + ); + + if (loadingTasksError) { + return ( + + {loadingTasksError} + + ); + } + if (!tasksLoaded) { + return ; + } + if (tasksCount === 0) { + // No tasks, nothing we can do here... + return ; + } + + return ( + n.id).join('-')} + id="pipeline-builder" + fluid + nodes={nodes} + edges={getEdgesFromNodes(nodes)} + layout={PipelineLayout.DAGRE_BUILDER} + /> + ); +}; + +export default PipelineBuilderVisualization; diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/const.ts b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/const.ts new file mode 100644 index 00000000000..e83d9357442 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/const.ts @@ -0,0 +1 @@ +export const TASK_INCOMPLETE_ERROR_MESSAGE = 'Incomplete Task'; diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/hooks.ts b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/hooks.ts new file mode 100644 index 00000000000..1e74b2f9451 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/hooks.ts @@ -0,0 +1,252 @@ +import * as React from 'react'; +import { k8sList } from '@console/internal/module/k8s'; +import { ClusterTaskModel, TaskModel } from '../../../models'; +import { + PipelineResourceTask, + PipelineTask, + PipelineTaskRef, +} from '../../../utils/pipeline-augment'; +import { PipelineVisualizationTaskItem } from '../../../utils/pipeline-utils'; +import { PipelineMixedNodeModel, PipelineTaskListNode } from '../pipeline-topology/types'; +import { + createTaskListNode, + handleParallelToParallelNodes, + tasksToBuilderNodes, +} from '../pipeline-topology/utils'; +import { AddNodeDirection } from '../pipeline-topology/const'; +import { + SelectTaskCallback, + SetTaskErrorCallback, + TaskErrorMap, + UpdateTaskCallback, +} from './types'; +import { convertResourceToTask } from './utils'; + +type UseTasks = { + namespacedTasks: PipelineResourceTask[] | null; + clusterTasks: PipelineResourceTask[] | null; + errorMsg?: string; + getTask: (taskRef: PipelineTaskRef) => PipelineResourceTask; +}; +export const useTasks = (namespace?: string, taskNames?: string[]): UseTasks => { + const [namespacedTasks, setNamespacedTasks] = React.useState(null); + const [clusterTasks, setClusterTasks] = React.useState(null); + const [loadErrorMsg, setLoadErrorMsg] = React.useState(undefined); + + React.useEffect(() => { + if (!namespacedTasks) { + if (!namespace) { + setNamespacedTasks([]); + } else { + k8sList(TaskModel, { ns: namespace }) + .then((res: PipelineResourceTask[]) => { + setNamespacedTasks(res); + }) + .catch(() => { + setLoadErrorMsg(`Failed to load namespace Tasks. ${loadErrorMsg || ''}`); + }); + } + } + + if (!clusterTasks) { + k8sList(ClusterTaskModel) + .then((res: PipelineResourceTask[]) => { + setClusterTasks(res); + }) + .catch(() => { + setLoadErrorMsg(`Failed to load ClusterTasks. ${loadErrorMsg || ''}`); + }); + } + }, [ + namespace, + namespacedTasks, + setNamespacedTasks, + clusterTasks, + setClusterTasks, + setLoadErrorMsg, + loadErrorMsg, + ]); + + const includedValues = (resource: PipelineResourceTask) => + !taskNames?.includes(resource.metadata.name); + + return { + namespacedTasks: namespacedTasks?.filter(includedValues), + clusterTasks: clusterTasks?.filter(includedValues), + errorMsg: loadErrorMsg, + getTask: (taskRef) => { + if (taskRef.kind === ClusterTaskModel.kind) { + return clusterTasks.find((task) => task.metadata.name === taskRef.name); + } + return namespacedTasks.find((task) => task.metadata.name === taskRef.name); + }, + }; +}; + +type UseNodes = { + nodes: PipelineMixedNodeModel[]; + tasksCount: number; + tasksLoaded: boolean; + loadingTasksError?: string; +}; +export const useNodes = ( + namespace: string, + onSetError: SetTaskErrorCallback, + onTaskSelection: SelectTaskCallback, + onUpdateTasks: UpdateTaskCallback, + pipelineTasks: PipelineTask[], + tasksInError: TaskErrorMap, +): UseNodes => { + const { clusterTasks, namespacedTasks, errorMsg, getTask } = useTasks( + namespace, + pipelineTasks.map((plTask) => plTask.name), + ); + const [listNodes, setListNodes] = React.useState([]); + + const pipelineTaskList = React.useRef(pipelineTasks); + pipelineTaskList.current = pipelineTasks; + + const newListNode = (name: string, runAfter?: string[]): PipelineTaskListNode => + createTaskListNode(name, { + namespaceTaskList: namespacedTasks, + clusterTaskList: clusterTasks, + onNewTask: (resource: PipelineResourceTask) => { + const newPipelineTask: PipelineTask = convertResourceToTask(resource, runAfter); + onUpdateTasks([ + ...pipelineTaskList.current.map((task) => { + if (!task.runAfter?.includes(name)) { + return task; + } + return { + ...task, + runAfter: task.runAfter.map((taskName) => { + if (taskName === name) { + return resource.metadata.name; + } + return taskName; + }), + }; + }), + newPipelineTask, + ]); + setListNodes(listNodes.filter((n) => n.id !== name)); + + const hasNonDefaultParams = newPipelineTask.params + ?.map(({ value }) => !value) + .reduce((acc, missingDefault) => missingDefault || acc, false); + const inputResourceCount = resource.spec?.inputs?.resources?.length || 0; + const outputResourceCount = resource.spec?.outputs?.resources?.length || 0; + const totalResourceCount = inputResourceCount + outputResourceCount; + + if (hasNonDefaultParams || totalResourceCount > 0) { + onSetError( + newPipelineTask.name, + inputResourceCount, + outputResourceCount, + hasNonDefaultParams, + ); + } + }, + task: { + name, + runAfter: runAfter || [], + }, + }); + + const onNewListNode = (task: PipelineVisualizationTaskItem, direction: AddNodeDirection) => { + let newNode: PipelineTaskListNode; + switch (direction) { + case AddNodeDirection.AFTER: { + const taskName = 'new-after-node'; + + onUpdateTasks( + pipelineTaskList.current.map((pipelineTask) => { + if (!pipelineTask?.runAfter?.includes(task.name)) { + return pipelineTask; + } + + const remainingRunAfters = (pipelineTask.runAfter || []).filter( + (runAfterName) => runAfterName !== task.name, + ); + + return { + ...pipelineTask, + runAfter: [...remainingRunAfters, taskName], + }; + }), + ); + newNode = newListNode(taskName, [task.name]); + break; + } + case AddNodeDirection.BEFORE: { + const taskName = 'new-before-node'; + const pipelineMap: { [name: string]: PipelineTask } = pipelineTaskList.current.reduce( + (map, pipelineTask) => ({ ...map, [pipelineTask.name]: pipelineTask }), + {}, + ); + + const runAfterItem = pipelineMap[task.name]; + const existingRunAfters = runAfterItem.runAfter || []; + pipelineMap[task.name] = { + ...pipelineMap[task.name], + runAfter: [taskName], + }; + + newNode = newListNode(taskName, existingRunAfters); + onUpdateTasks(Object.values(pipelineMap)); + break; + } + case AddNodeDirection.PARALLEL: { + const taskName = 'new-parallel-node'; + const pipelineMap: { [name: string]: PipelineTask } = pipelineTaskList.current.reduce( + (map, pipelineTask) => ({ ...map, [pipelineTask.name]: pipelineTask }), + {}, + ); + + const runParallelItem = pipelineMap[task.name]; + const myRunAfters: string[] = runParallelItem.runAfter || []; + onUpdateTasks( + pipelineTaskList.current.map((pipelineTask) => { + const currentRunAfters = pipelineTask?.runAfter || []; + if (!currentRunAfters.includes(runParallelItem.name)) { + return pipelineTask; + } + + return { + ...pipelineTask, + runAfter: [...currentRunAfters, taskName], + }; + }), + ); + + newNode = newListNode(taskName, myRunAfters); + break; + } + default: + throw new Error(`Invalid direction ${direction}`); + } + setListNodes([...listNodes, newNode]); + }; + + const existingNodes: PipelineMixedNodeModel[] = + pipelineTasks.length > 0 + ? tasksToBuilderNodes(pipelineTasks, tasksInError, onNewListNode, (task) => + onTaskSelection(task, getTask(task.taskRef)), + ) + : [newListNode('initial-node')]; + + const nodes: PipelineMixedNodeModel[] = handleParallelToParallelNodes([ + ...existingNodes, + ...listNodes, + ]); + + const localTaskCount = namespacedTasks?.length || 0; + const clusterTaskCount = clusterTasks?.length || 0; + + return { + tasksCount: localTaskCount + clusterTaskCount, + tasksLoaded: !!namespacedTasks && !!clusterTasks, + loadingTasksError: errorMsg, + nodes, + }; +}; diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/task-sidebar/Sidebar.scss b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/task-sidebar/Sidebar.scss new file mode 100644 index 00000000000..b620e0b15f8 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/task-sidebar/Sidebar.scss @@ -0,0 +1,51 @@ +$overview-sidebar-width: 550px; + +.odc-sidebar { + background: #fff; + bottom: 0; + box-shadow: var(--pf-global--BoxShadow--md); + overflow-x: hidden; + overflow-y: auto; + position: absolute; + right: 0; + transition: + opacity 175ms ease-out, + transform 225ms ease-out; + top: 0; + width: calc(100% - 15px); + visibility: hidden; + z-index: 5; + + @media(min-width: 762px) { + max-width: $overview-sidebar-width; + } + + &__content { + padding: var(--pf-global--spacer--lg); + } + + // CSSTransitionGroup classes + &-appear { // start in + opacity: 0; + transform: translateX(10%); + visibility: visible; + } + &-appear-active { // finish in + opacity: 1; + transform: translateX(0); + visibility: visible; + } + &-enter-done { + visibility: visible; + } + &-exit { // start out + opacity: 1; + transform: translateX(0); + visibility: visible; + } + &-exit-active { // finish out + opacity: 0; + transform: translateX(10%); + visibility: visible; + } +} diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/task-sidebar/Sidebar.tsx b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/task-sidebar/Sidebar.tsx new file mode 100644 index 00000000000..ec0c0f577a8 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/task-sidebar/Sidebar.tsx @@ -0,0 +1,69 @@ +import * as React from 'react'; +import { CSSTransition } from 'react-transition-group'; + +import './Sidebar.scss'; + +type LazyRender = () => React.ReactNode; +type SidebarProps = { + children: React.ReactNode | LazyRender; + onRequestClose: () => void; + open: boolean; +}; + +const DURATION = 225; + +const Sidebar: React.FC = (props) => { + const { children, onRequestClose, open } = props; + + const [canClose, setCanClose] = React.useState(false); + const contentRef = React.useRef(null); + const closeRef = React.useCallback( + (e) => { + if (canClose && !contentRef?.current?.contains(e?.target)) { + onRequestClose(); + } + }, + [canClose, onRequestClose], + ); + + React.useEffect(() => { + let timeout = null; + if (open) { + timeout = setTimeout(() => setCanClose(true), DURATION); + } else { + setCanClose(false); + } + + return () => { + clearTimeout(timeout); + }; + }, [open, setCanClose]); + React.useEffect(() => { + window.addEventListener('click', closeRef); + + return () => { + window.removeEventListener('click', closeRef); + }; + }, [closeRef]); + + const render = () => { + if (typeof children === 'function') { + if (open) { + return (children as LazyRender)(); + } + } else { + return children; + } + return null; + }; + + return ( + +
    + {render()} +
    +
    + ); +}; + +export default Sidebar; diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/task-sidebar/TaskSidebar.scss b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/task-sidebar/TaskSidebar.scss new file mode 100644 index 00000000000..4527f89cab3 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/task-sidebar/TaskSidebar.scss @@ -0,0 +1,5 @@ +.odc-task-sidebar { + &__header { + font-size: var(--pf-global--FontSize--md); + } +} diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/task-sidebar/TaskSidebar.tsx b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/task-sidebar/TaskSidebar.tsx new file mode 100644 index 00000000000..bb43fbd7b19 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/task-sidebar/TaskSidebar.tsx @@ -0,0 +1,175 @@ +import * as React from 'react'; +import * as _ from 'lodash'; +import { useField } from 'formik'; +import { TextInputTypes } from '@patternfly/react-core'; +import { InputField } from '@console/shared'; +import { ResourceIcon } from '@console/internal/components/utils'; +import { referenceFor } from '@console/internal/module/k8s'; +import { + getResourceModelFromTask, + PipelineResource, + PipelineResourceTask, + PipelineResourceTaskResource, + PipelineTask, + PipelineTaskParam, + PipelineTaskResource, +} from '../../../../utils/pipeline-augment'; +import TaskSidebarParam from './TaskSidebarParam'; +import { convertResourceToTask } from '../utils'; +import TaskSidebarResource from './TaskSidebarResource'; +import { TaskErrorMap } from '../types'; + +type TaskSidebarProps = { + errorMap: TaskErrorMap; + resourceList: PipelineResource[]; + setFieldValue: (formikId: string, newValue: any) => void; + selectedPipelineTaskIndex: number; + taskResource: PipelineResourceTask; + updateErrorMap: (errorMap: TaskErrorMap) => void; +}; + +const TaskSidebar: React.FC = (props) => { + const { + errorMap, + resourceList, + setFieldValue, + selectedPipelineTaskIndex, + taskResource, + updateErrorMap, + } = props; + const formikTaskReference = `tasks.${selectedPipelineTaskIndex}`; + const [taskField] = useField(formikTaskReference); + + const thisTaskError = errorMap[taskField.value.name]; + + const params = taskResource.spec?.inputs?.params; + const inputResources = taskResource.spec?.inputs?.resources; + const outputResources = taskResource.spec?.outputs?.resources; + + const renderResource = (type: 'inputs' | 'outputs') => ( + resource: PipelineResourceTaskResource, + ) => { + const taskResources: PipelineTaskResource[] = taskField.value?.resources?.[type] || []; + const thisResource = taskResources.find( + (taskFieldResource) => taskFieldResource.name === resource.name, + ); + + return ( + { + const newResources = [ + ...taskResources + .map((tResource) => { + if (tResource.name === resourceName) { + return null; + } + return taskResource; + }) + .filter((r) => !!r), + { + name: resourceName, + resource: selectedResource.name, + }, + ]; + setFieldValue(`${formikTaskReference}.resources.${type}`, newResources); + + const id = type === 'inputs' ? 'inputResourceCount' : 'outputResourceCount'; + if (thisTaskError && thisTaskError[id] === newResources.length) { + // Has errors but no longer resource errors + if (!thisTaskError[id]) { + return; + } + + updateErrorMap({ + ...errorMap, + [taskField.value.name]: _.omit(thisTaskError, id), + }); + } + }} + taskResource={thisResource} + resource={resource} + /> + ); + }; + + return ( +
    +

    + + {taskResource.metadata.name} +

    + + + {params && ( + <> +

    Parameters

    + {params.map((param) => { + const taskParams = taskField.value?.params || []; + const thisParam = taskParams.find( + (taskFieldParam) => taskFieldParam.name === param.name, + ); + return ( + { + const newParams = taskParams.map((taskParam) => { + if (taskParam === thisParam) { + return { + ...taskParam, + value, + } as PipelineTaskParam; + } + return taskParam; + }); + setFieldValue(formikTaskReference, { + ...taskField.value, + params: newParams, + }); + + const datalessParams = newParams.filter((p) => !p.value).length > 0; + if (thisTaskError && !datalessParams) { + // Has errors but no longer param errors + if (!thisTaskError.paramsMissingDefaults) { + return; + } + + updateErrorMap({ + ...errorMap, + [taskField.value.name]: _.omit(thisTaskError, 'paramsMissingDefaults'), + }); + } + }} + /> + ); + })} + + )} + + {inputResources && ( + <> +

    Input Resources

    + {inputResources && inputResources.map(renderResource('inputs'))} + + )} + {outputResources && ( + <> +

    Output Resources

    + {outputResources && outputResources.map(renderResource('outputs'))} + + )} +
    + ); +}; + +export default TaskSidebar; diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/task-sidebar/TaskSidebarParam.tsx b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/task-sidebar/TaskSidebarParam.tsx new file mode 100644 index 00000000000..966d630b3d2 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/task-sidebar/TaskSidebarParam.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; +import { FormGroup, TextInput } from '@patternfly/react-core'; +import { PipelineResourceTaskParam, PipelineTaskParam } from '../../../../utils/pipeline-augment'; + +type TaskSidebarParamProps = { + resourceParam: PipelineResourceTaskParam; + taskParam?: PipelineTaskParam; + onChange: (newValue: string) => void; +}; + +const TaskSidebarParam: React.FC = (props) => { + const { onChange, resourceParam, taskParam } = props; + + return ( + + { + onChange(value); + }} + /> + + ); +}; + +export default TaskSidebarParam; diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/task-sidebar/TaskSidebarResource.tsx b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/task-sidebar/TaskSidebarResource.tsx new file mode 100644 index 00000000000..70688926881 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/task-sidebar/TaskSidebarResource.tsx @@ -0,0 +1,62 @@ +import * as React from 'react'; +import { FormGroup } from '@patternfly/react-core'; +import { Dropdown } from '@console/internal/components/utils'; +import { + PipelineResource, + PipelineResourceTaskResource, + PipelineTaskResource, +} from '../../../../utils/pipeline-augment'; + +type TaskSidebarResourceProps = { + availableResources: PipelineResource[]; + onChange: (resourceName: string, resource: PipelineResource) => void; + resource: PipelineResourceTaskResource; + taskResource?: PipelineTaskResource; +}; + +const TaskSidebarResource: React.FC = (props) => { + const { availableResources, onChange, resource, taskResource } = props; + + const dropdownResources = availableResources.filter(({ type }) => resource.type === type); + + const defaultOptions = {}; + if (!taskResource?.resource) { + defaultOptions[''] = 'Select resource...'; + } + + return ( + 0} + isRequired + > + ({ ...acc, [name]: name }), + defaultOptions, + )} + disabled={dropdownResources.length === 0} + selectedKey={taskResource?.resource || ''} + dropDownClassName="dropdown--full-width" + onChange={(value: string) => { + onChange( + resource.name, + dropdownResources.find(({ name }) => name === value), + ); + }} + /> + + ); +}; + +export default TaskSidebarResource; diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/types.ts b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/types.ts new file mode 100644 index 00000000000..2dfd6340505 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/types.ts @@ -0,0 +1,45 @@ +import { FormikValues } from 'formik'; +import { + PipelineParam, + PipelineResource, + PipelineResourceTask, + PipelineTask, +} from '../../../utils/pipeline-augment'; +import { PipelineVisualizationTaskItem } from '../../../utils/pipeline-utils'; + +export type PipelineBuilderFormValues = { + name: string; + params: PipelineParam[]; + resources: PipelineResource[]; + tasks: PipelineTask[]; +}; + +export type PipelineBuilderFormikValues = FormikValues & PipelineBuilderFormValues; + +export type SelectedBuilderTask = { + resource: PipelineResourceTask; + taskIndex: number; +}; + +export type TaskErrorMapData = { + inputResourceCount: number; + outputResourceCount: number; + paramsMissingDefaults: boolean; + message: string; +}; +export type TaskErrorMap = { + [pipelineInErrorName: string]: TaskErrorMapData; +}; + +export type SetTaskErrorCallback = ( + pipelineInErrorName: string, + inputResourceCount: number, + outputResourceCount: number, + paramsMissingDefaults: boolean, +) => void; + +export type SelectTaskCallback = ( + task: PipelineVisualizationTaskItem, + taskResource: PipelineResourceTask, +) => void; +export type UpdateTaskCallback = (updatedTaskList: PipelineTask[]) => void; diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/utils.ts b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/utils.ts new file mode 100644 index 00000000000..e6a8fef477e --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/utils.ts @@ -0,0 +1,47 @@ +import { apiVersionForModel, referenceForModel } from '@console/internal/module/k8s'; +import { Pipeline, PipelineResourceTask, PipelineTask } from '../../../utils/pipeline-augment'; +import { ClusterTaskModel, PipelineModel } from '../../../models'; +import { PipelineBuilderFormikValues } from './types'; + +export const convertResourceToTask = ( + resource: PipelineResourceTask, + runAfter?: string[], +): PipelineTask => { + return { + name: resource.metadata.name, + runAfter, + taskRef: { + kind: resource.kind === ClusterTaskModel.kind ? ClusterTaskModel.kind : undefined, + name: resource.metadata.name, + }, + params: resource.spec.inputs?.params?.map((param) => ({ + name: param.name, + value: param.default, + })), + }; +}; + +export const getPipelineURL = (namespace: string) => { + return `/k8s/ns/${namespace}/${referenceForModel(PipelineModel)}`; +}; + +export const convertBuilderFormToPipeline = ( + formValues: PipelineBuilderFormikValues, + namespace: string, +): Pipeline => { + const { name, resources, params, tasks } = formValues; + + return { + apiVersion: apiVersionForModel(PipelineModel), + kind: PipelineModel.kind, + metadata: { + name, + namespace, + }, + spec: { + params, + resources, + tasks, + }, + }; +}; diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/validation-utils.ts b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/validation-utils.ts new file mode 100644 index 00000000000..774cf5422c5 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/validation-utils.ts @@ -0,0 +1,28 @@ +import * as yup from 'yup'; + +export const validationSchema = yup.object({ + name: yup.string().required('Required'), + params: yup.array().of( + yup.object({ + name: yup + .string() + .min(1) + .required('Required'), + description: yup.string(), + default: yup.string(), + }), + ), + resources: yup.array().of( + yup.object({ + name: yup + .string() + .min(1) + .required('Required'), + type: yup.string().required('Required'), + }), + ), + tasks: yup + .array() + .min(1, 'Must define at least one task') + .required('Required'), +}); diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/BuilderNode.scss b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/BuilderNode.scss new file mode 100644 index 00000000000..e4eddad923d --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/BuilderNode.scss @@ -0,0 +1,8 @@ +.odc-builder-node { + &__add-icon { + cursor: pointer; + } + &__error-icon { + font-size: 10px; + } +} diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/BuilderNode.tsx b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/BuilderNode.tsx new file mode 100644 index 00000000000..ae849406fef --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/BuilderNode.tsx @@ -0,0 +1,83 @@ +import * as React from 'react'; +import { Tooltip } from '@patternfly/react-core'; +import { ExclamationIcon, PlusIcon } from '@patternfly/react-icons'; +import { Node, observer } from '@console/topology'; +import { AddNodeDirection, BUILDER_NODE_ADD_RADIUS, BUILDER_NODE_ERROR_RADIUS } from './const'; +import TaskNode from './TaskNode'; +import { BuilderNodeModelData } from './types'; + +import './BuilderNode.scss'; + +const drawAdd = (x, y, onClick) => { + return ( + + + + + + + ); +}; + +const drawError = (errorStr: string) => { + return ( + + + + + + + + + + + ); +}; + +const BuilderNode: React.FC<{ element: Node }> = ({ element }) => { + const [showAdd, setShowAdd] = React.useState(false); + const { width, height } = element.getBounds(); + const data: BuilderNodeModelData = element.getData(); + const { error, onAddNode, onNodeSelection } = data; + + return ( + setShowAdd(true)} + onBlur={() => setShowAdd(false)} + onMouseOver={() => setShowAdd(true)} + onMouseOut={() => setShowAdd(false)} + > + + onNodeSelection(data)}> + + {error?.message && drawError(error.message)} + + + {drawAdd(width + BUILDER_NODE_ADD_RADIUS, height / 2, () => + onAddNode(AddNodeDirection.AFTER), + )} + {drawAdd(-BUILDER_NODE_ADD_RADIUS, height / 2, () => onAddNode(AddNodeDirection.BEFORE))} + {drawAdd(width / 2, height + BUILDER_NODE_ADD_RADIUS, () => + onAddNode(AddNodeDirection.PARALLEL), + )} + + + ); +}; + +export default observer(BuilderNode); diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/PipelineTopologyGraph.scss b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/PipelineTopologyGraph.scss new file mode 100644 index 00000000000..ae79d389e67 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/PipelineTopologyGraph.scss @@ -0,0 +1,9 @@ +.odc-pipeline-topology-visualization { + display: inline-block; + background: var(--pf-global--BackgroundColor--300); + border-radius: 20px; + font-size: var(--pf-global--FontSize--xs); + max-width: 100%; + overflow: auto; + padding: var(--pf-global--spacer--lg) var(--pf-global--spacer--xl); +} diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/PipelineTopologyGraph.tsx b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/PipelineTopologyGraph.tsx new file mode 100644 index 00000000000..8889abe18a7 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/PipelineTopologyGraph.tsx @@ -0,0 +1,44 @@ +import * as React from 'react'; +import { ModelKind } from '@console/topology'; +import PipelineVisualizationSurface from './PipelineVisualizationSurface'; +import { PipelineLayout } from './const'; +import { PipelineEdgeModel, PipelineMixedNodeModel } from './types'; + +import './PipelineTopologyGraph.scss'; + +type PipelineTopologyGraphProps = { + id: string; + fluid?: boolean; + nodes: PipelineMixedNodeModel[]; + edges: PipelineEdgeModel[]; + layout: PipelineLayout; +}; + +const PipelineTopologyGraph: React.FC = ({ + id, + fluid, + nodes, + edges, + layout, +}) => { + return ( +
    + +
    + ); +}; + +export default PipelineTopologyGraph; diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/PipelineVisualizationSurface.tsx b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/PipelineVisualizationSurface.tsx new file mode 100644 index 00000000000..f8547271c2b --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/PipelineVisualizationSurface.tsx @@ -0,0 +1,62 @@ +import * as React from 'react'; +import { Model, Visualization, VisualizationSurface } from '@console/topology'; +import { LayoutCallback } from '@console/topology/src/layouts/DagreLayout'; +import { componentFactory, layoutFactory } from './factories'; +import { DROP_SHADOW_SPACING, NODE_WIDTH, NODE_HEIGHT, PipelineLayout } from './const'; +import { getLayoutData } from './utils'; + +type PipelineVisualizationSurfaceProps = { + model: Model; +}; + +const PipelineVisualizationSurface: React.FC = ({ model }) => { + const [vis, setVis] = React.useState(null); + const [maxSize, setMaxSize] = React.useState(null); + + const layout: PipelineLayout = model.graph.layout as PipelineLayout; + + const onLayoutUpdate: LayoutCallback = React.useCallback( + (nodes) => { + const nodeBounds = nodes.map((node) => node.getBounds()); + const maxX = nodeBounds.map((bounds) => bounds.x).reduce((x1, x2) => Math.max(x1, x2), 0); + const maxY = nodeBounds.map((bounds) => bounds.y).reduce((y1, y2) => Math.max(y1, y2), 0); + + let horizontalMargin = 0; + let verticalMargin = 0; + if (layout) { + horizontalMargin = getLayoutData(layout).marginx || 0; + verticalMargin = getLayoutData(layout).marginy || 0; + } + + setMaxSize({ + // Nodes are rendered from the top-left + height: maxY + NODE_HEIGHT + DROP_SHADOW_SPACING + verticalMargin, + width: maxX + NODE_WIDTH + horizontalMargin, + }); + }, + [setMaxSize, layout], + ); + + React.useEffect(() => { + if (vis === null) { + const visualization = new Visualization(); + visualization.registerLayoutFactory(layoutFactory(onLayoutUpdate)); + visualization.registerComponentFactory(componentFactory); + visualization.fromModel(model); + setVis(visualization); + } else { + vis.fromModel(model); + vis.getGraph().layout(); + } + }, [vis, model, onLayoutUpdate]); + + if (!vis) return null; + + return ( +
    + +
    + ); +}; + +export default PipelineVisualizationSurface; diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/SpacerNode.tsx b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/SpacerNode.tsx new file mode 100644 index 00000000000..6aa54e0a870 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/SpacerNode.tsx @@ -0,0 +1,8 @@ +import * as React from 'react'; +import { observer, Node } from '@console/topology'; + +const SpacerNode: React.FC<{ element: Node }> = () => { + return ; +}; + +export default observer(SpacerNode); diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/TaskEdge.tsx b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/TaskEdge.tsx new file mode 100644 index 00000000000..f0b178d6d39 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/TaskEdge.tsx @@ -0,0 +1,24 @@ +import * as React from 'react'; +import { Edge, Point } from '@console/topology'; +import { integralShapePath } from './draw-utils'; + +const TaskEdge: React.FC<{ element: Edge }> = ({ element }) => { + const startPoint: Point = element.getStartPoint(); + const endPoint: Point = element.getEndPoint(); + const sourceNode = element.getSource(); + const targetNode = element.getTarget(); + + return ( + + ); +}; + +export default TaskEdge; diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/TaskListNode.scss b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/TaskListNode.scss new file mode 100644 index 00000000000..50f7c285915 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/TaskListNode.scss @@ -0,0 +1,16 @@ +.odc-task-list-node { + border: 1px solid var(--pf-global--BorderColor--light-100); + + &__trigger-background { + background: var(--pf-global--BackgroundColor--light-100); + } + &__trigger { + padding: var(--pf-global--spacer--xs) var(--pf-global--spacer--md); + width: 100%; + } + &__list-items { + max-height: 350px; + overflow: auto; + } +} + diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/TaskListNode.tsx b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/TaskListNode.tsx new file mode 100644 index 00000000000..9d44e1b295f --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/TaskListNode.tsx @@ -0,0 +1,84 @@ +import * as React from 'react'; +import * as FocusTrap from 'focus-trap-react'; +import { Button } from '@patternfly/react-core'; +import { CaretDownIcon } from '@patternfly/react-icons'; +import Popper from '@console/shared/src/components/popper/Popper'; +import { KebabMenuItems, KebabOption } from '@console/internal/components/utils'; +import { observer, Node } from '@console/topology'; +import { PipelineResourceTask } from '../../../utils/pipeline-augment'; +import { NewTaskNodeCallback, TaskListNodeModelData } from './types'; + +import './TaskListNode.scss'; + +const taskToOption = (task: PipelineResourceTask, callback: NewTaskNodeCallback): KebabOption => { + const { + metadata: { name }, + } = task; + + return { + label: name, + callback: () => { + callback(task); + }, + }; +}; + +const TaskListNode: React.FC<{ element: Node }> = ({ element }) => { + const triggerRef = React.useRef(null); + const [isMenuOpen, setMenuOpen] = React.useState(false); + const { height, width } = element.getBounds(); + const { + clusterTaskList, + namespaceTaskList, + onNewTask, + } = element.getData() as TaskListNodeModelData; + + const options = [ + ...namespaceTaskList.map((task) => taskToOption(task, onNewTask)), + ...clusterTaskList.map((task) => taskToOption(task, onNewTask)), + ]; + + return ( + +
    + +
    + { + if (!e || !triggerRef?.current?.contains(e.target as Element)) { + setMenuOpen(false); + } + }} + reference={() => triggerRef.current} + > + +
    + { + option.callback && option.callback(); + }} + className="oc-kebab__popper-items odc-task-list-node__list-items" + /> +
    +
    +
    +
    + ); +}; + +export default observer(TaskListNode); diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/TaskNode.tsx b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/TaskNode.tsx new file mode 100644 index 00000000000..ef28e7da3c0 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/TaskNode.tsx @@ -0,0 +1,24 @@ +import * as React from 'react'; +import { observer, Node } from '@console/topology'; +import { pipelineRunFilterReducer } from '../../../utils/pipeline-filter-reducer'; +import { PipelineVisualizationTask } from '../detail-page-tabs/pipeline-details/PipelineVisualizationTask'; +import { DROP_SHADOW_SPACING } from './const'; +import { TaskNodeModelData } from './types'; + +const TaskNode: React.FC<{ element: Node }> = ({ element }) => { + const { height, width } = element.getBounds(); + const { pipeline, pipelineRun, task } = element.getData() as TaskNodeModelData; + + return ( + + + + ); +}; + +export default observer(TaskNode); diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/const.ts b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/const.ts new file mode 100644 index 00000000000..4ca046489f8 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/const.ts @@ -0,0 +1,49 @@ +import * as dagre from 'dagre'; + +export const NODE_SEPARATION_HORIZONTAL = 25; +export const NODE_SEPARATION_VERTICAL = 20; +export const DROP_SHADOW_SPACING = 5; +export const BUILDER_NODE_ADD_RADIUS = 10; +export const BUILDER_NODE_ERROR_RADIUS = 9; + +export const NODE_WIDTH = 120; +export const NODE_HEIGHT = 30; + +export enum NodeType { + TASK_NODE = 'task', + SPACER_NODE = 'spacer', + TASK_LIST_NODE = 'task-list', + BUILDER_NODE = 'builder', +} +export enum DrawDesign { + INTEGRAL_SHAPE = 'integral-shape', + STRAIGHT = 'line', +} +export enum PipelineLayout { + DAGRE_BUILDER = 'dagre-builder', + DAGRE_VIEWER = 'dagre-viewer', +} + +export enum AddNodeDirection { + BEFORE = 'in-run-after', + AFTER = 'has-run-after', + PARALLEL = 'shared-parallel', +} + +const DAGRE_SHARED_PROPS: dagre.GraphLabel = { + nodesep: NODE_SEPARATION_VERTICAL, + ranksep: NODE_SEPARATION_HORIZONTAL, + edgesep: 0, + ranker: 'longest-path', + rankdir: 'LR', + align: 'UL', +}; +export const DAGRE_VIEWER_PROPS: dagre.GraphLabel = { + ...DAGRE_SHARED_PROPS, +}; +export const DAGRE_BUILDER_PROPS: dagre.GraphLabel = { + ...DAGRE_SHARED_PROPS, + ranksep: NODE_SEPARATION_HORIZONTAL + BUILDER_NODE_ADD_RADIUS, + marginx: 30, + marginy: 30, +}; diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/draw-utils.ts b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/draw-utils.ts new file mode 100644 index 00000000000..7d710426ca0 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/draw-utils.ts @@ -0,0 +1,87 @@ +import { Point } from '@console/topology'; +import { DrawDesign, NODE_SEPARATION_HORIZONTAL } from './const'; + +type SingleDraw = (p: Point) => string; +type DoubleDraw = (p1: Point, p2: Point) => string; +type TripleDraw = (p1: Point, p2: Point, p3: Point) => string; +type DetermineDirection = (p1: Point, p2: Point) => boolean; + +const join = (...segments: string[]) => segments.filter((seg) => !!seg).join(' '); + +const leftRight: DetermineDirection = (p1, p2) => p1.x < p2.x; +const topDown: DetermineDirection = (p1, p2) => p1.y < p2.y; +const bottomUp: DetermineDirection = (p1, p2) => p1.y > p2.y; + +const point: SingleDraw = (p) => `${p.x},${p.y}`; +const moveTo: SingleDraw = (p) => `M ${point(p)}`; +const lineTo: SingleDraw = (p) => `L ${point(p)}`; +const quadTo: DoubleDraw = (corner, end) => `Q ${point(corner)} ${point(end)}`; + +// TODO: Try to simplify +// x should not be greater than (NODE_SEPARATION_HORIZONTAL / 2) +const CURVE_SIZE = { x: 8, y: 10 }; +const curve: TripleDraw = (fromPoint, cornerPoint, toPoint) => { + const topToBottom = topDown(fromPoint, toPoint); + if (topToBottom) { + const rightAndDown = leftRight(fromPoint, cornerPoint) && topDown(cornerPoint, toPoint); + const downAndRight = topDown(fromPoint, cornerPoint) && leftRight(cornerPoint, toPoint); + if (rightAndDown) { + return join( + lineTo(cornerPoint.clone().translate(-CURVE_SIZE.x, 0)), + quadTo(cornerPoint, cornerPoint.clone().translate(0, CURVE_SIZE.y)), + ); + } + if (downAndRight) { + return join( + lineTo(cornerPoint.clone().translate(0, -CURVE_SIZE.y)), + quadTo(cornerPoint, cornerPoint.clone().translate(CURVE_SIZE.x, 0)), + ); + } + } else { + const rightAndUp = leftRight(fromPoint, cornerPoint) && bottomUp(cornerPoint, toPoint); + const upAndRight = bottomUp(fromPoint, cornerPoint) && leftRight(cornerPoint, toPoint); + if (rightAndUp) { + return join( + lineTo(cornerPoint.clone().translate(-CURVE_SIZE.x, 0)), + quadTo(cornerPoint, cornerPoint.clone().translate(0, -CURVE_SIZE.y)), + ); + } + if (upAndRight) { + return join( + lineTo(cornerPoint.clone().translate(0, CURVE_SIZE.y)), + quadTo(cornerPoint, cornerPoint.clone().translate(CURVE_SIZE.x, 0)), + ); + } + } + + return ''; +}; + +export const straightPath: DoubleDraw = (start, finish) => join(moveTo(start), lineTo(finish)); + +export const integralShapePath: DoubleDraw = (start, finish) => { + // Integral shape: ∫ + let firstCurve: string = null; + let secondCurve: string = null; + + if (start.y !== finish.y) { + const cornerX = start.x + Math.floor(NODE_SEPARATION_HORIZONTAL / 2); + const firstCorner = new Point(cornerX, start.y); + const secondCorner = new Point(cornerX, finish.y); + + firstCurve = curve(start, firstCorner, secondCorner); + secondCurve = curve(firstCorner, secondCorner, finish); + } + + return join(moveTo(start), firstCurve, secondCurve, lineTo(finish)); +}; + +export const path = (start: Point, finish: Point, drawDesign?: DrawDesign) => { + switch (drawDesign) { + case DrawDesign.INTEGRAL_SHAPE: + return integralShapePath(start, finish); + case DrawDesign.STRAIGHT: + default: + return straightPath(start, finish); + } +}; diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/factories.ts b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/factories.ts new file mode 100644 index 00000000000..de84776b1b4 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/factories.ts @@ -0,0 +1,52 @@ +import { + ComponentFactory, + DagreLayout, + GraphComponent, + LayoutFactory, + ModelKind, + Graph, +} from '@console/topology'; +import { LayoutCallback } from '@console/topology/src/layouts/DagreLayout'; +import { NodeType, PipelineLayout } from './const'; +import SpacerNode from './SpacerNode'; +import TaskNode from './TaskNode'; +import TaskEdge from './TaskEdge'; +import TaskListNode from './TaskListNode'; +import BuilderNode from './BuilderNode'; +import { getLayoutData } from './utils'; + +export const componentFactory: ComponentFactory = (kind: ModelKind, type: string) => { + switch (kind) { + case ModelKind.graph: + return GraphComponent; + case ModelKind.edge: + return TaskEdge; + case ModelKind.node: + switch (type) { + case NodeType.TASK_NODE: + return TaskNode; + case NodeType.SPACER_NODE: + return SpacerNode; + case NodeType.TASK_LIST_NODE: + return TaskListNode; + case NodeType.BUILDER_NODE: + return BuilderNode; + default: + return undefined; + } + default: + return undefined; + } +}; + +// TODO: Fix this hack as it's not the best way to get the layout update +type CallbackLayout = (onLayout: LayoutCallback) => LayoutFactory; +export const layoutFactory: CallbackLayout = (onLayout) => (type: string, graph: Graph) => { + switch (type) { + case PipelineLayout.DAGRE_BUILDER: + case PipelineLayout.DAGRE_VIEWER: + return new DagreLayout(graph, getLayoutData(type), { onLayout }); + default: + return undefined; + } +}; diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/types.ts b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/types.ts new file mode 100644 index 00000000000..2bd8d7c9f10 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/types.ts @@ -0,0 +1,56 @@ +import { EdgeModel, NodeModel } from '@console/topology'; +import { Pipeline, PipelineResourceTask, PipelineRun } from '../../../utils/pipeline-augment'; +import { PipelineVisualizationTaskItem } from '../../../utils/pipeline-utils'; +import { TaskErrorMapData } from '../pipeline-builder/types'; +import { AddNodeDirection, NodeType } from './const'; + +// Builder Callbacks +export type NewTaskListNodeCallback = (direction: AddNodeDirection) => void; +export type NewTaskNodeCallback = (resource: PipelineResourceTask) => void; +export type NodeSelectionCallback = (nodeData: BuilderNodeModelData) => void; + +// Node Data Models +export type PipelineRunAfterNodeModelData = { + task: { + name: string; + runAfter?: string[]; + }; +}; +export type TaskListNodeModelData = PipelineRunAfterNodeModelData & { + clusterTaskList: PipelineResourceTask[]; + namespaceTaskList: PipelineResourceTask[]; + onNewTask: NewTaskNodeCallback; +}; +export type BuilderNodeModelData = PipelineRunAfterNodeModelData & { + error?: TaskErrorMapData; + task: PipelineVisualizationTaskItem; + onAddNode: NewTaskListNodeCallback; + onNodeSelection: NodeSelectionCallback; +}; +export type SpacerNodeModelData = PipelineRunAfterNodeModelData & {}; +export type TaskNodeModelData = PipelineRunAfterNodeModelData & { + task: PipelineVisualizationTaskItem; + pipeline?: Pipeline; + pipelineRun?: PipelineRun; +}; + +// Graph Models +type PipelineNodeModel = NodeModel & { + data: D; + type: NodeType; +}; +export type PipelineMixedNodeModel = PipelineNodeModel; +export type PipelineTaskNodeModel = PipelineNodeModel; +export type PipelineTaskListNode = PipelineNodeModel; + +export type PipelineEdgeModel = EdgeModel; + +// Node Creators +export type NodeCreator = ( + name: string, + data: D, +) => PipelineNodeModel; +export type NodeCreatorSetup = ( + type: NodeType, + width?: number, +) => NodeCreator; diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/utils.ts b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/utils.ts new file mode 100644 index 00000000000..ee16dde4070 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/utils.ts @@ -0,0 +1,244 @@ +import * as dagre from 'dagre'; +import * as _ from 'lodash'; +import { Pipeline, PipelineRun } from '../../../utils/pipeline-augment'; +import { getPipelineTasks, PipelineVisualizationTaskItem } from '../../../utils/pipeline-utils'; +import { + NODE_HEIGHT, + NodeType, + NODE_WIDTH, + AddNodeDirection, + PipelineLayout, + DAGRE_BUILDER_PROPS, + DAGRE_VIEWER_PROPS, +} from './const'; +import { + PipelineEdgeModel, + NodeCreator, + NodeCreatorSetup, + SpacerNodeModelData, + TaskListNodeModelData, + TaskNodeModelData, + PipelineMixedNodeModel, + PipelineTaskNodeModel, + BuilderNodeModelData, + PipelineRunAfterNodeModelData, +} from './types'; +import { TaskErrorMap } from '../pipeline-builder/types'; + +const createGenericNode: NodeCreatorSetup = (type, width?) => (name, data) => ({ + id: name, + data, + height: NODE_HEIGHT, + width: width != null ? width : NODE_WIDTH, + type, +}); + +// Node variations +export const createTaskNode: NodeCreator = createGenericNode(NodeType.TASK_NODE); +export const createSpacerNode: NodeCreator = createGenericNode( + NodeType.SPACER_NODE, + 0, +); +export const createTaskListNode: NodeCreator = createGenericNode( + NodeType.TASK_LIST_NODE, +); +export const createBuilderNode: NodeCreator = createGenericNode( + NodeType.BUILDER_NODE, +); + +export const getNodeCreator = (type: NodeType): NodeCreator => { + switch (type) { + case NodeType.TASK_LIST_NODE: + return createTaskListNode; + case NodeType.BUILDER_NODE: + return createBuilderNode; + case NodeType.SPACER_NODE: + return createSpacerNode; + case NodeType.TASK_NODE: + default: + return createTaskNode; + } +}; + +export const handleParallelToParallelNodes = ( + nodes: PipelineMixedNodeModel[], +): PipelineMixedNodeModel[] => { + type ParallelNodeReference = { + node: PipelineTaskNodeModel; + runAfter: string[]; + atIndex: number; + }; + type ParallelNodeMap = { + [id: string]: ParallelNodeReference[]; + }; + + // Collect only multiple run-afters + const multipleRunBeforeMap: ParallelNodeMap = nodes.reduce((acc, node, idx) => { + const { + data: { + task: { runAfter }, + }, + } = node; + if (runAfter && runAfter.length > 1) { + const id: string = [...runAfter] + .sort((a, b) => a.localeCompare(b)) + .reduce((str, ref) => `${str}|${ref}`); + + if (!Array.isArray(acc[id])) { + acc[id] = []; + } + acc[id].push({ + node, + runAfter, + atIndex: idx, + }); + } + return acc; + }, {} as ParallelNodeMap); + + // Trim out single occurrences + const multiParallelToParallelList: ParallelNodeReference[][] = Object.values( + multipleRunBeforeMap, + ).filter((data: ParallelNodeReference[]) => data.length > 1); + + if (multiParallelToParallelList.length === 0) { + // No parallel to parallel + return nodes; + } + + // Insert a spacer node between the multiple nodes on the sides of a parallel-to-parallel + const newNodes: PipelineMixedNodeModel[] = []; + const newRunAfterNodeMap: { [nodeId: string]: string[] } = {}; + multiParallelToParallelList.forEach((p2p: ParallelNodeReference[]) => { + // All nodes in each array share their runAfters + const { runAfter } = p2p[0]; + + const names: string[] = p2p.map((p2pData) => p2pData.node.id); + const parallelSpacerName = `parallel-${names.join('-')}`; + + names.forEach((p2pNodeId) => { + if (!Array.isArray(newRunAfterNodeMap[p2pNodeId])) { + newRunAfterNodeMap[p2pNodeId] = []; + } + newRunAfterNodeMap[p2pNodeId].push(parallelSpacerName); + }); + + newNodes.push( + createSpacerNode(parallelSpacerName, { + task: { + name: parallelSpacerName, + runAfter, + }, + }), + ); + }); + + // Update all impacted nodes to point at the spacer node as the spacer points at their original runAfters + nodes.forEach((node) => { + const newRunAfters: string[] | undefined = newRunAfterNodeMap[node.id]; + if (newRunAfters && newRunAfters.length > 0) { + const { + data: { task }, + type, + } = node; + + const createNode: NodeCreator = getNodeCreator(type); + + // Recreate the node with the new runAfter pointing to the spacer node + newNodes.push( + createNode(node.id, { + ...node.data, + task: { + ...task, + runAfter: newRunAfters, + }, + }), + ); + } else { + // Unaffected node, just carry it over + newNodes.push(node); + } + }); + + return newNodes; +}; + +export const tasksToNodes = ( + taskList: PipelineVisualizationTaskItem[], + pipeline?: Pipeline, + pipelineRun?: PipelineRun, +): PipelineMixedNodeModel[] => { + const nodeList: PipelineTaskNodeModel[] = taskList.map((task) => + createTaskNode(task.name, { + task, + pipeline, + pipelineRun, + }), + ); + + return handleParallelToParallelNodes(nodeList); +}; + +export const tasksToBuilderNodes = ( + taskList: PipelineVisualizationTaskItem[], + taskErrorMap: TaskErrorMap, + onAddNode: (task: PipelineVisualizationTaskItem, direction: AddNodeDirection) => void, + onNodeSelection: (task: PipelineVisualizationTaskItem) => void, +): PipelineMixedNodeModel[] => { + return taskList.map((task) => { + return createBuilderNode(task.name, { + error: taskErrorMap[task.name], + task, + onNodeSelection: () => { + onNodeSelection(task); + }, + onAddNode: (direction: AddNodeDirection) => { + onAddNode(task, direction); + }, + }); + }); +}; + +export const getEdgesFromNodes = (nodes: PipelineMixedNodeModel[]): PipelineEdgeModel[] => + _.flatten( + nodes.map((node) => { + const { + data: { + task: { name: target, runAfter = [] }, + }, + } = node; + + if (runAfter.length === 0) return null; + + return runAfter.map((source) => ({ + id: `${source}~to~${target}`, + type: 'edge', + source, + target, + })); + }), + ).filter((edgeList) => !!edgeList); + +export const getTopologyNodesEdges = ( + pipeline: Pipeline, + pipelineRun?: PipelineRun, +): { nodes: PipelineMixedNodeModel[]; edges: PipelineEdgeModel[] } => { + const taskList: PipelineVisualizationTaskItem[] = _.flatten( + getPipelineTasks(pipeline, pipelineRun), + ); + const nodes: PipelineMixedNodeModel[] = tasksToNodes(taskList, pipeline, pipelineRun); + const edges: PipelineEdgeModel[] = getEdgesFromNodes(nodes); + + return { nodes, edges }; +}; + +export const getLayoutData = (layout: PipelineLayout): dagre.GraphLabel => { + switch (layout) { + case PipelineLayout.DAGRE_BUILDER: + return DAGRE_BUILDER_PROPS; + case PipelineLayout.DAGRE_VIEWER: + return DAGRE_VIEWER_PROPS; + default: + return null; + } +}; diff --git a/frontend/packages/dev-console/src/plugin.tsx b/frontend/packages/dev-console/src/plugin.tsx index caea61b3fd7..04052efddae 100644 --- a/frontend/packages/dev-console/src/plugin.tsx +++ b/frontend/packages/dev-console/src/plugin.tsx @@ -465,6 +465,20 @@ const plugin: Plugin = [ ).PipelinesPage, }, }, + { + type: 'Page/Route', + properties: { + perspective: 'dev', + exact: true, + path: [`/k8s/ns/:ns/${referenceForModel(PipelineModel)}/~new/builder`], + loader: async () => + ( + await import( + './components/pipelines/pipeline-builder/PipelineBuilderPage' /* webpackChunkName: "pipeline-builder-page" */ + ) + ).default, + }, + }, { type: 'Page/Route', properties: { diff --git a/frontend/packages/dev-console/src/utils/pipeline-augment.ts b/frontend/packages/dev-console/src/utils/pipeline-augment.ts index c2c1e4cc104..e278d90f3ad 100644 --- a/frontend/packages/dev-console/src/utils/pipeline-augment.ts +++ b/frontend/packages/dev-console/src/utils/pipeline-augment.ts @@ -34,13 +34,30 @@ export interface TaskStatus { Failed: number; } +export interface PipelineTaskRef { + kind?: string; + name: string; +} + +export interface PipelineTaskParam { + name: string; + value: any; +} +export interface PipelineTaskResources { + inputs?: PipelineTaskResource[]; + outputs?: PipelineTaskResource[]; +} +export interface PipelineTaskResource { + name: string; + resource?: string; + from?: string[]; +} export interface PipelineTask { name: string; runAfter?: string[]; - taskRef: { - kind?: string; - name: string; - }; + taskRef: PipelineTaskRef; + params?: PipelineTaskParam[]; + resources?: PipelineTaskResources; } export interface Resource { @@ -49,10 +66,10 @@ export interface Resource { } export interface PipelineResource { - name?: string; + name: string; type?: string; resourceRef?: { - name?: string; + name: string; }; } @@ -65,7 +82,6 @@ export type KeyedRuns = { [key: string]: Runs }; export interface Pipeline extends K8sResourceKind { latestRun?: PipelineRun; spec?: { - pipelineRef?: { name: string }; params?: PipelineParam[]; resources?: PipelineResource[]; tasks: PipelineTask[]; @@ -95,6 +111,32 @@ export interface PipelineRun extends K8sResourceKind { }; } +export interface PipelineResourceTaskParam extends PipelineParam { + type: string; +} +export interface PipelineResourceTaskResource { + name: string; + type: string; +} +export interface PipelineResourceTask extends K8sResourceKind { + spec: { + inputs?: { + params?: PipelineResourceTaskParam[]; + resources?: PipelineResourceTaskResource[]; + }; + outputs?: { + resources?: PipelineResourceTaskResource[]; + }; + steps: { + // TODO: Figure out required fields + args?: string[]; + command?: string[]; + image?: string; + resources?: {}[]; + }[]; + }; +} + export interface Condition { type: string; status: string; diff --git a/frontend/packages/dev-console/src/utils/pipeline-utils.ts b/frontend/packages/dev-console/src/utils/pipeline-utils.ts index 33911998d0a..f32cb03de67 100644 --- a/frontend/packages/dev-console/src/utils/pipeline-utils.ts +++ b/frontend/packages/dev-console/src/utils/pipeline-utils.ts @@ -14,6 +14,7 @@ import { runStatus, PipelineParam, PipelineRunParam, + PipelineTaskRef, } from './pipeline-augment'; import { pipelineFilterReducer, pipelineRunStatus } from './pipeline-filter-reducer'; @@ -43,9 +44,7 @@ export interface PipelineVisualizationTaskItem { resources?: Resources; params?: object; runAfter?: string[]; - taskRef: { - name: string; - }; + taskRef: PipelineTaskRef; } export const TaskStatusClassNameMap = { diff --git a/frontend/packages/topology/src/layouts/DagreLayout.ts b/frontend/packages/topology/src/layouts/DagreLayout.ts index 3d0751968ad..e6e52a47bfd 100644 --- a/frontend/packages/topology/src/layouts/DagreLayout.ts +++ b/frontend/packages/topology/src/layouts/DagreLayout.ts @@ -7,8 +7,15 @@ import { leafNodeElements } from '../utils/element-utils'; class DagreNode { private node: Node; + public width: number; + + public height: number; + constructor(node: Node) { this.node = node; + + this.width = this.node.getBounds().width; + this.height = this.node.getBounds().height; } getId(): string { @@ -69,13 +76,23 @@ class DagreEdge { } } +export type LayoutCallback = (nodes: Node[], edges: Edge[]) => void; +type AdditionalDagreLayoutOptions = { onLayout: LayoutCallback }; export default class DagreLayout implements Layout { - // eslint-disable-next-line @typescript-eslint/ban-ts-ignore - // @ts-ignore - private graph: Graph; // Usage is TBD + private graph: Graph; - constructor(graph: Graph) { + private readonly options: dagre.GraphLabel; + + private readonly onLayout: LayoutCallback | undefined; + + constructor( + graph: Graph, + options?: dagre.GraphLabel, + additionalOptions?: AdditionalDagreLayoutOptions, + ) { this.graph = graph; + this.options = options || {}; + this.onLayout = additionalOptions?.onLayout; } destroy(): void {} @@ -95,6 +112,7 @@ export default class DagreLayout implements Layout { marginy: 0, nodesep: 20, ranker: 'tight-tree', + ...this.options, }); _.forEach(nodes, (node) => { @@ -115,5 +133,7 @@ export default class DagreLayout implements Layout { ); } }); + + this.onLayout && this.onLayout(this.graph.getNodes(), this.graph.getEdges()); }; }