feat(ui): introduced new topology as libs (#1877)

close #1721
close #1670
This commit is contained in:
YannC
2023-08-25 12:03:22 +02:00
committed by Ludovic DEHON
parent c90051e05d
commit b6b426f617
33 changed files with 4793 additions and 1691 deletions

1
.gitignore vendored
View File

@@ -34,6 +34,7 @@ ui/.env.*.local
webserver/src/main/resources/ui
yarn.lock
ui/coverage
ui/stats.html
### Docker
/.env

4524
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,9 @@
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path ../.gitignore"
},
"dependencies": {
"@kestra-io/ui-libs": "^0.0.9",
"@popperjs/core": "npm:@sxzz/popperjs-es@2.11.7",
"@vue-flow/background": "^1.2.0",
"@vue-flow/controls": "1.0.6",
"@vue-flow/core": "1.14.3",
"ansi-to-html": "^0.7.2",
@@ -24,7 +27,7 @@
"element-plus": "^2.3.9",
"humanize-duration": "^3.29.0",
"js-yaml": "^4.1.0",
"lodash": "4.17.21",
"lodash": "^4.17.21",
"markdown-it": "^13.0.1",
"markdown-it-anchor": "^8.6.7",
"markdown-it-container": "^3.0.0",
@@ -35,6 +38,7 @@
"moment-range": "4.0.2",
"moment-timezone": "^0.5.43",
"node-modules-polyfill": "^0.1.4",
"npm": "^9.8.1",
"nprogress": "^0.2.0",
"prismjs": "^1.29.0",
"throttle-debounce": "^5.0.0",
@@ -47,11 +51,11 @@
"vue-router": "^4.2.4",
"vue-sidebar-menu": "^5.2.10",
"vue-virtual-scroller": "^2.0.0-beta.8",
"vue3-popper": "^1.5.0",
"vue3-tour": "git@github.com:kestra-io/vue3-tour.git",
"vuex": "^4.1.0",
"xss": "^1.0.14",
"yaml": "^2.3.1",
"@popperjs/core": "npm:@sxzz/popperjs-es@2.11.7"
"yaml": "^2.3.1"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.3.3",
@@ -64,6 +68,7 @@
"monaco-editor": "^0.39.0",
"monaco-yaml": "4.0.0-alpha.0",
"prettier": "^3.0.1",
"rollup-plugin-visualizer": "^5.9.2",
"sass": "^1.64.2",
"vite": "^4.4.9",
"vite-plugin-rewrite-all": "^1.0.1",

View File

@@ -1,13 +1,14 @@
<script setup>
import {ref, onMounted, inject, nextTick} from "vue";
import {ref, onMounted, inject, nextTick, getCurrentInstance} from "vue";
import {useRoute} from "vue-router";
import {VueFlow, useVueFlow, Position, MarkerType} from "@vue-flow/core"
import {Controls, ControlButton} from "@vue-flow/controls"
import {Background} from "@vue-flow/background";
import dagre from "dagre"
import ArrowExpandAll from "vue-material-design-icons/ArrowExpandAll.vue";
import {cssVariable} from "../../utils/global"
import FlowDependenciesBlock from "./FlowDependenciesBlock.vue";
import {DependenciesNode} from "@kestra-io/ui-libs"
import {linkedElements} from "../../utils/vueFlow"
@@ -15,6 +16,7 @@
const route = useRoute();
const axios = inject("axios")
const router = getCurrentInstance().appContext.config.globalProperties.$router;
const loaded = ref([]);
const dependencies = ref({
@@ -54,7 +56,7 @@
};
const expand = (data) => {
load({namespace: data.namespace, id: data.id})
load({namespace: data.namespace, id: data.flowId})
};
const generateDagreGraph = () => {
@@ -64,8 +66,8 @@
for (const node of dependencies.value.nodes) {
dagreGraph.setNode(node.uid, {
width: 250 ,
height: 62
width: 184 ,
height: 44
})
}
@@ -93,8 +95,8 @@
type: "flow",
position: getNodePosition(dagreNode),
style: {
width: "250px",
height: "62px",
width: "184px",
height: "44px",
},
sourcePosition: Position.Right,
targetPosition: Position.Left,
@@ -104,6 +106,8 @@
namespace: node.namespace,
flowId: node.id,
current: node.namespace === route.params.namespace && node.id === route.params.id,
color: "pink",
link: true
}
}]);
}
@@ -113,7 +117,10 @@
id: edge.source + "|" + edge.target,
source: edge.source,
target: edge.target,
markerEnd: MarkerType.ArrowClosed,
markerEnd: {
id: "marker-custom",
type: MarkerType.ArrowClosed,
},
type: "smoothstep"
}]);
}
@@ -134,6 +141,13 @@
removeSelectedNodes(getNodes.value);
removeSelectedEdges(getEdges.value);
}
const openFlow = (data) => {
router.push({
name: "flows/update",
params: {"namespace": data.namespace, "id": data.flowId, tab: "dependencies"},
});
}
</script>
<template>
@@ -145,13 +159,14 @@
:nodes-draggable="false"
:elevate-nodes-on-select="false"
>
<Background />
<template #node-flow="props">
<FlowDependenciesBlock
:node="props.data.node"
:loaded="props.data.loaded"
@expand="expand"
<DependenciesNode
v-bind="props"
@expand-dependencies="expand"
@mouseover="onMouseOver"
@mouseleave="onMouseLeave"
@open-link="openFlow($event)"
/>
</template>

View File

