Repository: kestra-io/kestra (java)

Issue:
Flow listeners should be fault tolerant on invalid flow & plugins
```
io.micronaut.context.exceptions.BeanInstantiationException: Error instantiating bean of type  [io.kestra.jdbc.runner.JdbcExecutor]

Message: io/reactivex/functions/BiConsumer
Path Taken: new JdbcExecutor() --> JdbcExecutor.flowListeners
	at io.micronaut.context.DefaultBeanContext.resolveByBeanFactory(DefaultBeanContext.java:2324)
	at io.micronaut.context.DefaultBeanContext.doCreateBean(DefaultBeanContext.java:2279)
	at io.micronaut.context.DefaultBeanContext.doCreateBean(DefaultBeanContext.java:2291)
	at io.micronaut.context.DefaultBeanContext.createRegistration(DefaultBeanContext.java:3054)
	at io.micronaut.context.SingletonScope.getOrCreate(SingletonScope.java:80)
	at io.micronaut.context.DefaultBeanContext.findOrCreateSingletonBeanRegistration(DefaultBeanContext.java:2956)
	at io.micronaut.context.DefaultBeanContext.resolveBeanRegistration(DefaultBeanContext.java:2917)
	at io.micronaut.context.DefaultBeanContext.resolveBeanRegistration(DefaultBeanContext.java:2730)
	at io.micronaut.context.DefaultBeanContext.getBean(DefaultBeanContext.java:1729)
	at io.micronaut.context.AbstractBeanResolutionContext.getBean(AbstractBeanResolutionContext.java:89)
	at io.micronaut.context.AbstractInitializableBeanDefinition.resolveBean(AbstractInitializableBeanDefinition.java:2165)
	at io.micronaut.context.AbstractInitializableBeanDefinition.getBeanForField(AbstractInitializableBeanDefinition.java:1672)
	at io.kestra.jdbc.runner.$JdbcExecutor$Definition.inject(Unknown Source)
	at io.kestra.jdbc.runner.$JdbcExecutor$Definition.instantiate(Unknown Source)
	at io.micronaut.context.DefaultBeanContext.resolveByBeanFactory(DefaultBeanContext.java:2309)
	at io.micronaut.context.DefaultBeanContext.doCreateBean(DefaultBeanContext.java:2279)
	at io.micronaut.context.DefaultBeanContext.doCreateBean(DefaultBeanContext.java:2291)
	at io.micronaut.context.DefaultBeanContext.createRegistration(DefaultBeanContext.java:3054)
	at io.micronaut.context.SingletonScope.getOrCreate(SingletonScope.java:80)
	at io.micronaut.context.DefaultBeanContext.findOrCreateSingletonBeanRegistration(DefaultBeanContext.java:2956)
	at io.micronaut.context.DefaultBeanContext.resolveBeanRegistration(DefaultBeanContext.java:2917)
	at io.micronaut.context.DefaultBeanContext.resolveBeanRegistration(DefaultBeanContext.java:2730)
	at io.micronaut.context.DefaultBeanContext.getBean(DefaultBeanContext.java:1729)
	at io.micronaut.context.DefaultBeanContext.getBean(DefaultBeanContext.java:856)
	at io.micronaut.context.DefaultBeanContext.getBean(DefaultBeanContext.java:848)
	at io.kestra.core.runners.StandAloneRunner.run(StandAloneRunner.java:38)
```
We should skip and warm about invalid flows

Pull request:
fix(jdbc): make the flow listener tolerant of plugin loader issue
Fixes #2956

It can only be tested by having an old plugin in the classpath (for ex le plugin-fs version 0.12), in this case the server will start with an error log and flows that didn't use this plugin will work.

There will also be error in the UI each time the UI will try to access the flow that uses the old plugin but at least the server will not crash.


