Repository: ovh/cds
Pull request #5203 (2020-05-26 06:56:30)

fix(api): don't  warn and skip if no previous run was found
Signed-off-by: francois  samin <francois.samin@corp.ovh.com>



Patch:
diff --git a/docs/content/development/contribute/development.md b/docs/content/development/contribute/development.md
index 24e5d0d53..0ea5712e9 100644
--- a/docs/content/development/contribute/development.md
+++ b/docs/content/development/contribute/development.md
@@ -82,23 +82,23 @@ $ tar xzf node-v12.4.0-darwin-x64.tar.gz
 
 ## Golang
 
-Download the latest Golang version from https://golang.org/dl/, version >= 1.13
+Download the latest Golang version from https://golang.org/dl/, version >= 1.14
 
-Example with version 1.13 on macOS:
+Example with version 1.14 on macOS:
 
 ```bash
 $ export GOROOT=~/go
 $ export PATH=$PATH:$GOROOT/bin
 $ cd ~
-$ curl -O https://dl.google.com/go/go1.13.darwin-amd64.tar.gz
-$ tar xzf go1.13.darwin-amd64.tar.gz
+$ curl -O https://dl.google.com/go/go1.14.darwin-amd64.tar.gz
+$ tar xzf go1.14.darwin-amd64.tar.gz
 ```
 
 Check if Go installation is ok
 
 ```bash
 $ go version
-go version go1.13 darwin/amd64
+go version go1.14 darwin/amd64
 ```
 
 ## CDS
@@ -131,11 +131,24 @@ $ engine config new > ~/.cds/dev.toml
 # edit ~/.cds/dev.toml file 
 ## in section [api]
 ### --> set variable defaultOS to your OS, darwin if you are on macOS for example
+
 ## in section [hatchery.local.commonConfiguration]
 ### --> set name to "hatchery-local"
+
 ## in section [hatchery.local.commonConfiguration.api.http]
 ### --> uncomment url, should be set to url = "http://localhost:8081" 
 
+## in section [hatchery.local]
+### basedir = "/tmp/cds" 
+# this directory will contains the cds workers workspace
+
+## in section [api.directories]
+### baseDirectory = "/your-gopath/src/github.com/ovh/cds/engine/worker/dist" 
+# this directory should contains the workers binaries
+
+## in section [api.artifact.local]
+# baseDirectory = "/tmp/cds/artifacts"
+
 ```
 
 Prepare database:
@@ -151,10 +164,10 @@ $ engine database upgrade --db-password cds --db-sslmode disable
 If you don't have a local PostgreSQL, you should run `engine database upgrade --help`
 and update `~/.cds/dev.toml` file.
 
-Launch CDS engine API & local hatchery:
+Launch CDS engine API:
 
 ```bash
-$ engine --config ~/.cds/dev.toml start api hatchery:local
+$ engine --config ~/.cds/dev.toml start api
 ```
 
 Launch CDS UI:
@@ -173,6 +186,12 @@ $ cdsctl signup -H http://localhost:8081 --email your-username@localhost.local -
 # Check CDS API logs to get the validation code
 ```
 
+Launch local hatchery:
+
+```bash
+$ engine --config ~/.cds/dev.toml start hatchery:local
+```
+
 Open a browser, go on http://localhost:8080 - Have fun.
 
 ## Notes
