Repository: ovh/cds
Pull request #6563 (2023-06-02 09:42:09)

fix(api): manage variable deletion and env keys as code
1. Description
1. Related issues
1. About tests
1. Mentions

@ovh/cds


ovh-cds: 
CDS Report build-all-cds#18862.0 ✘  
* Build
  * Build and Package All ✔ 
  * Build Debpacker ✔ 
  * Build SMTPMock ✔ 
  * Lint UI Translate ✔ 
  * Test CLI ✔ 
  * Test Engine ✘  
  * Test SDK ✔ 
  * Test UI ✔ 

ovh-cds: 
CDS Report build-all-cds#18863.0 ✘  
* Build
  * Build and Package All ✔ 
  * Build Debpacker ✔ 
  * Build SMTPMock ✔ 
  * Lint UI Translate ✔ 
  * Test CLI ✔ 
  * Test Engine ✘  
  * Test SDK ✔ 
  * Test UI ✔ 

ovh-cds: 
CDS Report build-all-cds#18864.0 ✘  
* Build
  * Build and Package All ✔ 
  * Build Debpacker ✔ 
  * Build SMTPMock ✔ 
  * Lint UI Translate ✔ 
  * Test CLI ✔ 
  * Test Engine ✘  
  * Test SDK ✔ 
  * Test UI ✔ 

ovh-cds: 
CDS Report build-all-cds#18870.0 ✘  
* Build
  * Build and Package All ✔ 
  * Build Debpacker ✔ 
  * Build SMTPMock ✔ 
  * Lint UI Translate ✔ 
  * Test CLI ✔ 
  * Test Engine ✔ 
  * Test SDK ✔ 
  * Test UI ✔ 
* Package
  * Docker ✘  


Patch:
diff --git a/engine/api/environment.go b/engine/api/environment.go
index e88023f6c..9d63ef726 100644
--- a/engine/api/environment.go
+++ b/engine/api/environment.go
@@ -240,12 +240,33 @@ func (api *API) updateAsCodeEnvironmentHandler() service.Handler {
 			return err
 		}
 
-		// check application name pattern
+		// check environment name pattern
 		regexp := sdk.NamePatternRegex
 		if !regexp.MatchString(env.Name) {
 			return sdk.WrapError(sdk.ErrInvalidApplicationPattern, "Environment name %s do not respect pattern", env.Name)
 		}
 
