Compare commits

...

1 Commits

Author SHA1 Message Date
Hemant M Mehta
a2dfd0d9bc feat: delete-trigger
closes: #11386
2025-11-04 19:51:32 +01:00
7 changed files with 447 additions and 121 deletions

View File

@@ -41,7 +41,9 @@
<template #expand>
<el-table-column type="expand">
<template #default="props">
<LogsWrapper class="m-3" :filters="props.row" v-if="hasLogsContent(props.row)" :withCharts="false" embed />
<LogsWrapper class="m-3" :filters="props.row" v-if="hasLogsContent(props.row)"
:withCharts="false" embed
/>
</template>
</el-table-column>
</template>
@@ -71,6 +73,9 @@
<el-button @click="deleteBackfills()">
{{ $t("delete backfills") }}
</el-button>
<el-button @click="deleteTriggers()" type="danger">
{{ $t("delete triggers") }}
</el-button>
</BulkSelect>
</template>
<el-table-column
@@ -95,17 +100,23 @@
:sortOrders="['flowId', 'namespace', 'nextExecutionDate'].includes(col.prop) ? ['ascending', 'descending'] : undefined"
>
<template #header v-if="col.prop === 'date'">
<el-tooltip :content="$t('last trigger date tooltip')" placement="top" effect="light" popperClass="wide-tooltip">
<el-tooltip :content="$t('last trigger date tooltip')" placement="top" effect="light"
popperClass="wide-tooltip"
>
<span>{{ col.label }}</span>
</el-tooltip>
</template>
<template #header v-else-if="col.prop === 'updatedDate'">
<el-tooltip :content="$t('context updated date tooltip')" placement="top" effect="light" popperClass="wide-tooltip">
<el-tooltip :content="$t('context updated date tooltip')" placement="top" effect="light"
popperClass="wide-tooltip"
>
<span>{{ col.label }}</span>
</el-tooltip>
</template>
<template #header v-else-if="col.prop === 'nextExecutionDate'">
<el-tooltip :content="$t('next evaluation date tooltip')" placement="top" effect="light" popperClass="wide-tooltip">
<el-tooltip :content="$t('next evaluation date tooltip')" placement="top" effect="light"
popperClass="wide-tooltip"
>
<span>{{ col.label }}</span>
</el-tooltip>
</template>
@@ -181,13 +192,24 @@
<LockOff />
</Kicon>
</el-button>
<el-button>
<Kicon
:tooltip="$t('delete trigger')"
placement="left"
@click="confirmDeleteTrigger(scope.row)"
>
<Delete />
</Kicon>
</el-button>
</template>
</el-table-column>
<el-table-column :label="$t('backfill')" columnKey="backfill">
<template #default="scope">
<div class="backfillContainer items-center gap-2">
<span v-if="scope.row.backfill" class="statusIcon">
<el-tooltip v-if="!scope.row.backfill.paused" :content="$t('backfill running')" effect="light">
<el-tooltip v-if="!scope.row.backfill.paused" :content="$t('backfill running')"
effect="light"
>
<PlayBox font />
</el-tooltip>
<el-tooltip v-else :content="$t('backfill paused')">
@@ -297,7 +319,7 @@
</template>
<script setup lang="ts">
import _merge from "lodash/merge";
import {ref, computed, watch} from "vue";
import {computed, ref, watch} from "vue";
import {useI18n} from "vue-i18n";
import {useRoute} from "vue-router";
import {ElMessage} from "element-plus";
@@ -311,18 +333,16 @@
import {useTriggerFilter} from "../filter/configurations";
import {useDataTableActions} from "../../composables/useDataTableActions";
import {useSelectTableActions} from "../../composables/useSelectTableActions";
import {useTableColumns, type ColumnConfig} from "../../composables/useTableColumns";
import {type ColumnConfig, useTableColumns} from "../../composables/useTableColumns";
import action from "../../models/action";
import permission from "../../models/permission";
const triggerFilter = useTriggerFilter();
import LockOff from "vue-material-design-icons/LockOff.vue";
import PlayBox from "vue-material-design-icons/PlayBox.vue";
import PauseBox from "vue-material-design-icons/PauseBox.vue";
import AlertCircle from "vue-material-design-icons/AlertCircle.vue";
import CalendarCollapseHorizontalOutline from "vue-material-design-icons/CalendarCollapseHorizontalOutline.vue";
import Delete from "vue-material-design-icons/Delete.vue";
import Id from "../Id.vue";
import Kicon from "../Kicon.vue";
@@ -341,6 +361,8 @@
import MarkdownTooltip from "../layout/MarkdownTooltip.vue";
import useRouteContext from "../../composables/useRouteContext";
const triggerFilter = useTriggerFilter();
const route = useRoute();
const toast = useToast();
@@ -369,55 +391,55 @@
end: null,
inputs: null,
labels: []
});
});
const optionalColumns = computed(() => [
{
label: t("flow"),
prop: "flowId",
default: true,
label: t("flow"),
prop: "flowId",
default: true,
description: t("filter.table_column.triggers.flow")
},
{
label: t("namespace"),
prop: "namespace",
default: true,
label: t("namespace"),
prop: "namespace",
default: true,
description: t("filter.table_column.triggers.namespace")
},
{
label: t("current execution"),
prop: "executionId",
default: false,
label: t("current execution"),
prop: "executionId",
default: false,
description: t("filter.table_column.triggers.current execution")
},
{
label: t("workerId"),
prop: "workerId",
default: false,
label: t("workerId"),
prop: "workerId",
default: false,
description: t("filter.table_column.triggers.workerId")
},
{
label: t("last trigger date"),
prop: "date",
default: true,
label: t("last trigger date"),
prop: "date",
default: true,
description: t("filter.table_column.triggers.last trigger date")
},
{
label: t("context updated date"),
prop: "updatedDate",
default: false,
label: t("context updated date"),
prop: "updatedDate",
default: false,
description: t("filter.table_column.triggers.context updated date")
},
{
label: t("next evaluation date"),
prop: "nextExecutionDate",
default: false,
label: t("next evaluation date"),
prop: "nextExecutionDate",
default: false,
description: t("filter.table_column.triggers.next evaluation date")
},
{
label: t("evaluation lock date"),
prop: "evaluateRunningDate",
default: false,
label: t("evaluation lock date"),
prop: "evaluateRunningDate",
default: false,
description: t("filter.table_column.triggers.evaluation lock date")
}
]);
@@ -430,7 +452,7 @@
initialVisibleColumns: optionalColumns.value.filter(col => col.default).map(col => col.prop)
});
const visibleColumns = computed(() =>
const visibleColumns = computed(() =>
displayColumns.value
.map(prop => optionalColumns.value.find(c => c.prop === prop))
.filter(Boolean) as ColumnConfig[]
@@ -468,7 +490,7 @@
});
const {
queryBulkAction,
queryBulkAction,
selection,
handleSelectionChange,
toggleAllUnselected,
@@ -544,7 +566,7 @@
const disabledEndDate = (time: Date) => {
return new Date() < time || (backfill.value.start && backfill.value.start > time);
};
const triggerLoadDataAfterBulkEditAction = () => {
loadData();
setTimeout(() => loadData(), 200);
@@ -601,9 +623,44 @@
});
};
const genericConfirmAction = (toastKey: string, queryAction: string, byIdAction: string, success: string, data?: any) => {
const confirmDeleteTrigger = (trigger) => {
toast.confirm(
t(toastKey, {"count": queryBulkAction.value ? total.value : selection.value?.length}) + ". " + t("bulk action async warning"),
t("delete trigger confirmation", {id: trigger.id}),
() => triggerStore.delete({
namespace: trigger.namespace,
flowId: trigger.flowId,
triggerId: trigger.triggerId
}).then(() => {
toast.success(t("delete trigger success", {id: trigger.id}));
loadData();
}).catch(error => {
toast.error(t("delete trigger error", {id: trigger.id}));
console.error(error);
}),
"warning"
);
};
const deleteTriggers = () => {
genericConfirmAction(
"bulk delete triggers",
"deleteByQuery",
"deleteByTriggers",
"bulk success delete triggers",
null,
"WARNING: deleting triggers may lead to duplicate executions if the triggers are still active in flows"
);
};
const genericConfirmAction = (toastKey: string, queryAction: string, byIdAction: string, success: string, data?: any, extraWarning = null) => {
let message = t(toastKey, {"count": queryBulkAction.value ? total.value : selection.value?.length}) + ". " + t("bulk action async warning");
if (extraWarning) {
message += "<br><br><strong>" + extraWarning + "</strong>";
}
toast.confirm(
message,
() => genericConfirmCallback(queryAction, byIdAction, success, data)
);
};
@@ -620,6 +677,8 @@
"unlockByTriggers": () => triggerStore.unlockByTriggers,
"setDisabledByQuery": () => triggerStore.setDisabledByQuery,
"setDisabledByTriggers": () => triggerStore.setDisabledByTriggers,
"deleteByQuery": () => triggerStore.deleteByQuery,
"deleteByTriggers": () => triggerStore.deleteByTriggers,
};
if (queryBulkAction.value) {
@@ -752,86 +811,86 @@
</script>
<style scoped lang="scss">
.data-table-wrapper {
margin-left: 0 !important;
padding-left: 0 !important;
}
.backfillContainer {
display: flex;
align-items: center;
}
.statusIcon {
font-size: large;
}
.trigger-issue-icon {
color: var(--ks-content-warning);
font-size: 1.4em;
}
.alert-circle-icon {
color: var(--ks-content-warning);
font-size: 1.4em;
}
:deep(.el-table__expand-icon) {
pointer-events: none;
.el-icon {
display: none;
}
}
:deep(.el-switch) {
.is-text {
padding: 0 3px;
color: inherit;
.data-table-wrapper {
margin-left: 0 !important;
padding-left: 0 !important;
}
&.is-checked {
.backfillContainer {
display: flex;
align-items: center;
}
.statusIcon {
font-size: large;
}
.trigger-issue-icon {
color: var(--ks-content-warning);
font-size: 1.4em;
}
.alert-circle-icon {
color: var(--ks-content-warning);
font-size: 1.4em;
}
:deep(.el-table__expand-icon) {
pointer-events: none;
.el-icon {
display: none;
}
}
:deep(.el-switch) {
.is-text {
color: #ffffff;
padding: 0 3px;
color: inherit;
}
&.is-checked {
.is-text {
color: #ffffff;
}
}
}
}
.el-table {
a {
color: var(--ks-content-link);
}
}
.wide-tooltip {
max-width: 400px;
white-space: normal;
word-break: break-word;
color: var(--ks-content-primary) !important;
}
:deep(.el-collapse) {
border-radius: var(--bs-border-radius-lg);
border: 1px solid var(--ks-border-primary);
background: var(--bs-gray-100);
.el-collapse-item__header {
background: transparent;
border-bottom: 1px solid var(--ks-border-primary);
font-size: var(--bs-font-size-sm);
.el-table {
a {
color: var(--ks-content-link);
}
}
.el-collapse-item__content {
.wide-tooltip {
max-width: 400px;
white-space: normal;
word-break: break-word;
color: var(--ks-content-primary) !important;
}
:deep(.el-collapse) {
border-radius: var(--bs-border-radius-lg);
border: 1px solid var(--ks-border-primary);
background: var(--bs-gray-100);
border-bottom: 1px solid var(--ks-border-primary);
}
.el-collapse-item__header,
.el-collapse-item__content {
&:last-child {
border-bottom-left-radius: var(--bs-border-radius-lg);
border-bottom-right-radius: var(--bs-border-radius-lg);
.el-collapse-item__header {
background: transparent;
border-bottom: 1px solid var(--ks-border-primary);
font-size: var(--bs-font-size-sm);
}
.el-collapse-item__content {
background: var(--bs-gray-100);
border-bottom: 1px solid var(--ks-border-primary);
}
.el-collapse-item__header,
.el-collapse-item__content {
&:last-child {
border-bottom-left-radius: var(--bs-border-radius-lg);
border-bottom-right-radius: var(--bs-border-radius-lg);
}
}
}
}
</style>
</style>

View File

@@ -183,4 +183,4 @@ export function useExecutionRoot() {
getBaseTabs,
setupLifecycle
};
}
}

View File

@@ -36,6 +36,12 @@ interface TriggerBulkOptions {
[key: string]: any;
}
interface TriggerDeleteOptions {
namespace: string;
flowId: string;
triggerId: string;
}
export const useTriggerStore = defineStore("trigger", {
state: () => ({}),
@@ -132,6 +138,21 @@ export const useTriggerStore = defineStore("trigger", {
async setDisabledByTriggers(options: TriggerBulkOptions) {
const response = await this.$http.post(`${apiUrl()}/triggers/set-disabled/by-triggers`, options);
return response.data;
}
},
async delete(options: TriggerDeleteOptions) {
const response = await this.$http.delete(`${apiUrl()}/triggers/${options.namespace}/${options.flowId}/${options.triggerId}`);
return response.data;
},
async deleteByQuery(options: TriggerBulkOptions) {
const response = await this.$http.post(`${apiUrl()}/triggers/delete/by-query`, null, {params: options});
return response.data;
},
async deleteByTriggers(options: TriggerBulkOptions) {
const response = await this.$http.post(`${apiUrl()}/triggers/delete/by-triggers`, options);
return response.data;
},
}
});

View File

@@ -1835,6 +1835,13 @@
"search_blueprints": "Search blueprints",
"search_plugins": "Search {count}+ plugins"
}
}
},
"delete trigger": "Delete trigger",
"delete triggers": "Delete triggers",
"bulk delete triggers": "Are you sure you want to delete {count} triggers?",
"bulk success delete triggers": "{count} triggers have been deleted successfully",
"delete trigger confirmation": "Are you sure you want to delete trigger {id}? WARNING: deleting a trigger may lead to duplicate executions if the trigger is still active in a flow",
"delete trigger success": "Trigger {id} has been deleted successfully",
"delete trigger error": "Error deleting trigger {id}"
}
}