Patch:
diff --git a/core/src/main/java/io/kestra/core/tasks/templating/TemplatedTask.java b/core/src/main/java/io/kestra/core/tasks/templating/TemplatedTask.java
new file mode 100644
index 000000000..2a6398f26
--- /dev/null
+++ b/core/src/main/java/io/kestra/core/tasks/templating/TemplatedTask.java
@@ -0,0 +1,64 @@
+package io.kestra.core.tasks.templating;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import io.kestra.core.exceptions.IllegalVariableEvaluationException;
+import io.kestra.core.models.annotations.Example;
+import io.kestra.core.models.annotations.Plugin;
+import io.kestra.core.models.annotations.PluginProperty;
+import io.kestra.core.models.tasks.*;
+import io.kestra.core.runners.RunContext;
+import io.kestra.core.serializers.JacksonMapper;
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotNull;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.ToString;
+import lombok.experimental.SuperBuilder;
+
+@SuperBuilder
+@ToString
+@EqualsAndHashCode
+@Getter
+@NoArgsConstructor
+@Schema(
+    title = "This task's `spec` property allows you to fully template all task properties using Kestra's Pebble templating. This way, all task properties and their values can be dynamically rendered based on your custom inputs, variables, and outputs from other tasks!"
+)
+@Plugin(
+    examples = {
+        @Example(
+            code = {
+                """
+                    spec: |
+                      type: io.kestra.plugin.fs.http.Download
+                      {{ task.property }}: {{ task.value }}"""
+            }
+        )
+    }
+)
+public class TemplatedTask extends Task implements RunnableTask<Output> {
+    private static final ObjectMapper OBJECT_MAPPER = JacksonMapper.ofYaml();
+
+    @NotNull
+    @PluginProperty(dynamic = true)
+    @Schema(title = "The templated task specification")
+    private String spec;
+
+    @Override
+    public Output run(RunContext runContext) throws Exception {
+        String taskSpec = runContext.render(this.spec);
+        try {
+            Task task = OBJECT_MAPPER.readValue(taskSpec, Task.class);
+            if (task instanceof TemplatedTask) {
+                throw new IllegalArgumentException("The templated task cannot be of type 'io.kestra.core.tasks.templating.TemplatedTask'");
+            }
+            if (task instanceof RunnableTask<?> runnableTask) {
+                return runnableTask.run(runContext);
+            }
+            throw new IllegalArgumentException("The templated task must be a runnable task");
+        } catch (JsonProcessingException e) {
+            throw new IllegalVariableEvaluationException(e);
+        }
+    }
+}
diff --git a/core/src/main/resources/docs/macro.peb b/core/src/main/resources/docs/macro.peb
index eaba9cfba..7d6d9102c 100644
--- a/core/src/main/resources/docs/macro.peb
+++ b/core/src/main/resources/docs/macro.peb
@@ -10,7 +10,7 @@
 {% macro fieldType(data) -%}
 {%- if data['$deprecated'] != null %}
 ::alert{type="warning"}
-🔒 Deprecated
+⚠ Deprecated
 ::
 {% endif %}
 
diff --git a/core/src/main/resources/docs/task.peb b/core/src/main/resources/docs/task.peb
index 3a4c479b5..ee7cdcca2 100644
--- a/core/src/main/resources/docs/task.peb
+++ b/core/src/main/resources/docs/task.peb
@@ -19,7 +19,7 @@ icon: {{ icon }}
 
 {% if deprecated %}
 ::alert{type="warning"}
-🔒 Deprecated
+⚠ Deprecated
 ::
 {%- endif %}
 
