feat(blueprints): impl templated flow blueprints

# Conflicts:
#	core/src/main/java/io/kestra/core/serializers/YamlParser.java
This commit is contained in:
Roman Acevedo
2025-12-02 17:17:05 +01:00
parent 379764a033
commit 24e61c81c0
11 changed files with 147 additions and 61 deletions

View File

@@ -8,6 +8,7 @@ import io.micronaut.core.annotation.Nullable;
import io.pebbletemplates.pebble.PebbleEngine;
import io.pebbletemplates.pebble.extension.Extension;
import io.pebbletemplates.pebble.extension.Function;
import io.pebbletemplates.pebble.lexer.Syntax;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
@@ -37,6 +38,13 @@ public class PebbleEngineFactory {
return builder.build();
}
public PebbleEngine createWithCustomSyntax(Syntax syntax, Class<? extends Extension> extension) {
PebbleEngine.Builder builder = newPebbleEngineBuilder()
.syntax(syntax);
this.applicationContext.getBeansOfType(extension).forEach(builder::extension);
return builder.build();
}
public PebbleEngine createWithMaskedFunctions(VariableRenderer renderer, final List<String> functionsToMask) {
PebbleEngine.Builder builder = newPebbleEngineBuilder();

View File

@@ -35,6 +35,10 @@ public final class YamlParser {
return read(input, cls, type(cls));
}
public static <T> T parse(String input, Class<T> cls, Boolean strict) {
return strict ? read(input, cls, type(cls)) : readNonStrict(input, cls, type(cls));
}
public static <T> T parse(Map<String, Object> input, Class<T> cls, Boolean strict) {
ObjectMapper currentMapper = strict ? STRICT_MAPPER : NON_STRICT_MAPPER;
@@ -81,6 +85,13 @@ public final class YamlParser {
throw toConstraintViolationException(input, resource, e);
}
}
private static <T> T readNonStrict(String input, Class<T> objectClass, String resource) {
try {
return NON_STRICT_MAPPER.readValue(input, objectClass);
} catch (JsonProcessingException e) {
throw toConstraintViolationException(input, resource, e);
}
}
private static String formatYamlErrorMessage(String originalMessage, JsonProcessingException e) {
StringBuilder friendlyMessage = new StringBuilder();
if (originalMessage.contains("Expected a field name")) {

46
ui/package-lock.json generated
View File

@@ -4220,18 +4220,18 @@
}
},
"node_modules/@shikijs/langs": {
"version": "3.19.0",
"resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.19.0.tgz",
"integrity": "sha512-dBMFzzg1QiXqCVQ5ONc0z2ebyoi5BKz+MtfByLm0o5/nbUu3Iz8uaTCa5uzGiscQKm7lVShfZHU1+OG3t5hgwg==",
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.20.0.tgz",
"integrity": "sha512-le+bssCxcSHrygCWuOrYJHvjus6zhQ2K7q/0mgjiffRbkhM4o1EWu2m+29l0yEsHDbWaWPNnDUTRVVBvBBeKaA==",
"license": "MIT",
"dependencies": {
"@shikijs/types": "3.19.0"
"@shikijs/types": "3.20.0"
}
},
"node_modules/@shikijs/langs/node_modules/@shikijs/types": {
"version": "3.19.0",
"resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.19.0.tgz",
"integrity": "sha512-Z2hdeEQlzuntf/BZpFG8a+Fsw9UVXdML7w0o3TgSXV3yNESGon+bs9ITkQb3Ki7zxoXOOu5oJWqZ2uto06V9iQ==",
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.20.0.tgz",
"integrity": "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw==",
"license": "MIT",
"dependencies": {
"@shikijs/vscode-textmate": "^10.0.2",
@@ -4258,18 +4258,18 @@
}
},
"node_modules/@shikijs/themes": {
"version": "3.19.0",
"resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.19.0.tgz",
"integrity": "sha512-H36qw+oh91Y0s6OlFfdSuQ0Ld+5CgB/VE6gNPK+Hk4VRbVG/XQgkjnt4KzfnnoO6tZPtKJKHPjwebOCfjd6F8A==",
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.20.0.tgz",
"integrity": "sha512-U1NSU7Sl26Q7ErRvJUouArxfM2euWqq1xaSrbqMu2iqa+tSp0D1Yah8216sDYbdDHw4C8b75UpE65eWorm2erQ==",
"license": "MIT",
"dependencies": {
"@shikijs/types": "3.19.0"
"@shikijs/types": "3.20.0"
}
},
"node_modules/@shikijs/themes/node_modules/@shikijs/types": {
"version": "3.19.0",
"resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.19.0.tgz",
"integrity": "sha512-Z2hdeEQlzuntf/BZpFG8a+Fsw9UVXdML7w0o3TgSXV3yNESGon+bs9ITkQb3Ki7zxoXOOu5oJWqZ2uto06V9iQ==",
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.20.0.tgz",
"integrity": "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw==",
"license": "MIT",
"dependencies": {
"@shikijs/vscode-textmate": "^10.0.2",
@@ -18914,24 +18914,6 @@
"hast-util-to-html": "^9.0.5"
}
},
"node_modules/shiki/node_modules/@shikijs/langs": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.20.0.tgz",
"integrity": "sha512-le+bssCxcSHrygCWuOrYJHvjus6zhQ2K7q/0mgjiffRbkhM4o1EWu2m+29l0yEsHDbWaWPNnDUTRVVBvBBeKaA==",
"license": "MIT",
"dependencies": {
"@shikijs/types": "3.20.0"
}
},
"node_modules/shiki/node_modules/@shikijs/themes": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.20.0.tgz",
"integrity": "sha512-U1NSU7Sl26Q7ErRvJUouArxfM2euWqq1xaSrbqMu2iqa+tSp0D1Yah8216sDYbdDHw4C8b75UpE65eWorm2erQ==",
"license": "MIT",
"dependencies": {
"@shikijs/types": "3.20.0"
}
},
"node_modules/shiki/node_modules/@shikijs/types": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.20.0.tgz",