View File

@@ -33,7 +33,7 @@ const render: Story["render"] = ({modelValue}) => ({
fontSize: "12px",
textAlign: "right",
padding: "0 1rem"
};
};
return () => <div style="padding: 1rem;border: 1px solid var(--ks-border-primary); border-radius: 4px; margin: 1rem; background: var(--ks-background-body)">
<div style={{...labelStyle, background: "red", width: "250px"}}>This is an example of 250px wide element.</div>
@@ -280,8 +280,8 @@ export const TabReorderTest: Story = {
// Perform drop operation at the calculated position
await fireEvent.drop(panelOverlay);
// Wait for the reorder to complete
await new Promise(resolve => setTimeout(resolve, 100));
// Wait for the reorder to complete
await new Promise(resolve => setTimeout(resolve, 100));
expect(canvas.getAllByRole("tab").map(tab => tab.querySelector(".tab-title")?.textContent?.trim())).toMatchObject(["Tab 3", "Tab 1", "Tab 2"]);
}
@@ -346,8 +346,8 @@ export const TabMoveBetweenPanelsTest: Story = {
// Perform drop operation at the calculated position
fireEvent.drop(panelOverlay);
// Wait for the reorder to complete
await new Promise(resolve => setTimeout(resolve, 100));
// Wait for the reorder to complete
await new Promise(resolve => setTimeout(resolve, 100));
// Verify the tabs have been reordered
expect(
@@ -383,4 +383,4 @@ export const SplitPanel: Story = {
expect(canvas.getAllByRole("tablist")).toHaveLength(2)
}
}
}