diff --git a/core/src/main/resources/icons/io.kestra.core.tasks.templating.svg b/core/src/main/resources/icons/io.kestra.core.tasks.templating.svg
new file mode 100644
index 000000000..42c9dbdd3
--- /dev/null
+++ b/core/src/main/resources/icons/io.kestra.core.tasks.templating.svg
@@ -0,0 +1,2 @@
+<?xml version="1.0" encoding="utf-8"?>
+<svg fill="#000000" width="800px" height="800px" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path d="M69.6,59.3A10.2,10.2,0,1,0,79.8,69.5,10.24,10.24,0,0,0,69.6,59.3Zm6.6,7.4-7.5,7.5a1.45,1.45,0,0,1-2,0L63,70.6a.67.67,0,0,1,0-1l1-1a.67.67,0,0,1,1,0l2.6,2.6,6.5-6.5a.67.67,0,0,1,1,0l1,1A.76.76,0,0,1,76.2,66.7Z"/><path d="M44.5,30H62.3a1.58,1.58,0,0,0,1.6-1.6V25.1A4.91,4.91,0,0,0,59,20.2H47.7a4.91,4.91,0,0,0-4.9,4.9v3.3A1.73,1.73,0,0,0,44.5,30Z"/><path d="M56.1,73.3H31.4a4.89,4.89,0,0,1-4.8-4.9v-34H25a4.89,4.89,0,0,0-4.8,4.9V74.7A4.89,4.89,0,0,0,25,79.6H59.9A12.11,12.11,0,0,1,56.1,73.3Z"/><path d="M35.6,69.5H55.7a15.14,15.14,0,0,1,.7-3.7,13.68,13.68,0,0,1,2.3-4.5H49.3a1.58,1.58,0,0,1-1.6-1.6V58a1.58,1.58,0,0,1,1.6-1.6H65.5c.2,0,.3,0,.4.1a13.61,13.61,0,0,1,3.6-.5A13.89,13.89,0,0,1,76,57.6V29.2a4.91,4.91,0,0,0-4.9-4.9H69.5a.74.74,0,0,0-.8.8v3.3A6.57,6.57,0,0,1,62.2,35H44.5A6.64,6.64,0,0,1,38,28.4V25.1a.74.74,0,0,0-.8-.8H35.6a4.91,4.91,0,0,0-4.9,4.9V64.5A5.06,5.06,0,0,0,35.6,69.5Zm12.1-28a1.58,1.58,0,0,1,1.6-1.6H65.5a1.58,1.58,0,0,1,1.6,1.6v1.6a1.58,1.58,0,0,1-1.6,1.6H49.4a1.58,1.58,0,0,1-1.6-1.6V41.5Zm0,8.2a1.58,1.58,0,0,1,1.6-1.6H65.5a1.58,1.58,0,0,1,1.6,1.6v1.6a1.58,1.58,0,0,1-1.6,1.6H49.4a1.58,1.58,0,0,1-1.6-1.6V49.7Zm-8-8.2a1.58,1.58,0,0,1,1.6-1.6h1.6a1.58,1.58,0,0,1,1.6,1.6v1.6a1.58,1.58,0,0,1-1.6,1.6H41.3a1.58,1.58,0,0,1-1.6-1.6Zm0,8.2a1.58,1.58,0,0,1,1.6-1.6h1.6a1.58,1.58,0,0,1,1.6,1.6v1.6a1.58,1.58,0,0,1-1.6,1.6H41.3a1.58,1.58,0,0,1-1.6-1.6Zm0,8.3a1.58,1.58,0,0,1,1.6-1.6h1.6A1.58,1.58,0,0,1,44.5,58v1.6a1.58,1.58,0,0,1-1.6,1.6H41.3a1.58,1.58,0,0,1-1.6-1.6Z"/></svg>
\ No newline at end of file
diff --git a/jdbc-h2/src/main/resources/migrations/h2/V1_16__log_timestamp_index.sql b/jdbc-h2/src/main/resources/migrations/h2/V1_16__log_timestamp_index.sql
new file mode 100644
index 000000000..9ade9e988
--- /dev/null
+++ b/jdbc-h2/src/main/resources/migrations/h2/V1_16__log_timestamp_index.sql
@@ -0,0 +1,1 @@
+CREATE INDEX IF NOT EXISTS logs_timestamp ON logs ("timestamp");
\ No newline at end of file
diff --git a/jdbc-mysql/src/main/resources/migrations/mysql/V1_16__log_timestamp_index.sql b/jdbc-mysql/src/main/resources/migrations/mysql/V1_16__log_timestamp_index.sql
new file mode 100644
index 000000000..1524f846c
--- /dev/null
+++ b/jdbc-mysql/src/main/resources/migrations/mysql/V1_16__log_timestamp_index.sql
@@ -0,0 +1,1 @@
+CREATE INDEX ix_timestamp ON logs (`timestamp`);
\ No newline at end of file
diff --git a/jdbc-postgres/src/main/resources/migrations/postgres/V1_16__log_timestamp_index.sql b/jdbc-postgres/src/main/resources/migrations/postgres/V1_16__log_timestamp_index.sql
new file mode 100644
index 000000000..9ade9e988
--- /dev/null
+++ b/jdbc-postgres/src/main/resources/migrations/postgres/V1_16__log_timestamp_index.sql
@@ -0,0 +1,1 @@
+CREATE INDEX IF NOT EXISTS logs_timestamp ON logs ("timestamp");
\ No newline at end of file
diff --git a/jdbc/src/main/java/io/kestra/jdbc/repository/AbstractJdbcFlowRepository.java b/jdbc/src/main/java/io/kestra/jdbc/repository/AbstractJdbcFlowRepository.java
index 433fcebcc..77060e4ef 100644
--- a/jdbc/src/main/java/io/kestra/jdbc/repository/AbstractJdbcFlowRepository.java
+++ b/jdbc/src/main/java/io/kestra/jdbc/repository/AbstractJdbcFlowRepository.java
@@ -23,8 +23,8 @@ import io.micronaut.context.ApplicationContext;
 import io.micronaut.context.event.ApplicationEventPublisher;
 import io.micronaut.data.model.Pageable;
 import io.micronaut.inject.qualifiers.Qualifiers;
-import jakarta.inject.Singleton;
 import lombok.SneakyThrows;
+import lombok.extern.slf4j.Slf4j;
 import org.jooq.Record;
 import org.jooq.*;
 import org.jooq.impl.DSL;
@@ -33,7 +33,7 @@ import jakarta.annotation.Nullable;
 import jakarta.validation.ConstraintViolationException;
 import java.util.*;
 