diff --git a/engine/api/environment/environment.go b/engine/api/environment/environment.go
index f488b2b47..f2a1b9c04 100644
--- a/engine/api/environment/environment.go
+++ b/engine/api/environment/environment.go
@@ -16,11 +16,14 @@ import (
 func LoadAllByIDs(db gorp.SqlExecutor, ids []int64) ([]sdk.Environment, error) {
 	var envs []sdk.Environment
 
-	query := `SELECT environment.id, environment.name, environment.last_modified, environment.from_repository, project.projectkey
-		  FROM environment
-		  JOIN project ON project.id = environment.project_id
-		  WHERE environment.id = ANY($1)
-		  ORDER by environment.name`
+	query := `
+    SELECT environment.id, environment.name, environment.project_id, environment.created,
+      environment.last_modified, environment.from_repository, project.projectkey
+		FROM environment
+		JOIN project ON project.id = environment.project_id
+		WHERE environment.id = ANY($1)
+    ORDER by environment.name
+  `
 	rows, err := db.Query(query, pq.Int64Array(ids))
 	if err != nil {
 		if err == sql.ErrNoRows {
@@ -32,13 +35,10 @@ func LoadAllByIDs(db gorp.SqlExecutor, ids []int64) ([]sdk.Environment, error) {
 
 	for rows.Next() {
 		var env sdk.Environment
-		var lastModified time.Time
-		var projectKey string
-		if err := rows.Scan(&env.ID, &env.Name, &lastModified, &env.FromRepository, &projectKey); err != nil {
+		if err := rows.Scan(&env.ID, &env.Name, &env.ProjectID, &env.Created,
+			&env.LastModified, &env.FromRepository, &env.ProjectKey); err != nil {
 			return envs, sdk.WithStack(err)
 		}
-		env.LastModified = lastModified.Unix()
-		env.ProjectKey = projectKey
 		envs = append(envs, env)
 	}
 	rows.Close()
@@ -55,15 +55,18 @@ func LoadAllByIDs(db gorp.SqlExecutor, ids []int64) ([]sdk.Environment, error) {
 func LoadEnvironments(db gorp.SqlExecutor, projectKey string) ([]sdk.Environment, error) {
 	var envs []sdk.Environment
 
-	query := `SELECT environment.id, environment.name, environment.last_modified, environment.from_repository
-		  FROM environment
-		  JOIN project ON project.id = environment.project_id
-		  WHERE project.projectKey = $1
-		  ORDER by environment.name`
+	query := `
+    SELECT environment.id, environment.name, environment.project_id, environment.created,
+      environment.last_modified, environment.from_repository, project.projectkey
+		FROM environment
+		JOIN project ON project.id = environment.project_id
+		WHERE project.projectKey = $1
+    ORDER by environment.name
+  `
 	rows, err := db.Query(query, projectKey)
 	if err != nil {
 		if err == sql.ErrNoRows {
-			return envs, sdk.ErrNoEnvironment
+			return envs, sdk.WithStack(sdk.ErrNoEnvironment)
 		}
 		return envs, sdk.WithStack(err)
 	}
@@ -71,12 +74,10 @@ func LoadEnvironments(db gorp.SqlExecutor, projectKey string) ([]sdk.Environment
 
 	for rows.Next() {
 		var env sdk.Environment
-		var lastModified time.Time
-		if err := rows.Scan(&env.ID, &env.Name, &lastModified, &env.FromRepository); err != nil {
+		if err := rows.Scan(&env.ID, &env.Name, &env.ProjectID, &env.Created,
+			&env.LastModified, &env.FromRepository, &env.ProjectKey); err != nil {
 			return envs, sdk.WithStack(err)
 		}
-		env.LastModified = lastModified.Unix()
-		env.ProjectKey = projectKey
 		envs = append(envs, env)
 	}
 	rows.Close()
@@ -112,10 +113,15 @@ func LoadEnvironmentByID(db gorp.SqlExecutor, ID int64) (*sdk.Environment, error
 		return &sdk.DefaultEnv, nil
 	}
 	var env sdk.Environment
-	query := `SELECT environment.id, environment.name, environment.project_id, environment.from_repository
-		  	FROM environment
-		 	WHERE id = $1`
-	if err := db.QueryRow(query, ID).Scan(&env.ID, &env.Name, &env.ProjectID, &env.FromRepository); err != nil {
+	query := `
+    SELECT environment.id, environment.name, environment.project_id, environment.created,
+      environment.last_modified, environment.from_repository, project.projectkey
+    FROM environment
+    JOIN project ON project.id = environment.project_id
+    WHERE environment.id = $1
+  `
+	if err := db.QueryRow(query, ID).Scan(&env.ID, &env.Name, &env.ProjectID, &env.Created,
+		&env.LastModified, &env.FromRepository, &env.ProjectKey); err != nil {
 		if err == sql.ErrNoRows {
 			return nil, sdk.WithStack(sdk.ErrEnvironmentNotFound)
 		}
@@ -131,19 +137,20 @@ func LoadEnvironmentByName(db gorp.SqlExecutor, projectKey, envName string) (*sd
 	}
 
 	var env sdk.Environment
-	query := `SELECT environment.id, environment.name,  environment.project_id, environment.from_repository, environment.last_modified
-		  FROM environment
-		  JOIN project ON project.id = environment.project_id
-		  WHERE project.projectKey = $1 AND environment.name = $2`
-	var lastModified time.Time
-	if err := db.QueryRow(query, projectKey, envName).Scan(&env.ID, &env.Name, &env.ProjectID, &env.FromRepository, &lastModified); err != nil {
+	query := `
+    SELECT environment.id, environment.name, environment.project_id, environment.created,
+      environment.last_modified, environment.from_repository, project.projectkey
+    FROM environment
+    JOIN project ON project.id = environment.project_id
+    WHERE project.projectKey = $1 AND environment.name = $2
+  `
+	if err := db.QueryRow(query, projectKey, envName).Scan(&env.ID, &env.Name, &env.ProjectID, &env.Created,
+		&env.LastModified, &env.FromRepository, &env.ProjectKey); err != nil {
 		if err == sql.ErrNoRows {
 			return nil, sdk.WithData(sdk.ErrEnvironmentNotFound, envName)
 		}
 		return nil, sdk.WithStack(err)
 	}
-	env.LastModified = lastModified.Unix()
-	env.ProjectKey = projectKey
 	return &env, sdk.WithStack(loadDependencies(db, &env))
 }
 
@@ -220,15 +227,14 @@ func loadDependencies(db gorp.SqlExecutor, env *sdk.Environment) error {
 
 // InsertEnvironment Insert new environment
 func InsertEnvironment(db gorp.SqlExecutor, env *sdk.Environment) error {
-	query := `INSERT INTO environment (name, project_id, from_repository) VALUES($1, $2, $3) RETURNING id, last_modified`
+	query := `INSERT INTO environment (name, project_id, from_repository) VALUES($1, $2, $3) RETURNING id, created, last_modified`
 
 	rx := sdk.NamePatternRegex
 	if !rx.MatchString(env.Name) {
 		return sdk.NewError(sdk.ErrInvalidName, fmt.Errorf("Invalid environment name. It should match %s", sdk.NamePattern))
 	}
 
-	var lastModified time.Time
-	err := db.QueryRow(query, env.Name, env.ProjectID, env.FromRepository).Scan(&env.ID, &lastModified)
+	err := db.QueryRow(query, env.Name, env.ProjectID, env.FromRepository).Scan(&env.ID, &env.Created, &env.LastModified)
 	if err != nil {
 		pqerr, ok := err.(*pq.Error)
 		if ok {
@@ -238,20 +244,20 @@ func InsertEnvironment(db gorp.SqlExecutor, env *sdk.Environment) error {
 		}
 		return err
 	}
-	env.LastModified = lastModified.Unix()
 	return nil
 }
 
 // UpdateEnvironment Update an environment
-func UpdateEnvironment(db gorp.SqlExecutor, environment *sdk.Environment) error {
+func UpdateEnvironment(db gorp.SqlExecutor, env *sdk.Environment) error {
 	rx := sdk.NamePatternRegex
-	if !rx.MatchString(environment.Name) {
-		return sdk.NewError(sdk.ErrInvalidName, fmt.Errorf("Invalid environment name. It should match %s", sdk.NamePattern))
+	if !rx.MatchString(env.Name) {
+		return sdk.NewErrorFrom(sdk.ErrInvalidName, "environment name should match pattern %s", sdk.NamePattern)
 	}
 
-	query := `UPDATE environment SET name=$1, from_repository=$3 WHERE id=$2`
-	if _, err := db.Exec(query, environment.Name, environment.ID, environment.FromRepository); err != nil {
-		return err
+	env.LastModified = time.Now()
+	query := `UPDATE environment SET name=$1, from_repository=$2, last_modified=$3 WHERE id=$4`
+	if _, err := db.Exec(query, env.Name, env.FromRepository, env.LastModified, env.ID); err != nil {
+		return sdk.WithStack(err)
 	}
 	return nil
 }
diff --git a/engine/api/environment/environment_exporter.go b/engine/api/environment/environment_exporter.go
index 2284626fb..84dd44780 100644
--- a/engine/api/environment/environment_exporter.go
+++ b/engine/api/environment/environment_exporter.go
@@ -45,7 +45,7 @@ func ExportEnvironment(db gorp.SqlExecutor, env sdk.Environment, encryptFunc sdk
 		case sdk.SecretVariable:
 			content, err := encryptFunc(db, env.ProjectID, fmt.Sprintf("envID:%d:%s", env.ID, v.Name), v.Value)
 			if err != nil {
-				return exportentities.Environment{}, sdk.WrapError(err, "Unknown key type")
+				return exportentities.Environment{}, sdk.WrapError(err, "unable to encrypt var for env %d in project %d", env.ID, env.ProjectID)
 			}
 			v.Value = content
 			envvars = append(envvars, v)
@@ -59,7 +59,7 @@ func ExportEnvironment(db gorp.SqlExecutor, env sdk.Environment, encryptFunc sdk
 	for _, k := range env.Keys {
 		content, err := encryptFunc(db, env.ProjectID, fmt.Sprintf("envID:%d:%s", env.ID, k.Name), k.Private)
 		if err != nil {
-			return exportentities.Environment{}, sdk.WrapError(err, "Unable to encrypt key")
+			return exportentities.Environment{}, sdk.WrapError(err, "unable to encrypt key for env %d in project %d", env.ID, env.ProjectID)
 		}
 		ek := exportentities.EncryptedKey{
 			Type:    string(k.Type),
diff --git a/engine/api/project/gorp_model.go b/engine/api/project/gorp_model.go
index 6429e703d..5565628c5 100644
--- a/engine/api/project/gorp_model.go
+++ b/engine/api/project/gorp_model.go
@@ -116,8 +116,6 @@ func (p *dbProject) PostGet(db gorp.SqlExecutor) error {
 		if len(clearVCSServer) > 0 {
 			if err := yaml.Unmarshal(clearVCSServer, &p.DeprecatedVCSServers); err != nil {
 				log.Error(context.TODO(), "Unable to load project %d: %v", p.ID, err)
-				p.VCSServers = nil
-				db.Update(p)
 			}
 		}
 	}
@@ -132,19 +130,6 @@ func (p *dbProject) PostUpdate(db gorp.SqlExecutor) error {
 		return errm
 	}
 
-	if len(p.VCSServers) > 0 {
-		b1, err := yaml.Marshal(p.VCSServers)
-		if err != nil {
-			return err
-		}
-		encryptedVCSServerStr, err := secret.Encrypt(b1)
-		if err != nil {
-			return err
-		}
-		_, err = db.Exec("update project set metadata = $2, vcs_servers = $3 where id = $1", p.ID, bm, encryptedVCSServerStr)
-		return err
-	}
-
 	_, err := db.Exec("update project set metadata = $2 where id = $1", p.ID, bm)
 	return err
 }
diff --git a/engine/api/router_util.go b/engine/api/router_util.go
index 59c5b2930..a7cf16455 100644
--- a/engine/api/router_util.go
+++ b/engine/api/router_util.go
@@ -81,6 +81,13 @@ func FormString(r *http.Request, s string) string {
 	return r.FormValue(s)
 }
 
+// FormIntr return a int
+func FormInt(r *http.Request, s string) int {
+	v := r.FormValue(s)
+	i, _ := strconv.Atoi(v)
+	return i
+}
+
 // QueryString return a string from a query parameter
 func QueryString(r *http.Request, s string) string {
 	return r.FormValue(s)
@@ -156,19 +163,6 @@ func QuerySort(r *http.Request) (map[string]SortOrder, error) {
 	return res, nil
 }
 
-// FormInt return a int from query params
-func FormInt(r *http.Request, s string) (int, error) {
-	stringValue := FormString(r, s)
-	if stringValue == "" {
-		return 0, nil
-	}
-	i, err := strconv.Atoi(stringValue)
-	if err != nil {
-		return i, sdk.WrapError(sdk.ErrInvalidNumber, "FormInt> %s is not a integer", stringValue)
-	}
-	return i, nil
-}
-
 // requestVarInt return int value for a var in Request
 func requestVarInt(r *http.Request, s string) (int64, error) {
 	vars := mux.Vars(r)
diff --git a/engine/api/timeline.go b/engine/api/timeline.go
index 98225f287..c7e60e2d4 100644
--- a/engine/api/timeline.go
+++ b/engine/api/timeline.go
@@ -17,10 +17,7 @@ func (api *API) getTimelineHandler() service.Handler {
 		consumer := getAPIConsumer(ctx)
 
 		// Get index of the first element to return
-		currentItem, err := FormInt(r, "currentItem")
-		if err != nil {
-			return sdk.WrapError(err, "invalid format for current item")
-		}
+		currentItem := FormInt(r, "currentItem")
 
 		// Get workflow to mute
 		timelineFilter, err := user.LoadTimelineFilter(api.mustDB(), consumer.AuthentifiedUser.ID)
diff --git a/engine/api/websocket.go b/engine/api/websocket.go
index e34bf4ecc..8ff1df925 100644
--- a/engine/api/websocket.go
+++ b/engine/api/websocket.go
@@ -374,6 +374,13 @@ func (c *websocketClient) send(ctx context.Context, event sdk.Event) (err error)
 			sendEvent = false
 			break
 		}
+
+		// sdk.EventRunWorkflow must always be sent for sidebar
+		if event.EventType == "sdk.EventRunWorkflow" {
+			sendEvent = true
+			break
+		}
+
 		if c.filter.WorkflowRunNumber != 0 && event.WorkflowRunNum != c.filter.WorkflowRunNumber {
 			sendEvent = false
 			break
diff --git a/engine/api/workflow.go b/engine/api/workflow.go
index 6b8eca0fd..af1aa0bdf 100644
--- a/engine/api/workflow.go
+++ b/engine/api/workflow.go
@@ -771,10 +771,11 @@ func (api *API) getWorkflowNotificationsConditionsHandler() service.Handler {
 func (api *API) getSearchWorkflowHandler() service.Handler {
 	return func(ctx context.Context, w http.ResponseWriter, r *http.Request) error {
 		var dao workflow.WorkflowDAO
-		dao.Filters.ProjectKey = r.FormValue("project")
-		dao.Filters.WorkflowName = r.FormValue("name")
-		dao.Filters.VCSServer = r.FormValue("vcs")
-		dao.Filters.ApplicationRepository = r.FormValue("repository")
+		dao.Filters.ProjectKey = FormString(r, "project")
+		dao.Filters.WorkflowName = FormString(r, "name")
+		dao.Filters.VCSServer = FormString(r, "vcs")
+		dao.Filters.ApplicationRepository = FormString(r, "repository")
+		dao.Loaders.WithRuns = FormInt(r, "runs")
 		dao.Loaders.WithFavoritesForUserID = getAPIConsumer(ctx).AuthentifiedUserID
 
 		groupIDS := getAPIConsumer(ctx).GetGroupIDs()
diff --git a/engine/api/workflow/dao_node_run.go b/engine/api/workflow/dao_node_run.go
index a177161db..8db9040a3 100644
--- a/engine/api/workflow/dao_node_run.go
+++ b/engine/api/workflow/dao_node_run.go
@@ -692,11 +692,10 @@ func GetNodeRunBuildCommits(ctx context.Context, db gorp.SqlExecutor, store cach
 }
 
 // PreviousNodeRun find previous node run
-func PreviousNodeRun(db gorp.SqlExecutor, nr sdk.WorkflowNodeRun, nodeName string, workflowID int64) (sdk.WorkflowNodeRun, error) {
-	var nodeRun sdk.WorkflowNodeRun
+func PreviousNodeRun(db gorp.SqlExecutor, nr sdk.WorkflowNodeRun, nodeName string, workflowID int64) (*sdk.WorkflowNodeRun, error) {
 	// check the first run of a workflow, no need to check previous
 	if nr.Number == 1 && nr.SubNumber == 0 {
-		return nodeRun, nil
+		return nil, nil
 	}
 	query := fmt.Sprintf(`
 					SELECT %s FROM workflow_node_run
@@ -711,14 +710,16 @@ func PreviousNodeRun(db gorp.SqlExecutor, nr sdk.WorkflowNodeRun, nodeName strin
 
 	var rr = NodeRun{}
 	if err := db.SelectOne(&rr, query, workflowID, nodeName, nr.VCSBranch, nr.VCSTag, nr.Number, nr.ID); err != nil {
-		return nodeRun, sdk.WrapError(err, "Cannot load previous run on workflow %d node %s nr.VCSBranch:%s nr.VCSTag:%s nr.Number:%d nr.ID:%d ", workflowID, nodeName, nr.VCSBranch, nr.VCSTag, nr.Number, nr.ID)
+		if err == sql.ErrNoRows {
+			return nil, nil
+		}
+		return nil, sdk.WrapError(err, "Cannot load previous run on workflow %d node %s nr.VCSBranch:%s nr.VCSTag:%s nr.Number:%d nr.ID:%d ", workflowID, nodeName, nr.VCSBranch, nr.VCSTag, nr.Number, nr.ID)
 	}
 	pNodeRun, errF := fromDBNodeRun(rr, LoadRunOptions{})
 	if errF != nil {
-		return nodeRun, sdk.WrapError(errF, "PreviousNodeRun> Cannot read node run")
+		return nil, sdk.WrapError(errF, "PreviousNodeRun> Cannot read node run")
 	}
-	nodeRun = *pNodeRun
-	return nodeRun, nil
+	return pNodeRun, nil
 }
 
 //PreviousNodeRunVCSInfos returns a struct with BuildNumber, Commit Hash, Branch, Remote, Remote_url
diff --git a/engine/api/workflow/dao_run.go b/engine/api/workflow/dao_run.go
index ccccf090e..3c5160d73 100644
--- a/engine/api/workflow/dao_run.go
+++ b/engine/api/workflow/dao_run.go
@@ -12,6 +12,7 @@ import (
 
 	"github.com/fsamin/go-dump"
 	"github.com/go-gorp/gorp"
+	"github.com/lib/pq"
 	"go.opencensus.io/stats"
 
 	"github.com/ovh/cds/engine/api/database/gorpmapping"
@@ -238,6 +239,15 @@ func LoadLastRun(db gorp.SqlExecutor, projectkey, workflowname string, loadOpts
 	return loadRun(db, loadOpts, query, projectkey, workflowname)
 }
 
+// LoadLastRuns returns the last run per workflowIDs
+func LoadLastRuns(db gorp.SqlExecutor, workflowIDs []int64, limit int) ([]sdk.WorkflowRun, error) {
+	query := fmt.Sprintf(`select %s
+	from workflow_run
+	where workflow_run.workflow_id = ANY($1)
+	order by workflow_run.workflow_id, workflow_run.num desc limit $2`, wfRunfields)
+	return loadRuns(db, query, pq.Int64Array(workflowIDs), limit)
+}
+
 // LoadRun returns a specific run
 func LoadRun(ctx context.Context, db gorp.SqlExecutor, projectkey, workflowname string, number int64, loadOpts LoadRunOptions) (*sdk.WorkflowRun, error) {
 	_, end := observability.Span(ctx, "workflow.LoadRun",
@@ -284,6 +294,22 @@ func LoadAndLockRunByJobID(db gorp.SqlExecutor, id int64, loadOpts LoadRunOption
 	return loadRun(db, loadOpts, query, id)
 }
 
+func loadRuns(db gorp.SqlExecutor, query string, args ...interface{}) ([]sdk.WorkflowRun, error) {
+	runs := []Run{}
+	if _, err := db.Select(&runs, query, args...); err != nil {
+		return nil, sdk.WrapError(err, "Unable to load runs")
+	}
+	wruns := make([]sdk.WorkflowRun, len(runs))
+	for i := range runs {
+		wr := sdk.WorkflowRun(runs[i])
+		if err := loadRunTags(db, &wr); err != nil {
+			return nil, sdk.WrapError(err, "Unable to load tags")
+		}
+		wruns[i] = wr
+	}
+	return wruns, nil
+}
+
 //LoadRuns loads all runs
 //It returns runs, offset, limit count and an error
 func LoadRuns(db gorp.SqlExecutor, projectkey, workflowname string, offset, limit int, tagFilter map[string]string) ([]sdk.WorkflowRun, int, int, int, error) {
@@ -367,18 +393,9 @@ func LoadRuns(db gorp.SqlExecutor, projectkey, workflowname string, offset, limi
 		args = append(args, strings.Join(tags, ","))
 	}
 
-	runs := []Run{}
-	if _, err := db.Select(&runs, query, args...); err != nil {
-		return nil, 0, 0, 0, sdk.WrapError(errc, "Unable to load runs")
-	}
-	wruns := make([]sdk.WorkflowRun, len(runs))
-	for i := range runs {
-		wr := sdk.WorkflowRun(runs[i])
-		if err := loadRunTags(db, &wr); err != nil {
-			return nil, 0, 0, 0, sdk.WrapError(err, "Unable to load tags")
-		}
-
-		wruns[i] = wr
+	wruns, err := loadRuns(db, query, args...)
+	if err != nil {
+		return nil, 0, 0, 0, err
 	}
 
 	return wruns, offset, limit, int(count), nil
diff --git a/engine/api/workflow/execute_node_job_run.go b/engine/api/workflow/execute_node_job_run.go
index aa43717bf..b7063841d 100644
--- a/engine/api/workflow/execute_node_job_run.go
+++ b/engine/api/workflow/execute_node_job_run.go
@@ -161,7 +161,9 @@ func UpdateNodeJobRunStatus(ctx context.Context, db gorp.SqlExecutor, store cach
 		job.Status = status
 
 		_, next := observability.Span(ctx, "workflow.LoadRunByID")
-		wf, errLoadWf := LoadRunByID(db, nodeRun.WorkflowRunID, LoadRunOptions{})
+		wf, errLoadWf := LoadRunByID(db, nodeRun.WorkflowRunID, LoadRunOptions{
+			WithDeleted: true,
+		})
 		next()
 		if errLoadWf != nil {
 			return nil, sdk.WrapError(errLoadWf, "workflow.UpdateNodeJobRunStatus> Unable to load run id %d", nodeRun.WorkflowRunID)
diff --git a/engine/api/workflow/factory_dao.go b/engine/api/workflow/factory_dao.go
index 615cd435a..07e79be4e 100644
--- a/engine/api/workflow/factory_dao.go
+++ b/engine/api/workflow/factory_dao.go
@@ -84,6 +84,7 @@ type LoadAllWorkflowsOptionsLoaders struct {
 	WithLabels             bool
 	WithAudits             bool
 	WithFavoritesForUserID string
+	WithRuns               int
 }
 
 type WorkflowDAO struct {
@@ -282,6 +283,13 @@ func (dao WorkflowDAO) GetLoaders() []gorpmapping.GetOptionFunc {
 		})
 	}
 
+	if dao.Loaders.WithRuns != 0 {
+		loaders = append(loaders, func(db gorp.SqlExecutor, i interface{}) error {
+			ws := i.(*[]Workflow)
+			return dao.withRuns(db, ws, dao.Loaders.WithRuns)
+		})
+	}
+
 	loaders = append(loaders,
 		func(db gorp.SqlExecutor, i interface{}) error {
 			ws := i.(*[]Workflow)
@@ -628,6 +636,29 @@ func (dao WorkflowDAO) withLabels(db gorp.SqlExecutor, ws *[]Workflow) error {
 	return nil
 }
 
+func (dao WorkflowDAO) withRuns(db gorp.SqlExecutor, ws *[]Workflow, limit int) error {
+	var ids = make([]int64, 0, len(*ws))
+	for _, w := range *ws {
+		ids = append(ids, w.ID)
+	}
+
+	runs, err := LoadLastRuns(db, ids, limit)
+	if err != nil {
+		return err
+	}
+
+	for x := range *ws {
+		w := &(*ws)[x]
+		for _, run := range runs {
+			if w.ID == run.WorkflowID {
+				w.Runs = append(w.Runs, run)
+			}
+		}
+	}
+
+	return nil
+}
+
 func (dao WorkflowDAO) withFavorites(db gorp.SqlExecutor, ws *[]Workflow, userID string) error {
 	workflowIDs, err := UserFavoriteWorkflowIDs(db, userID)
 	if err != nil {
diff --git a/engine/api/workflow_event.go b/engine/api/workflow_event.go
index 41f6f7541..bb75b5838 100644
--- a/engine/api/workflow_event.go
+++ b/engine/api/workflow_event.go
@@ -31,9 +31,9 @@ func WorkflowSendEvent(ctx context.Context, db gorp.SqlExecutor, store cache.Sto
 			continue
 		}
 
-		var previousNodeRun sdk.WorkflowNodeRun
+		var previousNodeRun *sdk.WorkflowNodeRun
 		if wnr.SubNumber > 0 {
-			previousNodeRun = wnr
+			previousNodeRun = &wnr
 		} else {
 			var errN error
 			previousNodeRun, errN = workflow.PreviousNodeRun(db, wnr, wnr.WorkflowNodeName, wr.WorkflowID)
@@ -55,7 +55,7 @@ func WorkflowSendEvent(ctx context.Context, db gorp.SqlExecutor, store cache.Sto
 			log.Warning(ctx, "WorkflowSendEvent> Unable to load workflow for event: %v", err)
 			continue
 		}
-		eventsNotif := notification.GetUserWorkflowEvents(ctx, db, store, wr.Workflow.ProjectID, wr.Workflow.ProjectKey, workDB.Name, wr.Workflow.Notifications, &previousNodeRun, *nr)
+		eventsNotif := notification.GetUserWorkflowEvents(ctx, db, store, wr.Workflow.ProjectID, wr.Workflow.ProjectKey, workDB.Name, wr.Workflow.Notifications, previousNodeRun, *nr)
 		event.PublishWorkflowNodeRun(ctx, *nr, wr.Workflow, eventsNotif)
 		e := &workflow.VCSEventMessenger{}
 		if err := e.SendVCSEvent(ctx, db, store, proj, *wr, wnr); err != nil {
diff --git a/engine/api/workflow_run.go b/engine/api/workflow_run.go
index 07c4b0f56..d7b611f37 100644
--- a/engine/api/workflow_run.go
+++ b/engine/api/workflow_run.go
@@ -348,9 +348,11 @@ func (api *API) stopWorkflowRunHandler() service.Handler {
 			return err
 		}
 
-		run, errL := workflow.LoadRun(ctx, api.mustDB(), key, name, number, workflow.LoadRunOptions{})
-		if errL != nil {
-			return sdk.WrapError(errL, "stopWorkflowRunHandler> Unable to load last workflow run")
+		run, err := workflow.LoadRun(ctx, api.mustDB(), key, name, number, workflow.LoadRunOptions{
+			WithDeleted: true,
+		})
+		if err != nil {
+			return sdk.WrapError(err, "unable to load last workflow run")
 		}
 
 		proj, errP := project.Load(api.mustDB(), key)
diff --git a/engine/api/workflow_trigger.go b/engine/api/workflow_trigger.go
index dad374fe7..5a84a6df4 100644
--- a/engine/api/workflow_trigger.go
+++ b/engine/api/workflow_trigger.go
@@ -18,7 +18,7 @@ func (api *API) getWorkflowTriggerConditionHandler() service.Handler {
 		vars := mux.Vars(r)
 		key := vars["key"]
 		name := vars["permWorkflowName"]
-		id, _ := FormInt(r, "nodeID")
+		id := FormInt(r, "nodeID")
 
 		proj, err := project.Load(api.mustDB(), key, project.LoadOptions.WithVariables, project.LoadOptions.WithIntegrations)
 		if err != nil {
diff --git a/engine/sql/205_environment_created.sql b/engine/sql/205_environment_created.sql
new file mode 100644
index 000000000..0ae6ebccf
--- /dev/null
+++ b/engine/sql/205_environment_created.sql
@@ -0,0 +1,8 @@
+-- +migrate Up
+
+ALTER TABLE "environment" ADD COLUMN IF NOT EXISTS created TIMESTAMP WITH TIME ZONE DEFAULT LOCALTIMESTAMP;
+UPDATE "environment" SET created = last_modified WHERE created > last_modified;
+
+-- +migrate Down
+
+SELECT 1;
diff --git a/sdk/environment.go b/sdk/environment.go
index 8ee5b40fd..86e97c1c4 100644
--- a/sdk/environment.go
+++ b/sdk/environment.go
@@ -1,6 +1,7 @@
 package sdk
 
 import (
+	json "encoding/json"
 	"time"
 )
 
@@ -11,12 +12,53 @@ type Environment struct {
 	Variables      []Variable       `json:"variables,omitempty" yaml:"variables"`
 	ProjectID      int64            `json:"-" yaml:"-"`
 	ProjectKey     string           `json:"project_key" yaml:"-"`
-	LastModified   int64            `json:"last_modified"`
+	Created        time.Time        `json:"created"`
+	LastModified   time.Time        `json:"last_modified"`
 	Keys           []EnvironmentKey `json:"keys"`
 	Usage          *Usage           `json:"usage,omitempty"`
 	FromRepository string           `json:"from_repository,omitempty"`
 }
 
+// UnmarshalJSON custom for last modified.
+func (e *Environment) UnmarshalJSON(data []byte) error {
+	var tmp struct {
+		ID             int64            `json:"id"`
+		Name           string           `json:"name"`
+		Variables      []Variable       `json:"variables"`
+		ProjectKey     string           `json:"project_key"`
+		Created        time.Time        `json:"created"`
+		Keys           []EnvironmentKey `json:"keys"`
+		Usage          *Usage           `json:"usage"`
+		FromRepository string           `json:"from_repository"`
+	}
+
+	if err := json.Unmarshal(data, &tmp); err != nil {
+		return err
+	}
+	e.ID = tmp.ID
+	e.Name = tmp.Name
+	e.Variables = tmp.Variables
+	e.ProjectKey = tmp.ProjectKey
+	e.Created = tmp.Created
+	e.Keys = tmp.Keys
+	e.Usage = tmp.Usage
+	e.FromRepository = tmp.FromRepository
+
+	var v map[string]interface{}
+	if err := json.Unmarshal(data, &v); err != nil {
+		return err
+	}
+	if lastModifiedNumber, ok := v["last_modified"].(float64); ok {
+		e.LastModified = time.Unix(int64(lastModifiedNumber), 0)
+	}
+	if lastModifiedString, ok := v["last_modified"].(string); ok {
+		date, _ := time.Parse(time.RFC3339, lastModifiedString)
+		e.LastModified = date
+	}
+
+	return nil
+}
+
 // EnvironmentVariableAudit represents an audit on an environment variable
 type EnvironmentVariableAudit struct {
 	ID             int64     `json:"id" yaml:"-" db:"id"`
diff --git a/sdk/workflow.go b/sdk/workflow.go
index 8500951ec..dbfb21559 100644
--- a/sdk/workflow.go
+++ b/sdk/workflow.go
@@ -64,6 +64,7 @@ type Workflow struct {
 	FromTemplate     string                    `json:"from_template,omitempty" db:"-" cli:"-"`
 	TemplateUpToDate bool                      `json:"template_up_to_date,omitempty" db:"-" cli:"-"`
 	URLs             URL                       `json:"urls" yaml:"-" db:"-" cli:"-"`
+	Runs             []WorkflowRun             `json:"runs,omitempty" yaml:"-" db:"-" cli:"-"`
 }
 
 type PurgeTags []string
diff --git a/ui/src/app/views/workflow/run/node/pipeline/node.pipeline.component.ts b/ui/src/app/views/workflow/run/node/pipeline/node.pipeline.component.ts
index 230f6c5a9..897360690 100644
--- a/ui/src/app/views/workflow/run/node/pipeline/node.pipeline.component.ts
+++ b/ui/src/app/views/workflow/run/node/pipeline/node.pipeline.component.ts
@@ -61,9 +61,6 @@ export class WorkflowRunNodePipelineComponent implements OnInit, OnDestroy {
     }
 
     ngOnInit() {
-        this.queryParamsSub = this._route.queryParams.subscribe((queryParams) => {
-            this.updateSelectedItems(queryParams);
-        });
         this.nodeJobRunSubs = this.nodeJobRun$.subscribe(rj => {
             if (!rj && !this.currentJob) {
                 return;
@@ -102,12 +99,6 @@ export class WorkflowRunNodePipelineComponent implements OnInit, OnDestroy {
         });
     }
 
-    updateSelectedItems(queryParams) {
-        if (queryParams['actionId']) {
-            this.selectJob(queryParams['actionId']);
-        }
-    }
-
     selectedJobManual(jobID: number) {
         if (!this.mapJobStatus.has(jobID)) {
             return;
@@ -157,11 +148,14 @@ export class WorkflowRunNodePipelineComponent implements OnInit, OnDestroy {
                                 { status: rj.status, warnings, start: rj.start, done: rj.done });
                         }
 
-                        if (!currentNodeJobRun && sIndex === 0 && rjIndex === 0) {
+                        if (!currentNodeJobRun && sIndex === 0 && rjIndex === 0 && !this._route.snapshot.queryParams['actionId']) {
                             refresh = true;
                             this.selectJob(s.jobs[0].pipeline_action_id);
                         } else if (currentNodeJobRun && currentNodeJobRun.job.pipeline_action_id === this.currentJob.pipeline_action_id) {
                             this.selectJob(this.currentJob.pipeline_action_id);
+                        } else if (this._route.snapshot.queryParams['actionId'] &&
+                            this._route.snapshot.queryParams['actionId'] === rj.job.pipeline_action_id.toString()) {
+                            this.selectJob(rj.job.pipeline_action_id);
                         }
                     });
                 }
diff --git a/ui/src/app/views/workflow/run/node/pipeline/step/step.log.component.ts b/ui/src/app/views/workflow/run/node/pipeline/step/step.log.component.ts
index e37d42d53..4b88d424e 100644
--- a/ui/src/app/views/workflow/run/node/pipeline/step/step.log.component.ts
+++ b/ui/src/app/views/workflow/run/node/pipeline/step/step.log.component.ts
@@ -141,8 +141,10 @@ export class WorkflowStepLogComponent implements OnInit, OnDestroy {
             let activeStep = parseInt(qps['stageId'], 10) === this.job.pipeline_stage_id &&
                 parseInt(qps['actionId'], 10) === this.job.pipeline_action_id && parseInt(qps['stepOrder'], 10) === this.stepOrder;
             if (activeStep) {
-                this.showLogs = true;
                 this.selectedLine = parseInt(qps['line'], 10);
+                if (!this.showLogs) {
+                    this.toggleLogs();
+                }
             } else {
                 this.selectedLine = null;
             }
@@ -343,7 +345,6 @@ export class WorkflowStepLogComponent implements OnInit, OnDestroy {
             stepOrder: this.stepOrder,
             line: lineNumber
         });
-        let fragment = 'L' + this.job.pipeline_stage_id + '-' + this.job.pipeline_action_id + '-' + this.stepOrder + '-' + lineNumber;
         this._router.navigate([
             'project',
             this._store.selectSnapshot(ProjectState.projectSnapshot).key,
@@ -353,6 +354,6 @@ export class WorkflowStepLogComponent implements OnInit, OnDestroy {
             (<WorkflowStateModel>this._store.selectSnapshot(WorkflowState)).workflowNodeRun.num,
             'node',
             (<WorkflowStateModel>this._store.selectSnapshot(WorkflowState)).workflowNodeRun.id
-        ], { queryParams: qps, fragment });
+        ], { queryParams: qps });
     }
 }


Test patch:
diff --git a/engine/api/workflow/factory_dao_test.go b/engine/api/workflow/factory_dao_test.go
index 3e40c0b4d..a06597b84 100644
--- a/engine/api/workflow/factory_dao_test.go
+++ b/engine/api/workflow/factory_dao_test.go
@@ -81,6 +81,7 @@ func TestLoadAllWorkflows(t *testing.T) {
 				WithIntegrations:       true,
 				WithPipelines:          true,
 				WithTemplate:           true,
+				WithRuns:               10,
 			},
 		},
 	}
diff --git a/engine/api/workflow_test.go b/engine/api/workflow_test.go
index b16658b4c..2b6c7231e 100644
--- a/engine/api/workflow_test.go
+++ b/engine/api/workflow_test.go
@@ -1982,7 +1982,10 @@ func Test_getSearchWorkflowHandler(t *testing.T) {
 		BuitinConsumerAuthenticationToken: jws,
 	})
 
-	wfs, err := sdkclientAdmin.WorkflowSearch(cdsclient.WithQueryParameter("repository", "ovh/"+repofullName))
+	wfs, err := sdkclientAdmin.WorkflowSearch(
+		cdsclient.WithQueryParameter("repository", "ovh/"+repofullName),
+		cdsclient.WithQueryParameter("runs", "10"),
+	)
 	require.NoError(t, err)
 	require.Len(t, wfs, 1)
 	require.Equal(t, wf.Name, wfs[0].Name)
diff --git a/sdk/environment_test.go b/sdk/environment_test.go
new file mode 100644
index 000000000..bb7de499e
--- /dev/null
+++ b/sdk/environment_test.go
@@ -0,0 +1,32 @@
+package sdk_test
+
+import (
+	json "encoding/json"
+	"fmt"
+	"testing"
+	"time"
+
+	"github.com/ovh/cds/sdk"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestEnvironmentUnmarshal(t *testing.T) {
+	now := time.Unix(time.Now().Unix(), 0)
+
+	nowBytes, err := json.Marshal(now)
+	require.NoError(t, err)
+
+	data1 := []byte(fmt.Sprintf("{\"name\":\"one\",\"last_modified\":%s}", nowBytes))
+	data2 := []byte(fmt.Sprintf("{\"name\":\"two\",\"last_modified\":%d}", now.Unix()))
+
+	var one sdk.Environment
+	require.NoError(t, json.Unmarshal(data1, &one))
+	assert.Equal(t, "one", one.Name)
+	assert.True(t, now.Equal(one.LastModified))
+
+	var two sdk.Environment
+	require.NoError(t, json.Unmarshal(data2, &two))
+	assert.Equal(t, "two", two.Name)
+	assert.True(t, now.Equal(two.LastModified))
+}