mirror of
https://github.com/kestra-io/kestra.git
synced 2025-12-19 18:05:41 -05:00
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:
@@ -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 [];
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
@@ -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>
|
||||
3
ui/src/components/executions/overview/utils/layout.ts
Normal file
3
ui/src/components/executions/overview/utils/layout.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import {useBreakpoints, breakpointsElement} from "@vueuse/core";
|
||||
|
||||
export const verticalLayout = useBreakpoints(breakpointsElement).smallerOrEqual("md");
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user