-@Singleton
+@Slf4j
 public abstract class AbstractJdbcFlowRepository extends AbstractJdbcRepository implements FlowRepositoryInterface {
     private final QueueInterface<Flow> flowQueue;
     private final QueueInterface<Trigger> triggerQueue;
@@ -200,7 +200,20 @@ public abstract class AbstractJdbcFlowRepository extends AbstractJdbcRepository
                     .from(fromLastRevision(true))
                     .where(this.defaultFilter());
 
-                return this.jdbcRepository.fetch(select);
+                // findAllForAllTenants() is used in the backend, so we want it to work even if messy plugins exist.
+                // That's why we will try to deserialize each flow and log an error but not crash in case of exception.
+                List<Flow> flows = new ArrayList<>();
+                select.fetch().forEach(
+                    item -> {
+                        try {
+                            Flow flow = this.jdbcRepository.map(item);
+                            flows.add(flow);
+                        } catch (Exception e) {
+                            log.error("Unable to load the following flow:\n{}", item.get("value", String.class), e);
+                        }
+                    }
+                );
+                return flows;
             });
     }
 
diff --git a/ui/src/components/ErrorToast.vue b/ui/src/components/ErrorToast.vue
index c1ad2b8ae..debd669e0 100644
--- a/ui/src/components/ErrorToast.vue
+++ b/ui/src/components/ErrorToast.vue
@@ -70,7 +70,7 @@
 
                 const children = [
                     h("a", {
-                        href: "https://kestra.io/slack",
+                        href: "https://kestra.io/slack?utm_source=app&utm_content=error",
                         class: "position-absolute slack-on-error el-button el-button--small is-text is-has-bg",
                         target: "_blank"
                     }, [h(Slack), h("span", {innerText: this.$t("slack support")})]),
diff --git a/ui/src/components/admin/stats/EditionComparator.vue b/ui/src/components/admin/stats/EditionComparator.vue
index 1acec5f1b..470f19be2 100644
--- a/ui/src/components/admin/stats/EditionComparator.vue
+++ b/ui/src/components/admin/stats/EditionComparator.vue
@@ -61,7 +61,7 @@
                         ],
                         button: {
                             text: "Talk to us",
-                            href: "https://kestra.io/demo"
+                            href: "https://kestra.io/demo?utm_source=app&utm_content=stats"
                         }
                     },
                     {
@@ -73,7 +73,7 @@
                         ],
                         button: {
                             text: "Sign up for a private Alpha",
-                            href: "https://kestra.io/cloud"
+                            href: "https://kestra.io/cloud?utm_source=app&utm_content=stats"
                         }
                     }
                 ]
diff --git a/ui/src/components/executions/ExecutionRoot.vue b/ui/src/components/executions/ExecutionRoot.vue
index a3c8e5139..b63af44bb 100644
--- a/ui/src/components/executions/ExecutionRoot.vue
+++ b/ui/src/components/executions/ExecutionRoot.vue
@@ -84,6 +84,7 @@
         },
         methods: {
             follow() {
+                this.$store.dispatch("execution/loadExecution", this.$route.params)
                 const self = this;
                 this.closeSSE();
                 this.previousExecutionId = this.$route.params.id;
diff --git a/ui/src/components/executions/Overview.vue b/ui/src/components/executions/Overview.vue
index 7c07e2e18..bec0681b0 100644
--- a/ui/src/components/executions/Overview.vue
+++ b/ui/src/components/executions/Overview.vue
@@ -48,7 +48,7 @@
 
         <div v-if="execution.trigger" class="mt-4">
             <h5>{{ $t("trigger") }}</h5>
-            <vars :execution="execution" :data="execution.trigger" />
+            <vars :execution="execution" :data="triggerVariables" />
         </div>
 
         <div v-if="execution.inputs" class="mt-4">
@@ -183,6 +183,14 @@
                     })
                 })
                 return inputs;
+            },
+            // This is used to display correctly trigger variables
+            triggerVariables() {
+                let trigger = this.execution.trigger
+                trigger["trigger"] = this.execution.trigger.variables
+                delete trigger["variables"]
+
+                return trigger
             }
         },
     };
diff --git a/ui/src/components/executions/Restart.vue b/ui/src/components/executions/Restart.vue
index 7f8feba9c..b8cb9ee0a 100644
--- a/ui/src/components/executions/Restart.vue
+++ b/ui/src/components/executions/Restart.vue
@@ -48,7 +48,7 @@
 
         <p v-html="$t(replayOrRestart + ' confirm', {id: execution.id})" />
 
-        <el-form>
+        <el-form v-if="revisionsOptions && revisionsOptions.length > 1">
             <p class="text-muted">
                 {{ $t("restart change revision") }}
             </p>
diff --git a/ui/src/components/executions/Vars.vue b/ui/src/components/executions/Vars.vue
index 47bdd9bbd..ba6814a3c 100644
--- a/ui/src/components/executions/Vars.vue
+++ b/ui/src/components/executions/Vars.vue
@@ -1,8 +1,8 @@
 <template>
     <el-table stripe table-layout="auto" fixed :data="variables">
