Repository: kestra-io/kestra
Pull request #3052 (2024-02-13 09:43:20)

feat(core): disable trigger

Skraye: closes #2912 

Patch:
diff --git a/core/src/main/java/io/kestra/core/models/triggers/Trigger.java b/core/src/main/java/io/kestra/core/models/triggers/Trigger.java
index 369312ce4..f23640730 100644
--- a/core/src/main/java/io/kestra/core/models/triggers/Trigger.java
+++ b/core/src/main/java/io/kestra/core/models/triggers/Trigger.java
@@ -6,10 +6,7 @@ import io.kestra.core.models.flows.Flow;
 import io.kestra.core.models.flows.State;
 import io.kestra.core.utils.IdUtils;
 import io.micronaut.core.annotation.Nullable;
-import lombok.EqualsAndHashCode;
-import lombok.Getter;
-import lombok.NoArgsConstructor;
-import lombok.ToString;
+import lombok.*;
 import lombok.experimental.SuperBuilder;
 
 import java.time.Instant;
@@ -91,6 +88,7 @@ public class Trigger extends TriggerContext {
             .triggerId(triggerContext.getTriggerId())
             .date(triggerContext.getDate())
             .backfill(triggerContext.getBackfill())
+            .disabled(triggerContext.getDisabled())
             .build();
     }
 
@@ -107,6 +105,7 @@ public class Trigger extends TriggerContext {
             .date(triggerContext.getDate())
             .nextExecutionDate(nextExecutionDate)
             .backfill(triggerContext.getBackfill())
+            .disabled(triggerContext.getDisabled())
             .build();
     }
 
@@ -127,6 +126,7 @@ public class Trigger extends TriggerContext {
             .updatedDate(Instant.now())
             .nextExecutionDate(triggerContext.getNextExecutionDate())
             .backfill(triggerContext.getBackfill())
+            .disabled(triggerContext.getDisabled())
             .build();
     }
 
@@ -148,6 +148,7 @@ public class Trigger extends TriggerContext {
             .updatedDate(Instant.now())
             .nextExecutionDate(nextExecutionDate)
             .backfill(triggerContext.getBackfill())
+            .disabled(triggerContext.getDisabled())
             .build();
     }
 
@@ -169,6 +170,7 @@ public class Trigger extends TriggerContext {
             .executionCurrentState(execution.getState().getCurrent())
             .updatedDate(Instant.now())
             .backfill(trigger.getBackfill())
+            .disabled(trigger.getDisabled())
             .build();
     }
 
@@ -189,6 +191,7 @@ public class Trigger extends TriggerContext {
             .evaluateRunningDate(evaluateRunningDate)
             .updatedDate(Instant.now())
             .backfill(trigger.getBackfill())
+            .disabled(trigger.getDisabled())
             .build();
     }
 
@@ -201,6 +204,7 @@ public class Trigger extends TriggerContext {
             .triggerId(abstractTrigger.getId())
             .date(ZonedDateTime.now().truncatedTo(ChronoUnit.SECONDS))
             .nextExecutionDate(((PollingTriggerInterface) abstractTrigger).nextEvaluationDate(conditionContext, lastTrigger))
+            .disabled(lastTrigger.map(TriggerContext::getDisabled).orElse(Boolean.FALSE))
             .build();
     }
 
@@ -226,6 +230,7 @@ public class Trigger extends TriggerContext {
 
         return updated.toBuilder()
             .nextExecutionDate(ZonedDateTime.now())
+            .disabled(newTrigger.getDisabled())
             .build();
     }
 
diff --git a/core/src/main/java/io/kestra/core/models/triggers/TriggerContext.java b/core/src/main/java/io/kestra/core/models/triggers/TriggerContext.java
index b9761443b..07ad964b9 100644
--- a/core/src/main/java/io/kestra/core/models/triggers/TriggerContext.java
+++ b/core/src/main/java/io/kestra/core/models/triggers/TriggerContext.java
@@ -1,18 +1,17 @@
 package io.kestra.core.models.triggers;
 