@@ -3,8 +3,9 @@
:is="component"
:icon="CodeTags"
@click="onShow"
ref="taskEdit"
>
<span v-if="component !== 'el-button'">{{ $t('show task source') }}</span>
<span v-if="component !== 'el-button' && !isHidden">{{ $t("show task source") }}</span>
<el-drawer
v-if="isModalOpen"
v-model="isModalOpen"
@@ -14,25 +15,37 @@
:append-to-body="true"
>
<template #header>
<code>{{ taskId || task.id }}</code>
<code>{{ taskId || task?.id || $t("add task") }}</code>
</template>
<template #footer>
<div v-loading="isLoading">
<ValidationError link :error="taskError" />
<ValidationError link :error="taskError" />
<el-button :icon="ContentSave" @click="saveTask" v-if="canSave && !isReadOnly" :disabled="taskError !== undefined" type="primary">
{{ $t('save') }}
<el-button
:icon="ContentSave"
@click="saveTask"
v-if="canSave && !isReadOnly"
:disabled="taskError !== undefined"
type="primary"
>
{{ $t("save") }}
</el-button>
<el-alert show-icon :closable="false" class="mb-0 mt-3" v-if="revision && isReadOnly" type="warning">
<strong>{{ $t('seeing old revision', {revision: revision}) }}</strong>
<el-alert
show-icon
:closable="false"
class="mb-0 mt-3"
v-if="revision && isReadOnly"
type="warning"
>
<strong>{{ $t("seeing old revision", {revision: revision}) }}</strong>
</el-alert>
</div>
</template>
<el-tabs v-if="taskYaml" v-model="activeTabs">
<el-tabs v-model="activeTabs">
<el-tab-pane name="form">
<template #label>
<span>{{ $t('form') }}</span>
<span>{{ $t("form") }}</span>
</template>
<task-editor
ref="editor"
@@ -43,10 +56,9 @@
</el-tab-pane>
<el-tab-pane name="source">
<template #label>
<span>{{ $t('source') }}</span>
<span>{{ $t("source") }}</span>
</template>
<editor
v-if="taskYaml"
:read-only="isReadOnly"
ref="editor"
@save="saveTask"
@@ -61,7 +73,7 @@
<el-tab-pane v-if="pluginMardown" name="documentation">
<template #label>
<span>
{{ $t('documentation.documentation') }}
{{ $t("documentation.documentation") }}
</span>
</template>
<div class="documentation">
@@ -91,7 +103,7 @@
export default {
components: {Editor, TaskEditor, Markdown, ValidationError},
emits: ["update:task"],
emits: ["update:task", "close"],
props: {
component: {
type: String,
@@ -127,6 +139,48 @@
emitOnly: {
type: Boolean,
default: false
},
emitTaskOnly: {
type: Boolean,
default: false
},
isHidden: {
type: Boolean,
default: false
},
},
watch: {
task: {
async handler() {
if (this.task) {
this.taskYaml = YamlUtils.stringify(this.task);
if (this.task.type) {
this.$store
.dispatch("plugin/load", {cls: this.task.type})
}
} else {
this.taskYaml = "";
}
},
immediate: true
},
taskYaml: {
handler() {
const task = YamlUtils.parse(this.taskYaml);
if (task?.type && task.type !== this.type) {
this.$store
.dispatch("plugin/load", {cls: task.type})
this.type = task.type
}
},
},
isModalOpen: {
handler() {
if (!this.isModalOpen) {
this.$emit("close");
this.activeTabs = "form";
}
}
}
},
methods: {
@@ -147,6 +201,13 @@
},
saveTask() {
if (this.emitTaskOnly) {
this.$emit("update:task", this.taskYaml);
this.taskYaml = "";
this.isModalOpen = false;
return
}
let updatedSource;
try {
updatedSource = YamlUtils.replaceTaskInDocument(
@@ -175,12 +236,12 @@
},
async onShow() {
this.isModalOpen = !this.isModalOpen;
if (this.taskId || this.task.id) {
if (this.taskId) {
this.taskYaml = await this.load(this.taskId ? this.taskId : this.task.id);
} else {
} else if (this.task) {
this.taskYaml = YamlUtils.stringify(this.task);
}
if(this.task.type) {
if (this.task?.type) {
this.$store
.dispatch("plugin/load", {cls: this.task.type})
}
@@ -195,13 +256,11 @@
data() {
return {
uuid: Utils.uid(),
taskYaml: undefined,
taskYaml: "",
isModalOpen: false,
activeTabs: "form",
type: null,
};
},
created() {
},
computed: {
...mapState("flow", ["flow"]),
@@ -211,7 +270,7 @@
...mapState("flow", ["revisions"]),
...mapState("plugin", ["plugin"]),
pluginMardown() {
if(this.plugin && this.plugin.markdown) {
if (this.plugin && this.plugin.markdown && YamlUtils.parse(this.taskYaml)?.type) {
return this.plugin.markdown
}
return null
@@ -224,11 +283,6 @@
},
isReadOnly() {
return this.flow && this.revision && this.flow.revision !== this.revision
},
taskErrorContent() {
return this.taskError
? "<pre style='max-width: 40vw; white-space: pre-wrap'>" + this.taskError + "</pre>"
: ""
}
}
};

View File

@@ -42,11 +42,17 @@
emits: ["update:modelValue"],
created() {
if (this.modelValue) {
this.taskObject = YamlUtils.parse(this.modelValue);
this.selectedTaskType = this.taskObject.type;
this.$store.dispatch("flow/validateTask", {task: this.modelValue, section: this.section})
this.load();
this.setup()
}
},
watch: {
modelValue: {
handler() {
if (!this.modelValue) {
this.taskObject = {};
this.selectedTaskType = undefined;
}
}
}
},
beforeUnmount() {
@@ -73,6 +79,13 @@
};
},
methods: {
setup() {
this.taskObject = YamlUtils.parse(this.modelValue);
this.selectedTaskType = this.taskObject.type;
this.$store.dispatch("flow/validateTask", {task: this.modelValue, section: this.section})
this.load();
},
load() {
this.isLoading = true;
this.$store

View File

@@ -50,7 +50,7 @@
<style scoped lang="scss">
@import "../../styles/variable";
@import "@kestra-io/ui-libs/src/scss/variables.scss";
.el-button.el-button--default {
transition: none;

View File

@@ -75,8 +75,6 @@
</div>
</el-col>
</el-row>
</div>
</template>
</div>
@@ -175,7 +173,7 @@
};
</script>
<style scoped lang="scss">
@import "../../../styles/variable";
@import "@kestra-io/ui-libs/src/scss/variables.scss";
.header-wrapper {
margin-bottom: calc($spacer * 2);

View File

@@ -74,104 +74,104 @@
</script>
<style scoped lang="scss">
.node-wrapper {
cursor: pointer;
display: flex;
width: 200px;
background: var(--bs-gray-100);
.el-button, .card-header {
border-radius: 0 !important;
}
&.node-disabled {
.card-header .task-title {
text-decoration: line-through;
}
}
> .icon {
width: 35px;
height: 53px;
background: var(--bs-white);
position: relative;
}
.status-color {
width: 10px;
height: 53px;
border-right: 1px solid var(--bs-border-color);
}
.is-success {
background-color: var(--green);
}
.is-running {
background-color: var(--blue);
}
.is-failed {
background-color: var(--red);
}
.bg-undefined {
background-color: var(--bs-gray-400);
}
.task-content {
flex-grow: 1;
width: 38px;
.card-header {
height: 25px;
padding: 2px;
margin: 0;
border-bottom: 1px solid var(--bs-border-color);
flex: 1;
flex-wrap: nowrap;
background-color: var(--bs-gray-200);
color: var(--bs-body-color);
html.dark & {
background-color: var(--bs-gray-300);
}
.task-title {
margin-left: 2px;
display: inline-block;
font-size: var(--font-size-sm);
flex-grow: 1;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
white-space: nowrap;
}
:deep(.node-action) {
flex-shrink: 2;
padding-top: 18px;
padding-right: 18px;
}
}
}
.card-wrapper {
top: 50px;
position: absolute;
}
.info-wrapper {
display: flex;
}
.node-action {
height: 28px;
padding-top: 1px;
padding-right: 5px;
padding-left: 5px;
}
}
</style>
//.node-wrapper {
// cursor: pointer;
// display: flex;
// width: 200px;
// background: var(--bs-gray-100);
//
// .el-button, .card-header {
// border-radius: 0 !important;
// }
//
// &.node-disabled {
// .card-header .task-title {
// text-decoration: line-through;
// }
// }
//
// > .icon {
// width: 35px;
// height: 53px;
// background: var(--bs-white);
// position: relative;
// }
//
// .status-color {
// width: 10px;
// height: 53px;
// border-right: 1px solid var(--bs-border-color);
// }
//
//
// .is-success {
// background-color: var(--green);
// }
//
// .is-running {
// background-color: var(--blue);
// }
//
// .is-failed {
// background-color: var(--red);
// }
//
// .bg-undefined {
// background-color: var(--bs-gray-400);
// }
//
// .task-content {
// flex-grow: 1;
// width: 38px;
//
// .card-header {
// height: 25px;
// padding: 2px;
// margin: 0;
// border-bottom: 1px solid var(--bs-border-color);
// flex: 1;
// flex-wrap: nowrap;
// background-color: var(--bs-gray-200);
// color: var(--bs-body-color);
//
// html.dark & {
// background-color: var(--bs-gray-300);
// }
//
// .task-title {
// margin-left: 2px;
// display: inline-block;
// font-size: var(--font-size-sm);
// flex-grow: 1;
// overflow: hidden;
// text-overflow: ellipsis;
// max-width: 100%;
// white-space: nowrap;
// }
//
// :deep(.node-action) {
// flex-shrink: 2;
// padding-top: 18px;
// padding-right: 18px;
// }
// }
//
// }
//
// .card-wrapper {
// top: 50px;
// position: absolute;
// }
//
// .info-wrapper {
// display: flex;
// }
//
// .node-action {
// height: 28px;
// padding-top: 1px;
// padding-right: 5px;
// padding-left: 5px;
// }
//}
</style>-

View File

@@ -1,29 +0,0 @@
<script setup>
const props = defineProps({
label: {
type: String,
required: true,
},
})
</script>
<template>
<div v-if="label" class="label" v-html="label" />
</template>
<style lang="scss">
@use "../../../styles/variable" as global-var;
.vue-flow__node-cluster {
background-color: rgba(global-var.$cyan, 0.05);
border: 1px solid var(--bs-cyan);
pointer-events: none !important;
.label {
color: var(--bs-cyan);
text-align: center;
font-size: var(--font-size-sm);
}
}
</style>

View File

@@ -1,49 +0,0 @@
<script setup>
import {Handle} from "@vue-flow/core"
const props = defineProps({
sourcePosition: {
type: String,
required: true
},
targetPosition: {
type: String,
required: true
},
data: {
type: Object,
required: true
},
})
</script>
<script>
export default {
inheritAttrs: false,
}
</script>
<template>
<Handle type="source" :position="sourcePosition" />
<div class="dot" />
<Handle type="target" :position="targetPosition" />
</template>
<style lang="scss">
.vue-flow__node-dot {
border: 0 !important;
.vue-flow__handle {
opacity: 0;
}
div.dot {
position: absolute;
top: 50%;
transform: translate(0, -50%);
border-radius: 50%;
height: 5px;
width: 5px;
background-color: var(--bs-cyan);
}
}
</style>

View File

@@ -1,314 +0,0 @@
<script lang="ts" setup>
import type {EdgeProps, Position} from '@vue-flow/core'
import {EdgeLabelRenderer, getSmoothStepPath, useEdge} from '@vue-flow/core'
import type {CSSProperties} from 'vue'
import {computed, getCurrentInstance, ref, watch} from 'vue'
import TaskEditor from "../../flows/TaskEditor.vue"
import Help from "vue-material-design-icons/Help.vue";
import HelpCircle from "vue-material-design-icons/HelpCircle.vue";
import Exclamation from "vue-material-design-icons/Exclamation.vue";
import Reload from "vue-material-design-icons/Reload.vue";
import ViewParallelOutline from "vue-material-design-icons/ViewParallelOutline.vue";
import ViewSequentialOutline from "vue-material-design-icons/ViewSequentialOutline.vue";
import Plus from "vue-material-design-icons/Plus.vue";
import ContentSave from "vue-material-design-icons/ContentSave.vue";
import yamlUtils from "../../../utils/yamlUtils.js";
import YamlUtils from "../../../utils/yamlUtils.js";
import {useStore} from "vuex";
import ValidationError from "../../flows/ValidationError.vue";
import {Ref} from "@vue/reactivity";
import {SECTIONS} from "../../../utils/constants.js";
const store = useStore();
const t = getCurrentInstance().appContext.config.globalProperties.$t;
interface CustomEdgeProps<T = any> extends /* @vue-ignore */ EdgeProps<T> {
id: string
sourceX: number
sourceY: number
targetX: number
targetY: number
sourcePosition: Position
targetPosition: Position
data: T
markerEnd: string
style: CSSProperties,
yamlSource: String,
flowablesIds: Array<String>,
isReadOnly: Boolean,
isAllowedEdit: Boolean
}
const props = defineProps<CustomEdgeProps>()
const isHover = ref(false);
const isOpen = ref(false);
const {edge} = useEdge()
const emit = defineEmits(["edit"])
const taskYaml = ref("");
const execution = store.getters["execution/execution"];
const timer = ref(undefined);
const taskError: Ref<string> = ref(store.getters["flow/taskError"])
watch(() => store.getters["flow/taskError"], async () => {
taskError.value = store.getters["flow/taskError"];
});
const isBorderEdge = () => {
if (!props.data.haveAdd && props.data.isFlowable) {
return false
}
const task1 = props.id.split("|")[0]
const task2 = props.id.split("|")[1]
// Check if relation is root > task or task > end or if it contains a haveAdd
return (task1.includes("_root") && yamlUtils.extractTask(props.yamlSource, task2)) || (task2.includes("_end") && yamlUtils.extractTask(props.yamlSource, task1)) || props.data.haveAdd
}
const getEdgeLabel = (relation) => {
let label = "";
if (relation.relationType) {
label = relation.relationType;
if (relation.relationType === "CHOICE" && relation.value) {
label += ` : ${relation.value}`;
}
} else if (isBorderEdge()) {
label += "SEQUENTIAL"
}
return label;
};
const getEdgeIcon = (relation) => {
if (relation.relationType) {
if (relation.relationType === "ERROR") {
return Exclamation;
} else if (relation.relationType === "DYNAMIC") {
return Reload;
} else if (relation.relationType === "CHOICE") {
return Help;
} else if (relation.relationType === "PARALLEL") {
return ViewParallelOutline;
} else {
return ViewSequentialOutline;
}
} else if (isBorderEdge()) {
return ViewSequentialOutline;
}
return HelpCircle;
};
const getClassName = computed(() => {
return {
[props.data.edge.relation.relationType]: true,
hover: isHover
}
})
const path = computed(() => getSmoothStepPath(props))
const onMouseOver = () => {
isHover.value = true;
}
const onMouseLeave = () => {
isHover.value = false;
}
const updateTask = (task) => {
taskYaml.value = task;
clearTimeout(timer.value);
timer.value = setTimeout(() => {
store.dispatch("flow/validateTask", {task: task, section: SECTIONS.TASKS})
}, 500);
}
const getAddTaskInformation = () => {
// end to end edge case
if (props.data.haveAdd) {
return {taskId: props.data.haveAdd[0], taskYaml: taskYaml.value, insertPosition: props.data.haveAdd[1]};
}
let leftNodeIsFlowable = false;
const leftNodeIsTask = YamlUtils.extractTask(props.yamlSource, props.id.split("|")[0]) !== undefined;
if (leftNodeIsTask) {
leftNodeIsFlowable = props.flowablesIds.includes(props.id.split("|")[0])
}
// If left node is a flowable task or is not a task, then we insert
// the new task before the right task node
const [taskId, insertPosition] = leftNodeIsTask && !leftNodeIsFlowable ? [props.id.split("|")[0], "after"] : [props.data.nextTaskId, "before"];
return {taskId: taskId, taskYaml: taskYaml.value, insertPosition: insertPosition}
}
const taskHaveId = () => {
return taskYaml.value.length > 0 ? !!YamlUtils.parse(taskYaml.value).id : false;
}
const checkTaskExist = () => {
return yamlUtils.checkTaskAlreadyExist(props.yamlSource, YamlUtils.parse(taskYaml.value).id)
}
const forwardTask = () => {
if (!checkTaskExist()) {
emit("edit", getAddTaskInformation());
isOpen.value = false;
} else {
store.dispatch("core/showMessage", {
variant: "error",
title: t("task id already exist"),
message: t(`Task Id already exist in the flow`, {taskId: YamlUtils.parse(taskYaml.value).id})
});
}
};
const addTooltip = () => {
const addInformation = getAddTaskInformation();
const taskId = addInformation.insertPosition === 'before' ? props.data.nextTaskId : addInformation.taskId;
if (execution || !taskId) {
return;
}
if (!props.data.initTask) {
return t("add at position", {
position: t(addInformation.insertPosition),
task: taskId
})
} else {
return t("create first task");
}
}
</script>
<script lang="ts">
export default {
inheritAttrs: false,
}
</script>
<template>
<path
:id="id"
:style="style"
class="vue-flow__edge-path"
:class="getClassName"
:d="path[0]"
:marker-end="markerEnd"
/>
<!-- hidden path to have largest hover region -->
<path
:d="path[0]"
fill="none"
stroke-opacity="0"
stroke-width="20"
@mouseover="onMouseOver"
@mouseleave="onMouseLeave"
/>
<EdgeLabelRenderer style="z-index: 10">
<div
v-if="getEdgeLabel(props.data.edge.relation) !== '' && !props.data.disabled"
@mouseover="onMouseOver"
@mouseleave="onMouseLeave"
:style="{
pointerEvents: 'all',
position: 'absolute',
transform: `translate(-50%, -50%) translate(${path[1]}px,${path[2]}px)`,
}"
class="nodrag nopan"
:class="props.data.edge.relation.relationType"
>
<el-tooltip placement="bottom" :persistent="false" transition="" :hide-after="0">
<template #content>
<template v-if="isHover && !isReadOnly && isAllowedEdit">
{{ getEdgeLabel(props.data.edge.relation) }}<br/>
<span v-html="addTooltip()"/>
</template>
<template v-else>
{{ getEdgeLabel(props.data.edge.relation) }}
</template>
</template>
<span>
<el-button v-if="isHover && !isReadOnly && isAllowedEdit" :icon="Plus" link @click="isOpen = true"/>
<el-button v-else :icon="getEdgeIcon(props.data.edge.relation)" link/>
</span>
</el-tooltip>
<el-drawer
v-if="isOpen"
v-model="isOpen"
title="Add a task"
destroy-on-close
size=""
:append-to-body="true"
>
<el-form label-position="top">
<task-editor
:section="SECTIONS.TASKS"
@update:model-value="updateTask($event)"
/>
</el-form>
<template #footer>
<ValidationError link :error="taskError"/>
<el-button
:disabled="!taskHaveId() || taskError !== undefined"
:icon="ContentSave"
@click="forwardTask"
type="primary"
>
{{ $t("save") }}
</el-button>
</template>
</el-drawer>
</div>
</EdgeLabelRenderer>
</template>
<style lang="scss">
.vue-flow__edge-path {
&.ERROR {
stroke: var(--bs-danger);
}
&.DYNAMIC {
stroke: var(--bs-teal);
}
&.CHOICE {
stroke: var(--bs-orange);
}
}
.vue-flow__edge-labels > div {
border-radius: 50%;
height: 18px;
width: 18px;
background: var(--bs-purple);
.el-button {
margin-top: -9px;
margin-left: -1px;
font-size: var(--font-size-sm);
&:hover, &:active {
color: var(--el-color-white) !important;
}
}
&.ERROR {
background: var(--bs-danger);
}
&.DYNAMIC {
background: var(--bs-teal);
}
&.CHOICE {
background: var(--bs-orange);
}
}
</style>

View File

@@ -1,73 +0,0 @@
<script setup>
import {Handle} from "@vue-flow/core"
import TreeTaskNode from "../TreeTaskNode.vue";
const emit = defineEmits(["follow", "mouseover", "mouseleave", "edit", "delete", "addFlowableError"])
const props = defineProps({
sourcePosition: {
type: String,
required: true
},
targetPosition: {
type: String,
required: true
},
data: {
type: Object,
required: true
},
isReadOnly: {
type: Boolean,
required: true
},
isAllowedEdit: {
type: Boolean,
required: true
},
})
const mouseover = () => {
emit("mouseover", props.data.node);
};
const mouseleave = () => {
emit("mouseleave", props.data.node);
};
const forwardEvent = (type, event) => {
emit(type, event);
};
</script>
<script>
export default {
inheritAttrs: false,
}
</script>
<template>
<Handle type="source" :position="sourcePosition" />
<TreeTaskNode
:n="data.node"
:namespace="data.namespace"
:flow-id="data.flowId"
:revision="data.revision"
:is-flowable="data.isFlowable"
:is-read-only="props.isReadOnly"
:is-allowed-edit="props.isAllowedEdit"
@follow="forwardEvent('follow', $event)"
@edit="forwardEvent('edit', $event)"
@delete="forwardEvent('delete', $event)"
@addFlowableError="forwardEvent('addFlowableError', $event)"
@mouseover="mouseover"
@mouseleave="mouseleave"
/>
<Handle type="target" :position="targetPosition" />
</template>
<style lang="scss">
.vue-flow__node-task {
border: 1px solid var(--bs-border-color);
}
</style>

View File

@@ -1,71 +0,0 @@
<script setup>
import {Handle} from "@vue-flow/core"
import TreeTriggerNode from "../TreeTriggerNode.vue";
import TreeTaskNode from "../TreeTaskNode.vue";
const emit = defineEmits(["mouseover", "mouseleave", "edit", "delete"])
const props = defineProps({
sourcePosition: {
type: String,
required: true
},
targetPosition: {
type: String,
required: true
},
data: {
type: Object,
required: true
},
isReadOnly: {
type: Boolean,
required: true
},
isAllowedEdit: {
type: Boolean,
required: true
},
})
const mouseover = () => {
emit("mouseover", props.data.node);
};
const mouseleave = () => {
emit("mouseleave", props.data.node);
};
const forwardEvent = (type, event) => {
emit(type, event);
};
</script>
<script>
export default {
inheritAttrs: false,
}
</script>
<template>
<Handle type="source" :position="sourcePosition" />
<TreeTriggerNode
:n="data.node"
:namespace="data.namespace"
:flow-id="data.flowId"
:revision="data.revision"
:is-read-only="props.isReadOnly"
:is-allowed-edit="props.isAllowedEdit"
@edit="forwardEvent('edit', $event)"
@delete="forwardEvent('delete', $event)"
@mouseover="mouseover"
@mouseleave="mouseleave"
/>
<Handle type="target" :position="targetPosition" />
</template>
<style lang="scss">
.vue-flow__node-task {
border: 1px solid var(--bs-border-color);
}
</style>

View File

@@ -82,7 +82,7 @@
</script>
<style lang="scss" scoped>
@import "../../styles/_variable.scss";
@import "@kestra-io/ui-libs/src/scss/variables.scss";
.status-pie {
div {

View File

@@ -1,29 +1,35 @@
<script setup>
// Core
import {getCurrentInstance, nextTick, onMounted, ref, watch} from "vue";
import {getCurrentInstance, nextTick, onMounted, onBeforeMount, ref, watch} from "vue";
import {useStore} from "vuex";
import {MarkerType, Position, useVueFlow, VueFlow} from "@vue-flow/core";
import {MarkerType, Position, useVueFlow} from "@vue-flow/core";
// Nodes
import Cluster from "../graph/nodes/Cluster.vue";
import Dot from "../graph/nodes/Dot.vue"
import Task from "../graph/nodes/Task.vue";
import Trigger from "../graph/nodes/Trigger.vue";
import Edge from "../graph/nodes/Edge.vue";
import TaskEdit from "../flows/TaskEdit.vue";
import SearchField from "../layout/SearchField.vue";
import LogLevelSelector from "../logs/LogLevelSelector.vue";
import LogList from "../logs/LogList.vue";
import Collapse from "../layout/Collapse.vue";
// Topology Control
import {Controls, ControlButton} from "@vue-flow/controls"
import SplitCellsHorizontal from "../../assets/icons/SplitCellsHorizontal.vue"
import SplitCellsVertical from "../../assets/icons/SplitCellsVertical.vue"
// Topology
import {
Topology
} from "@kestra-io/ui-libs"
// Utils
import YamlUtils from "../../utils/yamlUtils";
import {YamlUtils, VueFlowUtils} from "@kestra-io/ui-libs";
import {SECTIONS} from "../../utils/constants";
import {linkedElements} from "../../utils/vueFlow";
import {cssVariable} from "../../utils/global";
import dagre from "dagre";
import Utils from "../../utils/utils";
import ContentSave from "vue-material-design-icons/ContentSave.vue";
import TaskEditor from "../flows/TaskEditor.vue";
import ValidationError from "../flows/ValidationError.vue";
import Markdown from "../layout/Markdown.vue";
import yamlUtils from "../../utils/yamlUtils";
const router = getCurrentInstance().appContext.config.globalProperties.$router;
const vueflowId = ref(Math.random().toString());
// Vue flow methods to interact with Graph
const {
id,
@@ -36,8 +42,9 @@
removeSelectedElements,
onNodeDragStart,
onNodeDragStop,
onNodeDrag
} = useVueFlow({id: Math.random().toString()});
onNodeDrag,
setElements,
} = useVueFlow({id: vueflowId.value});
// props
const props = defineProps({
@@ -90,25 +97,41 @@
(props.viewType?.indexOf("blueprint") !== -1 ? true : localStorage.getItem("topology-orientation") === "1")
}
// Components variables
const dragging = ref(false);
const isHorizontal = ref(isHorizontalDefault());
const elements = ref([])
const lastPosition = ref(null)
const vueFlow = ref(null);
const timer = ref(null);
const icons = ref(store.getters["plugin/getIcons"]);
const taskObject = ref(null);
const taskEditData = ref(null);
const taskEdit = ref(null);
const isShowLogsOpen = ref(false);
const logFilter = ref("");
const logLevel = ref(localStorage.getItem("defaultLogLevel") || "INFO");
const isDrawerOpen = ref(false);
const isShowDescriptionOpen = ref(false);
const selectedTask = ref(null);
// Init components
onMounted( async() => {
onMounted(() => {
// Regenerate graph on window resize
observeWidth();
})
watch(() => props.flowGraph, () => {
generateGraph();
})
watch(() => isDrawerOpen.value, () => {
if (!isDrawerOpen.value) {
isShowDescriptionOpen.value = false;
isShowLogsOpen.value = false;
selectedTask.value = null;
}
})
watch(() => props.viewType, () => {
isHorizontal.value = props.viewType === "source-topology" ? false :
(props.viewType?.indexOf("blueprint") !== -1 ? true : localStorage.getItem("topology-orientation") === "1")
@@ -129,15 +152,15 @@
resizeObserver.observe(vueFlow.value);
}
const forwardEvent = (type, event) => {
emit(type, event);
};
// Source edit functions
const onDelete = (event) => {
const flowParsed = YamlUtils.parse(props.source);
toast.confirm(
t("delete task confirm", {taskId: flowParsed.id}),
t("delete task confirm", {taskId: event.id}),
() => {
const section = event.section ? event.section : SECTIONS.TASKS;
@@ -149,123 +172,74 @@
});
return;
}
emit("on-edit",YamlUtils.deleteTask(props.source, event.id, section))
emit("on-edit", YamlUtils.deleteTask(props.source, event.id, section))
},
() => {
}
)
}
// Source edit functions
const onCreateNewTask = (event) => {
const source = props.source;
emit("on-edit",YamlUtils.insertTask(source, event.taskId, event.taskYaml, event.insertPosition))
taskEditData.value = {
insertionDetails: event,
action: "create_task",
section: SECTIONS.TASKS
};
taskEdit.value.$refs.taskEdit.click()
}
const onEditTask = (event) => {
taskEditData.value = {
action: "edit_task",
section: event.section ? event.section : SECTIONS.TASKS,
oldTaskId: event.task.id,
};
taskObject.value = event.task
taskEdit.value.$refs.taskEdit.click()
}
const onAddFlowableError = (event) => {
taskEditData.value = {
action: "add_flowable_error",
taskId: event.task.id
};
taskEdit.value.$refs.taskEdit.click()
}
const confirmEdit = (event) => {
const source = props.source;
emit("on-edit",YamlUtils.insertErrorInFlowable(source, event.error, event.taskId))
}
// Flow check functions
const flowHaveTasks = (source) => {
const flow = source ? source : props.source
return flow ? YamlUtils.flowHaveTasks(flow) : false;
}
const flowables = () => {
return props.flowGraph && props.flowGraph.flowables ? props.flowGraph.flowables : [];
}
// Graph interactions functions
const onMouseOver = (node) => {
if (!dragging.value) {
linkedElements(id, node.uid).forEach((n) => {
if (n.type === "task") {
n.style = {...n.style, outline: "0.5px solid " + cssVariable("--bs-yellow")}
}
});
}
}
const onMouseLeave = () => {
resetNodesStyle();
}
const resetNodesStyle = () => {
getNodes.value.filter(n => n.type === "task" || n.type === " trigger")
.forEach(n => {
n.style = {...n.style, opacity: "1", outline: "none"}
})
}
onNodeDragStart((e) => {
dragging.value = true;
resetNodesStyle();
e.node.style = {...e.node.style, zIndex: 1976}
lastPosition.value = e.node.position;
})
onNodeDragStop((e) => {
dragging.value = false;
if (checkIntersections(e.intersections, e.node) === null) {
const taskNode1 = e.node;
// check multiple intersection with task
const taskNode2 = e.intersections.find(n => n.type === "task");
if (taskNode2) {
try {
emit("on-edit", YamlUtils.swapTasks(props.source, taskNode1.id, taskNode2.id))
} catch (e) {
store.dispatch("core/showMessage", {
variant: "error",
title: t("cannot swap tasks"),
message: t(e.message, e.messageOptions)
});
taskNode1.position = lastPosition.value;
}
} else {
taskNode1.position = lastPosition.value;
const task = YamlUtils.extractTask(props.source, YamlUtils.parse(event).id);
if (task === undefined || (task && YamlUtils.parse(event).id === taskEditData.value.oldTaskId)) {
switch (taskEditData.value.action) {
case("create_task"):
emit("on-edit", YamlUtils.insertTask(source, taskEditData.value.insertionDetails[0], event, taskEditData.value.insertionDetails[1]))
return;
case("edit_task"):
emit("on-edit", YamlUtils.replaceTaskInDocument(
source,
taskEditData.value.oldTaskId,
event
))
return;
case("add_flowable_error"):
emit("on-edit", YamlUtils.insertErrorInFlowable(props.source, event, taskEditData.value.taskId))
return;
}
} else {
e.node.position = lastPosition.value;
store.dispatch("core/showMessage", {
variant: "error",
title: t("error detected"),
message: t("Task Id already exist in the flow", {taskId: YamlUtils.parse(event).id})
});
}
resetNodesStyle();
e.node.style = {...e.node.style, zIndex: 1}
lastPosition.value = null;
})
taskEditData.value = null;
taskObject.value = null;
}
onNodeDrag((e) => {
resetNodesStyle();
getNodes.value.filter(n => n.id !== e.node.id).forEach(n => {
if (n.type === "trigger" || (n.type === "task" && YamlUtils.isParentChildrenRelation(props.source, n.id, e.node.id))) {
n.style = {...n.style, opacity: "0.5"}
} else {
n.style = {...n.style, opacity: "1"}
}
})
if (!checkIntersections(e.intersections, e.node) && e.intersections.filter(n => n.type === "task").length === 1) {
e.intersections.forEach(n => {
if (n.type === "task") {
n.style = {...n.style, outline: "0.5px solid " + cssVariable("--bs-primary")}
}
})
e.node.style = {...e.node.style, outline: "0.5px solid " + cssVariable("--bs-primary")}
}
})
const checkIntersections = (intersections, node) => {
const tasksMeet = intersections.filter(n => n.type === "task").map(n => n.id);
if (tasksMeet.length > 1) {
return "toomuchtaskerror";
}
if (tasksMeet.length === 1 && YamlUtils.isParentChildrenRelation(props.source, tasksMeet[0], node.id)) {
return "parentchildrenerror";
}
if (intersections.filter(n => n.type === "trigger").length > 0) {
return "triggererror";
}
return null;
const closeEdit = () => {
taskEditData.value = null;
taskObject.value = null;
}
const toggleOrientation = () => {
@@ -280,271 +254,75 @@
// Graph generation functions
const generateDagreGraph = () => {
const dagreGraph = new dagre.graphlib.Graph({compound: true})
dagreGraph.setDefaultEdgeLabel(() => ({}))
dagreGraph.setGraph({rankdir: isHorizontal.value ? "LR" : "TB"})
for (const node of props.flowGraph.nodes) {
dagreGraph.setNode(node.uid, {
width: getNodeWidth(node),
height: getNodeHeight(node)
})
}
for (const edge of props.flowGraph.edges) {
dagreGraph.setEdge(edge.source, edge.target)
}
for (let cluster of (props.flowGraph.clusters || [])) {
dagreGraph.setNode(cluster.cluster.uid, {clusterLabelPos: "top"});
if (cluster.parents) {
dagreGraph.setParent(cluster.cluster.uid, cluster.parents[cluster.parents.length - 1]);
}
for (let node of (cluster.nodes || [])) {
dagreGraph.setParent(node, cluster.cluster.uid)
}
}
dagre.layout(dagreGraph)
return dagreGraph;
return VueFlowUtils.generateDagreGraph(props.flowGraph, hiddenNodes.value, isHorizontal.value, clusterCollapseToNode.value, edgeReplacer.value, collapsed.value, clusterToNode.value);
}
const getNodePosition = (n, parent) => {
const position = {x: n.x - n.width / 2, y: n.y - n.height / 2};
// bug with parent node,
if (parent) {
const parentPosition = getNodePosition(parent);
position.x = position.x - parentPosition.x;
position.y = position.y - parentPosition.y;
const openFlow = (data) => {
if (data.link.executionId) {
store
.dispatch("execution/loadExecution", {id: data.link.executionId})
.then(value => {
store.commit("execution/setExecution", value);
window.open(router.resolve({
name: "executions/update",
params: {
namespace: data.link.namespace,
flowId: data.link.id,
tab: "topology",
id: data.link.executionId,
},
}).href,'_blank');;
})
} else {
window.open(router.resolve({
name: "flows/update",
params: {"namespace": data.link.namespace, "id": data.link.id, tab: "overview"},
}).href,'_blank');
}
return position;
};
const isTaskNode = (node) => {
return node.task !== undefined && (node.type === "io.kestra.core.models.hierarchies.GraphTask" || node.type === "io.kestra.core.models.hierarchies.GraphClusterRoot")
};
const isTriggerNode = (node) => {
return node.trigger !== undefined && (node.type === "io.kestra.core.models.hierarchies.GraphTrigger");
}
const getNodeWidth = (node) => {
return isTaskNode(node) || isTriggerNode(node) ? 202 : 5;
};
const getNodeHeight = (node) => {
return isTaskNode(node) || isTriggerNode(node) ? 55 : (isHorizontal.value ? 55 : 5);
};
const complexEdgeHaveAdd = (edge) => {
// Check if edge is an ending flowable
// If true, enable add button to add a task
// under the flowable task
const isEndtoEndEdge = edge.source.includes("_end") && edge.target.includes("_end")
if (isEndtoEndEdge) {
// Cluster uid contains the flowable task id
// So we look for the cluster having this end edge
// to return his flowable id
return [getClusterTaskIdWithEndNodeUid(edge.source), "after"];
}
if (isLinkToFirstFlowableTask(edge)) {
return [getFirstTaskId(), "before"];
}
return undefined;
}
const getClusterTaskIdWithEndNodeUid = (nodeUid) => {
const cluster = props.flowGraph.clusters.find(cluster => cluster.end === nodeUid);
if (cluster) {
return Utils.splitFirst(cluster.cluster.uid, "cluster_");
}
return undefined;
}
const isLinkToFirstFlowableTask = (edge) => {
const firstTaskId = getFirstTaskId();
return flowables().includes(firstTaskId) && edge.target === firstTaskId;
}
const getFirstTaskId = () => {
return YamlUtils.getFirstTask(props.source);
}
const getNextTaskId = (target) => {
while (YamlUtils.extractTask(props.source, target) === undefined) {
const edge = props.flowGraph.edges.find(e => e.source === target)
if (!edge) {
return null
}
target = edge.target
}
return target
}
const cleanGraph = () => {
removeEdges(getEdges.value)
removeNodes(getNodes.value)
removeSelectedElements(getElements.value)
elements.value = []
}
const generateGraph = () => {
cleanGraph();
// VueFlowUtils.cleanGraph(vueflowId.value);
//
// nextTick(() => {
// emit("loading", true);
// fitView();
// setElements(elements.value);
// emit("loading", false);
// })
}
nextTick(() => {
emit("loading", true);
try {
if (!props.flowGraph || !flowHaveTasks()) {
elements.value.push({
id: "start",
label: "",
type: "dot",
position: {x: 0, y: 0},
style: {
width: "5px",
height: "5px"
},
sourcePosition: isHorizontal.value ? Position.Right : Position.Bottom,
targetPosition: isHorizontal.value ? Position.Left : Position.Top,
parentNode: undefined,
draggable: false,
})
elements.value.push({
id: "end",
label: "",
type: "dot",
position: isHorizontal.value ? {x: 50, y: 0} : {x: 0, y: 50},
style: {
width: "5px",
height: "5px"
},
sourcePosition: isHorizontal.value ? Position.Right : Position.Bottom,
targetPosition: isHorizontal.value ? Position.Left : Position.Top,
parentNode: undefined,
draggable: false,
})
elements.value.push({
id: "start|end",
source: "start",
target: "end",
type: "edge",
markerEnd: MarkerType.ArrowClosed,
data: {
edge: {
relation: {
relationType: "SEQUENTIAL"
}
},
isFlowable: false,
initTask: true,
}
})
const showLogs = (event) => {
selectedTask.value = event
isShowLogsOpen.value = true;
isDrawerOpen.value = true;
}
emit("loading", false);
return;
}
if (props.flowGraph === undefined) {
emit("loading", false);
return;
}
const dagreGraph = generateDagreGraph();
const clusters = {};
for (let cluster of (props.flowGraph.clusters || [])) {
for (let nodeUid of cluster.nodes) {
clusters[nodeUid] = cluster.cluster;
}
const onSearch = (search) => {
logFilter.value = search;
}
const dagreNode = dagreGraph.node(cluster.cluster.uid)
const parentNode = cluster.parents ? cluster.parents[cluster.parents.length - 1] : undefined;
const onLevelChange = (level) => {
logLevel.value = level;
}
const clusterUid = cluster.cluster.uid;
elements.value.push({
id: clusterUid,
label: clusterUid,
type: "cluster",
parentNode: parentNode,
position: getNodePosition(dagreNode, parentNode ? dagreGraph.node(parentNode) : undefined),
style: {
width: clusterUid === "Triggers" && isHorizontal.value ? "400px" : dagreNode.width + "px",
height: clusterUid === "Triggers" && !isHorizontal.value ? "250px" : dagreNode.height + "px",
},
})
}
const showDescription = (event) => {
selectedTask.value = event
isShowDescriptionOpen.value = true;
isDrawerOpen.value = true;
}
let disabledLowCode = [];
const emitEdit = (event) => {
emit("on-edit", event)
}
for (const node of props.flowGraph.nodes) {
const dagreNode = dagreGraph.node(node.uid);
let nodeType = "task";
if (node.type.includes("GraphClusterEnd")) {
nodeType = "dot";
} else if (clusters[node.uid] === undefined && node.type.includes("GraphClusterRoot")) {
nodeType = "dot";
} else if (node.type.includes("GraphClusterRoot")) {
nodeType = "dot";
} else if (node.type.includes("GraphTrigger")) {
nodeType = "trigger";
}
// Disable interaction for Dag task
// because our low code editor can not handle it for now
if (isTaskNode(node) && node.task.type === "io.kestra.core.tasks.flows.Dag") {
disabledLowCode.push(node.task.id);
YamlUtils.getChildrenTasks(props.source, node.task.id).forEach(child => {
disabledLowCode.push(child);
})
}
elements.value.push({
id: node.uid,
label: isTaskNode(node) ? node.task.id : "",
type: nodeType,
position: getNodePosition(dagreNode, clusters[node.uid] ? dagreGraph.node(clusters[node.uid].uid) : undefined),
style: {
width: getNodeWidth(node) + "px",
height: getNodeHeight(node) + "px"
},
sourcePosition: isHorizontal.value ? Position.Right : Position.Bottom,
targetPosition: isHorizontal.value ? Position.Left : Position.Top,
parentNode: clusters[node.uid] ? clusters[node.uid].uid : undefined,
draggable: nodeType === "task" && !props.isReadOnly && isTaskNode(node) ? !disabledLowCode.includes(node.task.id) : false,
data: {
node: node,
namespace: props.namespace,
flowId: props.flowId,
revision: props.execution ? props.execution.flowRevision : undefined,
isFlowable: isTaskNode(node) ? flowables().includes(node.task.id) : false
},
})
}
for (const edge of props.flowGraph.edges) {
elements.value.push({
id: edge.source + "|" + edge.target,
source: edge.source,
target: edge.target,
type: "edge",
markerEnd: MarkerType.ArrowClosed,
data: {
edge: edge,
haveAdd: complexEdgeHaveAdd(edge),
isFlowable: flowables().includes(edge.source) || flowables().includes(edge.target),
nextTaskId: getNextTaskId(edge.target),
disabled: disabledLowCode.includes(edge.source)
}
})
}
} catch (e) {
console.error("Error while creating topology graph: " + e);
}
finally {
emit("loading", false);
}
})
const message = (event) => {
store.dispatch("core/showMessage", {
variant: event.variant,
title: t(event.title),
message: t(event.message)
});
}
// Expose method to be triggered by parents
@@ -555,71 +333,88 @@
<template>
<div ref="vueFlow" class="vueflow">
<slot name="top-bar" />
<VueFlow
v-model="elements"
:default-marker-color="cssVariable('--bs-cyan')"
:fit-view-on-init="true"
:nodes-draggable="false"
:nodes-connectable="false"
:elevate-nodes-on-select="false"
:elevate-edges-on-select="false"
<slot name="top-bar"/>
<Topology
:id="vueflowId"
:is-horizontal="isHorizontal"
:is-read-only="isReadOnly"
:is-allowed-edit="isAllowedEdit"
:source="source"
:toggle-orientation-button="['topology'].includes(viewType)"
:flowGraph="props.flowGraph"
:flow-id="flowId"
:namespace="namespace"
@toggle-orientation="toggleOrientation"
@edit="onEditTask($event)"
@delete="onDelete"
@open-link="openFlow($event)"
@show-logs="showLogs($event)"
@show-description="showDescription($event)"
@on-add-flowable-error="onAddFlowableError($event)"
@add-task="onCreateNewTask($event)"
@swapped-task="emitEdit($event)"
@message="message($event)"
/>
<!-- Drawer to create/add task -->
<task-edit
component="div"
is-hidden
:emit-task-only="true"
class="node-action"
:section="SECTIONS.TASKS"
:task="taskObject"
:flow-id="flowId"
size="small"
:namespace="namespace"
:revision="execution ? execution.flowRevision : undefined"
:emit-only="true"
@update:task="confirmEdit($event)"
@close="closeEdit()"
ref="taskEdit"
/>
<!-- Drawer to task informations (logs, description, ..) -->
<!-- Assuming selectedTask is always the id and the required data for the opened drawer -->
<el-drawer
v-if="isDrawerOpen && selectedTask"
v-model="isDrawerOpen"
destroy-on-close
size=""
:append-to-body="true"
>
<template #node-cluster="props">
<Cluster v-bind="props" />
<template #header>
<code>{{ selectedTask.id }}</code>
</template>
<template #node-dot="props">
<Dot v-bind="props" />
</template>
<template #node-task="props">
<Task
v-bind="props"
<div v-if="isShowLogsOpen">
<collapse>
<el-form-item>
<search-field :router="false" @search="onSearch" class="me-2"/>
</el-form-item>
<el-form-item>
<log-level-selector :value="logLevel" @update:model-value="onLevelChange"/>
</el-form-item>
</collapse>
<log-list
v-for="taskRun in selectedTask.taskRuns"
:key="taskRun.id"
:execution="execution"
:task-run-id="taskRun.id"
:filter="logFilter"
:exclude-metas="['namespace', 'flowId', 'taskId', 'executionId']"
:level="logLevel"
@follow="forwardEvent('follow', $event)"
@edit="forwardEvent('on-edit', $event)"
@delete="onDelete"
@addFlowableError="onAddFlowableError"
@mouseover="onMouseOver"
@mouseleave="onMouseLeave"
:is-read-only="isReadOnly"
:is-allowed-edit="isAllowedEdit"
:hide-others-on-select="true"
/>
</template>
<template #node-trigger="props">
<Trigger
v-bind="props"
@edit="forwardEvent('on-edit', $event)"
@delete="onDelete"
:is-read-only="isReadOnly"
:is-allowed-edit="isAllowedEdit"
/>
</template>
<template #edge-edge="props">
<Edge
v-bind="props"
:yaml-source="source"
:flowables-ids="flowables()"
@edit="onCreateNewTask"
:is-read-only="isReadOnly"
:is-allowed-edit="isAllowedEdit"
/>
</template>
<Controls :show-interactive="false">
<ControlButton @click="toggleOrientation" v-if="['topology'].includes(viewType)">
<SplitCellsVertical :size="48" v-if="!isHorizontal" />
<SplitCellsHorizontal v-if="isHorizontal" />
</ControlButton>
</Controls>
</VueFlow>
</div>
<div v-if="isShowDescriptionOpen">
<markdown class="markdown-tooltip" :source="selectedTask.description"/>
</div>
</el-drawer>
</div>
</template>
<style scoped lang="scss">
.vueflow {
height: 100%;

View File

@@ -40,9 +40,6 @@
}
}
},
components: {
},
computed: {
...mapState("plugin", ["icons"]),
name() {

View File

@@ -28,13 +28,20 @@
</el-radio-group>
</template>
<template #table>
<el-alert type="info" v-if="!blueprints || blueprints.length === 0" :closable="false">
<el-alert type="info" v-if="!blueprints || blueprints.length === 0" :closable="false">
{{ $t('no result') }}
</el-alert>
<el-card class="blueprint-card" :class="{'embed': embed}" v-for="blueprint in blueprints"
@click="goToDetail(blueprint.id)">
<component class="blueprint-link" :is="embed ? 'div' : 'router-link'"
:to="embed ? undefined : {name: 'blueprints/view', params: {blueprintId: blueprint.id}}">
<el-card
class="blueprint-card"
:class="{'embed': embed}"
v-for="blueprint in blueprints"
@click="goToDetail(blueprint.id)"
>
<component
class="blueprint-link"
:is="embed ? 'div' : 'router-link'"
:to="embed ? undefined : {name: 'blueprints/view', params: {blueprintId: blueprint.id}}"
>
<div class="left">
<div>
<div class="title">
@@ -45,15 +52,23 @@
</div>
</div>
<div class="tasks-container">
<task-icon :cls="task" only-icon
v-for="task in [...new Set(blueprint.includedTasks)]" />
<task-icon
:cls="task"
only-icon
v-for="task in [...new Set(blueprint.includedTasks)]"
/>
</div>
</div>
<div class="side buttons ms-auto">
<slot name="buttons" :blueprint="blueprint" />
<el-tooltip v-if="embed" trigger="click" content="Copied" placement="left" :auto-close="2000">
<el-button @click.prevent.stop="copy(blueprint.id)" :icon="icon.ContentCopy"
size="large" text bg>
<el-button
@click.prevent.stop="copy(blueprint.id)"
:icon="icon.ContentCopy"
size="large"
text
bg
>
{{ $t('copy') }}
</el-button>
</el-tooltip>
@@ -127,7 +142,7 @@
async blueprintToEditor(blueprintId) {
localStorage.setItem(editorViewTypes.STORAGE_KEY, editorViewTypes.SOURCE_TOPOLOGY);
localStorage.setItem("autoRestore-creation_draft", (await this.$http.get(`${this.blueprintBaseUri}/${blueprintId}/flow`)).data);
this.$router.push({name: 'flows/create'});
this.$router.push({name: "flows/create"});
},
tagsToString(blueprintTags) {
return blueprintTags?.map(id => this.tags?.[id]?.name).join(" ")
@@ -265,7 +280,7 @@
</script>
<style scoped lang="scss">
@use 'element-plus/theme-chalk/src/mixins/mixins' as *;
@import "../../../../styles/variable";
@import "@kestra-io/ui-libs/src/scss/variables.scss";
.sub-nav {
margin: 0 0 $spacer;

View File

@@ -13,7 +13,7 @@
</template>
<style scoped lang="scss">
@import "../../../../styles/variable";
@import "@kestra-io/ui-libs/src/scss/variables.scss";
.header {
$neg-offset-from-menu: calc(-1 * var(--offset-from-menu));

View File

@@ -54,7 +54,7 @@ export default {
});
},
loadInputsType({commit}) {
return this.$http.get(`/api/v1/plugins/inputs`, {}).then(response => {
return this.$http.get("/api/v1/plugins/inputs", {}).then(response => {
commit("setInputsType", response.data)
return response.data;
@@ -101,6 +101,7 @@ export default {
getters: {
getPluginSingleList: state => state.pluginSingleList,
getPluginsDocumentation: state => state.pluginsDocumentation,
getIcons: state => state.icons
}
}

View File

@@ -1,38 +0,0 @@
// gray
$gray-900: #CAC5DA;
$gray-800: #A69FC1;
$gray-700: #918BA9;
$gray-600: #404559;
$gray-500: #2F3342;
$gray-400: #2C303F;
$gray-300: #202435;
$gray-200: #21242E;
$gray-100: #1C1E27;
$light: $gray-200;
$dark: $gray-100;
// body
$body-color: $gray-900;
$border-color: $gray-600;
$body-bg: $gray-200;
$card-bg: $gray-500;
$input-bg: $gray-100;
/*
$gray-900: #d6e2f3;
$gray-800: #d0d7e0;
$gray-700: #c1c1d8;
$gray-600: #7c89ad;
$gray-500: #7989b4;
$gray-400: #5f72b1;
$gray-300: #283456;
$gray-200: #292e40;
$gray-100: #202331;
$border-color: $gray-200;
$body-bg: #1b1e2a;
$card-bg: #222635;
*/

View File

@@ -1,100 +0,0 @@
// color system
$blue: #1761FD !default;
$indigo: #8405FF !default;
$purple: #9F9DFF !default;
$pink: #FD3C97 !default;
$red: #E36065 !default;
$red-light: #FF9D9D !default;
$orange: #FCB37C !default;
$yellow: #FCE07C !default;
$green: #03DABA !default;
$teal: #03D87F !default;
$cyan: #60C5FE !default;
// primary color
$primary: #8405FF !default;
$secondary: #C182FF !default;
$tertiary: #2F3342 !default;
// gray
$white: #FFF !default;
$gray-100: #F5F5FF !default;
$gray-200: #f1f5fa !default;
$gray-300: #E5E4F7 !default;
$gray-400: #b6c2e4 !default;
$gray-500: #8997bd !default;
$gray-600: #7081b9 !default;
$gray-700: #303e67 !default;
$gray-800: #2c3652 !default;
$gray-900: #1d2c48 !default;
$black: #26282D !default;
$light: $gray-200 !default;
$dark: $gray-900 !default;
// fonts
$font-size-base: 1rem !default;
$font-family-sans-serif: "Public Sans", sans-serif;
$font-family-monospace: "Source Code Pro", monospace;
$font-size-xs: $font-size-base * 0.75 !default;
// border radius
$border-radius: 0.25rem !default;
$border-radius-lg: 0.5rem !default;
$border-radius-sm: 0.15rem !default;
// layout
$menu-width: 268px !default;
$spacer: 1rem !default;
// body
$body-color: $gray-800 !default;
$border-color: $gray-300 !default;
$body-bg: $gray-100 !default;
$card-bg: $white !default;
$input-bg: $white !default;
$link-color: $primary !default;
// border radius
$border-radius: .25rem !default;
$border-radius-sm: .15rem !default;
// shadow
$box-shadow-sm: 0 .125rem .25rem rgba($black, .075);
$box-shadow: 0 .5rem 1rem rgba($black, .15);
$box-shadow-lg: 0 1rem 3rem rgba($black, .175);
// boostrap flags
$enable-reduced-motion: false;
// element-plus
$types: primary, success, warning, danger, error, info !default;
$element-colors: (
'white': $white,
'black': $black,
'primary': (
'base': $primary,
),
'success': (
'base': $green,
),
'warning': (
'base': $orange,
),
'danger': (
'base': $red,
),
'error': (
'base': $red,
),
'info': (
'base': $cyan,
),
);
// bootstrap
@import "bootstrap/scss/functions";
@import "bootstrap/scss/mixins";
@import "bootstrap/scss/vendor/rfs";
@import 'bootstrap/scss/variables';

View File

@@ -1,6 +1,8 @@
@use "variable" as global-var;
@use "@kestra-io/ui-libs/src/scss/variables.scss" as global-var;
@use 'element-plus/theme-chalk/src/mixins/mixins' as mixin;
@use "@kestra-io/ui-libs/src/scss/app.scss";
// element-plus
@use "layout/element-plus-overload";

View File

@@ -7,40 +7,15 @@ $indigo: "" !default;
}
.vue-flow__edge-path, .vue-flow__connection-path {
stroke: $indigo;
.vue-flow__edge.selected & {
stroke: var(--bs-yellow);
}
}
.vue-flow__handle {
border: 0;
z-index: 3;
}
.vue-flow__edge-textbg {
z-index: 4;
}
.vue-flow__edge-text {
z-index: 5;
}
.vue-flow__node {
border: 1px solid var(--bs-border-color);
&.selected {
border: 1px solid var(--bs-yellow);
}
.vue-flow__edge-labels {
z-index: 10;
}
.vue-flow__controls-button {
border: 1px solid var(--bs-border-color);
background-color: var(--bs-body-bg);
color: var(--bs-body-color);
box-sizing: content-box !important;
&:hover {
background-color: var(--bs-body-bg);

View File

@@ -1,36 +0,0 @@
@import "../variable";
$include-column-box-sizing: true !default;
@import "bootstrap/scss/functions";
@import "bootstrap/scss/variables";
@import "bootstrap/scss/variables-dark";
@import "bootstrap/scss/maps";
@import "bootstrap/scss/mixins";
@import "bootstrap/scss/mixins/lists";
@import "bootstrap/scss/mixins/breakpoints";
@import "bootstrap/scss/mixins/container";
@import "bootstrap/scss/mixins/grid";
@import "bootstrap/scss/mixins/utilities";
@import "bootstrap/scss/vendor/rfs";
@import "bootstrap/scss/root";
@import "bootstrap/scss/reboot";
@import "bootstrap/scss/containers";
@import "bootstrap/scss/grid";
@import "bootstrap/scss/utilities";
@import "bootstrap/scss/utilities/api";
@import "bootstrap/scss/progress";
@import "bootstrap/scss/type";
@import "bootstrap/scss/helpers/text-truncation";
// overload
.font-monospace {
font-size: $font-size-base * 0.7;
}

View File

@@ -1,7 +1,7 @@
@use 'sass:math';
@use "sass:map";
@use 'element-plus/theme-chalk/src/mixins/mixins' as *;
@import "../variable";
@import "@kestra-io/ui-libs/src/scss/variables.scss";
// button
.el-button {

View File

@@ -1,6 +1,7 @@
@use 'sass:map';
@import "../theme-dark";
@import "../variable";
@import "@kestra-io/ui-libs/src/scss/theme-dark.scss";
@import "@kestra-io/ui-libs/src/scss/variables.scss";
// Bootstrap
@import "bootstrap/scss/functions";
@@ -8,6 +9,8 @@
@import "bootstrap/scss/vendor/rfs";
@import "bootstrap/scss/variables";
html.dark {
#{--bs-gray}: #{map.get($grays, "600")};
@each $key, $value in $grays {

View File

@@ -2,8 +2,7 @@
@use 'sass:math';
@use 'element-plus/theme-chalk/src/mixins/var' as *;
@import "../variable";
@import "@kestra-io/ui-libs/src/scss/variables.scss";
// use bootstrap for main style
:root {

View File

@@ -1,4 +1,4 @@
@import "../variable";
@import "@kestra-io/ui-libs/src/scss/variables.scss";
#app {
.v-step {

View File

@@ -1,8 +1,5 @@
@use "variable" as global-var;
// bootstrap
@use "layout/bootstrap";
@use "@kestra-io/ui-libs/src/scss/variables.scss" as global-var;
@use "@kestra-io/ui-libs/src/scss/vendor.scss";
// element-plus
@forward 'element-plus/theme-chalk/src/common/var.scss' as light-* with (
@@ -45,10 +42,6 @@
// third
@use "nprogress/nprogress.css";
@use "vue-material-design-icons/styles.css";
@use "@vue-flow/core/dist/style.css" as core;
@use "@vue-flow/core/dist/theme-default.css" as theme;
@use "@vue-flow/controls/dist/style.css" as controls;
//noinspection CssUnknownTarget
@import url("https://fonts.googleapis.com/css2?family=Public+Sans:wght@300;400;700;800&family=Source+Code+Pro:wght@400;700;800&display=swap");

View File

@@ -6,6 +6,7 @@
"close": "Close",
"namespace": "Namespace",
"description": "Description",
"show description": "Show description",
"revision": "Revision",
"Language": "Language",
"Set default page": "Set default page",
@@ -412,6 +413,7 @@
"focus task": "Focus on any element to see its documentation.",
"validate": "Validate",
"add global error handler": "Add global error handler",
"add error handler": "Add an error handler",
"add trigger": "Add trigger",
"edit metadata": "Edit Metadata",
"taskDefaults": "Task Defaults",
@@ -425,7 +427,7 @@
"sequential": "Sequential",
"can not delete": "Can not delete",
"can not have less than 1 task": "Flows can not have less than 1 task.",
"task id already exist": "Task Id already exists",
"task id already exists": "Task Id already exists",
"Task Id already exist in the flow": "Task Id {taskId} already exists in the flow.",
"flow already exists": "Flow already exists",
"namespace not allowed": "Namespace not allowed",
@@ -459,6 +461,8 @@
"expand error": "Expand only failed task",
"expand all": "Expand all",
"collapse all": "Collapse all",
"expand": "Expand",
"collapse": "Collapse",
"log expand setting": "Log default display",
"environment name setting": "Environment name",
"environment color setting": "Environment color",
@@ -481,7 +485,8 @@
"success": "Trigger is unlocked"
},
"date format": "Date format",
"timezone": "Timezone"
"timezone": "Timezone",
"add task": "Add a task"
},
"fr": {
"id": "Identifiant",
@@ -489,6 +494,7 @@
"ok": "OK",
"close": "Fermer",
"description": "Description",
"show description": "Afficher la description",
"namespace": "Espace de nom",
"revision": "Révision",
"Language": "Langue",
@@ -898,6 +904,7 @@
"focus task": "Cliquer sur une tâche pour voir sa documentation",
"validate": "Valider",
"add global error handler": "Gérer les erreurs globales",
"add error handler": "Gérer les erreurs",
"add trigger": "Ajouter un déclencheur",
"edit metadata": "Éditer les métadonnées",
"taskDefaults": "Valeur de tâches par défaut",
@@ -911,7 +918,7 @@
"sequential": "Séquentiel",
"can not delete": "Suppression impossible",
"can not have less than 1 task": "Un flow ne peut avoir moins d'1 tâche.",
"task id already exist": "Identifiant de tâche déjà utilisé",
"task id already exists": "Identifiant de tâche déjà utilisé",
"Task Id already exist in the flow": "L'identifiant {taskId} est déjà utilisé dans le flow.",
"flow already exists": "Flow déjà existant",
"namespace not allowed": "Espace de nom non autorisé",
@@ -945,6 +952,8 @@
"expand error": "Afficher uniquement les erreurs",
"expand all": "Afficher tout",
"collapse all": "Masquer tout",
"expand": "Afficher",
"collapse": "Masquer",
"log expand setting": "Affichage par défaut des journaux",
"slack support": "Demandez de l'aide sur notre Slack",
"error detected": "Erreur détectée",
@@ -965,6 +974,8 @@
"success": "Le déclencheur est débloqué"
},
"date format": "Format de date",
"timezone": "Fuseau horaire"
"timezone": "Fuseau horaire",
"add task": "Ajouter une tâche"
}
}

View File

@@ -207,10 +207,10 @@ export default class YamlUtils {
yaml.visit(yamlDoc, {
Pair(_, pair) {
if (pair.key.value === 'dependsOn' && pair.value.items.map(e => e.value).includes(taskId2)) {
if (pair.key.value === "dependsOn" && pair.value.items.map(e => e.value).includes(taskId2)) {
throw {
message: 'dependency task',
messageOptions: { taskId: taskId2 }
message: "dependency task",
messageOptions: {taskId: taskId2}
};
}
}
@@ -428,6 +428,66 @@ export default class YamlUtils {
return children;
}
static getParentTask(source, taskId) {
const yamlDoc = yaml.parseDocument(source);
let parentTask = null;
yaml.visit(yamlDoc, {
Map(_, map) {
if (map.get("id") !== taskId) {
yaml.visit(map, {
Map(_, childMap) {
if (childMap.get("id") === taskId) {
parentTask = map.get("id");
return yaml.visit.BREAK;
}
}
})
}
}
})
return parentTask;
}
static isTaskError(source, taskId) {
const yamlDoc = yaml.parseDocument(source);
let isTaskError = false;
yaml.visit(yamlDoc, {
Pair(_, pair) {
if (pair.key.value === "errors") {
yaml.visit(pair, {
Map(_, map) {
if (map.get("id") === taskId) {
isTaskError = true;
return yaml.visit.BREAK;
}
}
})
}
}
})
return isTaskError;
}
static isTrigger(source, taskId) {
const yamlDoc = yaml.parseDocument(source);
let isTrigger = false;
yaml.visit(yamlDoc, {
Pair(_, pair) {
if (pair.key.value === "triggers") {
yaml.visit(pair, {
Map(_, map) {
if (map.get("id") === taskId) {
isTrigger = true;
return yaml.visit.BREAK;
}
}
})
}
}
})
return isTrigger;
}
static replaceIdAndNamespace(source, id, namespace) {
return source.replace(/^(id\s*:\s*(["']?))\S*/m, "$1"+id+"$2").replace(/^(namespace\s*:\s*(["']?))\S*/m, "$1"+namespace+"$2")
}

View File

@@ -2,6 +2,7 @@ import path from "path";
import {defineConfig} from "vite";
import vue from "@vitejs/plugin-vue";
import pluginRewriteAll from 'vite-plugin-rewrite-all';
import {visualizer} from "rollup-plugin-visualizer";
export default defineConfig({
base: "",
@@ -15,9 +16,15 @@ export default defineConfig({
},
plugins: [
vue(),
pluginRewriteAll()
pluginRewriteAll(),
visualizer()
],
css: {
devSourcemap: true
},
optimizeDeps: {
exclude: [
'* > @kestra-io/ui-libs'
]
},
})