-        <el-table-column prop="key" rowspan="3" :label="$t('name')">
+        <el-table-column prop="key" min-width="500" :label="$t('name')">
             <template #default="scope">
-                <code>{{ scope.row.key }}</code>
+                <code class="key-col">{{ scope.row.key }}</code>
             </template>
         </el-table-column>
 
@@ -50,3 +50,8 @@
         },
     };
 </script>
+<style>
+    .key-col {
+        min-width: 200px;
+    }
+</style>
\ No newline at end of file
diff --git a/ui/src/components/executions/date-select/RelativeDateSelect.vue b/ui/src/components/executions/date-select/RelativeDateSelect.vue
index 7cdee5d70..044c8dbb0 100644
--- a/ui/src/components/executions/date-select/RelativeDateSelect.vue
+++ b/ui/src/components/executions/date-select/RelativeDateSelect.vue
@@ -40,6 +40,10 @@
                         value: "P1D",
                         label: "datepicker.last24hours"
                     },
+                    {
+                        value: "P2D",
+                        label: "datepicker.last48hours"
+                    },
                     {
                         value: "P7D",
                         label: "datepicker.last7days"
diff --git a/ui/src/components/flows/FlowDependencies.vue b/ui/src/components/flows/FlowDependencies.vue
index f99ca96ad..2c0f72f96 100644
--- a/ui/src/components/flows/FlowDependencies.vue
+++ b/ui/src/components/flows/FlowDependencies.vue
@@ -151,7 +151,6 @@
             params: {
                 "namespace": data.namespace,
                 "id": data.flowId,
-                tab: "dependencies",
                 tenant: route.params.tenant
             },
         });
diff --git a/ui/src/components/flows/FlowDependenciesBlock.vue b/ui/src/components/flows/FlowDependenciesBlock.vue
deleted file mode 100644
index 2dbb590c5..000000000
--- a/ui/src/components/flows/FlowDependenciesBlock.vue
+++ /dev/null
@@ -1,138 +0,0 @@
-<template>
-    <Handle type="source" position="right" />
-    <div class="deps-block-wrapper" @mouseover="mouseover" @mouseleave="mouseleave">
-        <div>
-            <el-tooltip placement="top" :persistent="false" :hide-after="0" transition="" :content="node.namespace">
-                <div class="namespace">
-                    {{ node.namespace === undefined ? "?" : node.namespace }}
-                </div>
-            </el-tooltip>
-
-            <el-tooltip placement="top" :persistent="false" :hide-after="0" transition="" :content="node.id">
-                <div class="flow-id">
-                    <code>{{ node.id === undefined ? $t('dependencies missing acls') : node.id }}</code>
-                </div>
-            </el-tooltip>
-        </div>
-        <div>
-            <el-button-group size="small" vertical>
-                <el-button @click="expand" :disabled="isExpandDisabled">
-                    <kicon :tooltip="$t('see dependencies')" placement="right">
-                        <arrow-expand-all />
-                    </kicon>
-                </el-button>
-
-                <el-button @click="link" :disabled="isFlowLinkDisabled">
-                    <kicon :tooltip="$t('sub flow')" placement="right">
-                        <link-variant />
-                    </kicon>
-                </el-button>
-            </el-button-group>
-        </div>
-    </div>
-    <Handle type="target" position="left" />
-</template>
-
-<script>
-    import {Handle} from "@vue-flow/core"
-    import Kicon from "../../components/Kicon.vue";
-    import ArrowExpandAll from "vue-material-design-icons/ArrowExpandAll.vue";
-    import LinkVariant from "vue-material-design-icons/LinkVariant.vue";
-
-    export default {
-        components:{
-            Handle,
-            Kicon,
-            ArrowExpandAll,
-            LinkVariant,
-        },
-        props: {
-            node: {
-                type: Object,
-                required: true
-            },
-            loaded: {
-                type: Boolean,
-                required: true
-            },
-        },
-        emits: ["expand", "mouseover", "mouseleave"],
-        methods: {
-            expand() {
-                this.$emit("expand", this.node);
-            },
-            mouseover() {
-                this.$emit("mouseover", this.node);
-            },
-            mouseleave() {
-                this.$emit("mouseleave", this.node);
-            },
-            link() {
-                this.$router.push({
-                    name: "flows/update",
-                    params: {
-                        "namespace": this.node.namespace,
-                        "id": this.node.id,
-                        tab: "dependencies",
-                        tenant: this.$route.params.tenant
-                    },
-                });
-            }
-        },
-        computed: {
-            isExpandDisabled() {
-                return this.noRight || this.loaded;
-            },
-            isFlowLinkDisabled() {
-                return this.noRight || (this.$route.params.id === this.node.id && this.$route.params.namespace === this.node.namespace)
-            },
-            noRight() {
-                return this.node.namespace === undefined || this.node.id === undefined;
-            },
-        }
-    };
-</script>
-
-<style lang="scss" scoped>
-    .deps-block-wrapper {
-        font-size: var(--font-size-xs);
-        background: var(--bs-gray-100);
-        height: 60px;
-        text-align: center;
-        display: flex;
-        width: 248px;
-
-        > div {
-            flex-direction: column;
-        }
-
-        .el-button-group {
-            height: 100%;
-        }
-
-        .el-button {
-            border-radius: 0;
-            height: 30px;
-        }
-
-        .namespace, .flow-id {
-            padding: 6px ;
-            width: 212px;
-            white-space: nowrap;
-            overflow: hidden;
-            text-overflow: ellipsis;
-        }
-
-        .namespace {
-            border-bottom: 1px solid var(--table-border-color);
-            background-color: var(--bs-gray-200);
-            html.dark & {
-                background-color: var(--bs-gray-300);
-            }
-        }
-
-        .flow-id {
-            font-size: 100%;
-        }
-    }
-</style>
diff --git a/ui/src/components/inputs/Editor.vue b/ui/src/components/inputs/Editor.vue
index 13e46ec6b..44ea86c05 100644
--- a/ui/src/components/inputs/Editor.vue
+++ b/ui/src/components/inputs/Editor.vue
@@ -77,6 +77,7 @@
             navbar: {type: Boolean, default: true},
             input: {type: Boolean, default: false},
             fullHeight: {type: Boolean, default: true},