-import io.kestra.core.models.TenantInterface;
 import io.kestra.core.utils.IdUtils;
 import io.micronaut.core.annotation.Introspected;
 import io.micronaut.core.annotation.Nullable;
-import io.swagger.v3.oas.annotations.Hidden;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Pattern;
+import lombok.Builder;
 import lombok.Getter;
 import lombok.NoArgsConstructor;
 import lombok.ToString;
 import lombok.experimental.SuperBuilder;
 
 import java.time.ZonedDateTime;
-import jakarta.validation.constraints.NotNull;
-import jakarta.validation.constraints.Pattern;
 
 @SuperBuilder(toBuilder = true)
 @ToString
@@ -44,6 +43,9 @@ public class TriggerContext {
     @Nullable
     private Backfill backfill;
 
+    @Builder.Default
+    private Boolean disabled = Boolean.FALSE;
+
     public String uid() {
         return uid(this);
     }
diff --git a/core/src/main/java/io/kestra/core/schedulers/AbstractScheduler.java b/core/src/main/java/io/kestra/core/schedulers/AbstractScheduler.java
index f456c9b93..f12746d80 100644
--- a/core/src/main/java/io/kestra/core/schedulers/AbstractScheduler.java
+++ b/core/src/main/java/io/kestra/core/schedulers/AbstractScheduler.java
@@ -299,23 +299,13 @@ public abstract class AbstractScheduler implements Scheduler {
                     .abstractTrigger(flowWithTriggers.getAbstractTrigger())
                     .pollingTrigger((PollingTriggerInterface) flowWithTriggers.getAbstractTrigger())
                     .conditionContext(flowWithTriggers.getConditionContext())
-                    .triggerContext(Trigger
-                        .builder()
-                        .tenantId(flowWithTriggers.getFlow().getTenantId())
-                        .namespace(flowWithTriggers.getFlow().getNamespace())
-                        .flowId(flowWithTriggers.getFlow().getId())
-                        .flowRevision(flowWithTriggers.getFlow().getRevision())
-                        .triggerId(flowWithTriggers.getAbstractTrigger().getId())
-                        .date(now())
-                        .nextExecutionDate(flowWithTriggers.getTriggerContext().getNextExecutionDate())
-                        .backfill(flowWithTriggers.getTriggerContext().getBackfill())
-                        .build()
-                    )
+                    .triggerContext(flowWithTriggers.TriggerContext.toBuilder().date(now()).build())
                     .build())
-                .filter(f -> f.getTriggerContext().getEvaluateRunningDate() == null)
+                .filter(f -> f.getTriggerContext().getEvaluateRunningDate() == null && !f.getTriggerContext().getDisabled())
                 .filter(this::isExecutionNotRunning)
                 .map(f -> {
                     try {
+
                         return FlowWithPollingTriggerNextDate.of(f);
                     } catch (Exception e) {
                         logError(f, e);
diff --git a/ui/src/components/executions/date-select/DateFilter.vue b/ui/src/components/executions/date-select/DateFilter.vue
index ebf276c69..76fccaaa5 100644
--- a/ui/src/components/executions/date-select/DateFilter.vue
+++ b/ui/src/components/executions/date-select/DateFilter.vue
@@ -19,7 +19,6 @@
     <relative-date-select
         v-if="selectedFilterType === filterType.RELATIVE"
         :start-range="startRange"
-        :end-range="endRange"
         @update:model-value="onRelFilterChange($event)"
     />
 </template>
@@ -52,10 +51,7 @@
         },
         computed: {
             startRange() {
-                return this.$route.query.startDateRange ? this.$route.query.startDateRange : "P1M";
-            },
-            endRange() {
-                return this.$route.query.endDateRange ? this.$route.query.endDateRange : "PT0";
+                return this.$route.query.startDateRange ? this.$route.query.startDateRange : "P30D";
             },
             startDate() {
                 return this.$route.query.startDate ? this.$route.query.startDate : this.$moment(this.endDate).add(-30, "days").toISOString(true);
@@ -81,8 +77,7 @@
                 const filter = {
                     "startDate": undefined,
                     "endDate": undefined,
-                    "startDateRange": event.startDateRange,
-                    "endDateRange": event.endDateRange
+                    "startDateRange": event.startDateRange
                 };
                 this.updateFilter(filter);
             },
diff --git a/ui/src/components/executions/date-select/RelativeDateSelect.vue b/ui/src/components/executions/date-select/RelativeDateSelect.vue
index 1616f4638..665257b17 100644
--- a/ui/src/components/executions/date-select/RelativeDateSelect.vue
+++ b/ui/src/components/executions/date-select/RelativeDateSelect.vue
@@ -1,16 +1,10 @@
 <template>
     <date-select
         :value="startRange"
-        :options="applicableFilterPresets"
+        :options="timeFilterPresets"
         :tooltip="$t('relative start date')"
         @change="onChangeStart($event)"
     />
-    <date-select
-        :value="endRange"
-        :options="applicableFilterPresetsEnd"
-        :tooltip="$t('relative end date')"
-        @change="onChangeEnd($event)"
-    />
 </template>
 
 <script>
@@ -47,16 +41,16 @@
                         label: "datepicker.last24hours"
                     },
                     {
-                        value: "P1W",
-                        label: "datepicker.thisWeekSoFar"
+                        value: "P7D",
+                        label: "datepicker.last7days"
                     },
                     {
-                        value: "P1M",
-                        label: "datepicker.thisMonthSoFar"
+                        value: "P30D",
+                        label: "datepicker.last30days"
                     },
                     {
-                        value: "P1Y",
-                        label: "datepicker.thisYearSoFar"
+                        value: "P365D",
+                        label: "datepicker.last365days"
                     }
                 ]
             }
@@ -65,30 +59,12 @@
             startRange: {
                 type: String,
                 default: undefined
-            },
-            endRange: {
-                type: String,
-                default: undefined
-            }
-        },
-        computed: {
-            timeFilterPresetsEnd() {
-                return [ {value: "PT0", label: "now"} ].concat(this.timeFilterPresets);
-            },
-            applicableFilterPresets() {
-                return this.timeFilterPresets.filter((filterPreset) => this.$moment.duration(filterPreset.value) > this.$moment.duration(this.endRange));
-            },
-            applicableFilterPresetsEnd() {
-                return this.timeFilterPresetsEnd.filter((filterPreset) => this.$moment.duration(filterPreset.value) < this.$moment.duration(this.startRange));
             }
         },
         methods: {
             onChangeStart(range) {
                 this.$emit("update:modelValue", {"startDateRange": range, "endDateRange": this.endRange});
             },
-            onChangeEnd(range) {
-                this.$emit("update:modelValue", {"startDateRange": this.startRange, "endDateRange": range});
-            }
         }
     }
 </script>
