Repository: kestra-io/kestra
Issue: Ability to unlock triggers from Administration tab
Labels: backend
enhancement

### Feature description

Following #1777, we should be able to unlock triggers locked in evaluation or execution

Issue comments:

Pull request review comments:
```suggestion
        "execution": "There is an execution running for this trigger",
```
```suggestion
        "execution": "Il existe une exécution en cours pour ce trigger",
```
```suggestion
    @Post(uri = "/{namespace}/{flowId}/{triggerId}/unlock")
```

PUT is usually used to update a resource, here you don't update the trigger with a new version but perform a specific action for this. Usually POST is used for such thing (what is called a non-resource scenario), this is a best practice when designing RESTful services.
Okay seems fair, in fact I went for a post initially but then I was like "but it actually modifies a trigger 'state'" :thinking: 
As you can see in the test ahah, I didn't even made the "put" change

Patch that resolved the issue:
diff --git a/ui/src/components/admin/Triggers.vue b/ui/src/components/admin/Triggers.vue
index 09c61bf9bb..46d0b6f918 100644
--- a/ui/src/components/admin/Triggers.vue
+++ b/ui/src/components/admin/Triggers.vue
@@ -86,13 +86,41 @@
                                 {{ scope.row.evaluateRunningDate ? $filters.date(scope.row.evaluateRunningDate, "iso") : "" }}
                             </template>
                         </el-table-column>
+                        <el-table-column column-key="action" class-name="row-action">
+                            <template #default="scope">
+                                <el-button text v-if="scope.row.executionId || scope.row.evaluateRunningDate">
+                                    <kicon
+                                        :tooltip="$t(`unlock trigger.tooltip.${scope.row.executionId ? 'execution' : 'evaluation'}`)"
+                                        placement="left" @click="triggerToUnlock = scope.row">
+                                        <lock-off />
+                                    </kicon>
+                                </el-button>
+                            </template>
+                        </el-table-column>
                     </el-table>
                 </template>
             </data-table>
+
+            <el-dialog v-model="triggerToUnlock" destroy-on-close :append-to-body="true">
+                <template #header>
+                    <span v-html="$t('unlock trigger.confirmation')" />
+                </template>
+                {{ $t("unlock trigger.warning") }}
+                <template #footer>
+                    <el-form-item class="submit">
+                        <el-button :icon="LockOff" @click="unlock" type="primary">
+                            {{ $t('unlock trigger.button') }}
+                        </el-button>
+                    </el-form-item>
+                </template>
+            </el-dialog>
         </div>
     </div>
 </template>
-
+<script setup>
+    import LockOff from "vue-material-design-icons/LockOff.vue";
+    import Kicon from "../Kicon.vue";
+</script>
 <script>
     import NamespaceSelect from "../namespace/NamespaceSelect.vue";
     import RouteContext from "../../mixins/routeContext";
@@ -115,7 +143,8 @@
         data() {
             return {
                 triggers: undefined,
-                total: undefined
+                total: undefined,
+                triggerToUnlock: undefined
             };
         },
         methods: {
@@ -131,8 +160,29 @@
                     this.total = triggersData.total;
                     callback();
                 });
+            },
+            async unlock() {
+                const namespace = this.triggerToUnlock.namespace;
+                const flowId = this.triggerToUnlock.flowId;
+                const triggerId = this.triggerToUnlock.triggerId;
+                const unlockedTrigger = await this.$store.dispatch("trigger/unlock", {
+                    namespace: namespace,
+                    flowId: flowId,
+                    triggerId: triggerId
+                });
+
+                this.$message({
+                    message: this.$t("trigger unlocked"),
+                    type: "success"
+                });
+
+                const triggerIdx = this.triggers.findIndex(trigger => trigger.namespace === namespace && trigger.flowId === flowId && trigger.triggerId === triggerId);
+                if (triggerIdx !== -1) {
+                    this.triggers[triggerIdx] = unlockedTrigger;
+                }
+
+                this.triggerToUnlock = undefined;
             }
         }
     };
-</script>
-
+</script>
\ No newline at end of file
diff --git a/ui/src/stores/trigger.js b/ui/src/stores/trigger.js
index 357151ddb1..a617697fba 100644
--- a/ui/src/stores/trigger.js
+++ b/ui/src/stores/trigger.js
@@ -10,6 +10,9 @@ export default {
             }).then(response => {
                 return response.data;
             })