+            customHeight: {type: Number, default: 7},
             theme: {type: String, default: undefined},
             placeholder: {type: [String, Number], default: ""},
             diffSideBySide: {type: Boolean, default: true},
@@ -298,7 +299,7 @@
 
                 if (!this.fullHeight) {
                     editor.onDidContentSizeChange(e => {
-                        this.$refs.container.style.height = (e.contentHeight + 7) + "px";
+                        this.$refs.container.style.height = (e.contentHeight + this.customHeight) + "px";
                     });
                 }
 
diff --git a/ui/src/components/inputs/EditorView.vue b/ui/src/components/inputs/EditorView.vue
index 748e67288..e8269741c 100644
--- a/ui/src/components/inputs/EditorView.vue
+++ b/ui/src/components/inputs/EditorView.vue
@@ -173,6 +173,7 @@
     const isLoading = ref(false);
     const haveChange = ref(props.isDirty)
     const flowYaml = ref("")
+    const flowYamlOrigin = ref("")
     const newTrigger = ref(null)
     const isNewTriggerOpen = ref(false)
     const newError = ref(null)
@@ -235,7 +236,7 @@
 
     const initYamlSource = async () => {
         flowYaml.value = props.flow.source;
-
+        flowYamlOrigin.value = props.flow.source;
         if (flowHaveTasks()) {
             if ([editorViewTypes.TOPOLOGY, editorViewTypes.SOURCE_TOPOLOGY].includes(viewType.value)) {
                 await fetchGraph();
@@ -759,7 +760,7 @@
             :is-read-only="props.isReadOnly"
             :can-delete="canDelete()"
             :is-allowed-edit="isAllowedEdit()"
-            :have-change="haveChange"
+            :have-change="flowYaml !== flowYamlOrigin"
             :flow-have-tasks="flowHaveTasks()"
             :errors="flowErrors"
             :warnings="flowWarnings"
diff --git a/ui/src/components/layout/Pagination.vue b/ui/src/components/layout/Pagination.vue
index 5b32b488a..07b6fb745 100644
--- a/ui/src/components/layout/Pagination.vue
+++ b/ui/src/components/layout/Pagination.vue
@@ -39,6 +39,8 @@
     </div>
 </template>
 <script>
+    import {storageKeys} from "../../utils/constants";
+
     export default {
         props: {
             total: {type: Number, default: 0},
@@ -61,14 +63,22 @@
         },
         methods: {
             initState() {
+                let internalSize = parseInt(localStorage.getItem(storageKeys.PAGINATION_SIZE) || this.$route.query.size || this.size)
+                let internalPage = parseInt(this.$route.query.page || this.page)
+                this.$emit("page-changed", {
+                    page: internalPage,
+                    size: internalSize,
+                });
+
                 return {
-                    internalSize: parseInt(this.$route.query.size || this.size),
-                    internalPage: parseInt(this.$route.query.page || this.page)
+                    internalSize: internalSize,
+                    internalPage: internalPage
                 }
             },
-            pageSizeChange(value) {
+            pageSizeChange: function (value) {
                 this.internalPage = 1;
                 this.internalSize = value;
+                localStorage.setItem(storageKeys.PAGINATION_SIZE, value);
                 this.$emit("page-changed", {
                     page: 1,
                     size: this.internalSize,
diff --git a/ui/src/components/layout/TopNavBar.vue b/ui/src/components/layout/TopNavBar.vue
index 581478fdd..55de43842 100644
--- a/ui/src/components/layout/TopNavBar.vue
+++ b/ui/src/components/layout/TopNavBar.vue
@@ -27,14 +27,14 @@
                     <template #dropdown>
                         <el-dropdown-menu>
                             <a
-                                href="https://kestra.io/slack"
+                                href="https://kestra.io/slack?utm_source=app&utm_content=top-nav-bar"
                                 target="_blank"
                                 class="d-flex gap-2 el-dropdown-menu__item"
                             >
                                 <HelpBox class="align-middle" /> {{ $t("live help") }}
                             </a>
                             <a
-                                href="https://kestra.io/docs"
+                                href="https://kestra.io/docs?utm_source=app&utm_content=top-nav-bar"
                                 target="_blank"
                                 class="d-flex gap-2 el-dropdown-menu__item"
                             >
@@ -54,12 +54,19 @@
                                 <Github class="align-middle" /> {{ $t("documentation.github") }}
                             </a>
                             <a
-                                href="https://kestra.io/slack"
+                                href="https://kestra.io/slack?utm_source=app&utm_content=top-nav-bar"
                                 target="_blank"
                                 class="d-flex gap-2 el-dropdown-menu__item"
                             >
                                 <Slack class="align-middle" /> {{ $t("join community") }}
                             </a>
+                            <a
+                                href="https://kestra.io/contact-us?utm_source=app&utm_content=top-nav-bar"
+                                target="_blank"
+                                class="d-flex gap-2 el-dropdown-menu__item"
+                            >
+                                <EmailHeartOutline class="align-middle" /> {{ $t("reach us") }}
+                            </a>
                             <a
                                 v-if="version"
                                 :href="version.url"
@@ -86,6 +93,7 @@
     import GoogleCirclesExtended from "vue-material-design-icons/GoogleCirclesExtended.vue";
     import Github from "vue-material-design-icons/Github.vue";
     import Slack from "vue-material-design-icons/Slack.vue";
+    import EmailHeartOutline from "vue-material-design-icons/EmailHeartOutline.vue";
     import GlobalSearch from "./GlobalSearch.vue"
 
     export default {
@@ -97,6 +105,7 @@
             GoogleCirclesExtended,
             Github,
             Slack,
+            EmailHeartOutline,
             GlobalSearch
         },
         props: {
diff --git a/ui/src/components/onboarding/OnboardingBottom.vue b/ui/src/components/onboarding/OnboardingBottom.vue
index d9442f8fc..0bd1630a7 100644
--- a/ui/src/components/onboarding/OnboardingBottom.vue
+++ b/ui/src/components/onboarding/OnboardingBottom.vue
@@ -35,7 +35,7 @@
                         title: this.$t("get started"),
                         content: this.$t("get started content"),
                         imgClass: "started",
-                        link: "https://kestra.io/docs/getting-started/"
+                        link: "https://kestra.io/docs/getting-started/?utm_source=app&utm_content=welcome"
                     },
                     {
                         title: this.$t("watch demo"),
@@ -48,7 +48,7 @@
                         title: this.$t("need help?"),
                         content: this.$t("need help? content"),
                         imgClass: "help",
-                        link: "https://kestra.io/slack"
+                        link: "https://kestra.io/slack?utm_source=app&utm_content=welcome"
                     }
                 ]
             }
diff --git a/ui/src/translations.json b/ui/src/translations.json
index f2ea81353..9b8a64592 100644
--- a/ui/src/translations.json
+++ b/ui/src/translations.json
@@ -73,6 +73,7 @@
       "last1hour": "Last 1 hour",
       "last12hours": "Last 12 hours",
       "last24hours": "Last 24 hours",
+      "last48hours": "Last 48 hours",
       "last7days": "Last 7 days",
       "last30days": "Last 30 days",
       "last365days": "Last 365 days",
@@ -529,6 +530,7 @@
     "environment color setting": "Environment color",
     "slack support": "Ask any question via Slack",
     "join community": "Join the Community",
+    "reach us": "Reach out to us",
     "error detected": "Error(s) detected",
     "warning detected": "Warning(s) detected",
     "cannot swap tasks": "Can't swap the tasks",
@@ -1117,6 +1119,7 @@
     "log expand setting": "Affichage par défaut des journaux",
     "slack support": "Demandez de l'aide sur notre Slack",
     "join community": "Rejoignez la communauté",
+    "reach us": "Contactez-nous",
     "error detected": "Erreur(s) détectée(s)",
     "warning detected": "Avertissement(s) détecté(s)",
     "cannot swap tasks": "Impossible de permuter les tâches",
diff --git a/ui/src/utils/constants.js b/ui/src/utils/constants.js
index 2d0cc18b2..873cf9547 100644
--- a/ui/src/utils/constants.js
+++ b/ui/src/utils/constants.js
@@ -30,7 +30,8 @@ export const storageKeys = {
     SELECTED_TENANT: "selectedTenant",
     EXECUTE_FLOW_BEHAVIOUR: "executeFlowBehaviour",
     DEFAULT_NAMESPACE: "defaultNamespace",
-    LATEST_NAMESPACE: "latestNamespace"
+    LATEST_NAMESPACE: "latestNamespace",
+    PAGINATION_SIZE: "paginationSize",
 }
 
 export const executeFlowBehaviours = {


Test patch:
diff --git a/core/src/test/java/io/kestra/core/docs/DocumentationGeneratorTest.java b/core/src/test/java/io/kestra/core/docs/DocumentationGeneratorTest.java
index a095c5a7c..d8074a6e4 100644
--- a/core/src/test/java/io/kestra/core/docs/DocumentationGeneratorTest.java
+++ b/core/src/test/java/io/kestra/core/docs/DocumentationGeneratorTest.java
@@ -129,7 +129,7 @@ class DocumentationGeneratorTest {
         String render = DocumentationGenerator.render(doc);
 
         assertThat(render, containsString("Echo"));
-        assertThat(render, containsString("\uD83D\uDD12 Deprecated"));
+        assertThat(render, containsString("⚠ Deprecated"));
     }
 
     @Test
diff --git a/core/src/test/java/io/kestra/core/tasks/templating/TemplatedTaskTest.java b/core/src/test/java/io/kestra/core/tasks/templating/TemplatedTaskTest.java
new file mode 100644
index 000000000..50f651904
--- /dev/null
+++ b/core/src/test/java/io/kestra/core/tasks/templating/TemplatedTaskTest.java
@@ -0,0 +1,73 @@
+package io.kestra.core.tasks.templating;
+
+import io.kestra.core.models.tasks.Output;
+import io.kestra.core.runners.RunContext;
+import io.kestra.core.runners.RunContextFactory;
+import io.kestra.core.tasks.debugs.Return;
+import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
+import jakarta.inject.Inject;
+import org.junit.jupiter.api.Test;
+
+import java.util.Map;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.instanceOf;
+import static org.hamcrest.Matchers.notNullValue;
+import static org.hamcrest.core.Is.is;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+@MicronautTest
+class TemplatedTaskTest {
+
+    @Inject
+    private RunContextFactory runContextFactory;
+
+    @Test
+    void templatedType() throws Exception {
+        RunContext runContext = runContextFactory.of(Map.of("type", "io.kestra.core.tasks.debugs.Return"));
+        TemplatedTask templatedTask = TemplatedTask.builder()
+            .id("template")
+            .type(TemplatedTask.class.getName())
+            .spec("""
+                type: {{ type }}
+                format: It's alive!""")
+            .build();
+
+        Output output = templatedTask.run(runContext);
+
+        assertThat(output, notNullValue());
+        assertThat(output, instanceOf(Return.Output.class));
+        assertThat(((Return.Output)output).getValue(), is("It's alive!"));
+    }
+
+    @Test
+    void templatedFlowable() {
+        RunContext runContext = runContextFactory.of();
+        TemplatedTask templatedTask = TemplatedTask.builder()
+            .id("template")
+            .type(TemplatedTask.class.getName())
+            .spec("""
+                type: io.kestra.core.tasks.flows.Pause
+                delay: PT10S""")
+            .build();
+
+        var exception = assertThrows(IllegalArgumentException.class, () -> templatedTask.run(runContext));
+        assertThat(exception.getMessage(), is("The templated task must be a runnable task"));
+    }
+
+    @Test
+    void templatedTemplated() {
+        RunContext runContext = runContextFactory.of();
+        TemplatedTask templatedTask = TemplatedTask.builder()
+            .id("template")
+            .type(TemplatedTask.class.getName())
+            .spec("""
+                type: io.kestra.core.tasks.templating.TemplatedTask
+                spec: whatever""")
+            .build();
+
+        var exception = assertThrows(IllegalArgumentException.class, () -> templatedTask.run(runContext));
+        assertThat(exception.getMessage(), is("The templated task cannot be of type 'io.kestra.core.tasks.templating.TemplatedTask'"));
+    }
+
+}
\ No newline at end of file