View File

@@ -507,6 +507,82 @@ public class TriggerController {
return HttpResponse.ok(BulkResponse.builder().count(count).build());
}
@ExecuteOn(TaskExecutors.IO)
@Delete(uri = "/{namespace}/{flowId}/{triggerId}")
@Operation(tags = {"Triggers"}, summary = "Delete a trigger")
public HttpResponse<?> deleteTrigger(
@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()
.tenantId(tenantService.resolveTenant())
.namespace(namespace)
.flowId(flowId)
.triggerId(triggerId)
.build());
if (triggerOpt.isEmpty()) {
return HttpResponse.notFound();
}
Trigger trigger = triggerOpt.get();
triggerRepository.delete(trigger);
return HttpResponse.noContent();
}
@ExecuteOn(TaskExecutors.IO)
@Post(uri = "/delete/by-triggers")
@Operation(tags = {"Triggers"}, summary = "Delete given triggers")
public MutableHttpResponse<?> deleteTriggersByIds(
@Parameter(description = "The triggers to delete") @Body List<Trigger> triggers
) {
AtomicInteger count = new AtomicInteger();
triggers.forEach(trigger -> {
try {
Optional<Trigger> triggerOpt = triggerRepository.findLast(TriggerContext.builder()
.tenantId(tenantService.resolveTenant())
.namespace(trigger.getNamespace())
.flowId(trigger.getFlowId())
.triggerId(trigger.getTriggerId())
.build());
if (triggerOpt.isPresent()) {
triggerRepository.delete(triggerOpt.get());
count.getAndIncrement();
}
} catch (Exception ignored) {
}
});
return HttpResponse.ok(BulkResponse.builder().count(count.get()).build());
}
@ExecuteOn(TaskExecutors.IO)
@Post(uri = "/delete/by-query")
@Operation(tags = {"Triggers"}, summary = "Delete triggers by query parameters")
public MutableHttpResponse<?> deleteTriggersByQuery(
@Parameter(description = "Filters") @QueryFilterFormat List<QueryFilter> filters
) {
Integer count = triggerRepository
.find(tenantService.resolveTenant(), filters)
.map(trigger -> {
try {
triggerRepository.delete(trigger);
return 1;
} catch (Exception ignored) {
return 0;
}
})
.reduce(Integer::sum)
.blockOptional()
.orElse(0);
return HttpResponse.ok(BulkResponse.builder().count(count).build());
}
@ExecuteOn(TaskExecutors.IO)
@Post(uri = "/set-disabled/by-triggers")
@Operation(tags = {"Triggers"}, summary = "Disable/enable given triggers")

