refactor(executions): improve the trigger cascader on the overview page (#13524)

Closes https://github.com/kestra-io/kestra/issues/12942.
Closes https://github.com/kestra-io/kestra/issues/13283.
Closes https://github.com/kestra-io/kestra/issues/13290.
Closes https://github.com/kestra-io/kestra/issues/13294.
This commit is contained in:
Miloš Paunović
2025-12-10 13:08:16 +01:00
committed by GitHub
parent 51adcfa908
commit 24355c2a88
6 changed files with 281 additions and 707 deletions

View File

@@ -120,14 +120,6 @@
:execution
/>
<!-- TODO: To be reworked and integrated into the Cascader component -->
<TriggerCascader
:title="t('trigger')"
:empty="t('no_trigger')"
:elements="execution.trigger"
:execution
/>
<div id="chart">
<div>
<section>
@@ -151,7 +143,7 @@
</section>
<TimeSeries
ref="chartRef"
:chart="{...chart, content: YAML_CHART}"
:chart
:filters
showDefault
execution
@@ -185,11 +177,9 @@
import {useI18n} from "vue-i18n";
const {t} = useI18n({useScope: "global"});
import {useBreakpoints, breakpointsElement} from "@vueuse/core";
const verticalLayout = useBreakpoints(breakpointsElement).smallerOrEqual("md");
import moment from "moment";
import {verticalLayout} from "./utils/layout";
import {createLink} from "./utils/links";
import Utils from "../../../utils/utils";
import {FilterObject} from "../../../utils/filters";
@@ -202,8 +192,7 @@
import ErrorAlert from "./components/main/ErrorAlert.vue";
import Id from "../../Id.vue";
import Cascader from "./components/main/Cascader.vue";
import TriggerCascader from "./components/main/TriggerCascader.vue";
import Cascader from "./components/main/cascaders/Cascader.vue";
import TimeSeries from "../../dashboard/sections/TimeSeries.vue";
import PrevNext from "./components/main/PrevNext.vue";
@@ -432,6 +421,13 @@
title: t("flow_outputs"),
empty: t("no_flow_outputs"),
elements: execution.value?.outputs,
includeDebug: "outputs",
},
{
title: t("trigger"),
empty: t("no_trigger"),
elements: execution.value?.trigger,
includeDebug: "trigger",
},
];
@@ -439,7 +435,7 @@
const timerange = ref<string>("PT168H"); // Default to last 7 days
const chartRef = ref<InstanceType<typeof TimeSeries> | null>(null);
const chart = yaml.parse(YAML_CHART);
const chart = {...yaml.parse(YAML_CHART), content: YAML_CHART};
const filters = computed((): FilterObject[] => {
if (!execution.value) return [];

View File

@@ -1,639 +0,0 @@
<template>
<div :id="`cascader-${props.title}`">
<div class="header">
<el-text truncated>
{{ props.title }}
</el-text>
<el-input
v-if="props.elements"
v-model="filter"
:placeholder="$t('search')"
:suffixIcon="Magnify"
/>
</div>
<el-splitter
v-if="props.elements"
:layout="verticalLayout ? 'vertical' : 'horizontal'"
>
<el-splitter-panel
v-model:size="leftWidth"
:min="'30%'"
:max="'70%'"
>
<div class="d-flex flex-column overflow-x-auto left">
<ElCascaderPanel
ref="cascader"
v-model="selected"
:options="filteredOptions"
:border="false"
class="flex-grow-1 cascader"
@change="onSelectionChange"
>
<template #default="{data}">
<div
class="w-100 d-flex justify-content-between"
@click="onNodeClick(data)"
>
<div class="pe-5 d-flex">
<span>{{ data.label }}</span>
</div>
<code>
<span class="regular">
{{ processedValue(data).label }}
</span>
</code>
</div>
</template>
</ElCascaderPanel>
</div>
</el-splitter-panel>
<el-splitter-panel v-model:size="rightWidth">
<div class="right wrapper">
<div class="w-100 overflow-auto debug-wrapper">
<div class="debug">
<div class="debug-title mb-3">
<span>{{ $t("eval.render") }}</span>
</div>
<div class="d-flex flex-column p-3 debug">
<Editor
ref="debugEditor"
:fullHeight="false"
:customHeight="20"
:input="true"
:navbar="false"
:modelValue="computedDebugValue"
@update:model-value="editorValue = $event"
@confirm="onDebugExpression($event)"
class="w-100"
/>
<el-button
type="primary"
:icon="Refresh"
@click="
onDebugExpression(
editorValue.length > 0
? editorValue
: computedDebugValue,
)
"
class="mt-3"
>
{{ $t("eval.render") }}
</el-button>
<Editor
v-if="debugExpression"
:readOnly="true"
:input="true"
:fullHeight="false"
:customHeight="20"
:navbar="false"
:modelValue="debugExpression"
:lang="isJSON ? 'json' : ''"
class="mt-3"
/>
</div>
</div>
<el-alert
v-if="debugError"
type="error"
:closable="false"
class="overflow-auto"
>
<p>
<strong>{{ debugError }}</strong>
</p>
<div class="my-2">
<CopyToClipboard
:text="`${debugError}\n\n${debugStackTrace}`"
label="Copy Error"
class="d-inline-block me-2"
/>
</div>
<pre class="mb-0" style="overflow: scroll">{{
debugStackTrace
}}</pre>
</el-alert>
<VarValue
v-if="selectedValue && displayVarValue()"
:value="
selectedValue?.uri
? selectedValue?.uri
: selectedValue
"
:execution="execution"
/>
</div>
</div>
</el-splitter-panel>
</el-splitter>
<span v-else class="empty">{{ props.empty }}</span>
</div>
</template>
<script setup lang="ts">
import {ref, computed, watch, onMounted} from "vue";
import {ElCascaderPanel} from "element-plus";
import CopyToClipboard from "../../../../layout/CopyToClipboard.vue";
import Magnify from "vue-material-design-icons/Magnify.vue";
import Editor from "../../../../inputs/Editor.vue";
import VarValue from "../../../VarValue.vue";
import Refresh from "vue-material-design-icons/Refresh.vue";
onMounted(() => {
if (props.elements) formatted.value = format(props.elements);
// Open first node by default on page mount
if (cascader?.value) {
const nodes = cascader.value.$el.querySelectorAll(".el-cascader-node");
if (nodes.length > 0) (nodes[0] as HTMLElement).click();
}
});
interface CascaderOption {
label: string;
value: string;
children?: CascaderOption[];
path?: string;
[key: string]: any;
}
const props = defineProps<{
title: string;
empty: string;
elements?: CascaderOption;
execution: any;
}>();
const cascader = ref<any>(null);
const debugEditor = ref<InstanceType<typeof Editor>>();
const selected = ref<string[]>([]);
const editorValue = ref("");
const debugExpression = ref("");
const debugError = ref("");
const debugStackTrace = ref("");
const isJSON = ref(false);
const expandedValue = ref("");
import {useBreakpoints, breakpointsElement} from "@vueuse/core";
const verticalLayout = useBreakpoints(breakpointsElement).smallerOrEqual("md");
const leftWidth = verticalLayout ? ref("50%") : ref("80%");
const rightWidth = verticalLayout ? ref("50%") : ref("20%");
const formatted = ref<Node[]>([]);
const format = (obj: Record<string, any>): Node[] => {
return Object.entries(obj).map(([key, value]) => {
const children =
typeof value === "object" && value !== null
? Object.entries(value).map(([k, v]) => format({[k]: v})[0])
: [{label: value, value: value}];
// Filter out children with undefined label and value
const filteredChildren = children.filter(
(child) => child.label !== undefined || child.value !== undefined,
);
// Return node with or without children based on existence
const node = {label: key, value: key};
// Include children only if there are valid entries
if (filteredChildren.length) {
node.children = filteredChildren;
}
return node;
});
};
const filter = ref("");
const filteredOptions = computed(() => {
if (filter.value === "") return formatted.value;
const lowercase = filter.value.toLowerCase();
return formatted.value.filter((node) => {
const matchesNode = node.label.toLowerCase().includes(lowercase);
if (!node.children) return matchesNode;
const matchesChildren = node.children.some((c) =>
c.label.toLowerCase().includes(lowercase),
);
return matchesNode || matchesChildren;
});
});
const selectedValue = computed(() => {
if (!selected.value?.length) return null;
const node = selectedNode();
return node?.value || node?.label;
});
const computedDebugValue = computed(() => {
if (selected.value?.length) {
const path = selected.value.join(".");
return `{{ trigger.${path} }}`;
}
if (expandedValue.value) {
return `{{ trigger.${expandedValue.value} }}`;
}
return "{{ trigger }}";
});
function selectedNode(): CascaderOption | null {
if (!selected.value?.length) return null;
let currentOptions: CascaderOption[] = props.elements;
let currentNode: CascaderOption | undefined = undefined;
for (const value of selected.value) {
currentNode = currentOptions?.find(
(option) => option.value === value || option.label === value,
);
if (currentNode?.children) {
currentOptions = currentNode.children;
}
}
return currentNode || null;
}
function processedValue(data: any) {
const trim = (value: any) =>
typeof value !== "string" || value.length < 16
? value
: `${value.substring(0, 16)}...`;
return {
label: trim(data.value || data.label),
regular: typeof data.value !== "object",
};
}
function onNodeClick(data: any) {
let path = "";
if (selected.value?.length) {
path = selected.value.join(".");
}
if (!path) {
const findNodePath = (
options: Record<string, any>[],
targetNode: any,
currentPath: string[] = [],
): string[] | null => {
const localOptions = Array.isArray(options)
? options
: [options]
for (const option of localOptions) {
const newPath = [...currentPath, option.value || option.label];
if (
option.value === targetNode.value ||
option.label === targetNode.label ||
option.value === (targetNode.value || targetNode.label) ||
option.label === (targetNode.value || targetNode.label)
) {
return newPath;
}
if (option.children) {
const found = findNodePath(
option.children ?? [],
targetNode,
newPath,
);
if (found) return found;
}
}
return null;
};
const nodePath = findNodePath(props.elements ?? [], data);
path = nodePath ? nodePath.join(".") : "";
}
if (path) {
expandedValue.value = path;
debugExpression.value = "";
debugError.value = "";
debugStackTrace.value = "";
}
}
function onSelectionChange(value: any) {
if (value?.length) {
const path = value.join(".");
expandedValue.value = path;
debugExpression.value = "";
debugError.value = "";
debugStackTrace.value = "";
}
}
function displayVarValue(): boolean {
return Boolean(
selectedValue.value &&
typeof selectedValue.value === "string" &&
(selectedValue.value.startsWith("kestra://") ||
selectedValue.value.startsWith("http://") ||
selectedValue.value.startsWith("https://")),
);
}
function evaluateExpression(expression: string, trigger: any): any {
try {
const cleanExpression = expression
.replace(/^\{\{\s*/, "")
.replace(/\s*\}\}$/, "")
.trim();
if (cleanExpression === "trigger") {
return trigger;
}
if (!cleanExpression.startsWith("trigger.")) {
throw new Error("Expression must start with \"trigger.\"");
}
const path = cleanExpression.substring(8);
const parts = path.split(".");
let result = trigger;
for (const part of parts) {
if (result && typeof result === "object" && part in result) {
result = result[part];
} else {
throw new Error(`Property "${part}" not found`);
}
}
return result;
} catch (error: any) {
throw new Error(`Failed to evaluate expression: ${error.message}`);
}
}
function onDebugExpression(expression: string): void {
try {
debugError.value = "";
debugStackTrace.value = "";
const result = evaluateExpression(expression, props.execution?.trigger);
try {
if (typeof result === "object" && result !== null) {
debugExpression.value = JSON.stringify(result, null, 2);
isJSON.value = true;
} else {
debugExpression.value = String(result);
isJSON.value = false;
}
} catch {
debugExpression.value = String(result);
isJSON.value = false;
}
} catch (error: any) {
debugError.value = error.message || "Failed to evaluate expression";
debugStackTrace.value = error.stack || "";
debugExpression.value = "";
isJSON.value = false;
}
}
watch(
selected,
(newValue) => {
if (newValue?.length) {
const path = newValue.join(".");
expandedValue.value = path;
debugExpression.value = "";
debugError.value = "";
debugStackTrace.value = "";
}
},
{deep: true},
);
</script>
<style scoped lang="scss">
.outputs {
height: fit-content;
display: flex;
position: relative;
}
.left {
overflow-x: auto;
height: 100%;
display: flex;
flex-direction: column;
}
:deep(.el-cascader-panel) {
min-height: 197px;
height: 100%;
border: 1px solid var(--ks-border-primary);
border-radius: 0;
overflow-x: auto !important;
overflow-y: hidden !important;
.el-scrollbar.el-cascader-menu:nth-of-type(-n + 2) ul li:first-child {
pointer-events: auto !important;
margin: 0 !important;
}
.el-cascader-node {
pointer-events: auto !important;
cursor: pointer !important;
}
.el-cascader-panel__wrap {
overflow-x: auto !important;
display: flex !important;
min-width: max-content !important;
}
.el-cascader-menu {
min-width: 300px;
max-width: 300px;
flex-shrink: 0;
&:last-child {
border-right: 1px solid var(--ks-border-primary);
}
.el-cascader-menu__wrap {
height: 100%;
}
.el-cascader-node {
height: 36px;
line-height: 36px;
font-size: var(--el-font-size-small);
color: var(--ks-content-primary);
&[aria-haspopup="false"] {
padding-right: 0.5rem !important;
}
&:hover {
background-color: var(--ks-border-primary);
}
&.in-active-path,
&.is-active {
background-color: var(--ks-border-primary);
font-weight: normal;
}
.el-cascader-node__prefix {
display: none;
}
code span.regular {
color: var(--ks-content-primary);
}
}
}
}
:deep(.el-cascader-node) {
cursor: pointer;
margin: 0 !important;
}
.el-cascader-menu__list {
padding: 6px;
}
.wrapper {
height: fit-content;
overflow: hidden;
z-index: 1000;
height: 100%;
display: flex;
flex-direction: column;
.debug-wrapper {
min-height: 197px;
border: 1px solid var(--ks-border-primary);
border-left-width: 0.5px;
border-radius: 0;
padding: 0;
background-color: var(--ks-background-body);
flex: 1;
}
.debug-title {
padding: 12px 16px;
background-color: var(--ks-background-body);
font-weight: bold;
font-size: var(--el-font-size-base);
}
}
@media (max-width: 768px) {
.outputs {
height: 600px;
margin-top: 15px;
}
:deep(.el-cascader-panel) {
height: 100%;
}
}
@import "@kestra-io/ui-libs/src/scss/variables";
[id^="cascader-"] {
overflow: hidden;
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: $spacer;
> .el-text {
width: 100%;
display: flex;
align-items: center;
font-size: $font-size-xl;
}
> .el-input {
display: flex;
align-items: center;
width: calc($spacer * 16);
}
}
.el-cascader-panel {
overflow: auto;
}
.empty {
font-size: $font-size-sm;
color: var(--ks-content-secondary);
}
:deep(.el-cascader-menu) {
min-width: 300px;
max-width: 300px;
.el-cascader-menu__list {
padding: 0;
}
.el-cascader-menu__wrap {
height: 100%;
}
.node {
width: 100%;
display: flex;
justify-content: space-between;
}
& .el-cascader-node {
height: 36px;
line-height: 36px;
font-size: $font-size-sm;
color: var(--ks-content-primary);
padding: 0 30px 0 5px;
&[aria-haspopup="false"] {
padding-right: 0.5rem !important;
}
&:hover {
background-color: var(--ks-border-primary);
}
&.in-active-path,
&.is-active {
background-color: var(--ks-border-primary);
font-weight: normal;
}
.el-cascader-node__prefix {
display: none;
}
code span.regular {
color: var(--ks-content-primary);
}
}
}
}
</style>

View File

@@ -1,5 +1,5 @@
<template>
<div :id="`cascader-${props.title}`">
<div :id="cascaderID">
<div class="header">
<el-text truncated>
{{ props.title }}
@@ -12,70 +12,86 @@
/>
</div>
<el-cascader-panel
v-if="props.elements"
ref="cascader"
:options="filteredOptions"
>
<template #default="{data}">
<VarValue
v-if="isFile(data.value)"
:value="data.value"
:execution="props.execution"
class="node"
/>
<div v-else class="node">
<div :title="data.label">
{{ data.label }}
<template v-if="props.elements">
<el-splitter
v-if="props.includeDebug"
:layout="verticalLayout ? 'vertical' : 'horizontal'"
lazy
>
<el-splitter-panel :size="verticalLayout ? '50%' : '70%'">
<el-cascader-panel
:options="filteredOptions"
@expand-change="(p: string[]) => (path = p.join('.'))"
class="debug"
>
<template #default="{data}">
<div class="node">
<div :title="data.label">
{{ data.label }}
</div>
<div v-if="data.value && data.children">
<code>{{ itemsCount(data) }}</code>
</div>
</div>
</template>
</el-cascader-panel>
</el-splitter-panel>
<el-splitter-panel>
<DebugPanel
:property="props.includeDebug"
:execution
:path
/>
</el-splitter-panel>
</el-splitter>
<el-cascader-panel v-else :options="filteredOptions">
<template #default="{data}">
<div class="node">
<div :title="data.label">
{{ data.label }}
</div>
<div v-if="data.value && data.children">
<code>{{ itemsCount(data) }}</code>
</div>
</div>
<div v-if="data.value && data.children">
<code>
{{ data.children.length }}
{{
$t(
data.children.length === 1
? "item"
: "items",
)
}}
</code>
</div>
</div>
</template>
</el-cascader-panel>
</template>
</el-cascader-panel>
</template>
<span v-else class="empty">{{ props.empty }}</span>
</div>
</template>
<script setup lang="ts">
import {onMounted, computed, ref} from "vue";
import {onMounted, nextTick, computed, ref} from "vue";
import VarValue from "../../../VarValue.vue";
import DebugPanel from "./DebugPanel.vue";
import {Execution} from "../../../../../stores/executions";
import {Execution} from "../../../../../../stores/executions";
import {verticalLayout} from "../../../utils/layout";
import {useI18n} from "vue-i18n";
const {t} = useI18n({useScope: "global"});
import Magnify from "vue-material-design-icons/Magnify.vue";
export interface Node {
label: string;
value: string;
children?: Node[];
}
const props = defineProps<{
title: string;
empty: string;
elements?: Record<string, any>;
includeDebug?: "outputs" | "trigger";
execution: Execution;
}>();
const isFile = (data: any) => {
if (typeof data !== "string") return false;
const prefixes = ["kestra:///", "file://", "nsfile://"];
return prefixes.some((prefix) => data.startsWith(prefix));
};
interface Node {
label: string;
value: string;
children?: Node[];
}
const path = ref<string>("");
const formatted = ref<Node[]>([]);
const format = (obj: Record<string, any>): Node[] => {
@@ -114,15 +130,25 @@
});
});
const cascader = ref<any>(null);
onMounted(() => {
const itemsCount = (item: Node) => {
const length = item.children?.length ?? 0;
if (!length) return undefined;
return `${length} ${length === 1 ? t("item") : t("items")}`;
};
const cascaderID = `cascader-${props.title.toLowerCase().replace(/\s+/g, "-")}`;
onMounted(async () => {
if (props.elements) formatted.value = format(props.elements);
// Open first node by default on page mount
if (cascader?.value) {
const nodes = cascader.value.$el.querySelectorAll(".el-cascader-node");
await nextTick(() => {
// Open first node by default on page mount
const selector = `#${cascaderID} .el-cascader-node`;
const nodes = document.querySelectorAll(selector);
if (nodes.length > 0) (nodes[0] as HTMLElement).click();
}
});
});
</script>
@@ -154,6 +180,12 @@
.el-cascader-panel {
overflow: auto;
&.debug {
min-height: -webkit-fill-available;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
}
.empty {

View File

@@ -0,0 +1,182 @@
<template>
<div id="debug">
<Editor
v-model="expression"
:shouldFocus="false"
:navbar="false"
input
class="expression"
/>
<div class="buttons">
<el-button type="primary" :icon="Refresh" @click="onRender">
{{ $t("eval.render") }}
</el-button>
<el-button
:disabled="!result && !error"
:icon="CloseCircleOutline"
@click="clearAll"
/>
</div>
<template v-if="result">
<VarValue v-if="isFile" :value="result.value" :execution />
<Editor
v-else
v-model="result.value"
:shouldFocus="false"
:navbar="false"
input
readOnly
:lang="result.type"
class="result"
/>
</template>
<el-alert
v-else-if="error"
type="error"
:title="error"
showIcon
:closable="false"
/>
</div>
</template>
<script setup lang="ts">
import {watch, ref, computed} from "vue";
import Editor from "../../../../../inputs/Editor.vue";
import VarValue from "../../../../VarValue.vue";
import {Execution} from "../../../../../../stores/executions";
import Refresh from "vue-material-design-icons/Refresh.vue";
import CloseCircleOutline from "vue-material-design-icons/CloseCircleOutline.vue";
const props = defineProps<{
property: "outputs" | "trigger";
execution: Execution;
path: string;
}>();
const result = ref<{ value: string; type: string } | undefined>(undefined);
const error = ref<string | undefined>(undefined);
const clearAll = () => {
result.value = undefined;
error.value = undefined;
};
const isFile = computed(() => {
if (!result.value || typeof result.value.value !== "string") return false;
const prefixes = ["kestra:///", "file://", "nsfile://"];
return prefixes.some((prefix) => result.value!.value.startsWith(prefix));
});
const expression = ref<string>("");
watch(
() => props.path,
(path?: string) => {
result.value = undefined;
expression.value = `{{ ${props.property}${path ? `.${path}` : ""} }}`;
},
{immediate: true},
);
const onRender = () => {
if (!props.execution) return;
result.value = undefined;
error.value = undefined;
const clean = expression.value
.replace(/^\{\{\s*/, "")
.replace(/\s*\}\}$/, "")
.trim();
if (clean === "outputs" || clean === "trigger") {
result.value = {
value: JSON.stringify(props.execution[props.property], null, 2),
type: "json",
};
}
if (!clean.startsWith("outputs.") && !clean.startsWith("trigger.")) {
result.value = undefined;
error.value = `Expression must start with "{{ ${props.property}. }}"`;
return;
}
const parts = clean.substring(props.property.length + 1).split(".");
let target: any = props.execution[props.property];
for (const part of parts) {
if (target && typeof target === "object" && part in target) {
target = target[part];
} else {
result.value = undefined;
error.value = `Property "${part}" does not exist on ${props.property}`;
return;
}
}
if (target && typeof target === "object") {
result.value = {
value: JSON.stringify(target, null, 2),
type: "json",
};
} else {
result.value = {value: String(target), type: "text"};
}
};
</script>
<style scoped lang="scss">
@import "@kestra-io/ui-libs/src/scss/variables";
#debug {
display: flex;
flex-direction: column;
height: 100%;
padding: calc($spacer / 2) $spacer;
border: 1px solid var(--el-border-color-light);
:deep(.ks-editor) {
&.expression {
height: calc($spacer * 2);
margin-bottom: $spacer;
}
&.result {
height: calc($spacer * 10);
}
}
.buttons {
display: inline-flex;
& :deep(.el-button) {
width: 100%;
margin-bottom: $spacer;
padding: $spacer;
font-size: $font-size-sm;
overflow: hidden;
span:not(i span) {
display: block;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
& :deep(.el-button:nth-of-type(2)) {
width: calc($spacer * 4);
}
}
}
</style>

View File

@@ -0,0 +1,3 @@
import {useBreakpoints, breakpointsElement} from "@vueuse/core";
export const verticalLayout = useBreakpoints(breakpointsElement).smallerOrEqual("md");

View File

@@ -262,7 +262,7 @@
"output": "Output",
"eval": {
"title": "Debug Expression",
"render": "Render Expression",
"render": "Render",
"tooltip": "Render any Pebble expression and inspect the Execution context."
},
"attempt": "Attempt",