+        },
+        async unlock({commit}, options) {
+            return (await this.$http.post(`/api/v1/triggers/${options.namespace}/${options.flowId}/${options.triggerId}/unlock`)).data;
         }
     }
 }
diff --git a/ui/src/translations.json b/ui/src/translations.json
index 7d5f901054..3034b70362 100644
--- a/ui/src/translations.json
+++ b/ui/src/translations.json
@@ -465,7 +465,16 @@
     "cannot swap tasks": "Can't swap the tasks",
     "dependency task": "Task {taskId} is a dependency of another task",
     "administration": "Administration",
-    "evaluation lock date": "Evaluation lock date"
+    "evaluation lock date": "Evaluation lock date",
+    "unlock trigger": {
+      "tooltip": {
+        "execution": "There is an execution running for this trigger",
+        "evaluation": "The trigger is currently in evaluation"
+      },
+      "confirmation": "Are you sure you want to unlock the trigger ?",
+      "warning": "It could lead to concurrent executions for the same trigger and should be considered as a last resort option.",
+      "button": "Unlock trigger"
+    }
   },
   "fr": {
     "id": "Identifiant",
@@ -935,6 +944,15 @@
     "cannot swap tasks": "Impossible de permuter les tâches",
     "dependency task": "La tâche {taskId} est une dépendance d'une autre tâche",
     "administration": "Administration",
-    "evaluation lock date": "Date verrou évaluation"
+    "evaluation lock date": "Date verrou évaluation",
+    "unlock trigger": {
+      "tooltip": {
+        "execution": "Il existe une exécution en cours pour ce trigger",
+        "evaluation": "Le déclencheur est en cours d'évaluation"
+      },
+      "confirmation": "Êtes vous sûr(e) de vouloir débloquer le déclencheur ?",
+      "warning": "Cela pourrait amener à de multiples exécutions concurrentes pour le même déclencheur et devrait être considéré comme une solution de dernier recours.",
+      "button": "Débloquer le déclencheur"
+    }
   }
 }
diff --git a/webserver/src/main/java/io/kestra/webserver/controllers/TriggerController.java b/webserver/src/main/java/io/kestra/webserver/controllers/TriggerController.java
index ce026498e4..bda87937ac 100644
--- a/webserver/src/main/java/io/kestra/webserver/controllers/TriggerController.java
+++ b/webserver/src/main/java/io/kestra/webserver/controllers/TriggerController.java
@@ -1,14 +1,15 @@
 package io.kestra.webserver.controllers;
 
 import io.kestra.core.models.triggers.Trigger;
+import io.kestra.core.models.triggers.TriggerContext;
+import io.kestra.core.queues.QueueInterface;
 import io.kestra.core.repositories.TriggerRepositoryInterface;
 import io.kestra.webserver.responses.PagedResults;
 import io.kestra.webserver.utils.PageableUtils;
 import io.micronaut.core.annotation.Nullable;
+import io.micronaut.http.HttpResponse;
 import io.micronaut.http.MediaType;
-import io.micronaut.http.annotation.Controller;
-import io.micronaut.http.annotation.Get;
-import io.micronaut.http.annotation.QueryValue;
+import io.micronaut.http.annotation.*;
 import io.micronaut.http.exceptions.HttpStatusException;
 import io.micronaut.scheduling.TaskExecutors;
 import io.micronaut.scheduling.annotation.ExecuteOn;
@@ -17,12 +18,16 @@
 import jakarta.inject.Inject;
 
 import java.util.List;
+import java.util.Optional;
 
 @Controller("/api/v1/triggers")
 public class TriggerController {
     @Inject
     private TriggerRepositoryInterface triggerRepository;
 
+    @Inject
+    private QueueInterface<Trigger> triggerQueue;
+
     @ExecuteOn(TaskExecutors.IO)
     @Get(uri = "/search", produces = MediaType.TEXT_JSON)
     @Operation(tags = {"Triggers"}, summary = "Search for triggers")
@@ -39,4 +44,33 @@ public PagedResults<Trigger> search(
             namespace
         ));
     }