\ No newline at end of file
diff --git a/ui/src/components/flows/FlowTriggers.vue b/ui/src/components/flows/FlowTriggers.vue
index 52e66b2a5..7a7425240 100644
--- a/ui/src/components/flows/FlowTriggers.vue
+++ b/ui/src/components/flows/FlowTriggers.vue
@@ -68,6 +68,29 @@
             </template>
         </el-table-column>
 
+        <el-table-column column-key="disable" class-name="row-action">
+            <template #default="scope">
+                <el-switch
+                    size="small"
+                    :active-text="$t('enabled')"
+                    :value="!scope.row.disabled"
+                    @change="setDisabled(scope.row, $event)"
+                    class="switch-text"
+                    :active-action-icon="Check"
+                />
+            </template>
+        </el-table-column>
+
+        <el-table-column column-key="unlock" class-name="row-action">
+            <template #default="scope">
+                <a href="#" v-if="scope.row.executionId">
+                    <kicon :tooltip="$t('unlock')">
+                        <lock-off />
+                    </kicon>
+                </a>
+            </template>
+        </el-table-column>
+
         <el-table-column column-key="action" class-name="row-action">
             <template #default="scope">
                 <a href="#" @click="triggerId = scope.row.id; isOpen = true">
@@ -162,6 +185,8 @@
     import Delete from "vue-material-design-icons/Delete.vue";
     import Vars from "../executions/Vars.vue";
     import LabelInput from "../labels/LabelInput.vue";