View File

@@ -27,6 +27,10 @@ import io.micronaut.reactor.http.client.ReactorHttpClient;
import jakarta.inject.Inject;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import java.util.Optional;
import io.kestra.core.repositories.FlowRepositoryInterface;
import io.kestra.core.repositories.TriggerRepositoryInterface;
import java.time.Duration;
import java.time.ZonedDateTime;
@@ -447,4 +451,163 @@ class TriggerControllerTest {
.disabled(disabled)
.build();
}
@Inject
FlowRepositoryInterface flowRepositoryInterface;
@Inject
TriggerRepositoryInterface triggerRepository;
@SuppressWarnings("unchecked")
@Test
void testGetTrigger() {
Flow flow = createTestFlow();
flowRepositoryInterface.create(flow);
Trigger trigger = Trigger.builder()
.triggerId("test-trigger")
.flowId(flow.getId())
.namespace(flow.getNamespace())
.flowRevision(1)
.triggerId("test-trigger")
.build();
triggerRepository.create(trigger);
HttpResponse<Trigger> response = client.toBlocking()
.exchange(
HttpRequest.GET("/triggers/" + flow.getNamespace() + "/" + flow.getId() + "/test-trigger"),
Trigger.class
);
assertEquals(HttpStatus.OK, response.getStatus());
assertNotNull(response.body());
assertEquals("test-trigger", response.body().getId());
assertEquals(flow.getId(), response.body().getFlowId());
}
@Test
void testGetTriggerNotFound() {
HttpClientResponseException exception = assertThrows(
HttpClientResponseException.class,
() -> client.toBlocking().exchange(
HttpRequest.GET("/triggers/nonexistent/flow/trigger"),
Trigger.class
)
);
assertEquals(HttpStatus.NOT_FOUND, exception.getStatus());
}
@Test
void testDeleteTriggersByQuery() {
Flow flow = createTestFlow();
flowRepositoryInterface.create(flow);
Trigger trigger = Trigger.builder()
.id("delete-test-trigger")
.flowId(flow.getId())
.namespace(flow.getNamespace())
.flowRevision(1)
.triggerId("delete-test-trigger")
.build();
Trigger triggerByQuery1 = Trigger.builder()
.id("query-test-trigger-1")
.flowId(flow.getId())
.namespace(flow.getNamespace())
.flowRevision(1)
.triggerId("query-test-trigger-1")
.build();
Trigger triggerByQuery2 = Trigger.builder()
.id("query-test-trigger-2")
.flowId(flow.getId())
.namespace(flow.getNamespace())
.flowRevision(1)
.triggerId("query-test-trigger-2")
.build();
triggerRepository.save(trigger);
triggerRepository.save(triggerByQuery1);
triggerRepository.save(triggerByQuery2);
triggerRepository.create(trigger);
List<Trigger> allBeforeDelete = triggerRepository.findByQuery(flow.getNamespace(), flow.getId());
assertEquals(3, allBeforeDelete.size(), "Expected 3 triggers before deletion");
HttpResponse<Integer> firstDeleteResponse = client.toBlocking()
.exchange(
HttpRequest.DELETE("/triggers/query" + "?filters[namespace][EQUALS]=" + flow.getNamespace() + "&filters[flowId][EQUALS]=" + flow.getId() + "&filters[triggerId][EQUALS]=delete-test-trigger"),
Integer.class
);
assertEquals(HttpStatus.OK, firstDeleteResponse.getStatus());
assertEquals(1, firstDeleteResponse.body(), "Expected 1 trigger deleted");
List<Trigger> remainingAfterFirstDelete = triggerRepository.findByQuery(flow.getNamespace(), flow.getId());
assertEquals(2, remainingAfterFirstDelete.size(), "Expected 2 triggers remaining after first delete");
HttpResponse<Integer> secondDeleteResponse = client.toBlocking()
.exchange(
HttpRequest.DELETE("/triggers/query" + "?filters[namespace][EQUALS]=" + flow.getNamespace() + "&filters[flowId][EQUALS]=" + flow.getId()),
Integer.class
);
assertEquals(HttpStatus.OK, secondDeleteResponse.getStatus());
assertEquals(2, secondDeleteResponse.body(), "Expected 2 remaining triggers deleted");
List<Trigger> finalRemaining = triggerRepository.findByQuery(flow.getNamespace(), flow.getId());
assertEquals(0, finalRemaining.size(), "Expected no triggers after final deletion");
Optional<Trigger> deletedTrigger = triggerRepository.findById(
flow.getNamespace(),
flow.getId(),
"delete-test-trigger"
);
assertFalse(deletedTrigger.isPresent());
}
@Test
void testDeleteTriggerById() {
Flow flow = createTestFlow();
flowRepositoryInterface.create(flow);
Trigger trigger = Trigger.builder()
.id("delete-by-id-trigger")
.flowId(flow.getId())
.namespace(flow.getNamespace())
.flowRevision(1)
.triggerId("delete-by-id-trigger")
.build();
triggerRepository.create(trigger);
HttpResponse<Void> response = client.toBlocking()
.exchange(
HttpRequest.DELETE("/triggers/" + flow.getNamespace() + "/" + flow.getId() + "/delete-by-id-trigger"),
Void.class
);
assertEquals(HttpStatus.NO_CONTENT, response.getStatus());
Optional<Trigger> deletedTrigger = triggerRepository.findById(
flow.getNamespace(),
flow.getId(),
"delete-by-id-trigger"
);
assertFalse(deletedTrigger.isPresent());
}
private Flow createTestFlow() {
return Flow.builder()
.id("trigger-test-flow")
.namespace("io.kestra.tests")
.revision(1)
.tasks(List.of())
.build();
}
}