+
+    @ExecuteOn(TaskExecutors.IO)
+    @Post(uri = "/{namespace}/{flowId}/{triggerId}/unlock")
+    @Operation(tags = {"Triggers"}, summary = "Unlock a trigger")
+    public HttpResponse<Trigger> unlock(
+        @Parameter(description = "The namespace") @PathVariable String namespace,
+        @Parameter(description = "The flow id") @PathVariable String flowId,
+        @Parameter(description = "The trigger id") @PathVariable String triggerId
+    ) throws HttpStatusException {
+        Optional<Trigger> triggerOpt = triggerRepository.findLast(TriggerContext.builder()
+            .namespace(namespace)
+            .flowId(flowId)
+            .triggerId(triggerId)
+            .build());
+
+        if (triggerOpt.isEmpty()) {
+            return HttpResponse.notFound();
+        }
+
+        Trigger trigger = triggerOpt.get();
+        if (trigger.getExecutionId() == null && trigger.getEvaluateRunningDate() == null) {
+            throw new IllegalStateException("Trigger is not locked");
+        }
+
+        trigger = trigger.resetExecution();
+        triggerQueue.emit(trigger);
+
+        return HttpResponse.ok(trigger);
+    }
 }


Test patch:
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 85053107b0..071056d1a4 100644
--- a/webserver/src/test/java/io/kestra/webserver/controllers/TriggerControllerTest.java
+++ b/webserver/src/test/java/io/kestra/webserver/controllers/TriggerControllerTest.java
@@ -2,13 +2,16 @@
 
 import io.kestra.core.models.triggers.Trigger;
 import io.kestra.core.utils.Await;
+import io.kestra.core.utils.IdUtils;
 import io.kestra.jdbc.repository.AbstractJdbcFlowRepository;
 import io.kestra.jdbc.repository.AbstractJdbcTriggerRepository;
 import io.kestra.webserver.controllers.h2.JdbcH2ControllerTest;
 import io.kestra.webserver.responses.PagedResults;
 import io.micronaut.core.type.Argument;
 import io.micronaut.http.HttpRequest;
+import io.micronaut.http.HttpStatus;
 import io.micronaut.http.client.annotation.Client;
+import io.micronaut.http.client.exceptions.HttpClientResponseException;
 import io.micronaut.rxjava2.http.client.RxHttpClient;
 import jakarta.inject.Inject;
 import org.hamcrest.Matchers;
@@ -20,6 +23,7 @@
 
 import static org.hamcrest.MatcherAssert.assertThat;
 import static org.hamcrest.Matchers.*;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 
 class TriggerControllerTest extends JdbcH2ControllerTest {
     @Inject
@@ -65,4 +69,50 @@ void search() {
             )
         );
     }
+
+    @Test
+    void unlock() {
+        Trigger trigger = Trigger.builder()
+            .flowId(IdUtils.create())
+            .namespace("io.kestra.unittest")
+            .triggerId(IdUtils.create())
+            .executionId(IdUtils.create())
+            .build();
+
+        jdbcTriggerRepository.save(trigger);
+
+        trigger = client.toBlocking().retrieve(HttpRequest.POST("/api/v1/triggers/%s/%s/%s/unlock".formatted(
+            trigger.getNamespace(),
+            trigger.getFlowId(),
+            trigger.getTriggerId()
+        ), null), Trigger.class);
+
+        assertThat(trigger.getExecutionId(), is(nullValue()));
+        assertThat(trigger.getEvaluateRunningDate(), is(nullValue()));
+
+        Trigger unlockedTrigger = jdbcTriggerRepository.findLast(trigger).orElseThrow();
+
+        assertThat(unlockedTrigger.getExecutionId(), is(nullValue()));
+        assertThat(unlockedTrigger.getEvaluateRunningDate(), is(nullValue()));
+
+        HttpClientResponseException e = assertThrows(HttpClientResponseException.class, () ->
+            client.toBlocking().exchange(HttpRequest.POST("/api/v1/triggers/%s/%s/%s/unlock".formatted(
+                unlockedTrigger.getNamespace(),
+                unlockedTrigger.getFlowId(),
+                unlockedTrigger.getTriggerId()
+            ), null)));
+
+        assertThat(e.getStatus(), is(HttpStatus.CONFLICT));
+        assertThat(e.getMessage(), is("Illegal state: Trigger is not locked"));
+
+        e = assertThrows(HttpClientResponseException.class, () ->
+            client.toBlocking().exchange(HttpRequest.POST("/api/v1/triggers/%s/%s/%s/unlock".formatted(
+                "bad.namespace",
+                "some-flow-id",
+                "some-trigger-id"
+            ), null))
+        );
+
+        assertThat(e.getStatus(), is(HttpStatus.NOT_FOUND));
+    }
 }