+    import LockOff from "vue-material-design-icons/LockOff.vue";
+    import Check from "vue-material-design-icons/Check.vue";
 </script>
 
 <script>
@@ -289,6 +314,12 @@
                     .then(_ => {
                         this.loadData();
                     })
+            },
+            setDisabled(trigger, value) {
+                this.$store.dispatch("trigger/update", {...trigger, disabled: !value})
+                    .then(_ => {
+                        this.loadData();
+                    })
             }
         }
     };
diff --git a/ui/src/styles/layout/element-plus-overload.scss b/ui/src/styles/layout/element-plus-overload.scss
index a15dfc920..78769cc85 100644
--- a/ui/src/styles/layout/element-plus-overload.scss
+++ b/ui/src/styles/layout/element-plus-overload.scss
@@ -873,4 +873,10 @@ form.ks-horizontal {
             color: var(--bs-gray-700) !important;
         }
     }
-}
\ No newline at end of file
+}
+
+
+.el-switch--small .el-switch__label {
+    color: var(--bs-white);
+    font-weight: bold;
+}
diff --git a/ui/src/translations.json b/ui/src/translations.json
index b8f2b49fa..fcddabbfb 100644
--- a/ui/src/translations.json
+++ b/ui/src/translations.json
@@ -73,6 +73,9 @@
       "last1hour": "Last 1 hour",
       "last12hours": "Last 12 hours",
       "last24hours": "Last 24 hours",
+      "last7days": "Last 7 days",
+      "last30days": "Last 30 days",
+      "last365days": "Last 365 days",
       "today": "Today",
       "yesterday": "Yesterday",
       "dayBeforeYesterday": "Day before yesterday",
@@ -351,6 +354,7 @@
     "import": "Import",
     "disable": "Disable",
     "enable": "Enable",
+    "enabled": "Enabled",
     "template delete": "Are you sure you want to delete <code>{templateCount}</code> template(s)?",
     "flow delete": "Are you sure you want to delete <code>{flowCount}</code> flow(s)?",
     "templates deleted": "<code>{count}</code> Template(s) deleted",
@@ -935,6 +939,7 @@
     "import": "Importer",
     "disable": "Désactiver",
     "enable": "Activer",
+    "enabled": "Activé",
     "environment name setting": "Nom de l'environnement",
     "environment color setting": "Couleur de l'environnement",
     "template delete": "Êtes vous sûr de vouloir supprimer <code>{templateCount}</code> template(s)?",
diff --git a/webserver/src/main/java/io/kestra/webserver/controllers/ExecutionController.java b/webserver/src/main/java/io/kestra/webserver/controllers/ExecutionController.java
index 0a8779041..23197a892 100644
--- a/webserver/src/main/java/io/kestra/webserver/controllers/ExecutionController.java
+++ b/webserver/src/main/java/io/kestra/webserver/controllers/ExecutionController.java
@@ -169,10 +169,6 @@ public class ExecutionController {
             @ExampleObject(name = "Filter last 5 minutes", value = "PT5M"),
             @ExampleObject(name = "Filter last 24 hours", value = "P1D")
         }) @Nullable @QueryValue Duration startDateRange,
-        @Parameter(description = "An end time range filter relative to the current time", examples = {
-            @ExampleObject(name = "Filter up to 5 minutes", value = "PT5M"),
-            @ExampleObject(name = "Filter up to 24 hours", value = "P1D")
-        }) @Nullable @QueryValue Duration endDateRange,
         @Parameter(description = "A state filter") @Nullable @QueryValue List<State.Type> state,
         @Parameter(description = "A labels filter as a list of 'key:value'") @Nullable @QueryValue List<String> labels,
         @Parameter(description = "The trigger execution id") @Nullable @QueryValue String triggerExecutionId,
