feat(executions): make prev/next buttons loop through the executions of that flow (#13296)

Closes https://github.com/kestra-io/kestra/issues/9873.
This commit is contained in:
Miloš Paunović
2025-12-04 12:28:24 +01:00
committed by GitHub
parent 4ec7f23a7b
commit cd4470044e
6 changed files with 199 additions and 132 deletions

View File

@@ -77,18 +77,13 @@
<div>
{{ $t("execution replay") }}
<router-link
:to="{
name: 'executions/update',
params: {
...(execution.tenantId
? {tenant: execution.tenantId}
: {}),
namespace: execution.namespace,
flowId: execution.flowId,
id: execution.originalId,
tab: 'overview',
},
}"
:to="
createLink(
'executions',
execution,
execution.originalId,
)
"
>
<Id
:value="execution.originalId"
@@ -164,20 +159,7 @@
</div>
</div>
<div id="buttons">
<el-button @click="navigateToExecution('previous')">
<el-icon class="el-icon--left">
<ChevronLeft />
</el-icon>
{{ $t("prev_execution") }}
</el-button>
<el-button @click="navigateToExecution('next')">
{{ $t("next_execution") }}
<el-icon class="el-icon--right">
<ChevronRight />
</el-icon>
</el-button>
</div>
<PrevNext :execution />
</div>
</el-splitter-panel>
</el-splitter>
@@ -191,11 +173,10 @@
<script setup lang="ts">
import {onMounted, computed, ref} from "vue";
import {useRouter, useRoute} from "vue-router";
const router = useRouter();
import {useRoute} from "vue-router";
const route = useRoute();
import {Execution, useExecutionsStore} from "../../../stores/executions";
import {useExecutionsStore} from "../../../stores/executions";
const store = useExecutionsStore();
import {useMiscStore} from "override/stores/misc";
@@ -209,6 +190,7 @@
import moment from "moment";
import {createLink} from "./utils/links";
import Utils from "../../../utils/utils";
import {FilterObject} from "../../../utils/filters";
@@ -223,6 +205,7 @@
import Cascader from "./components/main/Cascader.vue";
import TriggerCascader from "./components/main/TriggerCascader.vue";
import TimeSeries from "../../dashboard/sections/TimeSeries.vue";
import PrevNext from "./components/main/PrevNext.vue";
import NoData from "../../layout/NoData.vue";
@@ -256,8 +239,6 @@
import History from "vue-material-design-icons/History.vue";
import SortVariant from "vue-material-design-icons/SortVariant.vue";
import TimelineClockOutline from "vue-material-design-icons/TimelineClockOutline.vue";
import ChevronLeft from "vue-material-design-icons/ChevronLeft.vue";
import ChevronRight from "vue-material-design-icons/ChevronRight.vue";
const emits = defineEmits(["follow"]);
@@ -270,32 +251,13 @@
icon: DotsSquare,
label: t("namespace"),
value: execution.value.namespace,
to: {
name: "namespaces/update",
params: {
...(execution.value.tenantId
? {tenant: execution.value.tenantId}
: {}),
id: execution.value.namespace,
tab: "overview",
},
},
to: createLink("namespaces", execution.value),
},
{
icon: FileTreeOutline,
label: t("flow"),
value: execution.value.flowId,
to: {
name: "flows/update",
params: {
...(execution.value.tenantId
? {tenant: execution.value.tenantId}
: {}),
namespace: execution.value.namespace,
id: execution.value.flowId,
tab: "overview",
},
},
to: createLink("flows", execution.value),
},
{
icon: LayersTripleOutline,
@@ -397,18 +359,11 @@
icon: History,
label: t("parent execution"),
value: execution.value.trigger.variables.executionId,
to: {
name: "executions/update",
params: {
...(execution.value.tenantId
? {tenant: execution.value.tenantId}
: {}),
namespace: execution.value.namespace,
flowId: execution.value.flowId,
id: execution.value.trigger.variables.executionId,
tab: "overview",
},
},
to: createLink(
"executions",
execution.value,
execution.value.trigger.variables.executionId,
),
},
]
: []),
@@ -419,18 +374,11 @@
icon: History,
label: t("original execution"),
value: execution.value.originalId,
to: {
name: "executions/update",
params: {
...(execution.value.tenantId
? {tenant: execution.value.tenantId}
: {}),
namespace: execution.value.namespace,
flowId: execution.value.flowId,
id: execution.value.originalId,
tab: "overview",
},
},
to: createLink(
"executions",
execution.value,
execution.value.originalId,
),
},
]
: []),
@@ -523,51 +471,6 @@
];
});
const navigateToExecution = async (direction: "previous" | "next") => {
if (!execution.value) return;
try {
const params = {
namespace: execution.value.namespace,
flowId: execution.value.flowId,
pageSize: 100,
sort: "state.startDate:desc",
};
const response = await store.findExecutions(params);
const result = response?.results ?? [];
if (!result.length) return;
const currentIdx = result.findIndex(
(e: Execution) => e.id === execution.value!.id,
);
if (currentIdx === -1) return;
// next = newer (-1), previous = older (+1)
const targetIdx =
direction === "previous" ? currentIdx + 1 : currentIdx - 1;
if (targetIdx < 0 || targetIdx >= result.length) return;
const target = result[targetIdx];
router.push({
name: "executions/update",
params: {
...(target.tenantId ? {tenant: target.tenantId} : {}),
namespace: target.namespace,
flowId: target.flowId,
id: target.id,
tab: "overview",
},
});
} catch (error) {
console.error("Failed to navigate executions:", error);
}
};
onMounted(() => {
if (!route.params.id) return;
loadExecution(route.params.id as string);
@@ -710,15 +613,15 @@ $font-size-sm: $font-size-base * 0.875; // TODO: Move it into varaibles file of
}
}
#buttons {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $spacer;
& :deep(.el-empty) {
padding: 0;
.el-button {
width: calc($spacer * 12);
font-size: $font-size-sm;
& .el-empty__image {
width: calc($spacer * 8) !important;
}
& .el-empty__description {
margin-top: calc($spacer / 2);
}
}
}