View File

@@ -47,7 +47,7 @@
import {ref, computed, watch, onMounted, nextTick, useAttrs} from "vue";
import {useRoute} from "vue-router";
import EnterpriseBadge from "./EnterpriseBadge.vue";
import BlueprintDetail from "./flows/blueprints/BlueprintDetail.vue";
import BlueprintDetail from "../override/components/flows/blueprints/BlueprintDetail.vue";
interface Tab {
name?: string;

View File

@@ -37,6 +37,7 @@
const setupFlow = async () => {
const blueprintId = route.query.blueprintId as string;
const blueprintSource = route.query.blueprintSource as BlueprintType;
const blueprintSourceYaml = route.query.blueprintSourceYaml as string;
const implicitDefaultNamespace = authStore.user.getNamespacesForAction(
permission.FLOW,
action.CREATE,
@@ -50,12 +51,17 @@
if (route.query.copy && flowStore.flow) {
flowYaml = flowStore.flow.source;
} else if (blueprintId && blueprintSource) {
} else if (blueprintId && blueprintSourceYaml) {
flowYaml = blueprintSourceYaml;
} else if(blueprintId && blueprintSource === "community"){
flowYaml = await blueprintsStore.getBlueprintSource({
type: blueprintSource,
kind: "flow",
id: blueprintId
});
} else if (blueprintId) {
const flowBlueprint = await blueprintsStore.getFlowBlueprint(blueprintId);
flowYaml = flowBlueprint.source;
} else {
flowYaml = `
id: ${id}

View File

@@ -263,7 +263,7 @@
<script lang="ts">
import {ElMessage} from "element-plus";
import ValidationError from "../flows/ValidationError.vue";
import {toRaw} from "vue";
import {markRaw, toRaw} from "vue";
import {mapStores} from "pinia";
import {useExecutionsStore} from "../../stores/executions";
import debounce from "lodash/debounce";
@@ -336,10 +336,10 @@
editingArrayId: null,
editableItems: {},
// expose icon components to the template so linters and the template can resolve them
DeleteOutline,
Pencil,
Plus,
ContentSave
DeleteOutline: markRaw(DeleteOutline),
Pencil:markRaw(Pencil),
Plus:markRaw(Plus),
ContentSave:markRaw(ContentSave)
};
},
emits: ["update:modelValue", "update:modelValueNoDefault", "update:checks", "confirm", "validation"],
@@ -566,6 +566,7 @@
} else {
this.$emit("validation", {
formData: formData,
inputsMetaData: this.inputsMetaData,
callback: (response) => {
metadataCallback(response);
}

View File

@@ -90,16 +90,16 @@
import ChevronLeft from "vue-material-design-icons/ChevronLeft.vue";
import Editor from "../../inputs/Editor.vue";
import Markdown from "../../layout/Markdown.vue";
import TopNavBar from "../../layout/TopNavBar.vue";
import LowCodeEditor from "../../inputs/LowCodeEditor.vue";
import CopyToClipboard from "../../layout/CopyToClipboard.vue";
import Editor from "../../../../components/inputs/Editor.vue";
import Markdown from "../../../../components/layout/Markdown.vue";
import TopNavBar from "../../../../components/layout/TopNavBar.vue";
import LowCodeEditor from "../../../../components/inputs/LowCodeEditor.vue";
import CopyToClipboard from "../../../../components/layout/CopyToClipboard.vue";
import TaskIcon from "@kestra-io/ui-libs/src/components/misc/TaskIcon.vue";
import {useFlowStore} from "../../../stores/flow";
import {usePluginsStore} from "../../../stores/plugins";
import {useBlueprintsStore} from "../../../stores/blueprints";
import {useFlowStore} from "../../../../stores/flow";
import {usePluginsStore} from "../../../../stores/plugins";
import {useBlueprintsStore} from "../../../../stores/blueprints";
import {canCreate} from "override/composables/blueprintsPermissions";
import {parse as parseFlow} from "@kestra-io/ui-libs/flow-yaml-utils";

View File

@@ -35,9 +35,8 @@
import {useI18n} from "vue-i18n";
import TopNavBar from "../../../../components/layout/TopNavBar.vue";
import DottedLayout from "../../../../components/layout/DottedLayout.vue";
// @ts-expect-error - Component not typed
import BlueprintDetail from "../../../../components/flows/blueprints/BlueprintDetail.vue";
import BlueprintsBrowser from "./BlueprintsBrowser.vue";
import BlueprintDetail from "../../../../override/components/flows/blueprints/BlueprintDetail.vue";
import BlueprintsBrowser from "../../../../override/components/flows/blueprints/BlueprintsBrowser.vue";
import DemoBlueprints from "../../../../components/demo/Blueprints.vue";
import useRouteContext from "../../../../composables/useRouteContext";

View File

@@ -67,7 +67,6 @@
</div>
<div class="action-button">
<slot name="buttons" :blueprint="blueprint" />
<el-tooltip v-if="embed && !system" trigger="click" content="Copied" placement="left" :autoClose="2000" effect="light">
<el-button
type="primary"
@@ -77,9 +76,11 @@
class="p-2"
/>
</el-tooltip>
<el-button v-else-if="userCanCreate" type="primary" size="default" @click.prevent.stop="blueprintToEditor(blueprint.id)">
{{ $t('use') }}
</el-button>
<slot name="buttons" :blueprint="{...blueprint, kind: props.blueprintKind, type: props.blueprintType}">
<el-button v-if="!embed && userCanCreate" type="primary" size="default" @click.prevent.stop="blueprintToEditor(blueprint.id)">
{{ $t('use') }}
</el-button>
</slot>
</div>
</div>
</div>

View File

@@ -79,7 +79,7 @@ export default [
//Blueprints
{name: "blueprints", path: "/:tenant?/blueprints/:kind/:tab", component: () => import("override/components/flows/blueprints/Blueprints.vue"), props: true},
{name: "blueprints/view", path: "/:tenant?/blueprints/:kind/:tab/:blueprintId", component: () => import("../components/flows/blueprints/BlueprintDetail.vue"), props: true},
{name: "blueprints/view", path: "/:tenant?/blueprints/:kind/:tab/:blueprintId", component: () => import("../override/components/flows/blueprints/BlueprintDetail.vue"), props: true},
//Documentation
{name: "plugins/list", path: "/:tenant?/plugins", component: () => import("../components/plugins/Plugin.vue")},

View File

@@ -24,6 +24,31 @@ interface Blueprint {
[key: string]: any;
}
export interface TemplateArgument {
id: string,
displayName: string,
type: string,
itemType?: string,
required: boolean,
defaults?: any
}
export interface BlueprintTemplate {
source: string;
templateArguments: Record<string, TemplateArgument>;
}
export interface FlowBlueprint {
id: string,
title: string,
description: string,
includedTasks?: string[],
tags?: string[],
source: string,
publishedAt?: string,
template?: BlueprintTemplate
}
const API_URL = "https://api.kestra.io/v1";
const VALIDATE = {validateStatus: (status: number) => status === 200 || status === 401};
@@ -95,6 +120,54 @@ export const useBlueprintsStore = defineStore("blueprints", () => {
return response.data;
};
const getFlowBlueprint = async (id: string): Promise<FlowBlueprint> => {
const url = `${apiUrl()}/blueprints/flow/${id}`;
const response = await axios.get(url);
if (response.data?.id) {
trackBlueprintSelection(response.data.id);
}
blueprint.value = response.data;
return response.data;
};
const createFlowBlueprint = async (toCreate: {source: string, title: string, description: string, tags: string[]}): Promise<FlowBlueprint> => {
const url = `${apiUrl()}/blueprints/flows`;
const body = {
...toCreate
}
const response = await axios.post(url, body);
return response.data;
};
const updateFlowBlueprint = async (id: string, toUpdate: {source: string, title: string, description: string, tags: string[]}) :Promise<FlowBlueprint> => {
const url = `${apiUrl()}/blueprints/flows/${id}`;
const body = {
...toUpdate
}
const response = await axios.put(url, body);
return response.data;
};
const deleteFlowBlueprint = async (idToDelete: string) => {
const url = `${apiUrl()}/blueprints/flows/${idToDelete}`;
await axios.delete(url);
};
const useFlowBlueprintTemplate = async (id: string, inputs: Record<string, object>): Promise<{generatedFlowSource: string}> => {
const url = `${apiUrl()}/blueprints/flows/${id}/use-template`;
const body = {
templateArgumentsInputs: inputs
}
const response = await axios.post(url, body);
return response.data;
}
return {
blueprint,
blueprints,
@@ -106,5 +179,10 @@ export const useBlueprintsStore = defineStore("blueprints", () => {
getBlueprintSource,
getBlueprintGraph,
getBlueprintTags,
useFlowBlueprintTemplate,
getFlowBlueprint,
createFlowBlueprint,
updateFlowBlueprint,
deleteFlowBlueprint,
};
});