@@ -187,7 +183,7 @@ public class ExecutionController {
             namespace,
             flowId,
             resolveAbsoluteDateTime(startDate, startDateRange, now),
-            resolveAbsoluteDateTime(endDate, endDateRange, now),
+            null,
             state,
             RequestUtils.toMap(labels),
             triggerExecutionId,


Test patch:
diff --git a/core/src/test/java/io/kestra/core/schedulers/SchedulerScheduleTest.java b/core/src/test/java/io/kestra/core/schedulers/SchedulerScheduleTest.java
index 30419e250..3f8eb81e3 100644
--- a/core/src/test/java/io/kestra/core/schedulers/SchedulerScheduleTest.java
+++ b/core/src/test/java/io/kestra/core/schedulers/SchedulerScheduleTest.java
@@ -68,6 +68,7 @@ public class SchedulerScheduleTest extends AbstractSchedulerTest {
         );
     }
 
+
     @Test
     void schedule() throws Exception {
         // mock flow listeners
@@ -151,7 +152,7 @@ public class SchedulerScheduleTest extends AbstractSchedulerTest {
         // mock flow listeners
         FlowListeners flowListenersServiceSpy = spy(this.flowListenersService);
 
-        Flow flow = createScheduleFlow("Europe/Paris", "schedule", false);
+        Flow flow = createScheduleFlow("Europe/Paris", "retroSchedule", false);
 
         doReturn(List.of(flow))
             .when(flowListenersServiceSpy)
@@ -159,7 +160,7 @@ public class SchedulerScheduleTest extends AbstractSchedulerTest {
 
         Trigger trigger = Trigger
             .builder()
-            .triggerId("schedule")
+            .triggerId("retroSchedule")
             .flowId(flow.getId())
             .namespace(flow.getNamespace())
             .date(ZonedDateTime.now())
@@ -176,7 +177,7 @@ public class SchedulerScheduleTest extends AbstractSchedulerTest {
             Await.until(() -> {
                 Optional<Trigger> optionalTrigger = this.triggerState.findLast(trigger);
                 return optionalTrigger.filter(value -> value.getNextExecutionDate() != null).isPresent();
-            });
+            }, Duration.ofSeconds(1), Duration.ofSeconds(60));
 
             assertThat(this.triggerState.findLast(trigger).get().getNextExecutionDate().isAfter(trigger.getDate()), is(true));
         }
@@ -231,7 +232,7 @@ public class SchedulerScheduleTest extends AbstractSchedulerTest {
 
             Await.until(() -> {
                 Optional<Trigger> optionalTrigger = this.triggerState.findLast(lastTrigger);
-                return optionalTrigger.filter(value -> value.getBackfill() != null ).isPresent();
+                return optionalTrigger.filter(value -> value.getBackfill() != null).isPresent();
             }, Duration.ofSeconds(1), Duration.ofSeconds(15));
 
             Trigger lastTrigger2 = this.triggerState.findLast(trigger).get();
@@ -241,4 +242,47 @@ public class SchedulerScheduleTest extends AbstractSchedulerTest {
 
         }
     }
+
+    @Test
+    void disabled() throws Exception {
+        // mock flow listeners
+        FlowListeners flowListenersServiceSpy = spy(this.flowListenersService);
+        String triggerId = "disabled";
+
+        Flow flow = createScheduleFlow("Europe/Paris", triggerId, false);
+
+        doReturn(List.of(flow))
+            .when(flowListenersServiceSpy)
+            .flows();
+
+        ZonedDateTime now = ZonedDateTime.now().truncatedTo(ChronoUnit.HOURS);
+        // Shortcut the scheduler and create trigger before it
+        // and set the trigger to disabled with a specific nextExecutionDate
+        Trigger trigger = Trigger
+            .builder()
+            .triggerId(triggerId)
+            .flowId(flow.getId())
+            .namespace(flow.getNamespace())
+            .date(ZonedDateTime.now())
+            .nextExecutionDate(now)
+            .disabled(true)
+            .build();
+
+        triggerState.create(trigger);
+
+        // scheduler
+        try (AbstractScheduler scheduler = scheduler(flowListenersServiceSpy);
+             Worker worker = new TestMethodScopedWorker(applicationContext, 8, null)) {
+            worker.run();
+            scheduler.run();
+
+            // Wait 5s to see if things happen
+            Thread.sleep(5000);
+
+            Trigger lastTrigger = this.triggerState.findLast(trigger).get();
+
+            // Nothing changed because nothing happened
+            assertThat(lastTrigger.getNextExecutionDate().truncatedTo(ChronoUnit.HOURS).isEqual(now), is(true));
+        }
+    }
 }