View File

@@ -49,7 +49,7 @@
</template>
<script setup lang="ts">
import {computed, onMounted, ref} from "vue";
import {onMounted, computed, ref} from "vue";
import VarValue from "../../../VarValue.vue";

View File

@@ -0,0 +1,123 @@
<template>
<div id="buttons">
<el-button
:icon="ChevronLeft"
:disabled="prevDisabled"
@click="navigate('previous')"
>
{{ $t("prev_execution") }}
</el-button>
<el-button :disabled="nextDisabled" @click="navigate('next')">
{{ $t("next_execution") }}
<el-icon class="el-icon--right">
<ChevronRight />
</el-icon>
</el-button>
</div>
</template>
<script setup lang="ts">
import {onMounted, computed, ref} from "vue";
import {useRouter} from "vue-router";
const router = useRouter();
import {
Execution,
useExecutionsStore,
} from "../../../../../stores/executions";
const store = useExecutionsStore();
import {createLink} from "../../utils/links";
import ChevronLeft from "vue-material-design-icons/ChevronLeft.vue";
import ChevronRight from "vue-material-design-icons/ChevronRight.vue";
const props = defineProps<{ execution: Execution }>();
const currentPage = ref(1);
const total = ref(0);
const results = ref<Execution[]>([]);
const currentIdx = ref(-1);
const prevDisabled = computed(
() => total.value && currentIdx.value + 1 === total.value,
);
const nextDisabled = computed(() => total.value && currentIdx.value === 0);
const loadExecutions = async () => {
const params = {
"filters[namespace][PREFIX]": props.execution.namespace,
"filters[flowId][EQUALS]": props.execution.flowId,
"filters[timeRange][EQUALS]": "P365D", // Extended to 365 days for better navigation
page: currentPage.value,
size: 100,
sort: "state.startDate:desc",
};
const response = await store.findExecutions(params);
total.value = response.total;
results.value.push(...response.results);
currentIdx.value = results.value.findIndex(
(e: Execution) => e.id === props.execution.id,
);
// If not found and more pages exist, load next page
if (currentIdx.value === -1 && results.value.length < total.value) {
currentPage.value += 1;
await loadExecutions();
}
// If found, move router
if (currentIdx.value !== -1) {
router.push(createLink("executions", results.value[currentIdx.value]));
}
};
const navigate = async (direction: "previous" | "next") => {
if (currentIdx.value === -1) return;
if (direction === "previous") {
if (prevDisabled.value) return;
currentIdx.value += 1;
} else {
if (nextDisabled.value) return;
currentIdx.value -= 1;
}
// If we reached the end of loaded data but not total, load new page
if (
currentIdx.value >= results.value.length - 1 &&
results.value.length < total.value
) {
currentPage.value += 1;
await loadExecutions();
} else {
router.push(createLink("executions", results.value[currentIdx.value]));
}
};
onMounted(async () => {
await loadExecutions();
});
</script>
<style scoped lang="scss">
@import "@kestra-io/ui-libs/src/scss/variables";
#buttons {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $spacer;
.el-button {
font-size: $font-size-sm;
}
}
</style>

View File

@@ -27,6 +27,7 @@
<script setup lang="ts">
import type {Component} from "vue";
import {RouteLocationRaw} from "vue-router";
const props = defineProps<{

View File

@@ -0,0 +1,41 @@
import {RouteLocationRaw} from "vue-router";
import {Execution} from "../../../../stores/executions";
type Types = "namespaces" | "flows" | "executions";
/**
* Generates a Vue Router link object for a given execution and type.
*
* @param type - The type of route ("namespaces", "flows", or "executions").
* @param execution - The execution object containing tenantId, namespace, flowId, and id.
* @param customID - Optional ID to use instead of execution.id (only applies to "executions").
* @returns A RouteLocationRaw object to be used with router navigation.
*/
export const createLink = (
type: Types,
execution: Execution,
customID?: string,
): RouteLocationRaw => {
if (!execution) return {};
const params: Record<string, string> = {tab: "overview"};
if (execution?.tenantId) params.tenant = execution.tenantId;
switch (type) {
case "namespaces":
params.id = execution.namespace;
break;
case "flows":
params.id = execution.flowId;
params.namespace = execution.namespace;
break;
case "executions":
params.id = customID ?? execution.id; // Use customID if provided, otherwise fallback to execution.id
params.namespace = execution.namespace;
params.flowId = execution.flowId;
break;
}
return {name: `${type}/update`, params};
};

View File

@@ -26,7 +26,7 @@ export type Histories = {
export interface Execution{
id: string;
namespace: string;
flowId?: string;
flowId: string;
tenantId?: string;
taskRunList: {
id: string,
@@ -80,7 +80,6 @@ export const useExecutionsStore = defineStore("executions", () => {
const namespaces = ref<string[]>([]);
const flowsExecutable = ref<any[]>([]);
// clear flow graph when execution is reset
// since it is supposed to represent the current execution's flow
watch(execution, (newExecution) => {