+		envDB, err := environment.LoadEnvironmentByName(api.mustDB(), key, environmentName)
+		if err != nil {
+			return sdk.WrapError(err, "cannot load environment %s", environmentName)
+		}
+
+		// replace placeholder before export
+		envVarsClear, err := environment.LoadAllVariablesWithDecrytion(api.mustDB(), envDB.ID)
+		if err != nil {
+			return err
+		}
+		for i := range env.Variables {
+			v := &env.Variables[i]
+			if v.Type == sdk.SecretVariable && v.Value == sdk.PasswordPlaceholder {
+				for _, dbvar := range envVarsClear {
+					if dbvar.ID == v.ID {
+						v.Value = dbvar.Value
+					}
+				}
+			}
+		}
+
 		tx, err := api.mustDB().Begin()
 		if err != nil {
 			return sdk.WithStack(err)
@@ -257,11 +278,6 @@ func (api *API) updateAsCodeEnvironmentHandler() service.Handler {
 			return err
 		}
 
-		envDB, err := environment.LoadEnvironmentByName(tx, key, environmentName)
-		if err != nil {
-			return sdk.WrapError(err, "cannot load environment %s", environmentName)
-		}
-
 		if envDB.FromRepository == "" {
 			return sdk.NewErrorFrom(sdk.ErrForbidden, "current environment is not ascode")
 		}
@@ -302,15 +318,30 @@ func (api *API) updateAsCodeEnvironmentHandler() service.Handler {
 		}
 
 		// create keys
+		envKeys, err := environment.LoadAllKeysWithPrivateContent(tx, env.ID)
+		if err != nil {
+			return err
+		}
 		for i := range env.Keys {
 			k := &env.Keys[i]
-			newKey, err := keys.GenerateKey(k.Name, k.Type)
-			if err != nil {
-				return err
+			if k.ID == 0 {
+				newKey, err := keys.GenerateKey(k.Name, k.Type)
+				if err != nil {
+					return err
+				}
+				k.Public = newKey.Public
+				k.Private = newKey.Private
+				k.KeyID = newKey.KeyID
+			} else {
+				for _, kClear := range envKeys {
+					if kClear.ID == k.ID {
+						k.Public = kClear.Public
+						k.Private = kClear.Private
+						k.KeyID = kClear.KeyID
+						break
+					}
+				}
 			}
-			k.Public = newKey.Public
-			k.Private = newKey.Private
-			k.KeyID = newKey.KeyID
 		}
 
 		u := getUserConsumer(ctx)
diff --git a/engine/api/environment/dao_key.go b/engine/api/environment/dao_key.go
index 4e5b72416..8fb0a22cf 100644
--- a/engine/api/environment/dao_key.go
+++ b/engine/api/environment/dao_key.go
@@ -135,3 +135,9 @@ func DeleteEnvironmentKey(db gorp.SqlExecutor, envID int64, keyName string) erro
 	_, err := db.Exec("DELETE FROM environment_key WHERE environment_id = $1 AND name = $2", envID, keyName)
 	return sdk.WrapError(err, "Cannot delete key %s", keyName)
 }
+
+// DeleteAllEnvironmentKeys Delete all environment keys for the given env
+func DeleteAllEnvironmentKeys(db gorp.SqlExecutor, envID int64) error {
+	_, err := db.Exec("DELETE FROM environment_key WHERE environment_id = $1", envID)
+	return sdk.WrapError(err, "Cannot delete keys from %d", envID)
+}
diff --git a/engine/api/environment/environment_importer.go b/engine/api/environment/environment_importer.go
index 3f4b3b73c..1fd99ebca 100644
--- a/engine/api/environment/environment_importer.go
+++ b/engine/api/environment/environment_importer.go
@@ -67,53 +67,35 @@ func Import(db gorpmapper.SqlExecutorWithTx, proj sdk.Project, env *sdk.Environm
 
 // ImportInto import variables and groups on an existing environment
 func ImportInto(ctx context.Context, db gorpmapper.SqlExecutorWithTx, env *sdk.Environment, into *sdk.Environment, msgChan chan<- sdk.Message, u sdk.Identifiable) error {
-	var updateVar = func(v *sdk.EnvironmentVariable) {
-		log.Debug(ctx, "ImportInto> Updating var %q with value %q", v.Name, v.Value)
-
-		varBefore, errV := LoadVariable(db, into.ID, v.Name)
-		if errV != nil {
-			msgChan <- sdk.NewMessage(sdk.MsgEnvironmentVariableCannotBeUpdated, v.Name, into.Name, errV)
-			return
-		}
-
-		if err := UpdateVariable(db, into.ID, v, varBefore, u); err != nil {
-			msgChan <- sdk.NewMessage(sdk.MsgEnvironmentVariableCannotBeUpdated, v.Name, into.Name, err)
-			return
-		}
-		msgChan <- sdk.NewMessage(sdk.MsgEnvironmentVariableUpdated, v.Name, into.Name)
+	//Delete all Variables
+	if err := DeleteAllVariables(db, into.ID); err != nil {
+		return err
 	}
 
-	var insertVar = func(v *sdk.EnvironmentVariable) {
-		log.Debug(ctx, "ImportInto> Creating var %s", v.Name)
-		if err := InsertVariable(db, into.ID, v, u); err != nil {
-			msgChan <- sdk.NewMessage(sdk.MsgEnvironmentVariableCannotBeCreated, v.Name, into.Name, err)
-			return
-		}
-		msgChan <- sdk.NewMessage(sdk.MsgEnvironmentVariableCreated, v.Name, into.Name)
+	///Delete all Keys
+	if err := DeleteAllEnvironmentKeys(db, into.ID); err != nil {
+		return err
 	}
 
 	for i := range env.Variables {
-		log.Debug(ctx, "ImportInto> Checking >> %s", env.Variables[i].Name)
-		var found bool
-		for j := range into.Variables {
-			log.Debug(ctx, "ImportInto> \t with >> %s", into.Variables[j].Name)
-			if env.Variables[i].Name == into.Variables[j].Name {
-				env.Variables[i].ID = into.Variables[j].ID
-				found = true
-				updateVar(&env.Variables[i])
-				break
-			}
+		if err := InsertVariable(db, into.ID, &env.Variables[i], u); err != nil {
+			msgChan <- sdk.NewMessage(sdk.MsgEnvironmentVariableCannotBeCreated, env.Variables[i].Name, into.Name, err)
+			return err
 		}
-		if !found {
-			insertVar(&env.Variables[i])
+		msgChan <- sdk.NewMessage(sdk.MsgEnvironmentVariableCreated, env.Variables[i].Name, into.Name)
+	}
+
+	for i := range env.Keys {
+		if err := InsertKey(db, &env.Keys[i]); err != nil {
+			msgChan <- sdk.NewMessage(sdk.MsgEnvironmentKeyCannotBeCreated, sdk.SSHKeyVariable, env.Keys[i].Name, into.Name, err)
+			return err
 		}
+		msgChan <- sdk.NewMessage(sdk.MsgEnvironmentKeyCreated, sdk.SSHKeyVariable, env.Keys[i].Name, into.Name)
 	}
 
 	if err := UpdateEnvironment(db, env); err != nil {
 		return sdk.WrapError(err, "unable to update environment")
 	}
-
 	log.Debug(ctx, "ImportInto> Done")
-
 	return nil
 }
diff --git a/engine/api/v2_jsonschema.go b/engine/api/v2_jsonschema.go
index a80c1311a..04aee6e9e 100644
--- a/engine/api/v2_jsonschema.go
+++ b/engine/api/v2_jsonschema.go
@@ -27,7 +27,7 @@ func (api *API) getJsonSchemaHandler() ([]service.RbacChecker, service.Handler)
 			switch t {
 			case sdk.EntityTypeWorkerModel:
 				schema = sdk.GetWorkerModelJsonSchema()
-			case sdk.EntityTypeAction:
+			case sdk.EntityTypeAction, sdk.EntityTypeWorkflow:
 				// Load available action
 				var actionNames []string
 				if u != nil {
@@ -52,7 +52,12 @@ func (api *API) getJsonSchemaHandler() ([]service.RbacChecker, service.Handler)
 					actionNames = append(actionNames, p.Name)
 				}
 
-				schema = sdk.GetActionJsonSchema(actionNames)
+				switch t {
+				case sdk.EntityTypeWorkflow:
+					schema = sdk.GetWorkflowJsonSchema(actionNames)
+				case sdk.EntityTypeAction:
+					schema = sdk.GetActionJsonSchema(actionNames)
+				}
 			}
 			return service.WriteJSON(w, schema, http.StatusOK)
 		}
diff --git a/engine/api/v2_repository_analyze.go b/engine/api/v2_repository_analyze.go
index a19fa6009..0662c7698 100644
--- a/engine/api/v2_repository_analyze.go
+++ b/engine/api/v2_repository_analyze.go
@@ -552,6 +552,9 @@ func (api *API) handleEntitiesFiles(_ context.Context, filesContent map[string][
 		case strings.HasPrefix(filePath, ".cds/actions/"):
 			var actions []sdk.V2Action
 			es, err = sdk.ReadEntityFile(dir, fileName, content, &actions, sdk.EntityTypeAction, *analysis)
+		case strings.HasPrefix(filePath, ".cds/workflows/"):
+			var w []sdk.V2Workflow
+			es, err = sdk.ReadEntityFile(dir, fileName, content, &w, sdk.EntityTypeWorkflow, *analysis)
 		default:
 			continue
 		}
diff --git a/engine/cdn/storage/nfs/nfs.go b/engine/cdn/storage/nfs/nfs.go
index 53aa4a6c3..e547f4281 100644
--- a/engine/cdn/storage/nfs/nfs.go
+++ b/engine/cdn/storage/nfs/nfs.go
@@ -351,13 +351,14 @@ func (n *Buffer) ResyncWithDatabase(ctx context.Context, db gorp.SqlExecutor, t
 	}
 	for _, e := range entries {
 		if e.IsDir() {
-			log.Warn(ctx, "nfs-buffer: found directory inside %s: %s", string(t), e)
+			log.Warn(ctx, "nfs-buffer: found directory inside %s: %s", string(t), e.FileName)
 			continue
 		}
 		if e.FileName == "" {
 			log.Warn(ctx, "nfs-buffer: missing file name")
 			continue
 		}
+		log.Info(ctx, "Found file %s [%d]", e.FileName, e.Size())
 		has, err := storage.HashItemUnitByApiRefHash(db, e.FileName, n.ID())
 		if err != nil {
 			log.Error(ctx, "nfs-buffer: unable to check if unit item exist for api ref hash %s: %v", e.FileName, err)
diff --git a/engine/worker/internal/plugin/socket.go b/engine/worker/internal/plugin/socket.go
index 779751f38..f70a45b46 100644
--- a/engine/worker/internal/plugin/socket.go
+++ b/engine/worker/internal/plugin/socket.go
@@ -50,7 +50,7 @@ func createGRPCPluginSocket(ctx context.Context, pluginType string, pluginName s
 		//If the file doesn't exist. Download it.
 		fi, err := w.BaseDir().OpenFile(pluginBinaryInfos.Name, os.O_CREATE|os.O_RDWR, os.FileMode(pluginBinaryInfos.Perm))
 		if err != nil {
-			return nil, nil, sdk.WrapError(err, "unable to create the file %s", pluginBinaryInfos)
+			return nil, nil, sdk.WrapError(err, "unable to create the file %s", pluginBinaryInfos.Name)
 		}
 
 		log.Debug(ctx, "Get the binary plugin %s", pluginBinaryInfos.PluginName)
diff --git a/sdk/entity.go b/sdk/entity.go
index b1495744b..641cc1a55 100644
--- a/sdk/entity.go
+++ b/sdk/entity.go
@@ -10,6 +10,7 @@ import (
 const (
 	EntityTypeWorkerModel = "WorkerModel"
 	EntityTypeAction      = "Action"
+	EntityTypeWorkflow    = "Workflow"
 	EntityNamePattern     = "^[a-zA-Z0-9._-]{1,}$"
 )
 
@@ -47,6 +48,8 @@ func GetManageRoleByEntity(entityType string) (string, error) {
 		return ProjectRoleManageWorkerModel, nil
 	case EntityTypeAction:
 		return ProjectRoleManageAction, nil
+  case EntityTypeWorkflow:
+    return ProjectRoleManageWorkflow, nil
 	}
 	return "", NewErrorFrom(ErrInvalidData, "unknown entity of type %s", entityType)
 }
diff --git a/sdk/jsonschema.go b/sdk/jsonschema.go
index fbcbd0dfb..48e70bb47 100644
--- a/sdk/jsonschema.go
+++ b/sdk/jsonschema.go
@@ -68,3 +68,32 @@ func GetActionJsonSchema(publicActionNames []string) *jsonschema.Schema {
 	}
 	return actionSchema
 }
+
+func GetWorkflowJsonSchema(publicActionNames []string) *jsonschema.Schema {
+	workflowSchema := jsonschema.Reflect(&V2Workflow{})
+
+	if workflowSchema.Definitions == nil {
+		workflowSchema.Definitions = make(map[string]*jsonschema.Schema)
+	}
+
+	propName, _ := workflowSchema.Definitions["V2Workflow"].Properties.Get("name")
+	name := propName.(*jsonschema.Schema)
+	name.Pattern = EntityNamePattern
+
+	// Pattern jobs key
+	propsJobs, _ := workflowSchema.Definitions["V2Workflow"].Properties.Get("jobs")
+	jobs := propsJobs.(*jsonschema.Schema)
+	jobs.PatternProperties[EntityActionInputKey] = jobs.PatternProperties[".*"]
+	delete(jobs.PatternProperties, ".*")
+
+	propStepUses, _ := workflowSchema.Definitions["ActionStep"].Properties.Get("uses")
+	stepUses := propStepUses.(*jsonschema.Schema)
+	// Enum on step uses
+	if len(publicActionNames) > 0 {
+
+		for _, actName := range publicActionNames {
+			stepUses.Enum = append(stepUses.Enum, "actions/"+actName)
+		}
+	}
+	return workflowSchema
+}
diff --git a/sdk/messages.go b/sdk/messages.go
index 2fe5d3598..89562c17f 100644
--- a/sdk/messages.go
+++ b/sdk/messages.go
@@ -40,6 +40,7 @@ var (
 	MsgEnvironmentGroupDeleted              = &Message{"MsgEnvironmentGroupDeleted", trad{FR: "Le groupe %s de l'environnement %s a été supprimé", EN: "Group %s on environment %s has been deleted"}, nil, RunInfoTypInfo}
 	MsgEnvironmentGroupCannotBeDeleted      = &Message{"MsgEnvironmentGMsgEnvironmentGroupCannotBeDeletedroupCannotBeCreated", trad{FR: "Le groupe %s de l'environnement %s n'a pu être supprimé : %s", EN: "Group %s on environment %s cannot be deleted: %s"}, nil, RunInfoTypeError}
 	MsgEnvironmentKeyCreated                = &Message{"MsgEnvironmentKeyCreated", trad{FR: "La clé %s %s a été créée sur l'environnement %s", EN: "%s key %s created on environment %s"}, nil, RunInfoTypInfo}
+	MsgEnvironmentKeyCannotBeCreated        = &Message{"MsgEnvironmentKeyCannotBeCreated", trad{FR: "La clé %s %s n'a pas été créée sur l'environnement %s", EN: "%s key %s cannot be created on environment %s: %v"}, nil, RunInfoTypeError}
 	MsgJobNotValidActionNotFound            = &Message{"MsgJobNotValidActionNotFound", trad{FR: "Erreur de validation du Job %s : L'action %s à l'étape %d n'a pas été trouvée", EN: "Job %s validation Failure: Unknown action %s on step #%d"}, nil, RunInfoTypeError}
 	MsgJobNotValidInvalidActionParameter    = &Message{"MsgJobNotValidInvalidActionParameter", trad{FR: "Erreur de validation du Job %s : Le paramètre %s de l'étape %d - %s est invalide", EN: "Job %s validation Failure: Invalid parameter %s on step #%d %s"}, nil, RunInfoTypeError}
 	MsgPipelineGroupUpdated                 = &Message{"MsgPipelineGroupUpdated", trad{FR: "Les permissions du groupe %s sur le pipeline %s on été mises à jour", EN: "Permission for group %s on pipeline %s has been updated"}, nil, RunInfoTypInfo}
@@ -125,6 +126,7 @@ var Messages = map[string]*Message{
 	MsgEnvironmentGroupDeleted.ID:              MsgEnvironmentGroupDeleted,
 	MsgEnvironmentGroupCannotBeDeleted.ID:      MsgEnvironmentGroupCannotBeDeleted,
 	MsgEnvironmentKeyCreated.ID:                MsgEnvironmentKeyCreated,
+	MsgEnvironmentKeyCannotBeCreated.ID:        MsgEnvironmentKeyCannotBeCreated,
 	MsgJobNotValidActionNotFound.ID:            MsgJobNotValidActionNotFound,
 	MsgJobNotValidInvalidActionParameter.ID:    MsgJobNotValidInvalidActionParameter,
 	MsgPipelineGroupUpdated.ID:                 MsgPipelineGroupUpdated,
diff --git a/sdk/rbac.go b/sdk/rbac.go
index 4cd22ab3b..4db668ef0 100644
--- a/sdk/rbac.go
+++ b/sdk/rbac.go
@@ -18,6 +18,7 @@ const (
 	ProjectRoleManage            = "manage"
 	ProjectRoleManageWorkerModel = "manage-worker-model"
 	ProjectRoleManageAction      = "manage-action"
+	ProjectRoleManageWorkflow    = "manage-workflow"
 
 	// Hatchery Role
 	HatcheryRoleSpawn = "start-worker"
diff --git a/sdk/rbac_project.go b/sdk/rbac_project.go
index 7790fb8f1..82ad30929 100644
--- a/sdk/rbac_project.go
+++ b/sdk/rbac_project.go
@@ -1,7 +1,7 @@
 package sdk
 
 var (
-	ProjectRoles = []string{ProjectRoleRead, ProjectRoleManage, ProjectRoleManageWorkerModel, ProjectRoleManageAction}
+	ProjectRoles = []string{ProjectRoleRead, ProjectRoleManage, ProjectRoleManageWorkerModel, ProjectRoleManageAction, ProjectRoleManageWorkflow}
 )
 
 type RBACProject struct {
diff --git a/sdk/v2_action.go b/sdk/v2_action.go
index 80229db35..92eba6cad 100644
--- a/sdk/v2_action.go
+++ b/sdk/v2_action.go
@@ -36,7 +36,7 @@ type ActionOutput struct {
 type ActionStep struct {
 	ID   string            `json:"id,omitempty" jsonschema_extras:"order=2" jsonschema_description:"Identifier of the step"`
 	Uses string            `json:"uses,omitempty" jsonschema:"oneof_required=uses" jsonschema_extras:"order=1,onchange=loadentity,prefix=actions/" jsonschema_description:"Sub action to call"`
-	Run  string            `json:"run,omitempty" jsonschema:"oneof_required=run" jsonschema_extras:"order=1" jsonschema_description:"Script to execute"`
+	Run  string            `json:"run,omitempty" jsonschema:"oneof_required=run" jsonschema_extras:"order=1,code=true" jsonschema_description:"Script to execute"`
 	With map[string]string `json:"with,omitempty" jsonschema:"oneof_not_required=run" jsonschema_extras:"order=3,mode=use" jsonschema_description:"Action parameters"`
 }
 
diff --git a/sdk/v2_workflow.go b/sdk/v2_workflow.go
new file mode 100644
index 000000000..96964966e
--- /dev/null
+++ b/sdk/v2_workflow.go
@@ -0,0 +1,106 @@
+package sdk
+
+import (
+	"encoding/json"
+	"github.com/xeipuuv/gojsonschema"
+)
+
+type V2Workflow struct {
+	Name       string                   `json:"name"`
+	Repository WorkflowRepository       `json:"repository,omitempty"`
+	Stages     map[string]WorkflowStage `json:"stages,omitempty"`
+	Jobs       map[string]V2Job         `json:"jobs"`
+}
+
+type WorkflowRepository struct {
+	VCSServer string `json:"vcs,omitempty" jsonschema_extras:"order=1" jsonschema_description:"Server that host the git repository"`
+	Name      string `json:"name,omitempty" jsonschema_extras:"order=2" jsonschema_description:"Name of the git repository: <org>/<name>"`
+}
+
+type WorkflowStage struct {
+	Needs []string `json:"needs,omitempty" jsonschema_description:"Stage dependencies"`
+}
+
+type V2Job struct {
+	Name   string            `json:"name" jsonschema_extras:"order=1" jsonschema_description:"Name of the job"`
+	If     string            `json:"if,omitempty" jsonschema_extras:"order=2" jsonschema_description:"Condition to execute the job"`
+	Inputs map[string]string `json:"inputs,omitempty" jsonschema_extras:"order=3" jsonschema_description:"Input of thejob"`
+	Steps  []ActionStep      `json:"steps,omitempty" jsonschema_extras:"order=5" jsonschema_description:"List of steps"`
+	Needs  []string          `json:"needs,omitempty" jsonschema_extras:"order=6" jsonschema_description:"Job dependencies"`
+	Stage  string            `json:"stage,omitempty" jsonschema_extras:"order=7"`
+	// TODO
+	Concurrency V2JobConcurrency `json:"-"`
+	Strategy    V2JobStrategy    `json:"-"`
+}
+
+type V2JobStrategy struct {
+}
+
+type V2JobConcurrency struct {
+}
+
+func (w V2Workflow) GetName() string {
+	return w.Name
+}
+
+func (w V2Workflow) Lint() []error {
+	errs := make([]error, 0)
+
+	errs = w.CheckStageAndJobNeeds()
+
+	workflowSchema := GetWorkflowJsonSchema(nil)
+	workflowSchemaS, err := workflowSchema.MarshalJSON()
+	if err != nil {
+		return []error{NewErrorFrom(err, "unable to load action schema")}
+	}
+	schemaLoader := gojsonschema.NewStringLoader(string(workflowSchemaS))
+
+	modelJson, err := json.Marshal(w)
+	if err != nil {
+		return []error{NewErrorFrom(err, "unable to marshal action")}
+	}
+	documentLoader := gojsonschema.NewStringLoader(string(modelJson))
+
+	result, err := gojsonschema.Validate(schemaLoader, documentLoader)
+	if err != nil {
+		return []error{NewErrorFrom(err, "unable to validate action")}
+	}
+
+	for _, e := range result.Errors() {
+		errs = append(errs, NewErrorFrom(ErrInvalidData, "yaml validation failed: "+e.String()))
+	}
+
+	if len(errs) > 0 {
+		return errs
+	}
+	return nil
+}
+
+func (w V2Workflow) CheckStageAndJobNeeds() []error {
+	errs := make([]error, 0)
+	if len(w.Stages) > 0 {
+		stages := make(map[string]WorkflowStage)
+		for k, v := range w.Stages {
+			stages[k] = v
+		}
+		for k := range stages {
+			for _, n := range stages[k].Needs {
+				if _, exist := stages[n]; !exist {
+					errs = append(errs, NewErrorFrom(ErrInvalidData, "Stage %s: needs not found %s", k, n))
+				}
+			}
+		}
+	} else {
+		for k, j := range w.Jobs {
+			for _, n := range j.Needs {
+				if _, exist := w.Jobs[n]; !exist {
+					errs = append(errs, NewErrorFrom(ErrInvalidData, "Job %s: needs not found [%s]", k, n))
+				}
+			}
+		}
+	}
+	if len(errs) > 0 {
+		return errs
+	}
+	return nil
+}
diff --git a/ui/src/app/model/entity.model.ts b/ui/src/app/model/entity.model.ts
new file mode 100644
index 000000000..f48ae07be
--- /dev/null
+++ b/ui/src/app/model/entity.model.ts
@@ -0,0 +1,20 @@
+export const EntityWorkerModel = "WorkerModel";
+export const EntityAction = "Action";
+export const EntityWorkflow = "Workflow";
+
+export class Entity {
+    id: string;
+    name: string;
+    type: string;
+    branch: string;
+    data: string;
+    file_path: string;
+}
+
+export class EntityFullName {
+    name: string;
+    branch: string;
+    vcs_name: string;
+    repo_name: string;
+    project_key: string;
+}
diff --git a/ui/src/app/model/project.model.ts b/ui/src/app/model/project.model.ts
index bd49dbfbe..1362a29a4 100644
--- a/ui/src/app/model/project.model.ts
+++ b/ui/src/app/model/project.model.ts
@@ -92,22 +92,3 @@ export class ProjectRepository {
     created_by: string;
 }
 
-export const EntityWorkerModel = "WorkerModel";
-export const EntityAction = "Action";
-
-export class Entity {
-    id: string;
-    name: string;
-    type: string;
-    branch: string;
-    data: string;
-    file_path: string;
-}
-
-export class EntityFullName {
-    name: string;
-    branch: string;
-    vcs_name: string;
-    repo_name: string;
-    project_key: string;
-}
diff --git a/ui/src/app/model/schema.model.ts b/ui/src/app/model/schema.model.ts
index 8aacfec2e..30fe6f246 100644
--- a/ui/src/app/model/schema.model.ts
+++ b/ui/src/app/model/schema.model.ts
@@ -146,6 +146,7 @@ export class JSONSchema implements Schema {
         itemType.name = name;
         itemType.enum = properties?.enum;
         itemType.formOrder = properties?.order;
+        itemType.code = properties?.code;
         itemType.disabled = properties?.disabled;
         itemType.description = properties?.description;
         itemType.pattern = properties?.pattern;
@@ -230,6 +231,9 @@ export class JSONSchema implements Schema {
             if (properties.enum) {
                 flatElementPosition.enum = properties.enum;
             }
+            if (properties.code) {
+                flatElementPosition.code = properties.code;
+            }
         }
         flatElement.positions.push(flatElementPosition);
     }
@@ -261,6 +265,7 @@ export class FlatTypeElement {
     onchange: string;
     mode: string;
     prefix: string;
+    code: boolean;
 }
 
 export class FlatElementsOneOfRequired {
@@ -274,6 +279,7 @@ export class FlatElementPosition {
     condition: Array<FlatElementTypeCondition>;
     formOrder: number;
     enum: string[];
+    code: boolean;
 }
 
 export class FlatElementTypeCondition {
diff --git a/ui/src/app/service/entity/entity.service.ts b/ui/src/app/service/entity/entity.service.ts
index 63a37cb34..635331b38 100644
--- a/ui/src/app/service/entity/entity.service.ts
+++ b/ui/src/app/service/entity/entity.service.ts
@@ -1,7 +1,7 @@
 import {Injectable} from "@angular/core";
 import {HttpClient, HttpParams} from "@angular/common/http";
 import {Observable} from "rxjs";
-import {Entity, EntityFullName} from "../../model/project.model";
+import {EntityFullName} from "../../model/entity.model";
 
 
 @Injectable()
diff --git a/ui/src/app/service/project/project.service.ts b/ui/src/app/service/project/project.service.ts
index f8e18219f..f3c0b3586 100644
--- a/ui/src/app/service/project/project.service.ts
+++ b/ui/src/app/service/project/project.service.ts
@@ -4,11 +4,12 @@ import { Injectable } from '@angular/core';
 import { ProjectIntegration } from 'app/model/integration.model';
 import { Schema } from 'app/model/json-schema.model';
 import { Key } from 'app/model/keys.model';
-import { Entity, LoadOpts, Project, ProjectRepository, VCSProject } from 'app/model/project.model';
+import { LoadOpts, Project, ProjectRepository, VCSProject } from 'app/model/project.model';
 import { Observable } from 'rxjs';
 import { map } from 'rxjs/operators';
 import { RepositoryAnalysis } from "../../model/analysis.model";
 import { Branch } from "../../model/repositories.model";
+import {Entity} from "../../model/entity.model";
 
 /**
  * Service to access Project from API.
diff --git a/ui/src/app/shared/action/action.component.ts b/ui/src/app/shared/action/action.component.ts
index feea95020..b97e95066 100644
--- a/ui/src/app/shared/action/action.component.ts
+++ b/ui/src/app/shared/action/action.component.ts
@@ -13,7 +13,7 @@ import { Action } from 'app/model/action.model';
 import { AllKeys } from 'app/model/keys.model';
 import { Parameter } from 'app/model/parameter.model';
 import { Pipeline } from 'app/model/pipeline.model';
-import {EntityAction, EntityFullName, EntityWorkerModel, Project} from 'app/model/project.model';
+import { Project} from 'app/model/project.model';
 import { Requirement } from 'app/model/requirement.model';
 import { Stage } from 'app/model/stage.model';
 import {ActionAsCode, ActionTypeAscode} from 'app/model/action.ascode.model';
@@ -30,6 +30,7 @@ import { DragulaService } from 'ng2-dragula-sgu';
 import {finalize, first} from 'rxjs/operators';
 import {EntityService} from "../../service/entity/entity.service";
 import {ActionAsCodeService} from "../../service/action/actionAscode.service";
+import {EntityAction, EntityFullName, EntityWorkerModel} from "../../model/entity.model";
 
 @Component({
     selector: 'app-action',
diff --git a/ui/src/app/shared/entity/entity.form.component.html b/ui/src/app/shared/entity/entity.form.component.html
index 31de0859a..30e9ea51b 100644
--- a/ui/src/app/shared/entity/entity.form.component.html
+++ b/ui/src/app/shared/entity/entity.form.component.html
@@ -1,6 +1,7 @@
 <ng-container *ngIf="flatSchema && data">
-    <app-json-form [class.disableSelection]="resizing" [disabled]="resizing" [parentType]="parentType" [entityType]="entityType"
-        [schema]="flatSchema" [data]="dataForm" (dataChange)="onFormChange($event)"></app-json-form>
+    <app-json-form [class.disableSelection]="resizing" [disabled]="resizing" [parentType]="parentType"
+        [entityType]="entityType" [schema]="flatSchema" [data]="dataForm"
+        (dataChange)="onFormChange($event)"></app-json-form>
     <app-resizable-panel minSize="400" [initialSize]="panelSize" (onGrabbingStart)="panelStartResize()"
         (onGrabbingEnd)="panelEndResize($event)">
         <nz-tabset nzType="card" nzSize="small">
diff --git a/ui/src/app/shared/entity/entity.form.component.scss b/ui/src/app/shared/entity/entity.form.component.scss
index d1b3cfbc3..97e20d075 100644
--- a/ui/src/app/shared/entity/entity.form.component.scss
+++ b/ui/src/app/shared/entity/entity.form.component.scss
@@ -3,6 +3,7 @@
     flex-direction: row;
     height: 100%;
     width: 100%;
+    overflow: hidden;
 
     app-json-form {
         flex: 1;
diff --git a/ui/src/app/shared/entity/form/form-item/json-form-field.component.ts b/ui/src/app/shared/entity/form/form-item/json-form-field.component.ts
index 52556eb68..9d26e6fd2 100644
--- a/ui/src/app/shared/entity/form/form-item/json-form-field.component.ts
+++ b/ui/src/app/shared/entity/form/form-item/json-form-field.component.ts
@@ -1,13 +1,18 @@
-import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from "@angular/core";
+import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges, ViewChild } from "@angular/core";
 import { FlatElementTypeCondition } from "../../../../model/schema.model";
-import {JSONFormSchema, JSONFormSchemaOneOfItem} from "../json-form.component";
-import {EntityAction} from "../../../../model/project.model";
-import {ProjectService} from "../../../../service/project/project.service";
-import {ActivatedRoute} from "@angular/router";
-import {ProjectState} from "../../../../store/project.state";
-import {Store} from "@ngxs/store";
+import { JSONFormSchema, JSONFormSchemaOneOfItem } from "../json-form.component";
+import { ProjectService } from "../../../../service/project/project.service";
+import { ActivatedRoute } from "@angular/router";
+import { ProjectState } from "../../../../store/project.state";
+import { Store } from "@ngxs/store";
 import { load, LoadOptions } from 'js-yaml'
-import {PluginService} from "../../../../service/plugin.service";
+import { PluginService } from "../../../../service/plugin.service";
+import { DragulaService } from "ng2-dragula-sgu";
+import { AutoUnsubscribe } from "app/shared/decorator/autoUnsubscribe";
+import { Subscription } from "rxjs";
+import { PreferencesState } from "app/store/preferences.state";
+import { NzCodeEditorComponent } from "ng-zorro-antd/code-editor";
+import {EntityAction} from "../../../../model/entity.model";
 
 export class FormItem {
     name: string;
@@ -23,6 +28,7 @@ export class FormItem {
     onchange: string;
     mode: string;
     prefix: string;
+    code: boolean;
 }
 @Component({
     selector: 'app-json-form-field',
@@ -30,34 +36,70 @@ export class FormItem {
     styleUrls: ['./json-form-field.scss'],
     changeDetection: ChangeDetectionStrategy.OnPush
 })
-export class JSONFormFieldComponent implements OnChanges {
+@AutoUnsubscribe()
+export class JSONFormFieldComponent implements OnInit, OnChanges, OnDestroy {
+    @ViewChild('editor') editor: NzCodeEditorComponent;
+
     @Input() field: FormItem;
     @Input() jsonFormSchema: JSONFormSchema;
     @Input() model: any;
     @Input() parentType: string;
     @Input() disabled: boolean;
     @Input() hideLabel: boolean;
-    @Input() entityType: string
+    @Input() entityType: string;
+    @Input() indent: number = 0;
     @Output() modelChange = new EventEmitter();
 
     required: boolean;
     oneOf: Map<string, JSONFormSchemaOneOfItem>;
     oneOfSelected: string[] = new Array<string>();
     oneOfSelectOpts: string[];
-
-
+    timestamp = Date.now();
     currentModel: any;
     isConditionnal: boolean;
     selectedCondition: FlatElementTypeCondition;
     conditionRefProperties: string[];
+    resizingSubscription: Subscription;
 
     constructor(
         private _cd: ChangeDetectorRef,
         private _projectService: ProjectService,
         private _pluginService: PluginService,
         private _store: Store,
-        private _activatedRouter: ActivatedRoute
-    ) { }
+        private _activatedRouter: ActivatedRoute,
+        private _dragulaService: DragulaService
+    ) {
+        this.trackByIndex = this.trackByIndex.bind(this);
+    }
+
+    ngOnInit(): void {
+        if (!this._dragulaService.find('array-field')) {
+            this._dragulaService.createGroup('array-field', {
+                moves(el, source, handle) {
+                    const f = (element: Element) => {
+                        if (element.classList.contains('move')) {
+                            return true;
+                        };
+                        if (element.parentElement) {
+                            return f(element.parentElement);
+                        }
+                        return false;
+                    };
+                    return f(handle);
+                },
+                direction: 'vertical'
+            });
+        }
+
+        this.resizingSubscription = this._store.select(PreferencesState.resizing).subscribe(resizing => {
+            if (this.editor && !resizing) {
+                this.editor.layout();
+            }
+            this._cd.markForCheck();
+        });
+    }
+
+    ngOnDestroy(): void { } // Should be set to use @AutoUnsubscribe with AOT
 
     ngOnChanges(changes: SimpleChanges): void {
         if (!this.jsonFormSchema || !this.field || !this.model) {
@@ -95,8 +137,8 @@ export class JSONFormFieldComponent implements OnChanges {
         this._cd.markForCheck();
     }
 
-    trackByIndex(index: number) {
-        return index;
+    trackByIndex(index: number, v) {
+        return this.timestamp + '-' + index;
     }
 
     updateItemStruct(index: number) {
@@ -185,8 +227,8 @@ export class JSONFormFieldComponent implements OnChanges {
                 if (this.entityType === EntityAction && entitySplit.length === 1) {
                     this._pluginService.getPlugin(entityName).subscribe(pl => {
                         if (pl.inputs) {
-                            let keys =  Object.keys(pl.inputs);
-                            if(keys.length > 0) {
+                            let keys = Object.keys(pl.inputs);
+                            if (keys.length > 0) {
                                 this.currentModel['with'] = {};
                                 keys.forEach(k => {
                                     this.currentModel['with'][k] = pl.inputs[k].default;
@@ -201,8 +243,8 @@ export class JSONFormFieldComponent implements OnChanges {
                         switch (this.entityType) {
                             case EntityAction:
                                 if (ent.inputs) {
-                                    let keys =  Object.keys(ent.inputs);
-                                    if(keys.length > 0) {
+                                    let keys = Object.keys(ent.inputs);
+                                    if (keys.length > 0) {
                                         this.currentModel['with'] = {};
                                         keys.forEach(k => {
                                             this.currentModel['with'][k] = ent.inputs[k].default;
@@ -225,4 +267,23 @@ export class JSONFormFieldComponent implements OnChanges {
         this._cd.markForCheck();
         this.modelChange.emit(this.currentModel);
     }
+
+    onDragArrayItem(field: string, array: any): void {
+        this.timestamp = Date.now();
+        this.currentModel[field] = array;
+        this._cd.markForCheck();
+        this.modelChange.emit(this.currentModel);
+    }
+
+    onArrayItemDelete(field: string, index: number): void {
+        this.currentModel[field] = this.currentModel[field].filter((v: any, i: number) => i !== index);
+        this._cd.markForCheck();
+        this.modelChange.emit(this.currentModel);
+    }
+
+    onMapItemDelete(field: string, key: string): void {
+        delete this.currentModel[field][key];
+        this._cd.markForCheck();
+        this.modelChange.emit(this.currentModel);
+    }
 }
diff --git a/ui/src/app/shared/entity/form/form-item/json-form-field.html b/ui/src/app/shared/entity/form/form-item/json-form-field.html
index 97e42b493..5baeb4135 100644
--- a/ui/src/app/shared/entity/form/form-item/json-form-field.html
+++ b/ui/src/app/shared/entity/form/form-item/json-form-field.html
@@ -1,57 +1,75 @@
 <!-- OBJECT FIELD -->
 <ng-container *ngIf="field && currentModel && jsonFormSchema && field.type === 'object'">
-    <h3>{{field.name.charAt(0).toUpperCase() + field.name.substring(1, field.name.length)}}</h3>
+    <h3 [style]="{marginLeft: indent+'px'}">{{field.name.charAt(0).toUpperCase() + field.name.substring(1,
+        field.name.length)}}</h3>
     <ng-container *ngIf="isConditionnal; else tmplNoCondition">
         <ng-container *ngIf="selectedCondition">
             <app-json-form-field *ngFor="let f of jsonFormSchema.types[selectedCondition.type].fields"
                 [disabled]="disabled" [parentType]="selectedCondition.type" [jsonFormSchema]="jsonFormSchema"
                 [field]="f" [model]="currentModel[field.name]" [entityType]="entityType"
-                (modelChange)="onValueChanged($event)"></app-json-form-field>
+                (modelChange)="onValueChanged($event)" [indent]="indent+10"></app-json-form-field>
         </ng-container>
         <nz-alert *ngIf="!selectedCondition" nzType="info"
             nzDescription="Please select a value for field '{{conditionRefProperties}}'" nzShowIcon></nz-alert>
     </ng-container>
     <ng-template #tmplNoCondition>
         <ng-container *ngFor="let f of jsonFormSchema.types[field.objectType].fields">
-            <app-json-form-field
-                [parentType]="field.objectType" [jsonFormSchema]="jsonFormSchema" [field]="f" [entityType]="entityType"
-                [model]="currentModel[field.name]" (modelChange)="onValueChanged($event)"></app-json-form-field>
+            <app-json-form-field [parentType]="field.objectType" [jsonFormSchema]="jsonFormSchema" [field]="f"
+                [entityType]="entityType" [model]="currentModel[field.name]" (modelChange)="onValueChanged($event)"
+                [indent]="indent+10"></app-json-form-field>
         </ng-container>
-
     </ng-template>
 </ng-container>
 
 <!-- ARRAY -->
 <div class="array-field" *ngIf="field && currentModel && jsonFormSchema && field.type === 'array'">
-    <h3>{{field.name.charAt(0).toUpperCase() + field.name.substring(1, field.name.length)}}</h3>
-    <div class="item-container" *ngFor="let arrayItem of currentModel[field.name]; trackBy: trackByIndex; let index = index">
-        <div class="array-item">
-            <ng-container *ngIf="oneOf">
-                <div class="array-select">
-                    <nz-form-item>
-                        <nz-form-control>
-                            <nz-select class="oneof" [(ngModel)]="oneOfSelected[index]" (ngModelChange)="updateItemStruct(index)">
-                                <nz-option *ngFor="let o of oneOfSelectOpts" [nzLabel]="o" [nzValue]="o"></nz-option>
-                            </nz-select>
-                        </nz-form-control>
-                    </nz-form-item>
-                    <div class="array-select-key-form">
-                        <app-json-form-field [hideLabel]="true" [parentType]="field.objectType" [entityType]="entityType"
-                                             [jsonFormSchema]="jsonFormSchema" [field]="oneOf.get(oneOfSelected[index]).keyFormItem"
-                                             [model]="currentModel[field.name][index]" (modelChange)="onValueChanged($event, index)"></app-json-form-field>
+    <div class="item-title">
+        <h3 [style]="{marginLeft: indent+'px'}">{{field.name.charAt(0).toUpperCase() + field.name.substring(1,
+            field.name.length)}}</h3>
+    </div>
+    <div [dragula]="'array-field'" [dragulaModel]="currentModel[field.name]"
+        (dragulaModelChange)="onDragArrayItem(field.name, $event)">
+        <div class="item-container"
+            *ngFor="let arrayItem of currentModel[field.name]; trackBy: trackByIndex; let index = index">
+            <div class="actions">
+                <button class="action move" nz-button nzType="default"><span nz-icon nzType="drag"
+                        nzTheme="outline"></span></button>
+                <button class="action delete" nz-button nzType="primary" nzDanger><span nz-icon nzType="delete"
+                        nzTheme="outline" (click)="onArrayItemDelete(field.name, index)"></span></button>
+            </div>
+            <div class="array-item">
+                <ng-container *ngIf="oneOf">
+                    <div class="array-select">
+                        <nz-form-item>
+                            <nz-form-control>
+                                <nz-select class="oneof" [(ngModel)]="oneOfSelected[index]"
+                                    (ngModelChange)="updateItemStruct(index)">
+                                    <nz-option *ngFor="let o of oneOfSelectOpts" [nzLabel]="o"
+                                        [nzValue]="o"></nz-option>
+                                </nz-select>
+                            </nz-form-control>
+                        </nz-form-item>
+                        <div class="array-select-key-form">
+                            <app-json-form-field [hideLabel]="true" [parentType]="field.objectType"
+                                [entityType]="entityType" [jsonFormSchema]="jsonFormSchema"
+                                [field]="oneOf.get(oneOfSelected[index]).keyFormItem"
+                                [model]="currentModel[field.name][index]" (modelChange)="onValueChanged($event, index)"
+                                [indent]="indent+10"></app-json-form-field>
+                        </div>
                     </div>
-                </div>
-                <div class="array-form">
-                    <ng-container *ngFor="let f of oneOf.get(oneOfSelected[index]).fields">
-                        <app-json-form-field [hideLabel]="f.name === oneOfSelected[index]" [entityType]="entityType"
-                                             [parentType]="field.objectType" [jsonFormSchema]="jsonFormSchema" [field]="f"
-                                             [model]="currentModel[field.name][index]" (modelChange)="onValueChanged($event, index)"></app-json-form-field>
-                    </ng-container>
-                </div>
-            </ng-container>
-            <ng-container *ngIf="!oneOf">
-                <!-- NOT IMPLEMENTED : ARRAY WITHOUT ONEOF -->
-            </ng-container>
+                    <div class="array-form">
+                        <ng-container *ngFor="let f of oneOf.get(oneOfSelected[index]).fields">
+                            <app-json-form-field [hideLabel]="f.name === oneOfSelected[index]" [entityType]="entityType"
+                                [parentType]="field.objectType" [jsonFormSchema]="jsonFormSchema" [field]="f"
+                                [model]="currentModel[field.name][index]" (modelChange)="onValueChanged($event, index)"
+                                [indent]="indent+10"></app-json-form-field>
+                        </ng-container>
+                    </div>
+                </ng-container>
+                <ng-container *ngIf="!oneOf">
+                    <!-- NOT IMPLEMENTED : ARRAY WITHOUT ONEOF -->
+                </ng-container>
+            </div>
         </div>
     </div>
     <button nz-button nzType="default" nzBlock (click)="addArrayItem()">Add</button>
@@ -59,58 +77,77 @@
 
 <!-- MAP -->
 <div class="map-field" *ngIf="field && currentModel && jsonFormSchema && field.type === 'map'">
-    <h3>{{field.name.charAt(0).toUpperCase() + field.name.substring(1, field.name.length)}}</h3>
+    <div class="item-title">
+        <h3 [style]="{marginLeft: indent+'px'}">{{field.name.charAt(0).toUpperCase() + field.name.substring(1,
+            field.name.length)}}</h3>
+        <button class="item-add" nz-button nzType="default" (click)="addMapItem()" *ngIf="field.mode === 'edit'"><span
+                nz-icon nzType="plus" nzTheme="outline"></span></button>
+    </div>
     <div class="item-container" *ngFor="let item of currentModel[field.name] | keyvalue; trackBy: trackByIndex">
+        <div class="actions">
+            <button class="action delete" nz-button nzType="primary" nzDanger><span nz-icon nzType="delete"
+                    nzTheme="outline" (click)="onMapItemDelete(field.name, item.key)"></span></button>
+        </div>
         <div class="map-item">
             <div class="map-key">
+
                 <nz-form-item>
                     <nz-form-label *ngIf="field.mode === 'use'">{{item.key}}</nz-form-label>
-                    <nz-form-control nzErrorTip="Pattern should match {{field.keyMapPattern}}" *ngIf="field.keyMapType === 'string' && field.mode === 'edit'">
-                            <input nz-input [name]="field.name + '-' + item.key" [disabled]="disabled"
-                                   [pattern]="field.keyMapPattern"
-                                   [ngModel]="item.key" (ngModelChange)="onKeyMapChanged($event, item.key)">
+                    <nz-form-control nzErrorTip="Pattern should match {{field.keyMapPattern}}"
+                        *ngIf="field.keyMapType === 'string' && field.mode === 'edit'">
+                        <input nz-input [name]="field.name + '-' + item.key" [disabled]="disabled"
+                            [pattern]="field.keyMapPattern" [ngModel]="item.key"
+                            (ngModelChange)="onKeyMapChanged($event, item.key)">
                     </nz-form-control>
                 </nz-form-item>
             </div>
-
             <div class="map-value">
                 <ng-container *ngIf="field.objectType === 'string'; else mapObj">
                     <input nz-input [name]="field.name+'-value-' + item.key" [disabled]="disabled"
-                           [ngModel]="currentModel[field.name][item.key]" (ngModelChange)="onValueChanged($event, item.key)">
+                        [ngModel]="currentModel[field.name][item.key]"
+                        (ngModelChange)="onValueChanged($event, item.key)">
                 </ng-container>
                 <ng-template #mapObj>
-                    <app-json-form-field *ngFor="let f of jsonFormSchema.types[field.objectType].fields" [entityType]="entityType"
-                        [parentType]="field.objectType" [jsonFormSchema]="jsonFormSchema" [field]="f"
-                        [model]="currentModel[field.name][item.key]" (modelChange)="onValueChanged($event, item.key)"></app-json-form-field>
+                    <app-json-form-field *ngFor="let f of jsonFormSchema.types[field.objectType].fields"
+                        [entityType]="entityType" [parentType]="field.objectType" [jsonFormSchema]="jsonFormSchema"
+                        [field]="f" [model]="currentModel[field.name][item.key]"
+                        (modelChange)="onValueChanged($event, item.key)" [indent]="indent+10"></app-json-form-field>
                 </ng-template>
             </div>
         </div>
     </div>
-    <nz-alert *ngIf="!currentModel[field.name] || currentModel[field.name].length === 0" nzMessage="No inputs this action" nzType="info"></nz-alert>
-    <button nz-button nzType="default" nzBlock (click)="addMapItem()" *ngIf="field.mode === 'edit'">Add</button>
+    <nz-alert *ngIf="!currentModel[field.name] || currentModel[field.name].length === 0"
+        nzMessage="No inputs this action" nzType="info"></nz-alert>
 </div>
 
 <!-- SIMPLE FIELD (number / string) -->
-<nz-form-item *ngIf="field && currentModel && jsonFormSchema && field.type !== 'object' && field.type !== 'array' && field.type !== 'map'">
+<nz-form-item
+    *ngIf="field && currentModel && jsonFormSchema && field.type !== 'object' && field.type !== 'array' && field.type !== 'map'">
     <!-- FORM LABEL -->
     <nz-form-label nzFlex="120px" *ngIf="!hideLabel">
-            <span *ngIf="required" class="red">{{ '* '}}</span>
-            {{field.name.charAt(0).toUpperCase() + field.name.substring(1, field.name.length)}}
-            <i *ngIf="field?.description" nz-icon nzType="info-circle" nzTheme="outline"
-               [nz-tooltip]="field?.description"></i>
+        <span *ngIf="required" class="red">{{ '* '}}</span>
+        {{field.name.charAt(0).toUpperCase() + field.name.substring(1, field.name.length)}}
+        <i *ngIf="field?.description" nz-icon nzType="info-circle" nzTheme="outline"
+            [nz-tooltip]="field?.description"></i>
     </nz-form-label>
 
     <!-- FORM VALUE -->
     <nz-form-control nzErrorTip="Pattern should match {{field.pattern}}">
-        <ng-container *ngIf="field.type === 'string' && !field.enum">
+        <ng-container *ngIf="field.type === 'string' && !field.enum && !field.code">
             <input nz-input [pattern]="field.pattern" [name]="field.name" [disabled]="disabled"
                 [ngModel]="currentModel[field.name]" (ngModelChange)="onValueChanged($event)">
         </ng-container>
+        <ng-container *ngIf="field.code">
+            <app-editor-input #editor ngDefaultControl [ngModel]="currentModel[field.name]"
+                (ngModelChange)="onValueChanged($event)" [config]="{ language: 'shell' }">
+            </app-editor-input>
+        </ng-container>
         <ng-container *ngIf="field.enum">
             <nz-select [disabled]="disabled" [ngModel]="currentModel[field.name]"
                 (ngModelChange)="onValueChanged($event)">
                 <ng-container *ngIf="field.prefix">
-                    <nz-option *ngFor="let e of field.enum" [nzLabel]="e.substring(field.prefix.length, e.length)" [nzValue]="e"></nz-option>
+                    <nz-option *ngFor="let e of field.enum" [nzLabel]="e.substring(field.prefix.length, e.length)"
+                        [nzValue]="e"></nz-option>
                 </ng-container>
                 <ng-container *ngIf="!field.prefix">
                     <nz-option *ngFor="let e of field.enum" [nzLabel]="e" [nzValue]="e"></nz-option>
diff --git a/ui/src/app/shared/entity/form/form-item/json-form-field.scss b/ui/src/app/shared/entity/form/form-item/json-form-field.scss
index c625a2db9..987b945c6 100644
--- a/ui/src/app/shared/entity/form/form-item/json-form-field.scss
+++ b/ui/src/app/shared/entity/form/form-item/json-form-field.scss
@@ -1,34 +1,101 @@
 @import "../../../../../common";
+
 i {
     margin-left: 5px;
 }
 
+
+h3 {
+    margin: 10px;
+}
+
 .red {
     color: $cds_color_red;
 }
 
-.array-field, .map-field {
+.array-field,
+.map-field {
     margin-top: 10px;
     margin-bottom: 10px;
 }
+
+.item-title {
+    width: 100%;
+    display: flex;
+    flex-direction: row;
+    align-items: center;
+    margin-bottom: 5px;
+
+    h3 {
+        margin: 0 0 0 10px;
+    }
+
+    .item-add {
+        width: 20px;
+        height: 20px;
+        padding: 1px 2px;
+        font-size: 12px;
+        margin-left: 5px;
+    }
+}
+
 .item-container {
     border: 1px #D9D9D9 dashed;
-
-    padding: 20px 20px 0 20px;
+    padding: 15px 15px 0 15px;
     margin-bottom: 10px;
+    position: relative;
+
+    &:hover {
+        .actions {
+            .action {
+                visibility: visible;
+            }
+        }
+    }
+
+    .actions {
+        display: flex;
+        position: absolute;
+        right: -35px;
+        width: 35px;
+        height: 100%;
+        top: 0;
+        flex-direction: column;
+        align-items: center;
+        z-index: 9000;
+
+        .action {
+            visibility: hidden;
+            width: 20px;
+            height: 20px;
+            padding: 1px 2px;
+            margin-bottom: 5px;
+            font-size: 12px;
+            transition-duration: 0ms !important;
+        }
+
+        &:hover {
+            .action {
+                visibility: visible;
+            }
+        }
+    }
 
     .array-item {
         display: flex;
         flex-direction: column;
+        overflow: hidden;
 
         .array-select {
             display: flex;
+            overflow: hidden;
 
             nz-form-item {
                 nz-select.oneof {
                     width: 110px;
                 }
             }
+
             .array-select-key-form {
                 flex: 1;
                 margin-left: 10px;
@@ -39,10 +106,12 @@ i {
     .map-item {
         display: flex;
         flex-direction: row;
+        overflow: hidden;
 
         .map-key {
             width: 200px;
         }
+
         .map-value {
             flex: 1;
             margin-left: 10px;
diff --git a/ui/src/app/shared/entity/form/json-form.component.ts b/ui/src/app/shared/entity/form/json-form.component.ts
index b623f3bcb..dd78a04f6 100644
--- a/ui/src/app/shared/entity/form/json-form.component.ts
+++ b/ui/src/app/shared/entity/form/json-form.component.ts
@@ -66,9 +66,10 @@ export class JSONFormComponent implements OnInit, OnChanges {
                         pattern: value.pattern,
                         onchange: value.onchange,
                         mode: value.mode,
-                        prefix: value.prefix
+                        prefix: value.prefix,
+                        code: value.code
                     };
-                    if ( (item.type === 'object' || item.type === 'array') && value.type.length === 2) {
+                    if ((item.type === 'object' || item.type === 'array') && value.type.length === 2) {
                         item.objectType = value.type[1];
                     }
                     if (item.type === 'map') {
@@ -80,37 +81,37 @@ export class JSONFormComponent implements OnInit, OnChanges {
                 })
                 .sort((i, j) => i.formOrder - j.formOrder);
             let required = [];
-            let oneOf = new Map<string, JSONFormSchemaOneOfItem> ();
+            let oneOf = new Map<string, JSONFormSchemaOneOfItem>();
             if (schemaDefs[k]) {
                 // If sub jsonschema
-               if (schemaDefs[k]['$defs']) {
-                   required = schemaDefs[k]['$defs'][k].required
-               } else {
-                   required = schemaDefs[k].required
-               }
-               if (schemaDefs[k].oneOf) {
-                   let oneOfListItemName = schemaDefs[k].oneOf.map(o => {
-                       return o.required[0];
-                   });
-                   schemaDefs[k].oneOf.forEach(v => {
-                       let oneOfItem = new JSONFormSchemaOneOfItem();
-                       let listAllowedItem = items.filter(i => {
-                           if (v.not && v.not.required) {
-                               if (v.not.required.indexOf(i.name) !== -1) {
-                                   return false;
-                               }
-                           }
-                           let indexOf = oneOfListItemName.indexOf(i.name);
-                           if (i.name === v.required[0]) {
-                               oneOfItem.keyFormItem = i;
-                           }
-                           return indexOf === -1;
-                       });
-                       oneOfItem.fields = listAllowedItem;
+                if (schemaDefs[k]['$defs']) {
+                    required = schemaDefs[k]['$defs'][k].required
+                } else {
+                    required = schemaDefs[k].required
+                }
+                if (schemaDefs[k].oneOf) {
+                    let oneOfListItemName = schemaDefs[k].oneOf.map(o => {
+                        return o.required[0];
+                    });
+                    schemaDefs[k].oneOf.forEach(v => {
+                        let oneOfItem = new JSONFormSchemaOneOfItem();
+                        let listAllowedItem = items.filter(i => {
+                            if (v.not && v.not.required) {
+                                if (v.not.required.indexOf(i.name) !== -1) {
+                                    return false;
+                                }
+                            }
+                            let indexOf = oneOfListItemName.indexOf(i.name);
+                            if (i.name === v.required[0]) {
+                                oneOfItem.keyFormItem = i;
+                            }
+                            return indexOf === -1;
+                        });
+                        oneOfItem.fields = listAllowedItem;
 
-                       oneOf.set(v.required[0], oneOfItem);
-                   });
-               }
+                        oneOf.set(v.required[0], oneOfItem);
+                    });
+                }
             }
             allTypes[k] = <JSONFormSchemaTypeItem>{
                 fields: items,
@@ -204,7 +205,7 @@ export class JSONFormComponent implements OnInit, OnChanges {
                         if (k !== oneOfSelected) {
                             delete cleanData[k];
                         } else {
-                            let currentKeys  = Object.keys(cleanData);
+                            let currentKeys = Object.keys(cleanData);
                             let allowedKeys = schema.oneOf.get(k).fields;
                             currentKeys.forEach(subKey => {
                                 let ff = allowedKeys.find(i => {
diff --git a/ui/src/app/shared/entity/form/json-form.scss b/ui/src/app/shared/entity/form/json-form.scss
index cc93cb220..89d784495 100644
--- a/ui/src/app/shared/entity/form/json-form.scss
+++ b/ui/src/app/shared/entity/form/json-form.scss
@@ -1,3 +1,3 @@
 :host {
-    padding: 10px 20px;
+    padding: 10px 27px 10px 20px;
 }
diff --git a/ui/src/app/shared/input/editor/editor.input.component.ts b/ui/src/app/shared/input/editor/editor.input.component.ts
new file mode 100644
index 000000000..4fd2ab8e1
--- /dev/null
+++ b/ui/src/app/shared/input/editor/editor.input.component.ts
@@ -0,0 +1,89 @@
+import { Component, forwardRef, ChangeDetectionStrategy, ChangeDetectorRef, ElementRef, Input, OnInit, ViewChild } from "@angular/core";
+import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms";
+import { EditorOptions, NzCodeEditorComponent } from "ng-zorro-antd/code-editor";
+import { NzConfigService } from "ng-zorro-antd/core/config";
+import { OnChangeType, OnTouchedType } from "ng-zorro-antd/core/types";
+
+@Component({
+    selector: 'app-editor-input',
+    templateUrl: './editor.input.html',
+    styleUrls: ['./editor.input.scss'],
+    providers: [
+        {
+            provide: NG_VALUE_ACCESSOR,
+            useExisting: forwardRef(() => EditorInputComponent),
+            multi: true
+        }
+    ],
+    changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class EditorInputComponent implements ControlValueAccessor, OnInit {
+    @ViewChild('editor') editor: NzCodeEditorComponent;
+
+    @Input() set config(c: EditorOptions) {
+        this._config = {
+            minimap: { enabled: false },
+            scrollBeyondLastLine: false,
+            scrollbar: { alwaysConsumeMouseWheel: false },
+            ...c
+        };
+    }
+    _config: EditorOptions;
+
+    onChange: OnChangeType = () => { };
+    onTouched: OnTouchedType = () => { };
+    code: string = '';
+
+    constructor(
+        private _cd: ChangeDetectorRef,
+        private _el: ElementRef,
+        private _nzConfigService: NzConfigService
+    ) { }
+
+    ngOnInit(): void {
+        this.changeCode('');
+        this.computeTheme();
+        this._nzConfigService.getConfigChangeEventForComponent('codeEditor').subscribe(_ => {
+            this.computeTheme();
+        })
+    }
+
+    writeValue(code: string): void {
+        if (!code) {
+            return;
+        }
+        this.code = code;
+        this.computeHeight(this.code);
+        this._cd.markForCheck();
+    }
+
+    registerOnChange(fn: OnChangeType): void {
+        this.onChange = fn;
+    }
+
+    registerOnTouched(fn: OnTouchedType): void {
+        this.onTouched = fn;
+    }
+
+    changeCode(code: string): void {
+        this.computeHeight(code);
+        this.onChange(code);
+    }
+
+    computeHeight(code: string): void {
+        const height = (code.split('\n').length + 1) * 18;
+        this._el.nativeElement.style.height = `${Math.min(height, 400)}px`;
+    }
+
+    computeTheme(): void {
+        const editorConfig = this._nzConfigService.getConfigForComponent('codeEditor');
+        const theme = editorConfig?.defaultEditorOption?.theme;
+        this._el.nativeElement.style.borderColor = theme === 'vs-dark' ? '#434343' : '#d9d9d9';
+    }
+
+    layout(): void {
+        if (this.editor) {
+            this.editor.layout();
+        }
+    }
+}
diff --git a/ui/src/app/shared/input/editor/editor.input.html b/ui/src/app/shared/input/editor/editor.input.html
new file mode 100644
index 000000000..2e025eba5
--- /dev/null
+++ b/ui/src/app/shared/input/editor/editor.input.html
@@ -0,0 +1,2 @@
+<nz-code-editor #editor [ngModel]="code" (ngModelChange)="changeCode($event)" [nzEditorOption]="_config">
+</nz-code-editor>
diff --git a/ui/src/app/shared/input/editor/editor.input.scss b/ui/src/app/shared/input/editor/editor.input.scss
new file mode 100644
index 000000000..80d927772
--- /dev/null
+++ b/ui/src/app/shared/input/editor/editor.input.scss
@@ -0,0 +1,13 @@
+:host {
+    display: block;
+    overflow: hidden;
+    width: 100%;
+    border: 1px solid #d9d9d9;
+
+    nz-code-editor {
+        height: 400px; // usefull to limit editor grow
+        background-color: inherit; // prevent adding background while editor content loading
+        width: 100%;
+        overflow: hidden;
+    }
+}
diff --git a/ui/src/app/shared/resizable-panel/resizable-panel.component.ts b/ui/src/app/shared/resizable-panel/resizable-panel.component.ts
index 32387c2c3..c9ac8f5c7 100644
--- a/ui/src/app/shared/resizable-panel/resizable-panel.component.ts
+++ b/ui/src/app/shared/resizable-panel/resizable-panel.component.ts
@@ -59,7 +59,7 @@ export class ResizablePanelComponent implements AfterViewInit {
             } else if ((<string>this.initialSize).endsWith('%')) {
                 try {
                     const ratio = parseInt((<string>this.initialSize).replace('%', ''), 10);
-                    initialSize = (ratio * this._elementRef.nativeElement.parentNode.clientWidth) / 100;
+                    initialSize = (ratio * (this.direction === PanelDirection.HORIZONTAL ? this._elementRef.nativeElement.parentNode.clientWidth : this._elementRef.nativeElement.parentNode.clientHeight)) / 100;
                 } catch (e) { }
             }
         }
@@ -73,7 +73,7 @@ export class ResizablePanelComponent implements AfterViewInit {
         this.onGrabbingStart.emit();
     }
 
-    @HostListener('mouseup', ['$event'])
+    @HostListener('window:mouseup', ['$event'])
     onMouseUpGrabber(): void {
         if (!this.grabbing) {
             return;
@@ -87,9 +87,13 @@ export class ResizablePanelComponent implements AfterViewInit {
     onMouseMove(event: any): void {
         if (this.grabbing) {
             if (this.direction === PanelDirection.HORIZONTAL) {
-                this.size = Math.max(this.growDirection === PanelGrowDirection.AFTER ? event.clientX : window.innerWidth - event.clientX, this.minSize ?? 600);
+                const maxSize = this._elementRef.nativeElement.parentNode.clientWidth;
+                const newSize = Math.max(this.growDirection === PanelGrowDirection.AFTER ? event.clientX : window.innerWidth - event.clientX, this.minSize ?? 600);
+                this.size = Math.min(newSize, maxSize);
             } else {
-                this.size = Math.max(this.growDirection === PanelGrowDirection.AFTER ? event.clientY : window.innerHeight - event.clientY, this.minSize ?? 200);
+                const maxSize = this._elementRef.nativeElement.parentNode.clientHeight;
+                const newSize = Math.max(this.growDirection === PanelGrowDirection.AFTER ? event.clientY : window.innerHeight - event.clientY, this.minSize ?? 200);
+                this.size = Math.min(newSize, maxSize);
             }
             this.redraw();
         }
diff --git a/ui/src/app/shared/shared.module.ts b/ui/src/app/shared/shared.module.ts
index 44dec7ce1..d94d8cbbd 100644
--- a/ui/src/app/shared/shared.module.ts
+++ b/ui/src/app/shared/shared.module.ts
@@ -154,6 +154,7 @@ import { JSONFormFieldComponent } from "./entity/form/form-item/json-form-field.
 import { APIConfigGuard } from 'app/guard/api-config.guard';
 import { FavoriteButtonComponent } from './favorite-button/favorite-button.component';
 import { ResizablePanelComponent } from './resizable-panel/resizable-panel.component';
+import { EditorInputComponent } from './input/editor/editor.input.component';
 
 const ngZorroConfig: NzConfig = {
     notification: { nzPauseOnHover: true, nzPlacement: "topRight" },
@@ -240,6 +241,7 @@ const icons: IconDefinition[] = [AimOutline, ApartmentOutline, ArrowDownOutline,
         DiffItemComponent,
         DiffListComponent,
         DurationMsPipe,
+        EditorInputComponent,
         EntityFormComponent,
         FavoriteButtonComponent,
         FavoriteCardsComponent,
@@ -356,6 +358,7 @@ const icons: IconDefinition[] = [AimOutline, ApartmentOutline, ArrowDownOutline,
         DiffListComponent,
         DragulaModule,
         DurationMsPipe,
+        EditorInputComponent,
         EntityFormComponent,
         FavoriteButtonComponent,
         FavoriteCardsComponent,
diff --git a/ui/src/app/views/projectv2/project.module.ts b/ui/src/app/views/projectv2/project.module.ts
index 243c97d56..391559485 100644
--- a/ui/src/app/views/projectv2/project.module.ts
+++ b/ui/src/app/views/projectv2/project.module.ts
@@ -13,6 +13,20 @@ import {
 } from 'app/views/projectv2/vcs/repository/show/project.repository.show.component';
 import { ProjectV2WorkerModelShowComponent } from "./vcs/repository/workermodel/show/project.workermodel.show.component";
 import {ProjectV2ActionShowComponent} from "./vcs/repository/action/show/project.action.show.component";
+import {ProjectV2WorkflowShowComponent} from "./vcs/repository/workflow/show/project.workflow.show.component";
+import {
+    ProjectV2WorkflowForkJoinNodeComponent,
+} from "./vcs/repository/workflow/show/graph/node/fork-join-node.components";
+import {
+    ProjectV2WorkflowJobNodeComponent
+} from "./vcs/repository/workflow/show/graph/node/job-node.component";
+import {
+    ProjectV2WorkflowStagesGraphComponent
+} from "./vcs/repository/workflow/show/graph/stages-graph.component";
+import {
+    ProjectV2WorkflowJobsGraphComponent
+} from "./vcs/repository/workflow/show/graph/jobs-graph.component";
+import {ProjectWorkflowEntityComponent} from "./vcs/repository/workflow/show/entity/project.workflow.entity.component";
 
 @NgModule({
     declarations: [
@@ -23,6 +37,13 @@ import {ProjectV2ActionShowComponent} from "./vcs/repository/action/show/project
         ProjectV2TopMenuComponent,
         ProjectV2WorkerModelShowComponent,
         ProjectV2ActionShowComponent,
+        ProjectV2WorkflowShowComponent,
+
+        ProjectV2WorkflowForkJoinNodeComponent,
+        ProjectV2WorkflowJobNodeComponent,
+        ProjectV2WorkflowStagesGraphComponent,
+        ProjectV2WorkflowJobsGraphComponent,
+        ProjectWorkflowEntityComponent
     ],
     imports: [
         SharedModule,
diff --git a/ui/src/app/views/projectv2/project.routing.ts b/ui/src/app/views/projectv2/project.routing.ts
index 0cf46c3f7..4bf20c7b3 100644
--- a/ui/src/app/views/projectv2/project.routing.ts
+++ b/ui/src/app/views/projectv2/project.routing.ts
@@ -11,6 +11,7 @@ import {
 } from 'app/views/projectv2/vcs/repository/show/project.repository.show.component';
 import {ProjectV2WorkerModelShowComponent} from "./vcs/repository/workermodel/show/project.workermodel.show.component";
 import {ProjectV2ActionShowComponent} from "./vcs/repository/action/show/project.action.show.component";
+import {ProjectV2WorkflowShowComponent} from "./vcs/repository/workflow/show/project.workflow.show.component";
 
 
 const projectRoutes: Routes = [
@@ -49,6 +50,14 @@ const projectRoutes: Routes = [
                 resolve: {
                     project: Projectv2Resolver,
                 },
+            },
+            {
+                path: 'vcs/:vcsName/repository/:repoName/workflow/:workflowName',
+                component: ProjectV2WorkflowShowComponent,
+                data: { title: '{workflowName} • Workflow' },
+                resolve: {
+                    project: Projectv2Resolver,
+                },
             }
         ]
     }
diff --git a/ui/src/app/views/projectv2/project.scss b/ui/src/app/views/projectv2/project.scss
index 01de0026f..a28202dc0 100644
--- a/ui/src/app/views/projectv2/project.scss
+++ b/ui/src/app/views/projectv2/project.scss
@@ -18,6 +18,7 @@
 
         .content {
             flex: 1;
+            overflow: hidden;
         }
 
         .loading {
diff --git a/ui/src/app/views/projectv2/sidebar/sidebar.component.ts b/ui/src/app/views/projectv2/sidebar/sidebar.component.ts
index 1e91fe262..267a1cdf4 100644
--- a/ui/src/app/views/projectv2/sidebar/sidebar.component.ts
+++ b/ui/src/app/views/projectv2/sidebar/sidebar.component.ts
@@ -15,12 +15,13 @@ import {
     FlatNodeItemSelect
 } from 'app/shared/tree/tree.component';
 import { ProjectService } from 'app/service/project/project.service';
-import {EntityAction, EntityWorkerModel, Project, VCSProject} from 'app/model/project.model';
+import { Project, VCSProject} from 'app/model/project.model';
 import { Observable, of, Subscription } from 'rxjs';
 import { map } from 'rxjs/operators';
 import { Router } from '@angular/router';
 import { SidebarEvent, SidebarService } from 'app/service/sidebar/sidebar.service';
 import { AnalysisService } from "../../../service/analysis/analysis.service";
+import {EntityAction, EntityWorkerModel, EntityWorkflow} from "../../../model/entity.model";
 
 @Component({
     selector: 'app-projectv2-sidebar',
@@ -253,6 +254,14 @@ export class ProjectV2SidebarComponent implements OnDestroy, AfterViewInit {
                         queryParamsHandling: 'preserve'
                     }).then();
                 }
+                break;
+            case EntityWorkflow:
+                if (e.eventType === 'select') {
+                    this._router.navigate(['/', 'projectv2', this.project.key, 'vcs', e.node.parentNames[0], 'repository', e.node.parentNames[1], 'workflow', e.node.name], {
+                        queryParamsHandling: 'preserve'
+                    }).then();
+                }
+                break;
         }
     }
 
diff --git a/ui/src/app/views/projectv2/vcs/repository/action/show/project.action.show.component.ts b/ui/src/app/views/projectv2/vcs/repository/action/show/project.action.show.component.ts
index bc21659e2..1a2bb844f 100644
--- a/ui/src/app/views/projectv2/vcs/repository/action/show/project.action.show.component.ts
+++ b/ui/src/app/views/projectv2/vcs/repository/action/show/project.action.show.component.ts
@@ -1,8 +1,6 @@
 import {ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy} from "@angular/core";
 import {AutoUnsubscribe} from "app/shared/decorator/autoUnsubscribe";
 import {
-    Entity,
-    EntityAction,
     Project,
     ProjectRepository,
     VCSProject
@@ -15,6 +13,7 @@ import {SidebarEvent, SidebarService} from "app/service/sidebar/sidebar.service"
 import {ProjectState} from "app/store/project.state";
 import {forkJoin} from "rxjs";
 import {finalize} from "rxjs/operators";
+import {Entity, EntityAction} from "app/model/entity.model";
 
 @Component({
     selector: 'app-projectv2-action-show',
diff --git a/ui/src/app/views/projectv2/vcs/repository/action/show/project.action.show.scss b/ui/src/app/views/projectv2/vcs/repository/action/show/project.action.show.scss
index 15e8a4d7f..658a11614 100644
--- a/ui/src/app/views/projectv2/vcs/repository/action/show/project.action.show.scss
+++ b/ui/src/app/views/projectv2/vcs/repository/action/show/project.action.show.scss
@@ -7,7 +7,7 @@
     app-entity {
         flex: 1;
         width: 100%;
-        overflow-y: hidden;
+        overflow: hidden;
     }
 
     .footer {
diff --git a/ui/src/app/views/projectv2/vcs/repository/workermodel/show/project.workermodel.show.component.ts b/ui/src/app/views/projectv2/vcs/repository/workermodel/show/project.workermodel.show.component.ts
index 866d863f9..2ef5b64c9 100644
--- a/ui/src/app/views/projectv2/vcs/repository/workermodel/show/project.workermodel.show.component.ts
+++ b/ui/src/app/views/projectv2/vcs/repository/workermodel/show/project.workermodel.show.component.ts
@@ -1,6 +1,6 @@
 import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy } from '@angular/core';
 import { AutoUnsubscribe } from 'app/shared/decorator/autoUnsubscribe';
-import { Entity, EntityWorkerModel, Project, ProjectRepository, VCSProject } from 'app/model/project.model';
+import { Project, ProjectRepository, VCSProject } from 'app/model/project.model';
 import { ProjectState } from 'app/store/project.state';
 import { Store } from '@ngxs/store';
 import { forkJoin } from 'rxjs';
@@ -9,6 +9,7 @@ import { ProjectService } from 'app/service/project/project.service';
 import { SidebarEvent, SidebarService } from 'app/service/sidebar/sidebar.service';
 import { finalize } from "rxjs/operators";
 import { Schema } from 'app/model/json-schema.model';
+import {Entity, EntityWorkerModel} from "../../../../../../model/entity.model";
 
 @Component({
     selector: 'app-projectv2-workermodel-show',
diff --git a/ui/src/app/views/projectv2/vcs/repository/workflow/show/entity/project.workflow.entity.component.ts b/ui/src/app/views/projectv2/vcs/repository/workflow/show/entity/project.workflow.entity.component.ts
new file mode 100644
index 000000000..1939cc436
--- /dev/null
+++ b/ui/src/app/views/projectv2/vcs/repository/workflow/show/entity/project.workflow.entity.component.ts
@@ -0,0 +1,113 @@
+import {
+    ChangeDetectionStrategy,
+    ChangeDetectorRef,
+    Component,
+    Input,
+    OnChanges,
+    OnDestroy,
+    OnInit,
+    ViewChild
+} from "@angular/core";
+
+import {editor,} from 'monaco-editor';
+
+import {EditorOptions} from "ng-zorro-antd/code-editor/typings";
+import {NzCodeEditorComponent} from "ng-zorro-antd/code-editor";
+import {Store} from "@ngxs/store";
+import {PreferencesState} from "app/store/preferences.state";
+import * as actionPreferences from 'app/store/preferences.action';
+import {Subscription} from 'rxjs';
+import {Schema} from "app/model/json-schema.model";
+import {AutoUnsubscribe} from "app/shared/decorator/autoUnsubscribe";
+import {FlatSchema, JSONSchema} from "app/model/schema.model";
+import Debounce from "app/shared/decorator/debounce";
+
+declare const monaco: any;
+
+@Component({
+    selector: 'app-project-workflow-entity',
+    templateUrl: './project.workflow.entity.html',
+    styleUrls: ['./project.workflow.entity.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush
+})
+@AutoUnsubscribe()
+export class ProjectWorkflowEntityComponent implements OnInit, OnChanges, OnDestroy {
+    static PANEL_KEY = 'project-workflow-v2-entity-form';
+
+    @ViewChild('editor') editor: NzCodeEditorComponent;
+
+    @Input() path: string;
+    @Input() data: string;
+    @Input() schema: Schema;
+    @Input() disabled: boolean;
+    @Input() parentType: string;
+    @Input() entityType: string;
+
+    flatSchema: FlatSchema;
+    dataGraph: string;
+    dataEditor: string;
+    editorOption: EditorOptions;
+    panelSize: number | string;
+    resizing: boolean;
+    resizingSubscription: Subscription;
+
+    constructor(
+        private _cd: ChangeDetectorRef,
+        private _store: Store
+    ) {
+    }
+
+    ngOnInit(): void {
+        this.editorOption = {
+            language: 'yaml',
+            minimap: {enabled: false}
+        };
+
+        this.panelSize = this._store.selectSnapshot(PreferencesState.panelSize(ProjectWorkflowEntityComponent.PANEL_KEY)) ?? '50%';
+
+        this.resizingSubscription = this._store.select(PreferencesState.resizing).subscribe(resizing => {
+            this.resizing = resizing;
+            this._cd.markForCheck();
+        });
+
+        this._cd.markForCheck();
+    }
+
+    ngOnChanges(): void {
+        this.flatSchema = JSONSchema.flat(this.schema);
+        this.dataGraph = this.data;
+        this.dataEditor = this.data;
+        this._cd.markForCheck();
+    }
+
+    ngOnDestroy(): void {
+    } // Should be set to use @AutoUnsubscribe with AOT
+
+    onEditorInit(e: editor.ICodeEditor | editor.IEditor): void {
+        monaco.languages.json.jsonDefaults.setDiagnosticsOptions({schemas: [{uri: '', schema: this.flatSchema}]});
+    }
+
+    onEditorChange(data: string): void {
+        this.dataGraph = data;
+        this._cd.markForCheck();
+    }
+
+    @Debounce(200)
+    onFormChange(data: string): void {
+        this.dataEditor = data;
+        this._cd.markForCheck();
+    }
+
+    panelStartResize(): void {
+        this._store.dispatch(new actionPreferences.SetPanelResize({resizing: true}));
+    }
+
+    panelEndResize(size: number): void {
+        this._store.dispatch(new actionPreferences.SavePanelSize({
+            panelKey: ProjectWorkflowEntityComponent.PANEL_KEY,
+            size: size
+        }));
+        this._store.dispatch(new actionPreferences.SetPanelResize({resizing: false}));
+        this.editor.layout();
+    }
+}
diff --git a/ui/src/app/views/projectv2/vcs/repository/workflow/show/entity/project.workflow.entity.html b/ui/src/app/views/projectv2/vcs/repository/workflow/show/entity/project.workflow.entity.html
new file mode 100644
index 000000000..786059bbf
--- /dev/null
+++ b/ui/src/app/views/projectv2/vcs/repository/workflow/show/entity/project.workflow.entity.html
@@ -0,0 +1,13 @@
+<ng-container *ngIf="flatSchema && data">
+
+    <app-stages-graph [workflow]="dataGraph"></app-stages-graph>
+    <app-resizable-panel minSize="400" [initialSize]="panelSize" (onGrabbingStart)="panelStartResize()"
+                         (onGrabbingEnd)="panelEndResize($event)">
+        <nz-tabset nzType="card" nzSize="small">
+            <nz-tab [nzTitle]="path"></nz-tab>
+        </nz-tabset>
+        <nz-code-editor #editor [ngModel]="dataEditor" [nzEditorOption]="editorOption"
+                        (ngModelChange)="onEditorChange($event)"
+                        (nzEditorInitialized)="onEditorInit($event)"></nz-code-editor>
+    </app-resizable-panel>
+</ng-container>
diff --git a/ui/src/app/views/projectv2/vcs/repository/workflow/show/entity/project.workflow.entity.scss b/ui/src/app/views/projectv2/vcs/repository/workflow/show/entity/project.workflow.entity.scss
new file mode 100644
index 000000000..daec87b36
--- /dev/null
+++ b/ui/src/app/views/projectv2/vcs/repository/workflow/show/entity/project.workflow.entity.scss
@@ -0,0 +1,55 @@
+:host {
+    display: flex;
+    flex-direction: row;
+    height: 100%;
+    width: 100%;
+    overflow: hidden;
+
+    app-stages-graph {
+        flex: 1;
+        overflow: hidden;
+        overflow-y: auto;
+
+        &.disableSelection {
+            user-select: none;
+        }
+    }
+
+    app-resizable-panel {
+        height: 100%;
+
+        ::ng-deep {
+            .ant-tabs-tab {
+                padding: 3px 16px !important;
+                cursor: inherit !important;
+
+                :host-context(.night) & {
+                    background-color: #1e1e1e;
+                    border-bottom-color: #1e1e1e !important;
+                }
+            }
+
+            .ant-tabs-tab-btn {
+                color: #979797;
+
+                :host-context(.night) & {
+                    color: #828282;
+                }
+            }
+
+            nz-tabs-nav {
+                margin: 0;
+            }
+        }
+
+        nz-code-editor {
+            height: 100%;
+            border-left: 1px solid #f0f0f0;
+
+            :host-context(.night) & {
+                background-color: #1e1e1e;
+                border-left: 1px solid #303030;
+            }
+        }
+    }
+}
diff --git a/ui/src/app/views/projectv2/vcs/repository/workflow/show/graph/graph.lib.ts b/ui/src/app/views/projectv2/vcs/repository/workflow/show/graph/graph.lib.ts
new file mode 100644
index 000000000..aa1e8eb4b
--- /dev/null
+++ b/ui/src/app/views/projectv2/vcs/repository/workflow/show/graph/graph.lib.ts
@@ -0,0 +1,527 @@
+import {ComponentRef} from '@angular/core';
+import {PipelineStatus} from 'app/model/pipeline.model';
+import * as d3 from 'd3';
+import * as dagreD3 from 'dagre-d3';
+import {GraphNode} from "./graph.model";
+import {ProjectV2WorkflowForkJoinNodeComponent} from "./node/fork-join-node.components";
+import {ProjectV2WorkflowJobNodeComponent} from "./node/job-node.component";
+
+export type WorkflowNodeComponent = ProjectV2WorkflowForkJoinNodeComponent | ProjectV2WorkflowJobNodeComponent;
+
+export enum GraphDirection {
+    HORIZONTAL = 'horizontal',
+    VERTICAL = 'vertical'
+}
+
+export interface WithHighlight {
+    getNodes(): Array<GraphNode>;
+
+    setHighlight(active: boolean): void;
+
+    setSelect(active: boolean): void;
+}
+
+export type ComponentFactory<T> = (nodes: Array<GraphNode>, type: string) => ComponentRef<T>;
+
+export class Node {
+    key: string;
+    width: number;
+    height: number;
+}
+
+export class Edge {
+    from: string;
+    to: string;
+    options: {};
+}
+
+export class WorkflowV2Graph<T extends WithHighlight> {
+    static margin = 40; // let 40px on top and bottom of the graph
+    static marginSubGraph = 20; // let 20px on top and bottom of the sub graph
+    static maxOriginScale = 1;
+    static baseStageWidth = 300;
+    static minStageWidth = 200;
+    static minJobWidth = 60;
+
+    nodesComponent = new Map<string, ComponentRef<T>>();
+    nodes = new Array<Node>();
+    edges = new Array<Edge>();
+    direction: string;
+    zoom: d3.ZoomBehavior<Element, {}>;
+    svg: d3.Selection<any, any, any, any>;
+    g: d3.Selection<any, any, any, any>;
+    graph: dagreD3.graphlib.Graph;
+    render: dagreD3.Render;
+    minScale = 1;
+    maxScale = 1;
+    transformed: any = null;
+    componentFactory: ComponentFactory<T>;
+    nodeOutNames: { [key: string]: string } = {};
+    nodeInNames: { [key: string]: string } = {};
+    forks: { [key: string]: { parents: Array<string>, children: Array<string> } } = {};
+    joins: { [key: string]: { parents: Array<string>, children: Array<string> } } = {};
+    nodeStatus: { [key: string]: string } = {};
+    currentSelectedNodeKey: string = null;
+
+    constructor(
+        factory: ComponentFactory<T>,
+        direction: GraphDirection,
+        minScale: number,
+        maxScale: number
+    ) {
+        this.componentFactory = factory;
+        this.direction = direction;
+        this.graph = new dagreD3.graphlib.Graph().setGraph({
+            rankdir: this.direction === GraphDirection.VERTICAL ? 'TB' : 'LR',
+            nodesep: 10,
+            ranksep: 10,
+            edgesep: 0
+        });
+        this.minScale = minScale;
+        this.maxScale = maxScale;
+        this.render = new dagreD3.render();
+        this.render.shapes().customRectH = WorkflowV2Graph.customRect(GraphDirection.HORIZONTAL);
+        this.render.shapes().customRectV = WorkflowV2Graph.customRect(GraphDirection.VERTICAL);
+        this.render.shapes().customCircle = WorkflowV2Graph.customCircle;
+        this.render.arrows().customArrow = WorkflowV2Graph.customArrow;
+    }
+
+    static customRect = (direction: GraphDirection) => (parent, bbox, node) => {
+        let shapeSvg = parent.insert('rect', ':first-child')
+            .attr('rx', node.rx)
+            .attr('ry', node.ry)
+            .attr('x', -bbox.width / 2)
+            .attr('y', -bbox.height / 2)
+            .attr('width', bbox.width)
+            .attr('height', bbox.height);
+
+        node.intersect = (point) => {
+            if (direction === GraphDirection.VERTICAL) {
+                const h = ((node.height) / 2);
+                return {x: node.x, y: node.y + (point.y < node.y ? -h : h)};
+            }
+            const w = ((node.width) / 2);
+            return {x: node.x + (point.x < node.x ? -w : w), y: node.y};
+        };
+
+        return shapeSvg;
+    };
+
+    static customCircle = (parent, bbox, node) => {
+        let r = Math.max(bbox.width, bbox.height) / 2;
+        let shapeSvg = parent.insert('circle', ':first-child')
+            .attr('x', -bbox.width / 2)
+            .attr('y', -bbox.height / 2)
+            .attr('r', r);
+
+        node.intersect = point => ({x: node.x, y: node.y});
+
+        return shapeSvg;
+    };
+
+    static customArrow = (parent, id, edge, type) => {
+        let markerHead = parent.append('marker')
+            .attr('id', id)
+            .attr('viewBox', '0 0 10 10')
+            .attr('refX', 0)
+            .attr('refY', 5)
+            .attr('markerUnits', 'strokeWidth')
+            .attr('markerWidth', 5)
+            .attr('markerHeight', 5)
+            .attr('orient', 'auto');
+        let pathHead = markerHead.append('path')
+            .attr('d', 'M 0 5 L 10 5');
+        pathHead.attr('style', edge['style']);
+        pathHead.attr('class', edge['class']);
+
+        const arrowEndId = id.replace('head', 'start');
+        let markerEnd = parent.append('marker')
+            .attr('id', arrowEndId)
+            .attr('viewBox', '0 0 10 10')
+            .attr('refX', 10)
+            .attr('refY', 5)
+            .attr('markerUnits', 'strokeWidth')
+            .attr('markerWidth', 5)
+            .attr('markerHeight', 5)
+            .attr('orient', 'auto');
+        let pathEnd = markerEnd.append('path')
+            .attr('d', 'M 0 5 L 10 5');
+        pathEnd.attr('style', edge['style']);
+        pathEnd.attr('class', edge['class']);
+
+        const makeFragmentRef = (url, fragmentId) => {
+            const baseUrl = url.split('#')[0];
+            return `${baseUrl}#${fragmentId}`;
+        };
+
+        const defs = parent._groups[0][0];
+        const edgePath = d3.select(defs.parentNode.childNodes[0]);
+        edgePath.attr('marker-start', () => `url(${makeFragmentRef(location.href, arrowEndId)})`);
+    };
+
+    resize(width: number, height: number): void {
+        if (!this.svg) {
+            return;
+        }
+        this.svg.attr('width', width);
+        this.svg.attr('height', height);
+    }
+
+    clean(): void {
+        this.graph.edges().forEach(e => this.graph.removeEdge(e.v, e.w));
+        this.graph.nodes().forEach(n => this.graph.removeNode(n));
+        this.nodesComponent.forEach(c => c.destroy());
+        this.nodesComponent = new Map<string, ComponentRef<T>>();
+        this.nodes = new Array<Node>();
+        this.edges = new Array<Edge>();
+    }
+
+    draw(element: any, withZoom: boolean): void {
+        d3.select(element).selectAll('svg').remove();
+        this.svg = d3.select(element).insert('svg');
+        this.g = this.svg.insert('g');
+
+        this.graph.edges().forEach(e => this.graph.removeEdge(e.v, e.w));
+        this.graph.nodes().forEach(n => this.graph.removeNode(n));
+
+        this.drawNodes();
+        this.drawEdges();
+
+        this.render(this.g, <any>this.graph);
+
+        if (withZoom) {
+            this.zoom = d3.zoom().scaleExtent([this.minScale, this.maxScale]).on('zoom', () => {
+                if (d3.event.transform && d3.event.transform.x && d3.event.transform.x !== Number.POSITIVE_INFINITY
+                    && d3.event.transform.y && d3.event.transform.y !== Number.POSITIVE_INFINITY) {
+                    this.g.attr('transform', d3.event.transform);
+                    this.transformed = d3.event.transform;
+                }
+            });
+            this.svg.call(this.zoom);
+
+            if (!!this.transformed) {
+                this.svg.call(this.zoom.transform,
+                    d3.zoomIdentity.translate(this.transformed.x, this.transformed.y).scale(this.transformed.k));
+            }
+        }
+
+        if (this.currentSelectedNodeKey) {
+            this.unselectAllNode();
+            this.selectNode(this.currentSelectedNodeKey);
+        }
+    }
+
+    center(containerWidth: number, containerHeight: number): void {
+        if (this.zoom) {
+            let w = containerWidth - WorkflowV2Graph.margin * 2;
+            let h = containerHeight - WorkflowV2Graph.margin * 2;
+            let gw = this.graph.graph().width;
+            let gh = this.graph.graph().height;
+            let oScale = Math.min(w / gw, h / gh); // calculate optimal scale for current graph
+            // calculate final scale that fit min and max scale values
+            let scale = Math.min(
+                WorkflowV2Graph.maxOriginScale,
+                Math.max(this.minScale, oScale)
+            );
+            let centerX = (w - gw * scale + WorkflowV2Graph.margin * 2) / 2;
+            let centerY = (h - gh * scale + WorkflowV2Graph.margin * 2) / 2;
+            this.svg.call(this.zoom.transform, d3.zoomIdentity.translate(centerX, centerY).scale(scale));
+        } else {
+            let w = containerWidth - WorkflowV2Graph.marginSubGraph * 2;
+            let h = containerHeight - WorkflowV2Graph.marginSubGraph * 2;
+            let gw = this.graph.graph().width;
+            let gh = this.graph.graph().height;
+            let oScale = Math.min(w / gw, h / gh); // calculate optimal scale for current graph
+            let centerX = (w - gw * oScale + WorkflowV2Graph.marginSubGraph * 2) / 2;
+            let centerY = (h - gh * oScale + WorkflowV2Graph.marginSubGraph * 2) / 2;
+            this.g.attr('transform', `translate(${centerX}, ${centerY}) scale(${oScale})`);
+        }
+        this.transformed = null;
+    }
+
+    centerNode(nodeName: string, containerWidth: number, containerHeight: number): void {
+        if (!this.zoom) {
+            return;
+        }
+        let node = this.graph.node(nodeName);
+        // calculate optimal scale for current graph
+        let oScale = Math.min((containerWidth - WorkflowV2Graph.margin * 2) / 300,
+            (containerHeight - WorkflowV2Graph.margin * 2) / 169);
+        // calculate final scale that fit min and max scale values
+        let scale = Math.max(this.minScale, oScale);
+        let nodeDeltaCenterX = containerWidth / 2 - node.x * scale;
+        let nodeDeltaCenterY = containerHeight / 2 - node.y * scale;
+        this.svg.call(this.zoom.transform, d3.zoomIdentity.translate(nodeDeltaCenterX, nodeDeltaCenterY).scale(scale));
+    }
+
+    drawNodes(): void {
+        this.nodes.forEach(n => {
+            this.createGNode(`node-${n.key}`, this.nodesComponent.get(`node-${n.key}`), n.width, n.height, {
+                class: n.key
+            });
+        });
+    }
+
+    uniqueStrings(a: Array<string>): Array<string> {
+        let o = {};
+        a.forEach(s => o[s] = true);
+        return Object.keys(o);
+    }
+
+    drawEdges(): void {
+        let nodesChildren: { [key: string]: Array<string> } = {};
+        let nodesParents: { [key: string]: Array<string> } = {};
+
+        this.edges.forEach(e => {
+            if (!nodesChildren[e.from]) {
+                nodesChildren[e.from] = [e.to];
+            } else {
+                nodesChildren[e.from] = this.uniqueStrings([...nodesChildren[e.from], e.to]);
+            }
+            if (!nodesParents[e.to]) {
+                nodesParents[e.to] = [e.from];
+            } else {
+                nodesParents[e.to] = this.uniqueStrings([...nodesParents[e.to], e.from]);
+            }
+        });
+
+        // Create fork
+        this.forks = {};
+        this.nodeOutNames = {};
+        Object.keys(nodesChildren).forEach(c => {
+            if (nodesChildren[c].length > 1) {
+                const children = nodesChildren[c].map(n => n.split('node-')[1]).sort();
+                const keyFork = children.join('-');
+                if (this.forks[keyFork]) {
+                    this.forks[keyFork].parents = this.forks[keyFork].parents.concat(c);
+                    this.forks[keyFork].children = this.uniqueStrings(this.forks[keyFork].children.concat(nodesChildren[c]));
+                } else {
+                    this.forks[keyFork] = {
+                        parents: [c],
+                        children: nodesChildren[c]
+                    };
+                }
+            }
+        });
+        Object.keys(this.forks).forEach(f => {
+            let nodes = this.forks[f].parents.map(n => this.nodesComponent.get(n).instance.getNodes()).reduce((p, c) => p.concat(c));
+            const componentRef = this.componentFactory(nodes, 'fork');
+            let nodeKeys = this.forks[f].parents.map(n => n.split('node-')[1]).sort().join(' ');
+            this.createGFork(f, componentRef, {class: `${nodeKeys}`});
+            this.nodeStatus[`fork-${f}`] = PipelineStatus.sum(nodes.map(n => n.run ? n.run.status : null));
+            this.forks[f].parents.forEach(n => {
+                let edge = <Edge>{
+                    from: n,
+                    to: `fork-${f}`,
+                    options: {
+                        class: `${f} ${n.split('node-')[1]}`
+                    }
+                };
+                if (this.nodeStatus[`fork-${f}`]) {
+                    const color = this.nodeStatusToColor(this.nodeStatus[`fork-${f}`]);
+                    edge.options['class'] += ' ' + color;
+                    edge.options['style'] = 'stroke-width: 2px;';
+                }
+                this.createGEdge(edge);
+                this.nodeOutNames[n] = `fork-${f}`;
+            });
+        });
+
+        // Create join
+        this.joins = {};
+        this.nodeInNames = {};
+        Object.keys(nodesParents).forEach(p => {
+            if (nodesParents[p].length > 1) {
+                const parents = nodesParents[p].map(n => n.split('node-')[1]).sort();
+                const keyJoin = parents.join('-');
+                if (this.joins[keyJoin]) {
+                    this.joins[keyJoin].children = this.joins[keyJoin].children.concat(p);
+                    this.joins[keyJoin].parents = this.uniqueStrings(this.joins[keyJoin].children.concat(nodesParents[p]));
+                } else {
+                    this.joins[keyJoin] = {
+                        children: [p],
+                        parents: nodesParents[p]
+                    };
+                }
+            }
+        });
+        Object.keys(this.joins).forEach(j => {
+            let nodes = this.joins[j].parents.map(n => this.nodesComponent.get(n).instance.getNodes()).reduce((p, c) => p.concat(c));
+            const componentRef = this.componentFactory(nodes, 'join');
+            let nodeKeys = this.joins[j].children.map(n => n.split('node-')[1]).sort().join(' ');
+            this.createGJoin(j, componentRef, {class: `${nodeKeys}`});
+            this.nodeStatus[`join-${j}`] = PipelineStatus.sum(nodes.map(n => n.run ? n.run.status : null));
+            this.joins[j].children.forEach(n => {
+                let edge = <Edge>{
+                    from: `join-${j}`,
+                    to: n,
+                    options: {
+                        class: `${j} ${n.split('node-')[1]}`
+                    }
+                };
+                if (this.nodeStatus[`join-${j}`]) {
+                    const color = this.nodeStatusToColor(this.nodeStatus[`join-${j}`]);
+                    edge.options['class'] += ' ' + color;
+                    edge.options['style'] = 'stroke-width: 2px;';
+                }
+                this.createGEdge(edge);
+                this.nodeInNames[n] = `join-${j}`;
+            });
+        });
+
+        let uniqueEdges: { [key: string]: { from: string, to: string, classes: Array<string> } } = {};
+        this.edges.forEach(e => {
+            let from = this.nodeOutNames[e.from] ?? e.from;
+            let to = this.nodeInNames[e.to] ?? e.to;
+            let edgeKey = `${from}-${to}`;
+            let nodeKeyFrom = e.from.split('node-')[1];
+            let nodeKeyTo = e.to.split('node-')[1];
+            if (!uniqueEdges[edgeKey]) {
+                uniqueEdges[edgeKey] = {from, to, classes: [nodeKeyFrom, nodeKeyTo]};
+                return;
+            }
+            uniqueEdges[edgeKey].classes = this.uniqueStrings(uniqueEdges[edgeKey].classes.concat(nodeKeyFrom, nodeKeyTo));
+        });
+
+        Object.keys(uniqueEdges).forEach(edgeKey => {
+            let e = uniqueEdges[edgeKey];
+            let options = {
+                class: e.classes.join(' ')
+            };
+            if (this.nodeStatus[e.from]) {
+                const color = this.nodeStatusToColor(this.nodeStatus[e.from]);
+                options['class'] += ' ' + color;
+                options['style'] = 'stroke-width: 2px;';
+            }
+            this.createGEdge(<Edge>{
+                from: e.from, to: e.to, options
+            });
+        });
+
+    }
+
+    nodeStatusToColor(s: string): string {
+        switch (s) {
+            case PipelineStatus.SUCCESS:
+                return 'color-success';
+            case PipelineStatus.FAIL:
+                return 'color-fail';
+            case PipelineStatus.WAITING:
+            case PipelineStatus.DISABLED:
+            case PipelineStatus.BUILDING:
+            case PipelineStatus.PENDING:
+            case PipelineStatus.NEVER_BUILT:
+            case PipelineStatus.STOPPED:
+                return 'color-inactive';
+            default:
+                return '';
+        }
+    }
+
+    createNode(key: string, componentRef: ComponentRef<T>, status: string,
+               width: number = 130, height: number = 40): void {
+        this.nodes.push(<Node>{key, width, height});
+        this.nodesComponent.set(`node-${key}`, componentRef);
+        if (status) {
+            this.nodeStatus[`node-${key}`] = status;
+        }
+    }
+
+    createGNode(name: string, componentRef: ComponentRef<T>, width: number, height: number, options: {}): void {
+        this.graph.setNode(name, <any>{
+            shape: this.direction === GraphDirection.VERTICAL ? 'customRectV' : 'customRectH',
+            label: () => componentRef.location.nativeElement,
+            labelStyle: `width: ${width}px;height: ${height}px;`,
+            width,
+            height,
+            ...options
+        });
+    }
+
+    createGFork(key: string, componentRef: ComponentRef<T>, options: {}): void {
+        this.nodesComponent.set(`fork-${key}`, componentRef);
+        this.createGNode(`fork-${key}`, componentRef, 20, 20, {
+            shape: 'customCircle',
+            width: 60,
+            height: 60,
+            ...options
+        });
+    }
+
+    createGJoin(key: string, componentRef: ComponentRef<T>, options: {}): void {
+        this.nodesComponent.set(`join-${key}`, componentRef);
+        this.createGNode(`join-${key}`, componentRef, 20, 20, {
+            shape: 'customCircle',
+            width: 60,
+            height: 60,
+            ...options
+        });
+    }
+
+    createEdge(from: string, to: string): void {
+        this.edges.push(<Edge>{from, to});
+    }
+
+    createGEdge(e: Edge): void {
+        this.graph.setEdge(e.from, e.to, {
+            arrowhead: 'customArrow',
+            style: 'stroke: #B5B7BD;stroke-width: 2px;',
+            curve: d3.curveBasis,
+            ...e.options
+        });
+    }
+
+    nodeMouseEvent(type: string, key: string): void {
+        switch (type) {
+            case 'enter':
+                this.highlightNode(true, key);
+                break;
+            case 'out':
+                this.highlightNode(false, key);
+                break;
+            case 'click':
+                this.unselectAllNode();
+                this.selectNode(key);
+                break;
+        }
+    }
+
+    unselectAllNode(): void {
+        this.nodesComponent.forEach(n => n.instance.setSelect(false));
+    }
+
+    selectNode(key: string): void {
+        if (this.nodesComponent.has(`node-${key}`)) {
+            this.nodesComponent.get(`node-${key}`).instance.setSelect(true);
+            this.currentSelectedNodeKey = key;
+        } else {
+            this.currentSelectedNodeKey = null;
+        }
+    }
+
+    highlightNode(active: boolean, key: string) {
+        let selectionEdges = d3.selectAll(`.${key} > .path`);
+        if (selectionEdges.size() > 0) {
+            selectionEdges.attr('class', active ? 'path highlight' : 'path');
+        }
+        let selectionEdgeMarkers = d3.selectAll(`.${key} > defs > marker > path`);
+        if (selectionEdgeMarkers.size() > 0) {
+            selectionEdgeMarkers.attr('class', active ? 'highlight' : '');
+        }
+        if (this.nodesComponent.has(`node-${key}`)) {
+            this.nodesComponent.get(`node-${key}`).instance.setHighlight(active);
+        }
+        let inName = this.nodeInNames[`node-${key}`];
+        if (inName !== `node-${key}`) {
+            if (this.nodesComponent.has(inName)) {
+                this.nodesComponent.get(inName).instance.setHighlight(active);
+            }
+        }
+        let outName = this.nodeOutNames[`node-${key}`];
+        if (outName !== `node-${key}`) {
+            if (this.nodesComponent.has(outName)) {
+                this.nodesComponent.get(outName).instance.setHighlight(active);
+            }
+        }
+    }
+}
diff --git a/ui/src/app/views/projectv2/vcs/repository/workflow/show/graph/graph.model.ts b/ui/src/app/views/projectv2/vcs/repository/workflow/show/graph/graph.model.ts
new file mode 100644
index 000000000..aa1a00966
--- /dev/null
+++ b/ui/src/app/views/projectv2/vcs/repository/workflow/show/graph/graph.model.ts
@@ -0,0 +1,22 @@
+export class StepStatus {
+    step_order: number;
+    status: string;
+    start: string;
+    done: string;
+}
+
+export class JobRun {
+    status: string;
+    step_status: Array<StepStatus>;
+}
+
+export class GraphNode {
+    name: string;
+    depends_on: Array<string>;
+    sub_graph: Array<GraphNode>;
+    run: JobRun;
+    type: string;
+}
+
+export const GraphNodeTypeJob = "job";
+export const GraphNodeTypeStage = "stage";
diff --git a/ui/src/app/views/projectv2/vcs/repository/workflow/show/graph/jobs-graph.component.ts b/ui/src/app/views/projectv2/vcs/repository/workflow/show/graph/jobs-graph.component.ts
new file mode 100644
index 000000000..9e6f5b65d
--- /dev/null
+++ b/ui/src/app/views/projectv2/vcs/repository/workflow/show/graph/jobs-graph.component.ts
@@ -0,0 +1,153 @@
+import {
+    AfterViewInit,
+    ChangeDetectionStrategy,
+    ChangeDetectorRef,
+    Component,
+    ComponentRef,
+    Input,
+    OnDestroy,
+    ViewChild,
+    ViewContainerRef
+} from '@angular/core';
+import {AutoUnsubscribe} from 'app/shared/decorator/autoUnsubscribe';
+import {GraphNode} from "./graph.model";
+import {GraphDirection, WorkflowNodeComponent, WorkflowV2Graph} from "./graph.lib";
+import {ProjectV2WorkflowForkJoinNodeComponent} from "./node/fork-join-node.components";
+import {ProjectV2WorkflowJobNodeComponent} from "./node/job-node.component";
+
+@Component({
+    selector: 'app-jobs-graph',
+    templateUrl: './jobs-graph.html',
+    styleUrls: ['./jobs-graph.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush
+})
+@AutoUnsubscribe()
+export class ProjectV2WorkflowJobsGraphComponent implements AfterViewInit, OnDestroy {
+    static maxScale = 2;
+    static minScale = 0.1;
+
+    node: GraphNode;
+    nodes: Array<GraphNode> = [];
+
+    @Input() set graphNode(data: GraphNode) {
+        this.node = data;
+        this.nodes = data.sub_graph;
+        this.changeDisplay();
+    }
+
+    @Input() direction: GraphDirection;
+    @Input() centerCallback: any;
+    @Input() mouseCallback: (type: string, node: GraphNode) => void;
+    @Input() selectJobCallback: (name: string) => void;
+
+    ready: boolean;
+    highlight = false;
+
+    // workflow graph
+    @ViewChild('svgSubGraph', {read: ViewContainerRef}) svgContainer: ViewContainerRef;
+    graph: WorkflowV2Graph<WorkflowNodeComponent>;
+
+    constructor(
+        private _cd: ChangeDetectorRef
+    ) {
+    }
+
+    getNodes() {
+        return [this.node];
+    }
+
+    ngOnDestroy(): void {
+    } // Should be set to use @AutoUnsubscribe with AOT
+
+    onMouseEnter(): void {
+        if (this.mouseCallback) {
+            this.mouseCallback('enter', this.node);
+        }
+    }
+
+    onMouseOut(): void {
+        if (this.mouseCallback) {
+            this.mouseCallback('out', this.node);
+        }
+    }
+
+    setHighlight(active: boolean): void {
+        this.highlight = active;
+        this._cd.markForCheck();
+    }
+
+    setSelect(active: boolean): void {
+        this.graph.unselectAllNode();
+    }
+
+    ngAfterViewInit(): void {
+        this.ready = true;
+        this._cd.detectChanges();
+        this.changeDisplay();
+    }
+
+    changeDisplay(): void {
+        if (!this.ready) {
+            return;
+        }
+        this.initGraph();
+    }
+
+    initGraph() {
+        if (this.graph) {
+            this.graph.clean();
+        }
+        if (!this.graph || this.graph.direction !== this.direction) {
+            this.graph = new WorkflowV2Graph(this.createForkJoinNodeComponent.bind(this), this.direction,
+                ProjectV2WorkflowJobsGraphComponent.minScale, ProjectV2WorkflowJobsGraphComponent.maxScale);
+        }
+
+        this.nodes.forEach(n => {
+            this.graph.createNode(`${this.node.name}-${n.name}`, this.createJobNodeComponent(n),
+                n.run ? n.run.status : null);
+        });
+
+        this.nodes.forEach(n => {
+            if (n.depends_on && n.depends_on.length > 0) {
+                n.depends_on.forEach(d => {
+                    this.graph.createEdge(`node-${this.node.name}-${d}`, `node-${this.node.name}-${n.name}`);
+                });
+            }
+        });
+
+        const element = this.svgContainer.element.nativeElement;
+        this.graph.draw(element, false);
+        this.graph.center(300, 169);
+        this._cd.markForCheck();
+    }
+
+    createJobNodeComponent(node: GraphNode): ComponentRef<ProjectV2WorkflowJobNodeComponent> {
+        const componentRef = this.svgContainer.createComponent(ProjectV2WorkflowJobNodeComponent);
+        componentRef.instance.node = node;
+        componentRef.instance.mouseCallback = this.nodeMouseEvent.bind(this);
+        componentRef.changeDetectorRef.detectChanges();
+        return componentRef;
+    }
+
+    createForkJoinNodeComponent(nodes: Array<GraphNode>, type: string): ComponentRef<ProjectV2WorkflowForkJoinNodeComponent> {
+        const componentRef = this.svgContainer.createComponent(ProjectV2WorkflowForkJoinNodeComponent);
+        componentRef.instance.nodes = nodes;
+        componentRef.instance.type = type;
+        componentRef.instance.mouseCallback = this.nodeMouseEvent.bind(this);
+        componentRef.changeDetectorRef.detectChanges();
+        return componentRef;
+    }
+
+    nodeMouseEvent(type: string, n: GraphNode) {
+        if (type === 'click' && this.selectJobCallback) {
+            this.selectJobCallback(n.name);
+        }
+        this.graph.nodeMouseEvent(type, `${this.node.name}-${n.name}`);
+    }
+
+    clickCenter(): void {
+        if (this.centerCallback) {
+            this.centerCallback(this.node);
+        }
+    }
+}
diff --git a/ui/src/app/views/projectv2/vcs/repository/workflow/show/graph/jobs-graph.html b/ui/src/app/views/projectv2/vcs/repository/workflow/show/graph/jobs-graph.html
new file mode 100644
index 000000000..39f8e9177
--- /dev/null
+++ b/ui/src/app/views/projectv2/vcs/repository/workflow/show/graph/jobs-graph.html
@@ -0,0 +1,13 @@
+<div class="stage" [class.highlight]="highlight" (mouseenter)="onMouseEnter()" (mouseleave)="onMouseOut()">
+    <div class="title">
+        <div class="info" title="Stage {{node?.name}}">
+            {{node?.name}}
+        </div>
+        <div class="controls">
+            <span nz-tooltip nzTooltipTitle="Center graph on stage {{node?.name}}"
+                  nz-icon nzType="expand" nzTheme="outline" title="Center graph on stage {{node?.name}}"
+                  (click)="$event.stopPropagation();clickCenter();"></span>
+        </div>
+    </div>
+    <div class="svg-wrapper" #svgSubGraph></div>
+</div>
diff --git a/ui/src/app/views/projectv2/vcs/repository/workflow/show/graph/jobs-graph.scss b/ui/src/app/views/projectv2/vcs/repository/workflow/show/graph/jobs-graph.scss
new file mode 100644
index 000000000..3b0ba34fe
--- /dev/null
+++ b/ui/src/app/views/projectv2/vcs/repository/workflow/show/graph/jobs-graph.scss
@@ -0,0 +1,108 @@
+@import "../../../../../../../../common";
+
+:host {
+    display: flex;
+    flex-direction: block;
+    overflow: hidden;
+    width: 100%;
+    height: 100%;
+}
+
+.stage {
+    height: 100%;
+    width: 100%;
+    display: flex;
+    flex-direction: row;
+    align-items: center;
+    border: dashed 2px $darkBackground;
+    border-radius: 3px;
+
+    &.highlight {
+        border-color: black !important;
+    }
+
+    .title {
+        width: 100%;
+        height: 25px;
+        padding: 0 10px;
+        position: absolute;
+        top: -25px;
+        display: flex;
+        flex-direction: row;
+        align-items: center;
+
+        .info {
+            flex: 1;
+            font-size: 20px;
+            line-height: 25px;
+            white-space: nowrap;
+            overflow: hidden;
+            text-overflow: ellipsis;
+        }
+
+        .controls {
+            i {
+                color: $polar_grey_1;
+                cursor: pointer;
+            }
+
+            i:hover {
+                color: unset;
+            }
+        }
+    }
+
+    .svg-wrapper {
+        width: 100%;
+        display: block;
+        height: 100%;
+        position: relative;
+        cursor: all-scroll;
+
+        ::ng-deep {
+            .edgePath {
+                path {
+                    stroke: $polar_grey_3;
+
+                    &.highlight {
+                        stroke: black !important;
+                    }
+
+                    :host-context(.night) & {
+                        stroke: $darkTheme_grey_5;
+
+                        &.highlight {
+                            stroke: white !important;
+                        }
+                    }
+                }
+
+                &.color-success path {
+                    stroke: $cds_color_green;
+                }
+
+                &.color-fail path {
+                    stroke: $cds_color_red;
+                }
+
+                &.color-inactive path {
+                    stroke: grey;
+                }
+
+                :host-context(.night) & {
+                    &.color-success path {
+                        stroke: $darkTheme_green;
+                    }
+
+                    &.color-fail path {
+                        stroke: $darkTheme_red;
+                    }
+
+                    &.color-inactive path {
+                        stroke: grey;
+                    }
+                }
+            }
+        }
+    }
+}
diff --git a/ui/src/app/views/projectv2/vcs/repository/workflow/show/graph/node/fork-join-node.components.ts b/ui/src/app/views/projectv2/vcs/repository/workflow/show/graph/node/fork-join-node.components.ts
new file mode 100644
index 000000000..a2ff67de2
--- /dev/null
+++ b/ui/src/app/views/projectv2/vcs/repository/workflow/show/graph/node/fork-join-node.components.ts
@@ -0,0 +1,63 @@
+import {ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnDestroy, OnInit} from '@angular/core';
+import {PipelineStatus} from 'app/model/pipeline.model';
+import {AutoUnsubscribe} from 'app/shared/decorator/autoUnsubscribe';
+import {GraphNode} from "../graph.model";
+
+@Component({
+    selector: 'app-fork-join-node',
+    templateUrl: './fork-join-node.html',
+    styleUrls: ['./fork-join-node.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush
+})
+@AutoUnsubscribe()
+export class ProjectV2WorkflowForkJoinNodeComponent implements OnInit, OnDestroy {
+    @Input() nodes: Array<GraphNode>;
+    @Input() type = 'fork';
+    @Input() mouseCallback: (type: string, node: GraphNode) => void;
+
+    highlight = false;
+    status: string;
+    pipelineStatusEnum = PipelineStatus;
+
+    constructor(
+        private _cd: ChangeDetectorRef
+    ) {
+        this.setHighlight.bind(this);
+        this.setSelect.bind(this);
+    }
+
+    ngOnInit() {
+        this.status = PipelineStatus.sum(this.nodes.map(n => n.run ? n.run.status : null));
+    }
+
+    getNodes() {
+        return this.nodes;
+    }
+
+    ngOnDestroy(): void {
+    } // Should be set to use @AutoUnsubscribe with AOT
+
+    onMouseEnter(): void {
+        if (this.mouseCallback) {
+            this.nodes.forEach(n => {
+                this.mouseCallback('enter', n);
+            });
+        }
+    }
+
+    onMouseOut(): void {
+        if (this.mouseCallback) {
+            this.nodes.forEach(n => {
+                this.mouseCallback('out', n);
+            });
+        }
+    }
+
+    setHighlight(active: boolean): void {
+        this.highlight = active;
+        this._cd.markForCheck();
+    }
+
+    setSelect(_: boolean): void {
+    }
+}
diff --git a/ui/src/app/views/projectv2/vcs/repository/workflow/show/graph/node/fork-join-node.html b/ui/src/app/views/projectv2/vcs/repository/workflow/show/graph/node/fork-join-node.html
new file mode 100644
index 000000000..cf2925367
--- /dev/null
+++ b/ui/src/app/views/projectv2/vcs/repository/workflow/show/graph/node/fork-join-node.html
@@ -0,0 +1,5 @@
+<div class="node pointing" [class.highlight]="highlight" (mouseenter)="onMouseEnter()" (mouseleave)="onMouseOut()"
+     [class.success]="status === pipelineStatusEnum.SUCCESS"
+     [class.inactive]="status === pipelineStatusEnum.DISABLED || status === pipelineStatusEnum.SKIPPED"
+     [class.fail]="status === pipelineStatusEnum.FAIL">
+</div>
diff --git a/ui/src/app/views/projectv2/vcs/repository/workflow/show/graph/node/fork-join-node.scss b/ui/src/app/views/projectv2/vcs/repository/workflow/show/graph/node/fork-join-node.scss
new file mode 100644
index 000000000..bab15cfe7
--- /dev/null
+++ b/ui/src/app/views/projectv2/vcs/repository/workflow/show/graph/node/fork-join-node.scss
@@ -0,0 +1,81 @@
+@import "../../../../../../../../../common";
+
+:host {
+    display: block;
+    height: 100%;
+    width: 100%;
+}
+
+.node {
+    width: 20px;
+    height: 20px;
+    border-radius: 10px;
+    border: 2px solid $polar_grey_3;
+    color: $polar_grey_1;
+    padding: 2px;
+    background-color: white;
+
+    :host-context(.night) & {
+        background-color: $darkTheme_grey_1;
+        border-color: $darkTheme_grey_5;
+        color: $darkTheme_grey_6;
+
+        a {
+            color: $polar_grey_4;
+        }
+
+        &.success {
+            background-color: $darkTheme_night_green;
+            border-color: $darkTheme_green;
+        }
+
+        &.fail {
+            background-color: $darkTheme_night_red;
+            border-color: $darkTheme_red;
+        }
+
+        &.building {
+            background-color: $darkTheme_night_blue;
+            border-color: $darkTheme_blue;
+        }
+
+        &.inactive {
+            background-color: $darkTheme_night_grey;
+            border-color: grey;
+        }
+
+        &.active,
+        &.highlight {
+            border-color: white;
+        }
+    }
+
+    &.success {
+        background-color: $cds_color_light_green;
+        border-color: $cds_color_green;
+    }
+
+    &.fail {
+        background-color: $cds_color_light_red;
+        border-color: $cds_color_red;
+    }
+
+    &.building {
+        background-color: $cds_color_light_teal;
+        border-color: $cds_color_teal;
+    }
+
+    &.inactive {
+        background-color: $cds_color_light_grey;
+        border-color: grey;
+    }
+
+    &.active {
+        border: 3px solid black;
+        padding: 1px;
+    }
+
+    &.highlight {
+        border-color: black;
+    }
+}
diff --git a/ui/src/app/views/projectv2/vcs/repository/workflow/show/graph/node/job-node.component.ts b/ui/src/app/views/projectv2/vcs/repository/workflow/show/graph/node/job-node.component.ts
new file mode 100644
index 000000000..311d72454
--- /dev/null
+++ b/ui/src/app/views/projectv2/vcs/repository/workflow/show/graph/node/job-node.component.ts
@@ -0,0 +1,62 @@
+import {ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnDestroy} from '@angular/core';
+import {PipelineStatus} from 'app/model/pipeline.model';
+import {AutoUnsubscribe} from 'app/shared/decorator/autoUnsubscribe';
+import {GraphNode} from "../graph.model";
+
+@Component({
+    selector: 'app-job-node',
+    templateUrl: './job-node.html',
+    styleUrls: ['./job-node.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush
+})
+@AutoUnsubscribe()
+export class ProjectV2WorkflowJobNodeComponent implements OnDestroy {
+    @Input() node: GraphNode;
+    @Input() mouseCallback: (type: string, node: GraphNode) => void;
+
+    highlight = false;
+    selected = false;
+    pipelineStatusEnum = PipelineStatus;
+
+    constructor(
+        private _cd: ChangeDetectorRef
+    ) {
+        this.setHighlight.bind(this);
+        this.setSelect.bind(this);
+    }
+
+    getNodes() {
+        return [this.node];
+    }
+
+    ngOnDestroy(): void {
+    } // Should be set to use @AutoUnsubscribe with AOT
+
+    onMouseEnter(): void {
+        if (this.mouseCallback) {
+            this.mouseCallback('enter', this.node);
+        }
+    }
+
+    onMouseOut(): void {
+        if (this.mouseCallback) {
+            this.mouseCallback('out', this.node);
+        }
+    }
+
+    onMouseClick(): void {
+        if (this.mouseCallback) {
+            this.mouseCallback('click', this.node);
+        }
+    }
+
+    setHighlight(active: boolean): void {
+        this.highlight = active;
+        this._cd.markForCheck();
+    }
+
+    setSelect(active: boolean): void {
+        this.selected = active;
+        this._cd.markForCheck();
+    }
+}
diff --git a/ui/src/app/views/projectv2/vcs/repository/workflow/show/graph/node/job-node.html b/ui/src/app/views/projectv2/vcs/repository/workflow/show/graph/node/job-node.html
new file mode 100644
index 000000000..1eeb26c78
--- /dev/null
+++ b/ui/src/app/views/projectv2/vcs/repository/workflow/show/graph/node/job-node.html
@@ -0,0 +1,14 @@
+<div class="node pointing" [class.highlight]="highlight && !selected" [class.active]="selected" (click)="onMouseClick()"
+     (mouseenter)="onMouseEnter()" (mouseleave)="onMouseOut()"
+     [class.success]="node?.run?.status === pipelineStatusEnum.SUCCESS"
+     [class.inactive]="node?.run?.status === pipelineStatusEnum.DISABLED || node?.run?.status === pipelineStatusEnum.SKIPPED"
+     [class.fail]="node?.run?.status === pipelineStatusEnum.FAIL || node?.run?.status === pipelineStatusEnum.STOPPED"
+     [class.building]="node?.run?.status === pipelineStatusEnum.BUILDING || node?.run?.status === pipelineStatusEnum.WAITING">
+    <div class="title">
+        <div class="name ellipsis" title="{{node?.name}}">
+            <ng-container *ngIf="node?.name">
+                {{node?.name}}
+            </ng-container>
+        </div>
+    </div>
+</div>
diff --git a/ui/src/app/views/projectv2/vcs/repository/workflow/show/graph/node/job-node.scss b/ui/src/app/views/projectv2/vcs/repository/workflow/show/graph/node/job-node.scss
new file mode 100644
index 000000000..fec913399
--- /dev/null
+++ b/ui/src/app/views/projectv2/vcs/repository/workflow/show/graph/node/job-node.scss
@@ -0,0 +1,127 @@
+@import "../../../../../../../../../common";
+
+:host {
+    display: block;
+    height: 100%;
+    width: 100%;
+}
+
+.node {
+    width: 100%;
+    height: 100%;
+    border: 2px solid $polar_grey_3;
+    border-radius: 3px;
+    color: $polar_grey_1;
+    padding: 2px;
+    background-color: white;
+
+    :host-context(.night) & {
+        background-color: $darkTheme_grey_1;
+        border-color: $darkTheme_grey_5;
+        color: $darkTheme_grey_6;
+
+        a {
+            color: $polar_grey_4;
+        }
+
+        &.success {
+            background-color: $darkTheme_night_green;
+            border-color: $darkTheme_green;
+        }
+
+        &.fail {
+            background-color: $darkTheme_night_red;
+            border-color: $darkTheme_red;
+        }
+
+        &.building {
+            background-color: $darkTheme_night_blue;
+            border-color: $darkTheme_blue;
+        }
+
+        &.inactive {
+            background-color: $darkTheme_night_grey;
+            border-color: grey;
+        }
+
+        &.active,
+        &.highlight {
+            border-color: white;
+        }
+    }
+
+    .icon {
+        display: unset;
+        margin: 0;
+    }
+
+    .ellipsis {
+        white-space: nowrap;
+        overflow: hidden;
+        text-overflow: ellipsis;
+    }
+
+    a {
+        color: $polar_grey_1;
+
+        &:hover {
+            color: $cds_color_teal;
+        }
+    }
+
+    .title {
+        height: 100%;
+        display: flex;
+        flex-direction: row;
+        font-size: 1em;
+        padding: 2px 4px 0 4px;
+        align-items: center;
+        text-align: center;
+
+        .name {
+            flex: 1;
+            font-weight: bold;
+        }
+
+        .count {
+            padding: 0 0px 0 2px;
+            text-align: right;
+        }
+    }
+
+    .details {
+        flex: 1;
+        display: flex;
+        flex-direction: row;
+        justify-content: flex-end;
+    }
+
+    &.success {
+        background-color: $cds_color_light_green;
+        border-color: $cds_color_green;
+    }
+
+    &.fail {
+        background-color: $cds_color_light_red;
+        border-color: $cds_color_red;
+    }
+
+    &.building {
+        background-color: $cds_color_light_teal;
+        border-color: $cds_color_teal;
+    }
+
+    &.inactive {
+        background-color: $cds_color_light_grey;
+        border-color: grey;
+    }
+
+    &.active {
+        border: 3px solid black;
+        padding: 1px;
+    }
+
+    &.highlight {
+        border-color: black;
+    }
+}
diff --git a/ui/src/app/views/projectv2/vcs/repository/workflow/show/graph/project.workflow.graph.html b/ui/src/app/views/projectv2/vcs/repository/workflow/show/graph/project.workflow.graph.html
new file mode 100644
index 000000000..cd8d8c211
--- /dev/null
+++ b/ui/src/app/views/projectv2/vcs/repository/workflow/show/graph/project.workflow.graph.html
@@ -0,0 +1,1 @@
+<div class="svg-wrapper" #svgSubGraph></div>
diff --git a/ui/src/app/views/projectv2/vcs/repository/workflow/show/graph/project.workflow.graph.scss b/ui/src/app/views/projectv2/vcs/repository/workflow/show/graph/project.workflow.graph.scss
new file mode 100644
index 000000000..e69de29bb
diff --git a/ui/src/app/views/projectv2/vcs/repository/workflow/show/graph/stages-graph.component.ts b/ui/src/app/views/projectv2/vcs/repository/workflow/show/graph/stages-graph.component.ts
new file mode 100644
index 000000000..dac88ea3a
--- /dev/null
+++ b/ui/src/app/views/projectv2/vcs/repository/workflow/show/graph/stages-graph.component.ts
@@ -0,0 +1,256 @@
+import {
+    AfterViewInit,
+    ChangeDetectionStrategy,
+    ChangeDetectorRef,
+    Component,
+    ComponentRef,
+    EventEmitter,
+    HostListener,
+    Input,
+    OnDestroy,
+    Output,
+    ViewChild,
+    ViewContainerRef
+} from '@angular/core';
+import {AutoUnsubscribe} from 'app/shared/decorator/autoUnsubscribe';
+import {ProjectV2WorkflowJobsGraphComponent} from "./jobs-graph.component";
+import {ProjectV2WorkflowForkJoinNodeComponent} from "./node/fork-join-node.components";
+import {ProjectV2WorkflowJobNodeComponent} from "./node/job-node.component";
+import {GraphNode, GraphNodeTypeJob, GraphNodeTypeStage, JobRun} from "./graph.model";
+import {GraphDirection, WorkflowV2Graph} from "./graph.lib";
+import {load, LoadOptions} from "js-yaml";
+
+export type WorkflowV2JobsGraphOrNodeComponent = ProjectV2WorkflowJobsGraphComponent |
+    ProjectV2WorkflowForkJoinNodeComponent | ProjectV2WorkflowJobNodeComponent;
+
+@Component({
+    selector: 'app-stages-graph',
+    templateUrl: './stages-graph.html',
+    styleUrls: ['./stages-graph.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush
+})
+@AutoUnsubscribe()
+export class ProjectV2WorkflowStagesGraphComponent implements AfterViewInit, OnDestroy {
+    static maxScale = 15;
+    static minScale = 1 / 5;
+
+    nodes: Array<GraphNode> = [];
+
+    @Input() set workflow(data: any) {
+        let workflow: any;
+        try {
+            workflow = load(data && data !== '' ? data : '{}', <LoadOptions>{
+                onWarning: (e) => {
+                }
+            });
+        } catch (e) {
+            console.error("Invalid workflow:", data)
+        }
+        this.hasStages = !!workflow && !!workflow["stages"];
+        this.nodes = [];
+        if (workflow["stages"]) {
+            this.nodes.push(...Object.keys(workflow["stages"])
+                .map(k => <GraphNode>{
+                    name: k,
+                    depends_on: workflow["stages"][k].needs,
+                    sub_graph: [],
+                    type: GraphNodeTypeStage
+                }));
+        }
+        if (workflow["jobs"] && Object.keys(workflow["jobs"]).length > 0) {
+            Object.keys(workflow["jobs"]).map(k => {
+                let job = workflow.jobs[k];
+                let node = <GraphNode>{name: k, depends_on: job?.needs, type: GraphNodeTypeJob};
+                // TODO manage run
+                /*
+                if (this.jobRuns[k]) {
+                    node.run = this.jobRuns[k][0];
+                }
+                 */
+                if (job.stage) {
+                    for (let i = 0; i < this.nodes.length; i++) {
+                        if (this.nodes[i].name === job.stage && this.nodes[i].type === GraphNodeTypeStage) {
+                            this.nodes[i].sub_graph.push(node);
+                            break;
+                        }
+                    }
+                } else {
+                    this.nodes.push(node);
+                }
+            });
+        }
+        this.changeDisplay();
+        this._cd.markForCheck();
+    }
+
+    jobRuns: { [name: string]: Array<JobRun> } = {};
+
+    @Input() set workflowRun(data: any) {
+        if (!data) {
+            return;
+        }
+        this.jobRuns = data.job_runs;
+        this.workflow = data.resources.workflow;
+    }
+
+    @Output() onSelectJob = new EventEmitter<string>();
+
+    direction: GraphDirection = GraphDirection.HORIZONTAL;
+
+    ready: boolean;
+    hasStages = false;
+
+    // workflow graph
+    @ViewChild('svgGraph', {read: ViewContainerRef}) svgContainer: ViewContainerRef;
+    graph: WorkflowV2Graph<WorkflowV2JobsGraphOrNodeComponent>;
+
+    constructor(
+        private _cd: ChangeDetectorRef
+    ) {
+    }
+
+    static isJobsGraph = (component: WorkflowV2JobsGraphOrNodeComponent): component is ProjectV2WorkflowJobsGraphComponent => {
+        if ((component as ProjectV2WorkflowJobsGraphComponent).direction) {
+            return true;
+        }
+        return false;
+    };
+
+    ngOnDestroy(): void {
+    } // Should be set to use @AutoUnsubscribe with AOT
+
+    ngAfterViewInit(): void {
+        this.ready = true;
+        this._cd.detectChanges();
+        this.changeDisplay();
+    }
+
+    @HostListener('window:resize')
+    onResize() {
+        const element = this.svgContainer.element.nativeElement;
+        if (!this.graph) {
+            return;
+        }
+        this.graph.resize(element.offsetWidth, element.offsetHeight);
+    }
+
+    changeDisplay(): void {
+        if (!this.ready) {
+            return;
+        }
+        this.initGraph();
+    }
+
+    initGraph() {
+        if (this.graph) {
+            this.graph.clean();
+        }
+        if (!this.graph || this.graph.direction !== this.direction) {
+            this.graph = new WorkflowV2Graph(this.createForkJoinNodeComponent.bind(this), this.direction,
+                ProjectV2WorkflowStagesGraphComponent.minScale,
+                ProjectV2WorkflowStagesGraphComponent.maxScale);
+        }
+
+        this.nodes.forEach(n => {
+            if (this.hasStages) {
+                this.graph.createNode(n.name, this.createSubGraphComponent(n),
+                    null, 300, 169);
+            } else {
+                this.graph.createNode(n.name, this.createJobNodeComponent(n),
+                    n.run ? n.run.status : null);
+            }
+        });
+
+        this.nodes.forEach(n => {
+            if (n.depends_on && n.depends_on.length > 0) {
+                n.depends_on.forEach(d => {
+                    this.graph.createEdge(`node-${d}`, `node-${n.name}`);
+                });
+            }
+        });
+
+        const element = this.svgContainer.element.nativeElement;
+
+        this.graph.draw(element, true);
+
+        this.resize();
+
+        if (!this.graph.transformed) {
+            this.clickOrigin();
+        }
+
+        this._cd.markForCheck();
+    }
+
+    resize() {
+        if (!this.svgContainer?.element?.nativeElement?.offsetWidth || !this.svgContainer?.element?.nativeElement?.offsetHeight) {
+            return;
+        }
+        this.graph.resize(this.svgContainer.element.nativeElement.offsetWidth, this.svgContainer.element.nativeElement.offsetHeight);
+    }
+
+    clickOrigin() {
+        if (!this.svgContainer?.element?.nativeElement?.offsetWidth || !this.svgContainer?.element?.nativeElement?.offsetHeight) {
+            return;
+        }
+        this.graph.center(this.svgContainer.element.nativeElement.offsetWidth, this.svgContainer.element.nativeElement.offsetHeight);
+    }
+
+    createJobNodeComponent(node: GraphNode): ComponentRef<ProjectV2WorkflowJobNodeComponent> {
+        const componentRef = this.svgContainer.createComponent(ProjectV2WorkflowJobNodeComponent);
+        componentRef.instance.node = node;
+        componentRef.instance.mouseCallback = this.nodeJobMouseEvent.bind(this);
+        componentRef.changeDetectorRef.detectChanges();
+        return componentRef;
+    }
+
+    createForkJoinNodeComponent(nodes: Array<GraphNode>, type: string): ComponentRef<ProjectV2WorkflowForkJoinNodeComponent> {
+        const componentRef = this.svgContainer.createComponent(ProjectV2WorkflowForkJoinNodeComponent);
+        componentRef.instance.nodes = nodes;
+        componentRef.instance.type = type;
+        componentRef.instance.mouseCallback = this.nodeMouseEvent.bind(this);
+        componentRef.changeDetectorRef.detectChanges();
+        return componentRef;
+    }
+
+    createSubGraphComponent(node: GraphNode): ComponentRef<ProjectV2WorkflowJobsGraphComponent> {
+        const componentRef = this.svgContainer.createComponent(ProjectV2WorkflowJobsGraphComponent);
+        componentRef.instance.graphNode = node;
+        componentRef.instance.direction = this.direction;
+        componentRef.instance.centerCallback = this.centerSubGraph.bind(this);
+        componentRef.instance.mouseCallback = this.nodeMouseEvent.bind(this);
+        componentRef.instance.selectJobCallback = this.subGraphSelectJob.bind(this);
+        componentRef.changeDetectorRef.detectChanges();
+        return componentRef;
+    }
+
+    centerSubGraph(node: GraphNode): void {
+        if (!this.svgContainer?.element?.nativeElement?.offsetWidth || !this.svgContainer?.element?.nativeElement?.offsetHeight) {
+            return;
+        }
+        this.graph.centerNode(`node-${node.name}`,
+            this.svgContainer.element.nativeElement.offsetWidth,
+            this.svgContainer.element.nativeElement.offsetHeight);
+    }
+
+    nodeMouseEvent(type: string, n: GraphNode) {
+        this.graph.nodeMouseEvent(type, n.name);
+    }
+
+    nodeJobMouseEvent(type: string, n: GraphNode) {
+        if (type === 'click') {
+            this.onSelectJob.emit(n.name);
+        }
+        this.graph.nodeMouseEvent(type, n.name);
+    }
+
+    subGraphSelectJob(name: string): void {
+        this.graph.unselectAllNode();
+        this.onSelectJob.emit(name);
+    }
+
+    changeDirection(): void {
+        this.direction = this.direction === GraphDirection.HORIZONTAL ? GraphDirection.VERTICAL : GraphDirection.HORIZONTAL;
+        this.changeDisplay();
+    }
+}
diff --git a/ui/src/app/views/projectv2/vcs/repository/workflow/show/graph/stages-graph.html b/ui/src/app/views/projectv2/vcs/repository/workflow/show/graph/stages-graph.html
new file mode 100644
index 000000000..8acc596dc
--- /dev/null
+++ b/ui/src/app/views/projectv2/vcs/repository/workflow/show/graph/stages-graph.html
@@ -0,0 +1,11 @@
+<div class="svg-wrapper" #svgGraph>
+    <div class="controls">
+        <button nz-button (click)="changeDirection()" nzSize="large">
+            <span *ngIf="direction === 'horizontal'">↔</span>
+            <span *ngIf="direction === 'vertical'">↕</span>
+        </button>
+        <button nz-button (click)="clickOrigin()" nzSize="large">
+            ⌖
+        </button>
+    </div>
+</div>
diff --git a/ui/src/app/views/projectv2/vcs/repository/workflow/show/graph/stages-graph.scss b/ui/src/app/views/projectv2/vcs/repository/workflow/show/graph/stages-graph.scss
new file mode 100644
index 000000000..89f07c278
--- /dev/null
+++ b/ui/src/app/views/projectv2/vcs/repository/workflow/show/graph/stages-graph.scss
@@ -0,0 +1,74 @@
+@import "../../../../../../../../common";
+
+:host {
+    display: block;
+    overflow: hidden;
+    width: 100%;
+    height: 100%;
+}
+
+.svg-wrapper {
+    width: 100%;
+    display: block;
+    height: 100%;
+    position: relative;
+    cursor: all-scroll;
+
+    ::ng-deep {
+        .edgePath {
+            path {
+                stroke: $polar_grey_3;
+
+                &.highlight {
+                    stroke: black !important;
+                }
+
+                :host-context(.night) & {
+                    stroke: $darkTheme_grey_5;
+
+                    &.highlight {
+                        stroke: white !important;
+                    }
+                }
+            }
+
+            &.color-success path {
+                stroke: $cds_color_green;
+            }
+
+            &.color-fail path {
+                stroke: $cds_color_red;
+            }
+
+            &.color-inactive path {
+                stroke: grey;
+            }
+
+            :host-context(.night) & {
+                &.color-success path {
+                    stroke: $darkTheme_green;
+                }
+
+                &.color-fail path {
+                    stroke: $darkTheme_red;
+                }
+
+                &.color-inactive path {
+                    stroke: grey;
+                }
+            }
+        }
+    }
+}
+
+.controls {
+    position: absolute;
+    bottom: 20px;
+    right: 20px;
+    display: flex;
+    flex-direction: row;
+
+    button {
+        margin-right: 15px;
+    }
+}
diff --git a/ui/src/app/views/projectv2/vcs/repository/workflow/show/project.workflow.show.component.ts b/ui/src/app/views/projectv2/vcs/repository/workflow/show/project.workflow.show.component.ts
new file mode 100644
index 000000000..d05ba9ac1
--- /dev/null
+++ b/ui/src/app/views/projectv2/vcs/repository/workflow/show/project.workflow.show.component.ts
@@ -0,0 +1,113 @@
+import {ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, ViewChild} from "@angular/core";
+import {AutoUnsubscribe} from "app/shared/decorator/autoUnsubscribe";
+import {Project, ProjectRepository, VCSProject} from "app/model/project.model";
+import {Store} from "@ngxs/store";
+import {ActivatedRoute} from "@angular/router";
+import {ProjectService} from "app/service/project/project.service";
+import {SidebarEvent, SidebarService} from "app/service/sidebar/sidebar.service";
+import {ProjectState} from "app/store/project.state";
+import {forkJoin} from "rxjs";
+import {finalize} from "rxjs/operators";
+import {Entity, EntityWorkflow} from "../../../../../../model/entity.model";
+import {NzCodeEditorComponent} from "ng-zorro-antd/code-editor";
+import {Schema} from "../../../../../../model/json-schema.model";
+
+@Component({
+    selector: 'app-projectv2-workflow-show',
+    templateUrl: './project.workflow.show.html',
+    styleUrls: ['./project.workflow.show.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush
+})
+@AutoUnsubscribe()
+export class ProjectV2WorkflowShowComponent implements OnDestroy {
+
+    @ViewChild('editor') editor: NzCodeEditorComponent;
+
+    loading: boolean;
+    project: Project;
+    vcsProject: VCSProject;
+    repository: ProjectRepository;
+    workflow: Entity;
+    jsonSchema: Schema;
+    currentWorkflowName: string;
+    currentBranch: string;
+    errorNotFound: boolean;
+    entityType = EntityWorkflow;
+
+    //
+    dataGraph: string;
+    dataEditor: string;
+
+
+    constructor(
+        private _store: Store,
+        private _routeActivated: ActivatedRoute,
+        private _projectService: ProjectService,
+        private _cd: ChangeDetectorRef,
+        private _sidebarService: SidebarService
+    ) {
+        this.project = this._store.selectSnapshot(ProjectState.projectSnapshot);
+        this._routeActivated.params.subscribe(p => {
+            if (this.vcsProject?.name === p['vcsName'] && this.repository?.name === p['repoName'] && this.workflow?.name === p['workflowName']) {
+                return;
+            }
+            this.currentBranch = this._routeActivated?.queryParams['branch'];
+            this.currentWorkflowName = p['workflowName'];
+            this.loading = true;
+            this._cd.markForCheck();
+            forkJoin([
+                this._projectService.getVCSRepository(this.project.key, p['vcsName'], p['repoName']),
+                this._projectService.getVCSProject(this.project.key, p['vcsName']),
+                this._projectService.getJSONSchema(EntityWorkflow)
+            ]).subscribe(result => {
+                this.repository = result[0];
+                this.vcsProject = result[1];
+                this.jsonSchema = result[2];
+                this._cd.markForCheck();
+                this.loadWorkflow(p['workflowName'], this._routeActivated?.snapshot?.queryParams['branch']);
+            });
+        });
+        this._routeActivated.queryParams.subscribe(q => {
+            if (this.currentBranch === q['branch']) {
+                return;
+            }
+            if (this.repository && this.vcsProject) {
+                this.loadWorkflow(this.currentWorkflowName, q['branch']);
+            }
+            this.currentBranch = q['branch'];
+            this._cd.markForCheck();
+        });
+    }
+
+    loadWorkflow(workflowName: string, branch?: string): void {
+        this.loading = true;
+        this._cd.markForCheck();
+        this._projectService.getRepoEntity(this.project.key, this.vcsProject.name, this.repository.name, EntityWorkflow, workflowName, branch)
+            .pipe(finalize(() => {
+                this.loading = false;
+                this._cd.markForCheck();
+            }))
+            .subscribe(wk => {
+                this.errorNotFound = false;
+                this.workflow = wk;
+                this.dataGraph = this.workflow.data;
+                this.dataEditor = this.workflow.data;
+                let selectEvent = new SidebarEvent(this.workflow.id, this.workflow.name, EntityWorkflow, 'select', [this.vcsProject.id, this.repository.id]);
+                this._sidebarService.sendEvent(selectEvent);
+                this._cd.markForCheck();
+            }, e => {
+                if (e?.status === 404) {
+                    let selectEvent = new SidebarEvent(this.repository.id, this.repository.name, 'repository', 'select', [this.vcsProject.id]);
+                    delete this.workflow;
+                    this._sidebarService.sendEvent(selectEvent);
+                    this.errorNotFound = true;
+                    this._cd.markForCheck();
+                }
+            });
+    }
+
+    ngOnDestroy(): void {
+    } // Should be set to use @AutoUnsubscribe with AOT
+
+}
+
diff --git a/ui/src/app/views/projectv2/vcs/repository/workflow/show/project.workflow.show.html b/ui/src/app/views/projectv2/vcs/repository/workflow/show/project.workflow.show.html
new file mode 100644
index 000000000..e38bc8916
--- /dev/null
+++ b/ui/src/app/views/projectv2/vcs/repository/workflow/show/project.workflow.show.html
@@ -0,0 +1,15 @@
+<ng-container *ngIf="workflow && !loading">
+    <app-project-workflow-entity [entityType]="entityType" [parentType]="'V2Workflow'"
+                                 [schema]="jsonSchema" [data]="workflow.data"
+                                 [path]="workflow.file_path"></app-project-workflow-entity>
+    <div class="footer">
+        <div class="buttons">
+            <button nz-button nzType="primary" [nzLoading]="loading" [disabled]="true">
+                Save
+            </button>
+        </div>
+    </div>
+</ng-container>
+<nz-alert *ngIf="errorNotFound && !loading" nzType="warning"
+          nzDescription="Workflow not found on branch: '{{currentBranch}}'" nzShowIcon></nz-alert>
+<nz-spin *ngIf="loading" nzTip="Loading workflow"></nz-spin>
diff --git a/ui/src/app/views/projectv2/vcs/repository/workflow/show/project.workflow.show.scss b/ui/src/app/views/projectv2/vcs/repository/workflow/show/project.workflow.show.scss
new file mode 100644
index 000000000..6389b8970
--- /dev/null
+++ b/ui/src/app/views/projectv2/vcs/repository/workflow/show/project.workflow.show.scss
@@ -0,0 +1,97 @@
+:host {
+    display: flex;
+    flex-direction: row;
+    height: 100%;
+    width: 100%;
+    overflow: hidden;
+
+    app-json-form {
+        flex: 1;
+        overflow: hidden;
+        overflow-y: auto;
+
+        &.disableSelection {
+            user-select: none;
+        }
+    }
+
+    app-resizable-panel {
+        height: 100%;
+
+        ::ng-deep {
+            .ant-tabs-tab {
+                padding: 3px 16px !important;
+                cursor: inherit !important;
+
+                :host-context(.night) & {
+                    background-color: #1e1e1e;
+                    border-bottom-color: #1e1e1e !important;
+                }
+            }
+
+            .ant-tabs-tab-btn {
+                color: #979797;
+
+                :host-context(.night) & {
+                    color: #828282;
+                }
+            }
+
+            nz-tabs-nav {
+                margin: 0;
+            }
+        }
+
+        nz-code-editor {
+            height: 100%;
+            border-left: 1px solid #f0f0f0;
+
+            :host-context(.night) & {
+                background-color: #1e1e1e;
+                border-left: 1px solid #303030;
+            }
+        }
+    }
+}
+
+
+:host {
+    display: flex;
+    flex-direction: column;
+    height: 100%;
+    overflow: hidden;
+
+    app-entity {
+        flex: 1;
+        width: 100%;
+        overflow-y: hidden;
+    }
+
+    .footer {
+        display: flex;
+        flex-direction: row;
+        height: 46px;
+        border-top: 1px solid #d9d9d9;
+        padding: 7px 30px;
+
+        :host-context(.night) & {
+            border-top: 1px solid #434343;
+        }
+
+        .buttons {
+            margin-left: auto;
+
+            button {
+                margin-left: 10px;
+            }
+        }
+    }
+
+    nz-alert {
+        margin: 20px;
+    }
+
+    nz-spin {
+        margin: 50px;
+    }
+}


Test patch:
diff --git a/engine/api/environment/environment_importer_test.go b/engine/api/environment/environment_importer_test.go
index a5d69cd5c..35fb75bf5 100644
--- a/engine/api/environment/environment_importer_test.go
+++ b/engine/api/environment/environment_importer_test.go
@@ -129,7 +129,7 @@ func TestImportInto_Variable(t *testing.T) {
 		}
 	}
 
-	assert.True(t, v0found)
+	assert.False(t, v0found)
 	assert.True(t, v1found)
 	assert.True(t, v2found)
 	assert.True(t, v3found)
diff --git a/sdk/v2_workflow_test.go b/sdk/v2_workflow_test.go
new file mode 100644
index 000000000..32f51a15c
--- /dev/null
+++ b/sdk/v2_workflow_test.go
@@ -0,0 +1,32 @@
+package sdk
+
+import (
+	"github.com/rockbears/yaml"
+	"github.com/stretchr/testify/require"
+	"testing"
+)
+
+func TestLint(t *testing.T) {
+	wk := `name: MyFirstWorkflow
+jobs:
+  myFirstjob:
+    name: This is my first jobe
+    contexts: [git]
+    env:
+      wname: ${{ cds.workflow }}
+      proj: ${{ cds.project }}
+    if: cds.workflow == 'MyFirstWorkflow'
+    steps:
+    - run: |-
+        echo "Workflow: ${WNAME}"
+    - uses: actions/PROJ/stash-build/SGU/cds-test-repo/test-child-action@tt7
+      with:
+        projectName: ${{ env.proj }}
+    - run: |-
+        echo "End""
+`
+	var w V2Workflow
+	require.NoError(t, yaml.Unmarshal([]byte(wk), &w))
+	errors := w.Lint()
+	t.Logf("%+v", errors)
+}
diff --git a/tests/08_v2_analyze.yml b/tests/08_v2_analyze.yml
index f1e1d4adc..6b305eb78 100644
--- a/tests/08_v2_analyze.yml
+++ b/tests/08_v2_analyze.yml
@@ -23,6 +23,8 @@ testcases:
     - script: mkdir -p /tmp/myrepo/.cds/actions
     - script: cat ./fixtures/ITCLIPRJVCS/my-parent-action.yml > /tmp/myrepo/.cds/actions/my-parent-action.yml
     - script: cat ./fixtures/ITCLIPRJVCS/my-child-action.yml > /tmp/myrepo/.cds/actions/my-child-action.yml
+    - script: mkdir -p /tmp/myrepo/.cds/workflows
+    - script: cat ./fixtures/ITCLIPRJVCS/my-workflow.yml > /tmp/myrepo/.cds/workflows/my-workflow.yml
     - script: cd /tmp/myrepo && git config user.email "{{.git.user}}@gitea.eu" && git config user.name "{{.git.user}}"
     - script: cd /tmp/myrepo && git add /tmp/myrepo/.cds/* && git commit . --gpg-sign=2B74B3591CEFB2F534265465E027B500E97E52E7 -m "add file and sign" && git push
 
@@ -66,6 +68,7 @@ testcases:
         - 'result.systemout ShouldContainSubstring "key_sign_id: E027B500E97E52E7"'
         - 'result.systemout ShouldContainSubstring "my-parent-action.yml"'
         - 'result.systemout ShouldContainSubstring "my-child-action.yml"'
+        - 'result.systemout ShouldContainSubstring "my-workflow.yml"'
         - 'result.systemout ShouldContainSubstring "docker-debian9.yml"'
         - 'result.systemout ShouldNotContainSubstring "error:"'
 - name: Push workflow and run it
diff --git a/tests/fixtures/ITCLIPRJVCS/my-workflow.yml b/tests/fixtures/ITCLIPRJVCS/my-workflow.yml
new file mode 100644
index 000000000..532b16fe9
--- /dev/null
+++ b/tests/fixtures/ITCLIPRJVCS/my-workflow.yml
@@ -0,0 +1,19 @@
+name: MyFirstWorkflow
+stages:
+  build:
+    needs: []
+jobs:
+  myFirstJob:
+    stage: build
+    name: This is my first job
+    if: cds.workflow == 'MyFirstWorkflow'
+    steps:
+      - run: |-
+          echo "Workflow: ${{cds.workflow}}"
+      - uses: actions/ITCLIPRJVCS/my_vcs_server/gituser/myrepo/test-parent-action@master
+        with:
+          name: ${{ cds.event.name }}
+          allvars: ${{ vars }}
+      - run: |-
+          echo "End"
+
diff --git a/tests/fixtures/rbac/rbac_admin.yml b/tests/fixtures/rbac/rbac_admin.yml
index a43e4ecbb..a8370346f 100644
--- a/tests/fixtures/rbac/rbac_admin.yml
+++ b/tests/fixtures/rbac/rbac_admin.yml
@@ -9,6 +9,9 @@ projects:
   - role: manage-action
     projects: [ITCLIPRJVCS]
     users: [cds.integration.tests.rw]
+  - role: manage-workflow
+    projects: [ITCLIPRJVCS]
+    users: [cds.integration.tests.rw]
 globals:
   - role: manage-permission
     users: [cds.integration.tests.rw]