diff --git a/webserver/src/test/java/io/kestra/webserver/controllers/ExecutionControllerTest.java b/webserver/src/test/java/io/kestra/webserver/controllers/ExecutionControllerTest.java
index 613eec5ad..d501f71ec 100644
--- a/webserver/src/test/java/io/kestra/webserver/controllers/ExecutionControllerTest.java
+++ b/webserver/src/test/java/io/kestra/webserver/controllers/ExecutionControllerTest.java
@@ -700,6 +700,19 @@ class ExecutionControllerTest extends JdbcH2ControllerTest {
 
         assertThat(e.getStatus(), is(HttpStatus.UNPROCESSABLE_ENTITY));
         assertThat(e.getResponse().getBody(String.class).get(), containsString("are mutually exclusive"));
+
+        executions = client.toBlocking().retrieve(
+            HttpRequest.GET("/api/v1/executions/search?startDateRange=PT12H"), PagedResults.class
+        );
+
+        assertThat(executions.getTotal(), is(1L));
+
+        e = assertThrows(
+            HttpClientResponseException.class,
+            () -> client.toBlocking().retrieve(HttpRequest.GET("/api/v1/executions/search?startDateRange=P1Y"))
+        );
+
+        assertThat(e.getStatus(), is(HttpStatus.UNPROCESSABLE_ENTITY));
     }
 
     @Test
diff --git a/webserver/src/test/java/io/kestra/webserver/controllers/TriggerControllerTest.java b/webserver/src/test/java/io/kestra/webserver/controllers/TriggerControllerTest.java
index ae45734c5..85a058a4f 100644
--- a/webserver/src/test/java/io/kestra/webserver/controllers/TriggerControllerTest.java
+++ b/webserver/src/test/java/io/kestra/webserver/controllers/TriggerControllerTest.java
@@ -152,6 +152,7 @@ class TriggerControllerTest extends JdbcH2ControllerTest {
             .namespace("io.kestra.unittest")
             .triggerId("controllerUpdated")
             .executionId(IdUtils.create())
+            .disabled(true)
             .build();
 
         jdbcTriggerRepository.create(trigger);
@@ -159,14 +160,15 @@ class TriggerControllerTest extends JdbcH2ControllerTest {
         Trigger updatedBad = trigger
             .toBuilder()
             .executionId("hello")
+            .disabled(false)
             .build();
 
-        Trigger afterUpdatedBad = client.toBlocking().retrieve(HttpRequest.PUT(("/api/v1/triggers"), updatedBad), Trigger.class);
+        Trigger afterUpdated = client.toBlocking().retrieve(HttpRequest.PUT(("/api/v1/triggers"), updatedBad), Trigger.class);
 
         // Assert that executionId cannot be edited
-        assertThat(afterUpdatedBad.getExecutionId(), not("hello"));
-
-
+        assertThat(afterUpdated.getExecutionId(), not("hello"));
+        // Assert that disabled can be edited
+        assertThat(afterUpdated.getDisabled(), is(false));
     }
 
     @Test