Compare commits

...

94 Commits

Author SHA1 Message Date
brian.mulier
02a22faed4 chore(version): update to version '0.23.2' 2025-06-24 14:19:20 +02:00
Ludovic DEHON
169d6610f5 test(core): fix falling test on schedule 2025-06-24 14:19:20 +02:00
Loïc Mathieu
e253958cf4 fix(system): possible NPE on trigger when computing variables 2025-06-24 14:19:20 +02:00
brian-mulier-p
c75f06a036 fix: avoid failure to deserialize json objects that have unknown fields with http client (#9668)
closes #9667
2025-06-24 14:19:20 +02:00
Loïc Mathieu
b3b1b7a5cb feat(executions)*: add tasks to set and unset execution variables
Closes #9555
2025-06-24 14:19:20 +02:00
Loïc Mathieu
34e07b9e2b fix(execution): parent flow never ends when subflow fail due to SLA
This is because the executor didn't have the flow inside it so the execution is not correctly terminated.
It may fix other issues (like flow triggers, purge, ...)

Fixes #9618
2025-06-20 18:04:12 +02:00
Loïc Mathieu
85b449c926 fix(system): flow graph fail to be created while editting a flow
Fixes #9551

It is not the validation per se that fail, it's the graph dependency computation that is also done while editing a flow that fail.
2025-06-20 12:09:18 +02:00
Loïc Mathieu
0017ead9b3 fix(system)*: runIf inside a WorkingDirectory can crash the Worker
Fixes #9639
2025-06-20 12:09:04 +02:00
Barthélémy Ledoux
b0292f02f7 fix(ui): default value for expression cannot be null (#9636) 2025-06-20 11:12:32 +02:00
Piyush Bhaskar
202dc7308d feat(namespaces): show ns description (#9610)
* feat(namespaces): show ns description

* add slot and data for description
2025-06-20 13:59:03 +05:30
François Delbrayelle
3273a9a40c fix(plugin-versioning): replace current JAR if more recent (#9629) 2025-06-20 09:51:21 +02:00
Loïc Mathieu
bd303f4529 fix(system): support allowFailure and allowWarning for the Pause task
Fixes #9416
2025-06-19 17:34:38 +02:00
Barthélémy Ledoux
db57326f0f tests: nocode editor (#9624) 2025-06-19 14:21:15 +02:00
github-actions[bot]
90a576490f chore(version): update to version '0.23.1' 2025-06-19 10:32:53 +00:00
Loïc Mathieu
2cdd968100 feat(system): store version in the settings 2025-06-19 12:23:20 +02:00
Barthélémy Ledoux
adfc3bf526 perf(ui): load a sample schema while waiting (#9558) 2025-06-19 11:34:15 +02:00
Nicolas K.
3a61f9b1ba Fix/tutorial flows with migration (#9620)
* fix(core): #9609 delete tutorial flows and triggers before migrating the database

* fix(core): #9609 delete tutorial flows and triggers before migrating the database for EE version

---------

Co-authored-by: nKwiatkowski <nkwiatkowski@kestra.io>
2025-06-19 10:58:29 +02:00
YannC
64e3014426 fix: correctly use default tenant when synchronizing file with local (#9605)
close #9568
2025-06-19 10:04:58 +02:00
François Delbrayelle
1f68e5f4ed fix(podman): do not pass the tag directly to pullImageCmd (withTag) (#9607) 2025-06-18 18:50:54 +02:00
François Delbrayelle
9bfa888e36 fix(plugin): FileSystems.newFileSystem caused a Path component should be / in plugins tests (#9570) 2025-06-18 16:03:45 +02:00
github-actions[bot]
691a77538a chore(version): update to version '0.23.0' 2025-06-17 09:35:23 +00:00
Bart Ledoux
b07086f553 chore: update ui-libs 2025-06-17 11:21:21 +02:00
Ludovic DEHON
ee12c884e9 fix(tasks): sleep example are a full one 2025-06-16 15:02:34 +02:00
Barthélémy Ledoux
712d6da84f fix(ui): make file panel appear beside main panel in namespace (#9546) 2025-06-16 14:45:05 +02:00
Bart Ledoux
fcc5fa2056 fix: package-lock 2025-06-16 14:44:01 +02:00
Loïc Mathieu
dace30ded7 fix(system): compilation issue 2025-06-16 14:18:55 +02:00
github-actions[bot]
2b578f0f94 chore(version): update to version '0.23.0-rc5-SNAPSHOT' 2025-06-16 12:05:27 +00:00
Florian Hussonnois
91f958b26b fix(executor): delete WorkerJobRunning for any terminated task (#9493)
Make ExecutorService responsible for deleting WorkerJobRunning
when a terminated TaskRun is added to an execution.

Changes:
 - Remove unecessary read before delete on WorkerJobRunning table.

Close: #9493
2025-06-16 14:03:11 +02:00
Bart Ledoux
d7fc6894fe tests: fix storybook tests 2025-06-16 13:29:34 +02:00
Bart Ledoux
c286348d27 fix(ui): make array and KV Pairs work in nocode 2025-06-16 12:17:23 +02:00
brian.mulier
de4ec49721 fix(core): yaml utils migration 2025-06-16 11:18:47 +02:00
Barthélémy Ledoux
1966ac6012 fix: cleanup empty metadata to fix variable creation (#9529) 2025-06-16 11:17:52 +02:00
Barthélémy Ledoux
a293a37ec9 fix(ui): nocode API calls on EE needs tenant (#9527) 2025-06-16 11:17:43 +02:00
Barthélémy Ledoux
f295724bb6 fix: small tweaks on tabs (#9520) 2025-06-16 11:17:34 +02:00
Barthélémy Ledoux
06505ad977 fix(ui): snafu on duplicate input pair (#9514) 2025-06-16 11:15:30 +02:00
Barthélémy Ledoux
cb31ef642f fix(ui): [nocode] make dag tasks work (#9506) 2025-06-16 11:14:17 +02:00
Barthélémy Ledoux
c320323371 fix(ui): nocode updating inputs from yaml (#9430) 2025-06-16 11:12:35 +02:00
Barthélémy Ledoux
a190cdd0e7 fix(ui): add datepicker to nocode string field (#9351)
Co-authored-by: GitHub Action <actions@github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-06-16 11:12:27 +02:00
Barthélémy Ledoux
0678f7c5e9 fix(ui): rename namespace field (#9492) 2025-06-16 11:08:05 +02:00
Barthélémy Ledoux
f39ba5c95e fix(ui): prevent cursor change in Editor component when modelValue is updated from outside (#9371) 2025-06-16 11:07:55 +02:00
Karuna Tata
b4e334c5d8 feat(ui): drag and convert tabs to panels (#9198)
Co-authored-by: Barthélémy Ledoux <bledoux@kestra.io>
2025-06-16 11:07:37 +02:00
Bart Ledoux
561380c942 fix(ui): restore add button as a button 2025-06-16 11:07:25 +02:00
Satvik Kushwaha
68b4867b5a fix(ui): make download and preview visible for text ouputs (#8348)
Co-authored-by: Barthélémy Ledoux <ledouxb@me.com>
2025-06-16 11:06:24 +02:00
Barthélémy Ledoux
cb7f99d107 fix(ui): variables should work with duplicated keys (#9425) 2025-06-16 11:05:17 +02:00
Barthélémy Ledoux
efac7146ff fix: properly detect condition fields (#9353) 2025-06-16 11:02:41 +02:00
Barthélémy Ledoux
11de42c0b8 fix(ui): nocode - open onPause in a new tab (#9366) 2025-06-16 11:02:31 +02:00
Barthélémy Ledoux
b58d9e10dd fix: initialize array fields without any value (#9367) 2025-06-16 11:00:04 +02:00
Barthélémy Ledoux
e25e70d37e refactor: load nocode root form from server schema (#9327) 2025-06-16 10:59:53 +02:00
Karuna Tata
f2dac28997 fix(ui): clear selection of retry form radio buttons (#9268)
Co-authored-by: Barthélémy Ledoux <ledouxb@me.com>
thank you so much for this geat work ! ❤️
2025-06-16 10:59:44 +02:00
Barthélémy Ledoux
0ac8819d95 fix(ui): allow key of sub-tasks to be other than tasks (#9333) 2025-06-16 10:59:24 +02:00
Ludovic DEHON
d261de0df3 fix(core): robots.txt was not served
close kestra-io/kestra#9015
2025-06-13 23:01:48 +02:00
brian.mulier
02cac65614 fix(core): filters was triggering endless refresh
closes #9508
2025-06-13 16:25:34 +02:00
MilosPaunovic
5064687b7e fix(core)*: make sure tour always opens with code & topology tabs visible (#9513)
Closes https://github.com/kestra-io/kestra-ee/issues/4073.
2025-06-13 08:55:20 +02:00
YannC
7c8419b266 fix(ui): Better duplicate key management in the pair component (#9431)
* fix(ui): Better duplicate key mananage in the pair component

close #9220

* fix(ui): add a have-error prop on inputText that show a red shadow

* refactor: simplify inputpair component (#9491)

* fix: only show lock if disabled

* alertState define order

---------

Co-authored-by: Barthélémy Ledoux <bledoux@kestra.io>
2025-06-12 13:28:02 +02:00
Roman Acevedo
84e4c62c6d fix(tests): test editor was showing previous shown plugin doc
fixes https://github.com/kestra-io/kestra-ee/issues/4066
2025-06-12 13:21:29 +02:00
Nicolas K.
9aa605e23b Feat/rework compatibility layer (#9490)
* feat(core): rework compatibility layer

* feat(core): #4062 rework compatibility layer

---------

Co-authored-by: nKwiatkowski <nkwiatkowski@kestra.io>
2025-06-12 10:42:49 +02:00
Roman Acevedo
faa77aed79 feat(tests): add execution url in test result 2025-06-12 10:03:05 +02:00
brian-mulier-p
fdce552528 feat(core): introduce tasksWithState autocompletion (#9485)
part of #8350
2025-06-12 09:55:57 +02:00
brian.mulier
a028a61792 fix(core): avoid infinite load upon route redirect (#9480)
closes #9479
2025-06-11 17:03:52 +02:00
brian.mulier
023a77a320 fix(core): properly map labels filters from query (#9480)
closes #9324
2025-06-11 17:03:52 +02:00
brian.mulier
bfee04bca2 fix(core): prevent incompatible timeRange & start/endDate filters + prevent multiple scope filters (#9480)
closes #9240
2025-06-11 17:03:52 +02:00
YannC
3756f01bdf fix(ui): base the required prop on the requiredProperties list (#9433)
close #9377
2025-06-11 13:09:27 +02:00
YannC
c1240d7391 feat(ui): allow to close a tab with mouse middle click like in a navigator/ide (#9434) 2025-06-11 08:55:13 +02:00
YannC
ac37ae6032 fix(core): use Min annotation instead of Positive (#9432)
close #9380
2025-06-10 17:15:11 +02:00
github-actions[bot]
9e51b100b0 chore(version): update to version '0.23.0-rc3-SNAPSHOT' 2025-06-10 12:51:54 +00:00
Miloš Paunović
bc81e01608 fix(core)*: properly display chart colors for logs (#9429) 2025-06-10 13:51:56 +02:00
YannC.
9f2162c942 feat(): add Kestra plugin in the list 2025-06-10 12:44:09 +02:00
brian-mulier-p
97992d99ee fix(core): handle properly dot in nested keys & commas in quoted filter values (#9410) 2025-06-10 11:55:30 +02:00
brian.mulier
f90f6b8429 chore(deps): bump vitest to 3.2.3 2025-06-10 11:55:30 +02:00
brian.mulier
0f7360ae81 build(tests): replace workspaces with proper storybook config + working aliases 2025-06-10 11:53:11 +02:00
Florian Hussonnois
938590f31f fix(plugins): check whether plugin registry support versioning (#9122) 2025-06-10 11:49:40 +02:00
YannC.
b2d1c84a86 fix(): display correctly doc/chart preview when editing custom dashboard
close #9411
2025-06-10 10:25:41 +02:00
Ludovic DEHON
d7ca302830 feat(system): add server_type as global metrics tags 2025-06-10 09:23:14 +02:00
Roman Acevedo
8656e852cc build(ci): fix setversion workflow not making tag push trigger main 2025-06-09 18:03:49 +02:00
brian-mulier-p
cc72336350 fix(core): avoid adding invalid keys from query parameters to filter (#9383)
closes #9364
2025-06-09 18:03:49 +02:00
Roman Acevedo
316d89764e tests(core): add storybook on executions filters (#9354) 2025-06-09 18:03:49 +02:00
Barthélémy Ledoux
4873bf4d36 chore: upgrade storybook (#9326) 2025-06-09 14:40:21 +02:00
Florian Hussonnois
204bf7f5e1 chore: add script to update gradle kestraVersion prop on plugins 2025-06-09 14:31:45 +02:00
Loïc Mathieu
1e0950fdf8 fix(system): import flow should set the tenantId 2025-06-09 13:51:53 +02:00
github-actions[bot]
4cddc704f4 chore(version): update to version '0.23.0-rc2-SNAPSHOT' 2025-06-09 10:48:43 +00:00
Miloš Paunović
f2f0e29f93 fix(namespaces): properly load flows when changing namespace (#9393)
Closes https://github.com/kestra-io/kestra/issues/9352.
2025-06-09 12:34:36 +02:00
Miloš Paunović
95011e022e fix(namespaces): reload namespace once the id parameter changes (#9372)
Closes https://github.com/kestra-io/kestra-ee/issues/3630.
2025-06-06 12:25:37 +02:00
brian.mulier
65503b708a fix(core): add DefaultFilterLanguage as default in KestraFilter
closes #9365
2025-06-05 17:42:34 +02:00
brian-mulier-p
876b8cb2e6 fix(core): avoid crashing in case of taskrun having too large value (#9359)
closes #9312
2025-06-05 14:11:37 +02:00
Nicolas K.
f3b7592dfa fix(flows): #9319 error when puase with timeout trigger an execution (#9334)
* fix(flows): #9319 error when puase with timeout trigger an execution even after it's terminated

* fix(flows): only skip paused flow when execution is terminated

---------

Co-authored-by: nKwiatkowski <nkwiatkowski@kestra.io>
2025-06-05 10:15:49 +02:00
brian.mulier
4dbeaf86bb fix(core): larger debounce for filter 2025-06-05 09:48:53 +02:00
brian.mulier
f98e78399d fix(core): handle whitespaces in label key and value 2025-06-05 09:48:43 +02:00
brian.mulier
71dac0f311 fix(core): smarter autocomplete order in editor 2025-06-05 09:48:00 +02:00
brian-mulier-p
3077d0ac7a fix(core): additional plugins are now properly shown in plugin docs (#9329)
closes kestra-io/plugin-langchain4j#61
2025-06-05 09:46:57 +02:00
YannC.
9504bbaffe fix(ci): put back bump helm chart and remove if condition 2025-06-05 08:48:56 +02:00
YannC.
159c9373ad fix(ci): checkout actions from main branch 2025-06-04 21:12:56 +02:00
YannC.
55b9088b55 fix(ci): modify actions order 2025-06-04 21:06:17 +02:00
YannC.
601d1a0abb fix(ci): Correctly pass all the secrets through all workflows 2025-06-04 15:10:33 +02:00
Florian Hussonnois
4a1cf98f26 chore(version): bump to version '0.23.0-rc1-SNAPSHOT' 2025-06-04 14:07:30 +02:00
197 changed files with 5433 additions and 3519 deletions

View File

@@ -43,6 +43,9 @@ jobs:
SONATYPE_GPG_KEYID: ${{ secrets.SONATYPE_GPG_KEYID }}
SONATYPE_GPG_PASSWORD: ${{ secrets.SONATYPE_GPG_PASSWORD }}
SONATYPE_GPG_FILE: ${{ secrets.SONATYPE_GPG_FILE }}
GH_PERSONAL_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }}
SLACK_RELEASES_WEBHOOK_URL: ${{ secrets.SLACK_RELEASES_WEBHOOK_URL }}
end:
runs-on: ubuntu-latest

View File

@@ -22,11 +22,11 @@ jobs:
echo "Invalid release version. Must match regex: ^[0-9]+(\.[0-9]+)(\.[0-9]+)-(rc[0-9])?(-SNAPSHOT)?$"
exit 1
fi
# Extract the major and minor versions
BASE_VERSION=$(echo "$RELEASE_VERSION" | sed -E 's/^([0-9]+\.[0-9]+)\..*/\1/')
RELEASE_BRANCH="refs/heads/releases/v${BASE_VERSION}.x"
CURRENT_BRANCH="$GITHUB_REF"
if ! [[ "$CURRENT_BRANCH" == "$RELEASE_BRANCH" ]]; then
echo "Invalid release branch. Expected $RELEASE_BRANCH, was $CURRENT_BRANCH"
@@ -54,4 +54,4 @@ jobs:
git commit -m"chore(version): update to version '$RELEASE_VERSION'"
git push
git tag -a "v$RELEASE_VERSION" -m"v$RELEASE_VERSION"
git push origin "v$RELEASE_VERSION"
git push --tags

View File

@@ -6,23 +6,15 @@ on:
GH_PERSONAL_TOKEN:
description: "The Github personal token."
required: true
push:
tags:
- '*'
SLACK_RELEASES_WEBHOOK_URL:
description: "The Slack webhook URL."
required: true
jobs:
publish:
name: Github - Release
runs-on: ubuntu-latest
steps:
# Download Exec
- name: Artifacts - Download executable
uses: actions/download-artifact@v4
if: startsWith(github.ref, 'refs/tags/v')
with:
name: exe
path: build/executable
# Check out
- name: Checkout - Repository
uses: actions/checkout@v4
@@ -36,11 +28,20 @@ jobs:
with:
repository: kestra-io/actions
sparse-checkout-cone-mode: true
ref: fix/core-release
path: actions
sparse-checkout: |
.github/actions
# Download Exec
# Must be done after checkout actions
- name: Artifacts - Download executable
uses: actions/download-artifact@v4
if: startsWith(github.ref, 'refs/tags/v')
with:
name: exe
path: build/executable
# GitHub Release
- name: Create GitHub release
uses: ./actions/.github/actions/github-release
@@ -49,3 +50,16 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }}
SLACK_RELEASES_WEBHOOK_URL: ${{ secrets.SLACK_RELEASES_WEBHOOK_URL }}
# Trigger gha workflow to bump helm chart version
- name: GitHub - Trigger the Helm chart version bump
uses: peter-evans/repository-dispatch@v3
with:
token: ${{ secrets.GH_PERSONAL_TOKEN }}
repository: kestra-io/helm-charts
event-type: update-helm-chart-version
client-payload: |-
{
"new_version": "${{ github.ref_name }}",
"github_repository": "${{ github.repository }}",
"github_actor": "${{ github.actor }}"
}

View File

@@ -42,6 +42,12 @@ on:
SONATYPE_GPG_FILE:
description: "The Sonatype GPG file."
required: true
GH_PERSONAL_TOKEN:
description: "The Github personal token."
required: true
SLACK_RELEASES_WEBHOOK_URL:
description: "The Slack webhook URL."
required: true
jobs:
build-artifacts:
name: Build - Artifacts
@@ -77,4 +83,5 @@ jobs:
if: startsWith(github.ref, 'refs/tags/v')
uses: ./.github/workflows/workflow-github-release.yml
secrets:
GH_PERSONAL_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }}
GH_PERSONAL_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }}
SLACK_RELEASES_WEBHOOK_URL: ${{ secrets.SLACK_RELEASES_WEBHOOK_URL }}

View File

@@ -61,6 +61,7 @@
#plugin-jenkins:io.kestra.plugin:plugin-jenkins:LATEST
#plugin-jira:io.kestra.plugin:plugin-jira:LATEST
#plugin-kafka:io.kestra.plugin:plugin-kafka:LATEST
#plugin-kestra:io.kestra.plugin:plugin-kestra:LATEST
#plugin-kubernetes:io.kestra.plugin:plugin-kubernetes:LATEST
#plugin-langchain4j:io.kestra.plugin:plugin-langchain4j:LATEST
#plugin-ldap:io.kestra.plugin:plugin-ldap:LATEST

View File

@@ -12,8 +12,8 @@ import io.kestra.core.services.PluginDefaultService;
import io.micronaut.context.annotation.Requires;
import io.micronaut.context.annotation.Value;
import io.micronaut.scheduling.io.watch.FileWatchConfiguration;
import jakarta.inject.Inject;
import jakarta.annotation.Nullable;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import jakarta.validation.ConstraintViolationException;
import lombok.extern.slf4j.Slf4j;
@@ -26,6 +26,8 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import static io.kestra.core.tenant.TenantService.MAIN_TENANT;
@Singleton
@Slf4j
@Requires(property = "micronaut.io.watch.enabled", value = "true")
@@ -111,6 +113,8 @@ public class FileChangedEventListener {
}
public void startListening(List<Path> paths) throws IOException, InterruptedException {
String tenantId = this.tenantId != null ? this.tenantId : MAIN_TENANT;
for (Path path : paths) {
path.register(watchService, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_DELETE, StandardWatchEventKinds.ENTRY_MODIFY);
}
@@ -189,6 +193,8 @@ public class FileChangedEventListener {
}
private void loadFlowsFromFolder(Path folder) {
String tenantId = this.tenantId != null ? this.tenantId : MAIN_TENANT;
try {
Files.walkFileTree(folder, new SimpleFileVisitor<Path>() {
@Override
@@ -232,6 +238,8 @@ public class FileChangedEventListener {
}
private Optional<FlowWithSource> parseFlow(String content, Path entry) {
String tenantId = this.tenantId != null ? this.tenantId : MAIN_TENANT;
try {
FlowWithSource flow = pluginDefaultService.parseFlowWithAllDefaults(tenantId, content, false);
modelValidator.validate(flow);

View File

@@ -15,6 +15,9 @@ micronaut:
static:
paths: classpath:static
mapping: /static/**
root:
paths: classpath:root
mapping: /**
server:
max-request-size: 10GB
multipart:

View File

@@ -21,7 +21,7 @@ import static org.assertj.core.api.Assertions.assertThat;
class PluginDocCommandTest {
public static final String PLUGIN_TEMPLATE_TEST = "plugin-template-test-0.18.0-SNAPSHOT.jar";
public static final String PLUGIN_TEMPLATE_TEST = "plugin-template-test-0.24.0-SNAPSHOT.jar";
@Test
void run() throws IOException, URISyntaxException {

View File

@@ -20,7 +20,7 @@ import static org.assertj.core.api.Assertions.assertThat;
class PluginListCommandTest {
private static final String PLUGIN_TEMPLATE_TEST = "plugin-template-test-0.18.0-SNAPSHOT.jar";
private static final String PLUGIN_TEMPLATE_TEST = "plugin-template-test-0.24.0-SNAPSHOT.jar";
@Test
void shouldListPluginsInstalledLocally() throws IOException, URISyntaxException {

View File

@@ -284,7 +284,7 @@ public class HttpClient implements Closeable {
} else if (cls.isAssignableFrom(Byte[].class)) {
return (T) ArrayUtils.toObject(EntityUtils.toByteArray(entity));
} else {
return (T) JacksonMapper.ofJson().readValue(entity.getContent(), cls);
return (T) JacksonMapper.ofJson(false).readValue(entity.getContent(), cls);
}
}

View File

@@ -1,11 +1,14 @@
package io.kestra.core.metrics;
import io.kestra.core.models.ServerType;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import io.micronaut.configuration.metrics.aggregator.MeterRegistryConfigurer;
import io.micronaut.context.annotation.Requires;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import io.micronaut.context.annotation.Value;
import io.micronaut.core.annotation.Nullable;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
@@ -15,20 +18,26 @@ public class GlobalTagsConfigurer implements MeterRegistryConfigurer<SimpleMeter
@Inject
MetricConfig metricConfig;
@Nullable
@Value("${kestra.server-type}")
ServerType serverType;
@Override
public void configure(SimpleMeterRegistry meterRegistry) {
if (metricConfig.getTags() != null) {
meterRegistry
.config()
.commonTags(
metricConfig.getTags()
.entrySet()
.stream()
.flatMap(e -> Stream.of(e.getKey(), e.getValue()))
.toList()
.toArray(String[]::new)
);
}
String[] tags = Stream
.concat(
metricConfig.getTags() != null ? metricConfig.getTags()
.entrySet()
.stream()
.flatMap(e -> Stream.of(e.getKey(), e.getValue())) : Stream.empty(),
serverType != null ? Stream.of("server_type", serverType.name()) : Stream.empty()
)
.toList()
.toArray(String[]::new);
meterRegistry
.config()
.commonTags(tags);
}
@Override

View File

@@ -15,6 +15,8 @@ import jakarta.validation.constraints.NotNull;
@NoArgsConstructor
public class Setting {
public static final String INSTANCE_UUID = "instance.uuid";
public static final String INSTANCE_VERSION = "instance.version";
@NotNull
private String key;

View File

@@ -156,6 +156,7 @@ public class Execution implements DeletedInterface, TenantInterface {
.flowRevision(flow.getRevision())
.state(new State())
.scheduleDate(scheduleDate.map(ChronoZonedDateTime::toInstant).orElse(null))
.variables(flow.getVariables())
.build();
List<Label> executionLabels = new ArrayList<>(LabelService.labelsExcludingSystem(flow));

View File

@@ -1,20 +1,19 @@
package io.kestra.core.models.flows;
import io.micronaut.core.annotation.Introspected;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
@SuperBuilder
@Getter
@NoArgsConstructor
@Introspected
public class Concurrency {
@Positive
@Min(1)
@NotNull
private Integer limit;

View File

@@ -3,6 +3,7 @@ package io.kestra.core.models.flows;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.kestra.core.models.tasks.Task;
import io.micronaut.core.annotation.Introspected;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
@@ -253,9 +254,22 @@ public class State {
return this == Type.KILLED;
}
/**
* @return states that are terminal to an execution
*/
public static List<Type> terminatedTypes() {
return Stream.of(Type.values()).filter(type -> type.isTerminated()).toList();
}
/**
* Compute the final 'failure' of a task depending on <code>allowFailure</code> and <code>allowWarning</code>:
* - if both are true -> SUCCESS
* - if only <code>allowFailure</code> is true -> WARNING
* - if none -> FAILED
*/
public static State.Type fail(Task task) {
return task.isAllowFailure() ? (task.isAllowWarning() ? State.Type.SUCCESS : State.Type.WARNING) : State.Type.FAILED;
}
}
@Value

View File

@@ -329,6 +329,14 @@ public class DefaultPluginRegistry implements PluginRegistry {
pluginClassByIdentifier.clear();
}
/**
* {@inheritDoc}
**/
@Override
public boolean isVersioningSupported() {
return false;
}
public record PluginBundleIdentifier(@Nullable URL location) {
public static PluginBundleIdentifier CORE = new PluginBundleIdentifier(null);

View File

@@ -151,7 +151,7 @@ public class LocalPluginManager implements PluginManager {
* {@inheritDoc}
**/
@Override
public PluginArtifact install(File file, boolean installForRegistration, @Nullable Path localRepositoryPath) {
public PluginArtifact install(File file, boolean installForRegistration, @Nullable Path localRepositoryPath, boolean forceInstallOnExistingVersions) {
try {
PluginArtifact artifact = PluginArtifact.fromFile(file);
log.info("Installing managed plugin artifact '{}'", artifact);

View File

@@ -55,14 +55,16 @@ public interface PluginManager extends AutoCloseable {
/**
* Installs the given plugin artifact.
*
* @param file the plugin JAR file.
* @param installForRegistration specify whether plugin artifacts should be scanned and registered.
* @param localRepositoryPath the optional local repository path to install artifact.
* @param file the plugin JAR file.
* @param installForRegistration specify whether plugin artifacts should be scanned and registered.
* @param localRepositoryPath the optional local repository path to install artifact.
* @param forceInstallOnExistingVersions specify whether plugin should be forced install upon the existing one
* @return The URI of the installed plugin.
*/
PluginArtifact install(final File file,
boolean installForRegistration,
@Nullable Path localRepositoryPath);
@Nullable Path localRepositoryPath,
boolean forceInstallOnExistingVersions);
/**
* Installs the given plugin artifact.

View File

@@ -116,4 +116,11 @@ public interface PluginRegistry {
default void clear() {
}
/**
* Checks whether plugin-versioning is supported by this registry.
*
* @return {@code true} if supported. Otherwise {@code false}.
*/
boolean isVersioningSupported();
}

View File

@@ -18,9 +18,11 @@ import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.IOUtils;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileSystemNotFoundException;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -202,19 +204,13 @@ public class PluginScanner {
var guidesDirectory = classLoader.getResource("doc/guides");
if (guidesDirectory != null) {
try (var fileSystem = FileSystems.newFileSystem(guidesDirectory.toURI(), Collections.emptyMap())) {
var root = fileSystem.getPath("/doc/guides");
try (var stream = Files.walk(root, 1)) {
stream
.skip(1) // first element is the root element
.sorted(Comparator.comparing(path -> path.getName(path.getParent().getNameCount()).toString()))
.forEach(guide -> {
var guideName = guide.getName(guide.getParent().getNameCount()).toString();
guides.add(guideName.substring(0, guideName.lastIndexOf('.')));
});
}
try {
var root = Path.of(guidesDirectory.toURI());
addGuides(root, guides);
} catch (IOException | URISyntaxException e) {
// silently fail
} catch (FileSystemNotFoundException e) {
addGuidesThroughNewFileSystem(guidesDirectory, guides);
}
}
@@ -243,6 +239,27 @@ public class PluginScanner {
.build();
}
private static void addGuidesThroughNewFileSystem(URL guidesDirectory, List<String> guides) {
try (var fileSystem = FileSystems.newFileSystem(guidesDirectory.toURI(), Collections.emptyMap())) {
var root = fileSystem.getPath("doc/guides");
addGuides(root, guides);
} catch (IOException | URISyntaxException e) {
// silently fail
}
}
private static void addGuides(Path root, List<String> guides) throws IOException {
try (var stream = Files.walk(root, 1)) {
stream
.skip(1) // first element is the root element
.sorted(Comparator.comparing(path -> path.getName(path.getParent().getNameCount()).toString()))
.forEach(guide -> {
var guideName = guide.getName(guide.getParent().getNameCount()).toString();
guides.add(guideName.substring(0, guideName.lastIndexOf('.')));
});
}
}
public static Manifest getManifest(ClassLoader classLoader) {
try {
URL url = classLoader.getResource(JarFile.MANIFEST_NAME);

View File

@@ -86,7 +86,7 @@ public final class PluginDeserializer<T extends Plugin> extends JsonDeserializer
DeserializationContext context) throws IOException {
Class<? extends Plugin> pluginType = null;
final String identifier = extractPluginRawIdentifier(node);
final String identifier = extractPluginRawIdentifier(node, pluginRegistry.isVersioningSupported());
if (identifier != null) {
log.trace("Looking for Plugin for: {}",
identifier
@@ -103,7 +103,7 @@ public final class PluginDeserializer<T extends Plugin> extends JsonDeserializer
);
if (DataChart.class.isAssignableFrom(pluginType)) {
final Class<? extends Plugin> dataFilterClass = pluginRegistry.findClassByIdentifier(extractPluginRawIdentifier(node.get("data")));
final Class<? extends Plugin> dataFilterClass = pluginRegistry.findClassByIdentifier(extractPluginRawIdentifier(node.get("data"), pluginRegistry.isVersioningSupported()));
ParameterizedType genericDataFilterClass = (ParameterizedType) dataFilterClass.getGenericSuperclass();
Type dataFieldsEnum = genericDataFilterClass.getActualTypeArguments()[0];
TypeFactory typeFactory = JacksonMapper.ofJson().getTypeFactory();
@@ -142,7 +142,7 @@ public final class PluginDeserializer<T extends Plugin> extends JsonDeserializer
);
}
static String extractPluginRawIdentifier(final JsonNode node) {
static String extractPluginRawIdentifier(final JsonNode node, final boolean isVersioningSupported) {
String type = Optional.ofNullable(node.get(TYPE)).map(JsonNode::textValue).orElse(null);
String version = Optional.ofNullable(node.get(VERSION)).map(JsonNode::textValue).orElse(null);
@@ -150,6 +150,6 @@ public final class PluginDeserializer<T extends Plugin> extends JsonDeserializer
return null;
}
return version != null && !version.isEmpty() ? type + ":" + version : type;
return isVersioningSupported && version != null && !version.isEmpty() ? type + ":" + version : type;
}
}

View File

@@ -67,6 +67,9 @@ public class ExecutorService {
@Inject
private WorkerGroupExecutorInterface workerGroupExecutorInterface;
@Inject
private WorkerJobRunningStateStore workerJobRunningStateStore;
protected FlowMetaStoreInterface flowExecutorInterface;
@Inject
@@ -664,7 +667,7 @@ public class ExecutorService {
.taskRunId(workerTaskResult.getTaskRun().getId())
.executionId(executor.getExecution().getId())
.date(workerTaskResult.getTaskRun().getState().maxDate().plus(duration != null ? duration : timeout))
.state(duration != null ? behavior.mapToState() : State.Type.FAILED)
.state(duration != null ? behavior.mapToState() : State.Type.fail(pauseTask))
.delayType(ExecutionDelay.DelayType.RESUME_FLOW)
.build();
}
@@ -1072,6 +1075,25 @@ public class ExecutorService {
newExecution = executionService.killParentTaskruns(taskRun, newExecution);
}
executor.withExecution(newExecution, "addWorkerTaskResult");
if (taskRun.getState().isTerminated()) {
log.trace("TaskRun terminated: {}", taskRun);
workerJobRunningStateStore.deleteByKey(taskRun.getId());
metricRegistry
.counter(
MetricRegistry.METRIC_EXECUTOR_TASKRUN_ENDED_COUNT,
MetricRegistry.METRIC_EXECUTOR_TASKRUN_ENDED_COUNT_DESCRIPTION,
metricRegistry.tags(workerTaskResult)
)
.increment();
metricRegistry
.timer(
MetricRegistry.METRIC_EXECUTOR_TASKRUN_ENDED_DURATION,
MetricRegistry.METRIC_EXECUTOR_TASKRUN_ENDED_DURATION_DESCRIPTION,
metricRegistry.tags(workerTaskResult)
)
.record(taskRun.getState().getDuration());
}
}
// Note: as the flow is only used in an error branch and it can take time to load, we pass it thought a Supplier

View File

@@ -12,6 +12,7 @@ import io.kestra.core.models.flows.input.SecretInput;
import io.kestra.core.models.tasks.Task;
import io.kestra.core.models.triggers.AbstractTrigger;
import io.kestra.core.utils.ListUtils;
import io.kestra.plugin.core.trigger.Schedule;
import lombok.AllArgsConstructor;
import lombok.With;
@@ -181,9 +182,6 @@ public final class RunVariables {
// Flow
if (flow != null) {
builder.put("flow", RunVariables.of(flow));
if (flow.getVariables() != null) {
builder.put("vars", flow.getVariables());
}
}
// Task
@@ -298,16 +296,19 @@ public final class RunVariables {
if (execution.getTrigger() != null && execution.getTrigger().getVariables() != null) {
builder.put("trigger", execution.getTrigger().getVariables());
// temporal hack to add back the `schedule`variables
// will be removed in 2.0
if (Schedule.class.getName().equals(execution.getTrigger().getType())) {
// add back its variables inside the `schedule` variables
builder.put("schedule", execution.getTrigger().getVariables());
}
}
if (execution.getLabels() != null) {
builder.put("labels", Label.toNestedMap(execution.getLabels()));
}
if (execution.getVariables() != null) {
builder.putAll(execution.getVariables());
}
if (flow == null) {
Flow flowFromExecution = Flow.builder()
.id(execution.getFlowId())
@@ -319,6 +320,15 @@ public final class RunVariables {
}
}
// variables
if (execution != null && execution.getVariables() != null) {
builder.put("vars", execution.getVariables());
}
else if (execution == null && flow != null && flow.getVariables() != null) {
// flow variables are added to the execution variables at execution creation time so they must only be added if the execution is null
builder.put("vars", flow.getVariables());
}
// Kestra configuration
if (kestraConfiguration != null) {
Map<String, String> kestra = HashMap.newHashMap(2);

View File

@@ -4,7 +4,6 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Throwables;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import io.kestra.core.exceptions.DeserializationException;
import io.kestra.core.exceptions.IllegalVariableEvaluationException;
import io.kestra.core.metrics.MetricRegistry;
@@ -395,11 +394,16 @@ public class Worker implements Service, Runnable, AutoCloseable {
} catch (IllegalVariableEvaluationException e) {
RunContextLogger contextLogger = runContextLoggerFactory.create(currentWorkerTask);
contextLogger.logger().error("Failed evaluating runIf: {}", e.getMessage(), e);
try {
this.workerTaskResultQueue.emit(new WorkerTaskResult(workerTask.fail()));
} catch (QueueException ex) {
log.error("Unable to emit the worker task result for task {} taskrun {}", currentWorkerTask.getTask().getId(), currentWorkerTask.getTaskRun().getId(), e);
}
} catch (QueueException e) {
log.error("Unable to emit the worker task result for task {} taskrun {}", currentWorkerTask.getTask().getId(), currentWorkerTask.getTaskRun().getId(), e);
}
if (workerTaskResult.getTaskRun().getState().isFailed() && !currentWorkerTask.getTask().isAllowFailure()) {
if (workerTaskResult == null || workerTaskResult.getTaskRun().getState().isFailed() && !currentWorkerTask.getTask().isAllowFailure()) {
break;
}
@@ -776,7 +780,7 @@ public class Worker implements Service, Runnable, AutoCloseable {
if (!(workerTask.getTask() instanceof RunnableTask<?> task)) {
// This should never happen but better to deal with it than crashing the Worker
var state = workerTask.getTask().isAllowFailure() ? workerTask.getTask().isAllowWarning() ? SUCCESS : WARNING : FAILED;
var state = State.Type.fail(workerTask.getTask());
TaskRunAttempt attempt = TaskRunAttempt.builder()
.state(new io.kestra.core.models.flows.State().withState(state))
.workerId(this.id)

View File

@@ -0,0 +1,20 @@
package io.kestra.core.runners;
/**
* State store containing all workers' jobs in RUNNING state.
*
* @see WorkerJob
*/
public interface WorkerJobRunningStateStore {
/**
* Deletes a running worker job for the given key.
*
* <p>
* A key can be a {@link WorkerTask} Task Run ID.
* </p>
*
* @param key the key of the worker job to be deleted.
*/
void deleteByKey(String key);
}

View File

@@ -48,7 +48,7 @@ public class WorkerTask extends WorkerJob {
* @return this worker task, updated
*/
public TaskRun fail() {
var state = this.task.isAllowFailure() ? this.task.isAllowWarning() ? State.Type.SUCCESS : State.Type.WARNING : State.Type.FAILED;
var state = State.Type.fail(task);
return this.getTaskRun().withState(state);
}
}

View File

@@ -5,6 +5,7 @@ import com.amazon.ion.IonSystem;
import com.amazon.ion.system.*;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.StreamReadConstraints;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
@@ -36,6 +37,8 @@ import java.util.List;
import java.util.Map;
import java.util.TimeZone;
import static com.fasterxml.jackson.core.StreamReadConstraints.DEFAULT_MAX_STRING_LEN;
public final class JacksonMapper {
public static final TypeReference<Map<String, Object>> MAP_TYPE_REFERENCE = new TypeReference<>() {};
public static final TypeReference<List<Object>> LIST_TYPE_REFERENCE = new TypeReference<>() {};
@@ -43,6 +46,12 @@ public final class JacksonMapper {
private JacksonMapper() {}
static {
StreamReadConstraints.overrideDefaultStreamReadConstraints(
StreamReadConstraints.builder().maxNameLength(DEFAULT_MAX_STRING_LEN).build()
);
}
private static final ObjectMapper MAPPER = JacksonMapper.configure(
new ObjectMapper()
);
@@ -52,7 +61,7 @@ public final class JacksonMapper {
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
public static ObjectMapper ofJson() {
return MAPPER;
return JacksonMapper.ofJson(true);
}
public static ObjectMapper ofJson(boolean strict) {

View File

@@ -176,7 +176,7 @@ public class FlowService {
previous :
FlowWithSource.of(flowToImport.toBuilder().revision(previous.getRevision() + 1).build(), source)
)
.orElseGet(() -> FlowWithSource.of(flowToImport, source).toBuilder().revision(1).build());
.orElseGet(() -> FlowWithSource.of(flowToImport, source).toBuilder().tenantId(tenantId).revision(1).build());
} else {
return maybeExisting
.map(previous -> repository().update(flow, previous))

View File

@@ -5,16 +5,19 @@ import io.kestra.core.test.TestState;
import jakarta.annotation.Nullable;
import jakarta.validation.constraints.NotNull;
import java.net.URI;
import java.util.List;
public record UnitTestResult(
@NotNull
String unitTestId,
String testId,
@NotNull
String unitTestType,
String testType,
@NotNull
String executionId,
@NotNull
URI url,
@NotNull
TestState state,
@NotNull
List<AssertionResult> assertionResults,
@@ -22,14 +25,13 @@ public record UnitTestResult(
List<AssertionRunError> errors,
Fixtures fixtures
) {
public static UnitTestResult of(String unitTestId, String unitTestType, String executionId, List<AssertionResult> results, List<AssertionRunError> errors, @Nullable Fixtures fixtures) {
public static UnitTestResult of(String unitTestId, String unitTestType, String executionId, URI url, List<AssertionResult> results, List<AssertionRunError> errors, @Nullable Fixtures fixtures) {
TestState state;
if(!errors.isEmpty()){
state = TestState.ERROR;
} else {
state = results.stream().anyMatch(assertion -> !assertion.isSuccess()) ? TestState.FAILED : TestState.SUCCESS;
}
return new UnitTestResult(unitTestId, unitTestType, executionId, state, results, errors, fixtures);
return new UnitTestResult(unitTestId, unitTestType, executionId, url, state, results, errors, fixtures);
}
}

View File

@@ -73,7 +73,7 @@ public class GraphUtils {
)))
.orElse(Collections.emptyMap());
triggersDeclarations.forEach(trigger -> {
triggersDeclarations.stream().filter(trigger -> trigger != null).forEach(trigger -> {
GraphTrigger triggerNode = new GraphTrigger(trigger, triggersById.get(trigger.getId()));
triggerCluster.addNode(triggerNode);
triggerCluster.addEdge(triggerCluster.getRoot(), triggerNode, new Relation());

View File

@@ -1,5 +1,7 @@
package io.kestra.core.utils;
import io.kestra.core.models.Setting;
import io.kestra.core.repositories.SettingRepositoryInterface;
import io.micronaut.context.env.Environment;
import io.micronaut.context.env.PropertiesPropertySourceLoader;
import io.micronaut.context.env.PropertySource;
@@ -29,6 +31,9 @@ public class VersionProvider {
@Inject
private Environment environment;
@Inject
private Optional<SettingRepositoryInterface> settingRepository; // repositories are not always there on unit tests
@PostConstruct
public void start() {
final Optional<PropertySource> gitProperties = new PropertiesPropertySourceLoader()
@@ -40,6 +45,18 @@ public class VersionProvider {
this.revision = loadRevision(gitProperties);
this.date = loadTime(gitProperties);
this.version = loadVersion(buildProperties, gitProperties);
// check the version in the settings and update if needed, we did't use it would allow us to detect incompatible update later if needed
if (settingRepository.isPresent()) {
Optional<Setting> versionSetting = settingRepository.get().findByKey(Setting.INSTANCE_VERSION);
if (versionSetting.isEmpty() || !versionSetting.get().getValue().equals(this.version)) {
settingRepository.get().save(Setting.builder()
.key(Setting.INSTANCE_VERSION)
.value(this.version)
.build()
);
}
}
}
private String loadVersion(final Optional<PropertySource> buildProperties,

View File

@@ -0,0 +1,75 @@
package io.kestra.plugin.core.execution;
import io.kestra.core.models.annotations.Example;
import io.kestra.core.models.annotations.Plugin;
import io.kestra.core.models.executions.Execution;
import io.kestra.core.models.property.Property;
import io.kestra.core.models.tasks.ExecutionUpdatableTask;
import io.kestra.core.models.tasks.Task;
import io.kestra.core.runners.RunContext;
import io.kestra.core.utils.MapUtils;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.*;
import lombok.experimental.SuperBuilder;
import java.util.List;
import java.util.Map;
@SuperBuilder
@ToString
@EqualsAndHashCode
@Getter
@NoArgsConstructor
@Schema(
title = "Allow to set execution variables. These variables will then be available via the `{{ vars.name }}` expression."
)
@Plugin(
examples = {
@Example(
full = true,
title = "Set variables",
code = """
id: variables
namespace: company.team
variables:
name: World
tasks:
- id: set_vars
type: io.kestra.plugin.core.execution.SetVariables
variables:
message: Hello
name: Loïc
- id: hello
type: io.kestra.plugin.core.log.Log
message: "{{ vars.message }} {{ vars.name }}\""""
)
}
)
public class SetVariables extends Task implements ExecutionUpdatableTask {
@Schema(title = "The variables")
@NotNull
private Property<Map<String, Object>> variables;
@Schema(title = "Whether to overwrite existing variables")
@NotNull
@Builder.Default
private Property<Boolean> overwrite = Property.ofValue(true);
@Override
public Execution update(Execution execution, RunContext runContext) throws Exception {
Map<String, Object> renderedVars = runContext.render(this.variables).asMap(String.class, Object.class);
boolean renderedOverwrite = runContext.render(overwrite).as(Boolean.class).orElseThrow();
if (!renderedOverwrite) {
// check that none of the new variables already exist
List<String> duplicated = renderedVars.keySet().stream().filter(key -> execution.getVariables().containsKey(key)).toList();
if (!duplicated.isEmpty()) {
throw new IllegalArgumentException("`overwrite` is set to false and the following variables already exist: " + String.join(",", duplicated));
}
}
return execution.withVariables(MapUtils.merge(execution.getVariables(), renderedVars));
}
}

View File

@@ -0,0 +1,89 @@
package io.kestra.plugin.core.execution;
import io.kestra.core.models.annotations.Example;
import io.kestra.core.models.annotations.Plugin;
import io.kestra.core.models.executions.Execution;
import io.kestra.core.models.property.Property;
import io.kestra.core.models.tasks.ExecutionUpdatableTask;
import io.kestra.core.models.tasks.Task;
import io.kestra.core.runners.RunContext;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.*;
import lombok.experimental.SuperBuilder;
import java.util.List;
import java.util.Map;
@SuperBuilder
@ToString
@EqualsAndHashCode
@Getter
@NoArgsConstructor
@Schema(
title = "Allow to unset execution variables."
)
@Plugin(
examples = {
@Example(
full = true,
title = "Set and later unset variables",
code = """
id: variables
namespace: company.team
variables:
name: World
tasks:
- id: set_vars
type: io.kestra.plugin.core.execution.SetVariables
variables:
message: Hello
name: Loïc
- id: hello
type: io.kestra.plugin.core.log.Log
message: "{{ vars.message }} {{ vars.name }}"
- id: unset_variables
type: io.kestra.plugin.core.execution.UnsetVariables
variables:
- message
- name"""
)
}
)
public class UnsetVariables extends Task implements ExecutionUpdatableTask {
@Schema(title = "The variables")
@NotNull
private Property<List<String>> variables;
@Schema(title = "Whether to ignore missing variables")
@NotNull
@Builder.Default
private Property<Boolean> ignoreMissing = Property.ofValue(false);
@Override
public Execution update(Execution execution, RunContext runContext) throws Exception {
List<String> renderedVariables = runContext.render(variables).asList(String.class);
boolean renderedIgnoreMissing = runContext.render(ignoreMissing).as(Boolean.class).orElseThrow();
Map<String, Object> variables = execution.getVariables();
for (String key : renderedVariables) {
removeVar(variables, key, renderedIgnoreMissing);
}
return execution.withVariables(variables);
}
private void removeVar(Map<String, Object> vars, String key, boolean ignoreMissing) {
if (key.indexOf('.') >= 0) {
String prefix = key.substring(0, key.indexOf('.'));
String suffix = key.substring(key.indexOf('.') + 1);
removeVar((Map<String, Object>) vars.get(prefix), suffix, ignoreMissing);
} else {
if (ignoreMissing && !vars.containsKey(key)) {
return;
}
vars.remove(key);
}
}
}

View File

@@ -555,7 +555,7 @@ public class ForEachItem extends Task implements FlowableTask<VoidOutput>, Child
builder.uri(uri);
} catch (Exception e) {
runContext.logger().warn("Failed to extract outputs with the error: '{}'", e.getLocalizedMessage(), e);
var state = this.isAllowFailure() ? State.Type.WARNING : State.Type.FAILED;
var state = State.Type.fail(this);
taskRun = taskRun
.withState(state)
.withAttempts(Collections.singletonList(TaskRunAttempt.builder().state(new State().withState(state)).build()))

View File

@@ -29,8 +29,13 @@ import java.util.concurrent.TimeUnit;
@Plugin(
examples = {
@Example(
full = true,
code = """
id: sleep
id: sleep
namespace: company.team
tasks:
- id: sleep
type: io.kestra.plugin.core.flow.Sleep
duration: "PT5S"
"""

View File

@@ -238,7 +238,7 @@ public class Subflow extends Task implements ExecutableTask<Subflow.Output>, Chi
builder.outputs(outputs);
} catch (Exception e) {
runContext.logger().warn("Failed to extract outputs with the error: '{}'", e.getLocalizedMessage(), e);
var state = this.isAllowFailure() ? this.isAllowWarning() ? State.Type.SUCCESS : State.Type.WARNING : State.Type.FAILED;
var state = State.Type.fail(this);
Variables variables = variablesService.of(StorageContext.forTask(taskRun), builder.build());
taskRun = taskRun
.withState(state)

View File

@@ -82,12 +82,12 @@ import java.util.stream.Stream;
code = """
id: daily_flow
namespace: company.team
tasks:
- id: log
type: io.kestra.plugin.core.log.Log
message: It's {{ trigger.date ?? taskrun.startDate | date("HH:mm") }}
triggers:
- id: schedule
type: io.kestra.plugin.core.trigger.Schedule
@@ -437,13 +437,6 @@ public class Schedule extends AbstractTrigger implements Schedulable, TriggerOut
Optional.empty()
);
execution = execution.toBuilder()
// keep to avoid breaking compatibility
.variables(ImmutableMap.of(
"schedule", execution.getTrigger().getVariables()
))
.build();
return Optional.of(execution);
}

View File

@@ -49,7 +49,6 @@ class DocumentationGeneratorTest {
assertThat(render).contains("description: \"Short description for this task\"");
assertThat(render).contains("`VALUE_1`");
assertThat(render).contains("`VALUE_2`");
assertThat(render).contains("This plugin is exclusively available on the Cloud and Enterprise editions of Kestra.");
}
@SuppressWarnings({"rawtypes", "unchecked"})

View File

@@ -1,6 +1,7 @@
package io.kestra.core.http.client;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.google.common.net.HttpHeaders;
import io.kestra.core.context.TestRunContextFactory;
import io.kestra.core.exceptions.IllegalVariableEvaluationException;
@@ -262,6 +263,30 @@ class HttpClientTest {
}
}
@Test
void postCustomObject_WithUnknownResponseField() throws IllegalVariableEvaluationException, HttpClientException, IOException {
CustomObject test = CustomObject.builder()
.id(IdUtils.create())
.name("test")
.build();
Map<String, String> withAdditionalField = JacksonMapper.ofJson().convertValue(test, new TypeReference<>() {
});
withAdditionalField.put("foo", "bar");
try (HttpClient client = client()) {
HttpResponse<CustomObject> response = client.request(
HttpRequest.of(URI.create(embeddedServerUri + "/http/json-post"), "POST", HttpRequest.JsonRequestBody.builder().content(withAdditionalField).build()),
CustomObject.class
);
assertThat(response.getStatus().getCode()).isEqualTo(200);
assertThat(response.getBody().id).isEqualTo(test.id);
assertThat(response.getHeaders().firstValue(HttpHeaders.CONTENT_TYPE).orElseThrow()).isEqualTo(MediaType.APPLICATION_JSON);
}
}
@Test
void postMultipart() throws IOException, URISyntaxException, IllegalVariableEvaluationException, HttpClientException {
Map<String, Object> multipart = Map.of(
@@ -509,4 +534,4 @@ class HttpClientTest {
String id;
String name;
}
}
}

View File

@@ -4,6 +4,7 @@ import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.exc.InvalidTypeIdException;
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.fasterxml.jackson.databind.node.TextNode;
import io.kestra.core.models.Plugin;
import io.kestra.core.plugins.PluginRegistry;
@@ -15,12 +16,14 @@ import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.stubbing.Answer;
import static org.assertj.core.api.Assertions.assertThat;
@ExtendWith(MockitoExtension.class)
class PluginDeserializerTest {
@Mock
private PluginRegistry registry;
@Test
void shouldSucceededDeserializePluginGivenValidType() throws JsonProcessingException {
// Given
@@ -38,8 +41,9 @@ class PluginDeserializerTest {
TestPluginHolder deserialized = om.readValue(input, TestPluginHolder.class);
// Then
Assertions.assertEquals(TestPlugin.class.getCanonicalName(), deserialized.plugin().getType());
Mockito.verify(registry, Mockito.only()).findClassByIdentifier(identifier);
assertThat(TestPlugin.class.getCanonicalName()).isEqualTo(deserialized.plugin().getType());
Mockito.verify(registry, Mockito.times(1)).isVersioningSupported();
Mockito.verify(registry, Mockito.times(1)).findClassByIdentifier(identifier);
}
@Test
@@ -57,17 +61,33 @@ class PluginDeserializerTest {
});
// Then
Assertions.assertEquals("io.kestra.core.plugins.serdes.Unknown", exception.getTypeId());
assertThat("io.kestra.core.plugins.serdes.Unknown").isEqualTo(exception.getTypeId());
}
@Test
void shouldReturnNullPluginIdentifierGivenNullType() {
Assertions.assertNull(PluginDeserializer.extractPluginRawIdentifier(new TextNode(null)));
assertThat(PluginDeserializer.extractPluginRawIdentifier(new TextNode(null), true)).isNull();
}
@Test
void shouldReturnNullPluginIdentifierGivenEmptyType() {
Assertions.assertNull(PluginDeserializer.extractPluginRawIdentifier(new TextNode("")));
assertThat(PluginDeserializer.extractPluginRawIdentifier(new TextNode(""), true)).isNull();
}
@Test
void shouldReturnTypeWithVersionGivenSupportedVersionTrue() {
ObjectNode jsonNodes = new ObjectNode(new ObjectMapper().getNodeFactory());
jsonNodes.set("type", new TextNode("io.kestra.core.plugins.serdes.Unknown"));
jsonNodes.set("version", new TextNode("1.0.0"));
assertThat(PluginDeserializer.extractPluginRawIdentifier(jsonNodes, true)).isEqualTo("io.kestra.core.plugins.serdes.Unknown:1.0.0");
}
@Test
void shouldReturnTypeWithVersionGivenSupportedVersionFalse() {
ObjectNode jsonNodes = new ObjectNode(new ObjectMapper().getNodeFactory());
jsonNodes.set("type", new TextNode("io.kestra.core.plugins.serdes.Unknown"));
jsonNodes.set("version", new TextNode("1.0.0"));
assertThat(PluginDeserializer.extractPluginRawIdentifier(jsonNodes, false)).isEqualTo("io.kestra.core.plugins.serdes.Unknown");
}
public record TestPluginHolder(Plugin plugin) {

View File

@@ -492,6 +492,12 @@ public abstract class AbstractRunnerTest {
slaTestCase.executionConditionSLAShouldLabel();
}
@Test
@LoadFlows({"flows/valids/sla-parent-flow.yaml", "flows/valids/sla-subflow.yaml"})
void executionConditionSLAShouldLaslaViolationOnSubflowMayEndTheParentFlowbel() throws Exception {
slaTestCase.slaViolationOnSubflowMayEndTheParentFlow();
}
@Test
@LoadFlows({"flows/valids/if.yaml"})
void multipleIf() throws TimeoutException, QueueException {

View File

@@ -48,4 +48,10 @@ public class SLATestCase {
assertThat(execution.getState().getCurrent()).isEqualTo(State.Type.SUCCESS);
assertThat(execution.getLabels()).contains(new Label("sla", "violated"));
}
public void slaViolationOnSubflowMayEndTheParentFlow() throws QueueException, TimeoutException {
Execution execution = runnerUtils.runOne(MAIN_TENANT, "io.kestra.tests", "sla-parent-flow");
assertThat(execution.getState().getCurrent()).isEqualTo(State.Type.FAILED);
}
}

View File

@@ -0,0 +1,31 @@
package io.kestra.plugin.core.execution;
import io.kestra.core.junit.annotations.ExecuteFlow;
import io.kestra.core.junit.annotations.KestraTest;
import io.kestra.core.models.executions.Execution;
import io.kestra.core.models.flows.State;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
@KestraTest(startRunner = true)
class SetVariablesTest {
@ExecuteFlow("flows/valids/set-variables.yaml")
@Test
void shouldUpdateExecution(Execution execution) {
assertThat(execution.getState().getCurrent()).isEqualTo(State.Type.SUCCESS);
assertThat(execution.getTaskRunList()).hasSize(2);
assertThat(((Map<String, Object>) execution.getTaskRunList().get(1).getOutputs().get("values"))).containsEntry("message", "Hello Loïc");
}
@ExecuteFlow("flows/valids/set-variables-duplicate.yaml")
@Test
void shouldFailWhenExistingVariable(Execution execution) {
assertThat(execution.getState().getCurrent()).isEqualTo(State.Type.FAILED);
assertThat(execution.getTaskRunList()).hasSize(1);
assertThat(execution.getTaskRunList().getFirst().getState().getCurrent()).isEqualTo(State.Type.FAILED);
}
}

View File

@@ -0,0 +1,23 @@
package io.kestra.plugin.core.execution;
import io.kestra.core.junit.annotations.ExecuteFlow;
import io.kestra.core.junit.annotations.KestraTest;
import io.kestra.core.models.executions.Execution;
import io.kestra.core.models.flows.State;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
@KestraTest(startRunner = true)
class UnsetVariablesTest {
@ExecuteFlow("flows/valids/unset-variables.yaml")
@Test
void shouldUpdateExecution(Execution execution) {
assertThat(execution.getState().getCurrent()).isEqualTo(State.Type.SUCCESS);
assertThat(execution.getTaskRunList()).hasSize(3);
assertThat(((Map<String, Object>) execution.getTaskRunList().get(2).getOutputs().get("values"))).containsEntry("message", "default");
}
}

View File

@@ -79,6 +79,12 @@ public class PauseTest {
suite.runTimeout(runnerUtils);
}
@Test
@LoadFlows({"flows/valids/pause-timeout-allow-failure.yaml"})
void timeoutAllowFailure() throws Exception {
suite.runTimeoutAllowFailure(runnerUtils);
}
@Test
@LoadFlows({"flows/valids/pause_no_tasks.yaml"})
void runEmptyTasks() throws Exception {
@@ -235,6 +241,25 @@ public class PauseTest {
assertThat(execution.getTaskRunList()).hasSize(1);
}
public void runTimeoutAllowFailure(RunnerUtils runnerUtils) throws Exception {
Execution execution = runnerUtils.runOneUntilPaused(MAIN_TENANT, "io.kestra.tests", "pause-timeout-allow-failure", null, null, Duration.ofSeconds(30));
String executionId = execution.getId();
assertThat(execution.getState().getCurrent()).isEqualTo(State.Type.PAUSED);
assertThat(execution.getTaskRunList()).hasSize(1);
execution = runnerUtils.awaitExecution(
e -> e.getId().equals(executionId) && e.getState().getCurrent() == State.Type.WARNING,
() -> {},
Duration.ofSeconds(5)
);
assertThat(execution.getTaskRunList().getFirst().getState().getHistories().stream().filter(history -> history.getState() == State.Type.PAUSED).count()).as("Task runs were: " + execution.getTaskRunList().toString()).isEqualTo(1L);
assertThat(execution.getTaskRunList().getFirst().getState().getHistories().stream().filter(history -> history.getState() == State.Type.RUNNING).count()).isEqualTo(1L);
assertThat(execution.getTaskRunList().getFirst().getState().getHistories().stream().filter(history -> history.getState() == State.Type.WARNING).count()).isEqualTo(1L);
assertThat(execution.getTaskRunList()).hasSize(2);
}
public void runEmptyTasks(RunnerUtils runnerUtils) throws Exception {
Execution execution = runnerUtils.runOneUntilPaused(MAIN_TENANT, "io.kestra.tests", "pause_no_tasks", null, null, Duration.ofSeconds(30));
String executionId = execution.getId();

View File

@@ -111,6 +111,12 @@ public class WorkingDirectoryTest {
suite.encryption(runnerUtils, runContextFactory);
}
@Test
@LoadFlows({"flows/valids/working-directory-invalid-runif.yaml"})
void invalidRunIf() throws Exception {
suite.invalidRunIf(runnerUtils);
}
@Singleton
public static class Suite {
@Inject
@@ -310,6 +316,15 @@ public class WorkingDirectoryTest {
assertThat(execution.findTaskRunsByTaskId("decrypted").getFirst().getOutputs().get("value")).isEqualTo("Hello World");
}
public void invalidRunIf(RunnerUtils runnerUtils) throws TimeoutException, QueueException {
Execution execution = runnerUtils.runOne(MAIN_TENANT, "io.kestra.tests", "working-directory-invalid-runif", null,
(f, e) -> ImmutableMap.of("failed", "false"), Duration.ofSeconds(60)
);
assertThat(execution.getTaskRunList()).hasSize(2);
assertThat(execution.getState().getCurrent()).isEqualTo(State.Type.FAILED);
}
private void put(String path, String content) throws IOException {
put(path, content, "io.kestra.tests");
}

View File

@@ -49,7 +49,7 @@ class ScheduleTest {
@Test
void failed() throws Exception {
Schedule trigger = Schedule.builder().id("schedule").cron("1 1 1 1 1").build();
Schedule trigger = Schedule.builder().id("schedule").type(Schedule.class.getName()).cron("1 1 1 1 1").build();
Optional<Execution> evaluate = trigger.evaluate(
conditionContext(trigger),
@@ -82,9 +82,8 @@ class ScheduleTest {
}
@Test
@SuppressWarnings("unchecked")
void success() throws Exception {
Schedule trigger = Schedule.builder().id("schedule").cron("0 0 1 * *").build();
Schedule trigger = Schedule.builder().id("schedule").type(Schedule.class.getName()).cron("0 0 1 * *").build();
ZonedDateTime date = ZonedDateTime.now()
.withDayOfMonth(1)
@@ -103,12 +102,12 @@ class ScheduleTest {
assertThat(evaluate.get().getLabels()).hasSize(3);
assertTrue(evaluate.get().getLabels().stream().anyMatch(label -> label.key().equals(Label.CORRELATION_ID)));
var vars = (Map<String, String>) evaluate.get().getVariables().get("schedule");
var vars = evaluate.get().getTrigger().getVariables();
var inputs = evaluate.get().getInputs();
assertThat(dateFromVars(vars.get("date"), date)).isEqualTo(date);
assertThat(dateFromVars(vars.get("next"), date)).isEqualTo(date.plusMonths(1));
assertThat(dateFromVars(vars.get("previous"), date)).isEqualTo(date.minusMonths(1));
assertThat(dateFromVars((String) vars.get("date"), date)).isEqualTo(date);
assertThat(dateFromVars((String) vars.get("next"), date)).isEqualTo(date.plusMonths(1));
assertThat(dateFromVars((String) vars.get("previous"), date)).isEqualTo(date.minusMonths(1));
assertThat(evaluate.get().getLabels()).contains(new Label("flow-label-1", "flow-label-1"));
assertThat(evaluate.get().getLabels()).contains(new Label("flow-label-2", "flow-label-2"));
assertThat(inputs.size()).isEqualTo(2);
@@ -118,7 +117,7 @@ class ScheduleTest {
@Test
void successWithInput() throws Exception {
Schedule trigger = Schedule.builder().id("schedule").cron("0 0 1 * *").inputs(Map.of("input1", "input1")).build();
Schedule trigger = Schedule.builder().id("schedule").type(Schedule.class.getName()).cron("0 0 1 * *").inputs(Map.of("input1", "input1")).build();
ZonedDateTime date = ZonedDateTime.now()
.withDayOfMonth(1)
@@ -147,7 +146,7 @@ class ScheduleTest {
@Test
void success_withLabels() throws Exception {
var scheduleTrigger = Schedule.builder()
.id("schedule")
.id("schedule").type(Schedule.class.getName())
.cron("0 0 1 * *")
.labels(List.of(
new Label("trigger-label-1", "trigger-label-1"),
@@ -173,10 +172,9 @@ class ScheduleTest {
assertThat(evaluate.get().getLabels()).contains(new Label("trigger-label-3", ""));
}
@SuppressWarnings("unchecked")
@Test
void everyMinute() throws Exception {
Schedule trigger = Schedule.builder().id("schedule").cron("* * * * *").build();
Schedule trigger = Schedule.builder().id("schedule").type(Schedule.class.getName()).cron("* * * * *").build();
ZonedDateTime date = ZonedDateTime.now()
.minus(Duration.ofMinutes(1))
@@ -191,18 +189,16 @@ class ScheduleTest {
assertThat(evaluate.isPresent()).isTrue();
var vars = (Map<String, String>) evaluate.get().getVariables().get("schedule");
var vars = evaluate.get().getTrigger().getVariables();;
assertThat(dateFromVars(vars.get("date"), date)).isEqualTo(date);
assertThat(dateFromVars(vars.get("next"), date)).isEqualTo(date.plus(Duration.ofMinutes(1)));
assertThat(dateFromVars(vars.get("previous"), date)).isEqualTo(date.minus(Duration.ofMinutes(1)));
assertThat(dateFromVars((String) vars.get("date"), date)).isEqualTo(date);
assertThat(dateFromVars((String) vars.get("next"), date)).isEqualTo(date.plus(Duration.ofMinutes(1)));
assertThat(dateFromVars((String) vars.get("previous"), date)).isEqualTo(date.minus(Duration.ofMinutes(1)));
}
@SuppressWarnings("unchecked")
@Test
void everySecond() throws Exception {
Schedule trigger = Schedule.builder().id("schedule").cron("* * * * * *").withSeconds(true).build();
Schedule trigger = Schedule.builder().id("schedule").type(Schedule.class.getName()).cron("* * * * * *").withSeconds(true).build();
ZonedDateTime date = ZonedDateTime.now()
.truncatedTo(ChronoUnit.SECONDS)
@@ -215,18 +211,17 @@ class ScheduleTest {
assertThat(evaluate.isPresent()).isTrue();
var vars = (Map<String, String>) evaluate.get().getVariables().get("schedule");
var vars = evaluate.get().getTrigger().getVariables();;
assertThat(dateFromVars(vars.get("date"), date)).isEqualTo(date);
assertThat(dateFromVars(vars.get("next"), date)).isEqualTo(date.plus(Duration.ofSeconds(1)));
assertThat(dateFromVars(vars.get("previous"), date)).isEqualTo(date.minus(Duration.ofSeconds(1)));
assertThat(dateFromVars((String) vars.get("date"), date)).isEqualTo(date);
assertThat(dateFromVars((String) vars.get("next"), date)).isEqualTo(date.plus(Duration.ofSeconds(1)));
assertThat(dateFromVars((String) vars.get("previous"), date)).isEqualTo(date.minus(Duration.ofSeconds(1)));
}
@Test
void shouldNotReturnExecutionForBackFillWhenCurrentDateIsBeforeScheduleDate() throws Exception {
// Given
Schedule trigger = Schedule.builder().id("schedule").cron(TEST_CRON_EVERYDAY_AT_8).build();
Schedule trigger = Schedule.builder().id("schedule").type(Schedule.class.getName()).cron(TEST_CRON_EVERYDAY_AT_8).build();
ZonedDateTime now = ZonedDateTime.now();
TriggerContext triggerContext = triggerContext(now, trigger).toBuilder()
.backfill(Backfill
@@ -246,7 +241,7 @@ class ScheduleTest {
void
shouldReturnExecutionForBackFillWhenCurrentDateIsAfterScheduleDate() throws Exception {
// Given
Schedule trigger = Schedule.builder().id("schedule").cron(TEST_CRON_EVERYDAY_AT_8).build();
Schedule trigger = Schedule.builder().id("schedule").type(Schedule.class.getName()).cron(TEST_CRON_EVERYDAY_AT_8).build();
ZonedDateTime now = ZonedDateTime.of(2025, 1, 1, 0, 0, 0, 0, ZoneId.systemDefault());
TriggerContext triggerContext = triggerContext(ZonedDateTime.now(), trigger).toBuilder()
.backfill(Backfill
@@ -265,7 +260,7 @@ class ScheduleTest {
@Test
void noBackfillNextDate() {
Schedule trigger = Schedule.builder().id("schedule").cron("0 0 * * *").build();
Schedule trigger = Schedule.builder().id("schedule").type(Schedule.class.getName()).cron("0 0 * * *").build();
ZonedDateTime next = trigger.nextEvaluationDate(conditionContext(trigger), Optional.empty());
assertThat(next.getDayOfMonth()).isEqualTo(ZonedDateTime.now().plusDays(1).getDayOfMonth());
@@ -273,7 +268,7 @@ class ScheduleTest {
@Test
void noBackfillNextDateContext() {
Schedule trigger = Schedule.builder().id("schedule").cron("0 0 * * *").timezone("Europe/Paris").build();
Schedule trigger = Schedule.builder().id("schedule").type(Schedule.class.getName()).cron("0 0 * * *").timezone("Europe/Paris").build();
ZonedDateTime date = ZonedDateTime.parse("2020-01-01T00:00:00+01:00[Europe/Paris]");
ZonedDateTime next = trigger.nextEvaluationDate(conditionContext(trigger), Optional.of(triggerContext(date, trigger)));
@@ -281,9 +276,8 @@ class ScheduleTest {
}
@Test
@SuppressWarnings("unchecked")
void systemBackfillChangedFromCronExpression() throws Exception {
Schedule trigger = Schedule.builder().id("schedule").cron("30 0 1 * *").build();
Schedule trigger = Schedule.builder().id("schedule").type(Schedule.class.getName()).cron("30 0 1 * *").build();
ZonedDateTime date = ZonedDateTime.now()
.withDayOfMonth(1)
@@ -303,17 +297,16 @@ class ScheduleTest {
assertThat(evaluate.isPresent()).isTrue();
var vars = (Map<String, String>) evaluate.get().getVariables().get("schedule");
assertThat(dateFromVars(vars.get("date"), expexted)).isEqualTo(expexted);
assertThat(dateFromVars(vars.get("next"), expexted)).isEqualTo(expexted.plusMonths(1));
assertThat(dateFromVars(vars.get("previous"), expexted)).isEqualTo(expexted.minusMonths(1));
var vars = evaluate.get().getTrigger().getVariables();;
assertThat(dateFromVars((String) vars.get("date"), expexted)).isEqualTo(expexted);
assertThat(dateFromVars((String) vars.get("next"), expexted)).isEqualTo(expexted.plusMonths(1));
assertThat(dateFromVars((String) vars.get("previous"), expexted)).isEqualTo(expexted.minusMonths(1));
}
@SuppressWarnings("unchecked")
@Test
void conditions() throws Exception {
Schedule trigger = Schedule.builder()
.id("schedule")
.id("schedule").type(Schedule.class.getName())
.type(Schedule.class.getName())
.cron("0 12 * * 1")
.timezone("Europe/Paris")
@@ -338,17 +331,16 @@ class ScheduleTest {
assertThat(evaluate.isPresent()).isTrue();
var vars = (Map<String, String>) evaluate.get().getVariables().get("schedule");
assertThat(dateFromVars(vars.get("date"), date)).isEqualTo(date);
assertThat(dateFromVars(vars.get("next"), next)).isEqualTo(next);
assertThat(dateFromVars(vars.get("previous"), previous)).isEqualTo(previous);
var vars = evaluate.get().getTrigger().getVariables();;
assertThat(dateFromVars((String) vars.get("date"), date)).isEqualTo(date);
assertThat(dateFromVars((String) vars.get("next"), next)).isEqualTo(next);
assertThat(dateFromVars((String) vars.get("previous"), previous)).isEqualTo(previous);
}
@SuppressWarnings("unchecked")
@Test
void impossibleNextConditions() throws Exception {
Schedule trigger = Schedule.builder()
.id("schedule")
.id("schedule").type(Schedule.class.getName())
.type(Schedule.class.getName())
.cron("0 12 * * 1")
.timezone("Europe/Paris")
@@ -371,16 +363,16 @@ class ScheduleTest {
assertThat(evaluate.isPresent()).isTrue();
var vars = (Map<String, String>) evaluate.get().getVariables().get("schedule");
assertThat(dateFromVars(vars.get("date"), date)).isEqualTo(date);
assertThat(dateFromVars(vars.get("previous"), previous)).isEqualTo(previous);
var vars = evaluate.get().getTrigger().getVariables();;
assertThat(dateFromVars((String) vars.get("date"), date)).isEqualTo(date);
assertThat(dateFromVars((String) vars.get("previous"), previous)).isEqualTo(previous);
assertThat(vars.containsKey("next")).isFalse();
}
@Test
void lateMaximumDelay() {
Schedule trigger = Schedule.builder()
.id("schedule")
.id("schedule").type(Schedule.class.getName())
.cron("* * * * *")
.lateMaximumDelay(Duration.ofMinutes(5))
.build();
@@ -401,17 +393,15 @@ class ScheduleTest {
}
@SuppressWarnings("unchecked")
@Test
void hourly() throws Exception {
Schedule trigger = Schedule.builder()
.id("schedule")
.id("schedule").type(Schedule.class.getName())
.cron("@hourly")
.build();
ZonedDateTime date = ZonedDateTime.now().minusHours(1).withMinute(0).withSecond(0).withNano(0);
Optional<Execution> evaluate = trigger.evaluate(
conditionContext(trigger),
TriggerContext.builder()
@@ -422,14 +412,13 @@ class ScheduleTest {
);
assertThat(evaluate.isPresent()).isTrue();
var vars = (Map<String, String>) evaluate.get().getVariables().get("schedule");
assertThat(dateFromVars(vars.get("date"), date)).isEqualTo(date);
var vars = evaluate.get().getTrigger().getVariables();;
assertThat(dateFromVars((String) vars.get("date"), date)).isEqualTo(date);
}
@SuppressWarnings("unchecked")
@Test
void timezone() throws Exception {
Schedule trigger = Schedule.builder().id("schedule").cron("12 9 1 * *").timezone("America/New_York").build();
Schedule trigger = Schedule.builder().id("schedule").type(Schedule.class.getName()).cron("12 9 1 * *").timezone("America/New_York").build();
ZonedDateTime date = ZonedDateTime.now()
.withZoneSameLocal(ZoneId.of("America/New_York"))
@@ -449,18 +438,18 @@ class ScheduleTest {
assertThat(evaluate.isPresent()).isTrue();
var vars = (Map<String, String>) evaluate.get().getVariables().get("schedule");
var vars = evaluate.get().getTrigger().getVariables();;
assertThat(dateFromVars(vars.get("date"), date)).isEqualTo(date);
assertThat(ZonedDateTime.parse(vars.get("date")).getZone().getId()).isEqualTo("-04:00");
assertThat(dateFromVars(vars.get("next"), date)).isEqualTo(date.plusMonths(1));
assertThat(dateFromVars(vars.get("previous"), date)).isEqualTo(date.minusMonths(1));
assertThat(dateFromVars((String) vars.get("date"), date)).isEqualTo(date);
assertThat(ZonedDateTime.parse((String) vars.get("date")).getZone().getId()).isEqualTo("-04:00");
assertThat(dateFromVars((String) vars.get("next"), date)).isEqualTo(date.plusMonths(1));
assertThat(dateFromVars((String) vars.get("previous"), date)).isEqualTo(date.minusMonths(1));
}
@Test
void timezone_with_backfile() throws Exception {
Schedule trigger = Schedule.builder()
.id("schedule")
.id("schedule").type(Schedule.class.getName())
.cron(TEST_CRON_EVERYDAY_AT_8)
.timezone("America/New_York")
.build();
@@ -480,8 +469,6 @@ class ScheduleTest {
assertThat(result.isPresent()).isTrue();
}
private ConditionContext conditionContext(AbstractTrigger trigger) {
Flow flow = Flow.builder()
.id(IdUtils.create())

View File

@@ -0,0 +1,15 @@
id: pause-timeout-allow-failure
namespace: io.kestra.tests
tasks:
- id: pause
type: io.kestra.plugin.core.flow.Pause
timeout: PT1S
allowFailure: true
tasks:
- id: ko
type: io.kestra.plugin.core.log.Log
message: "trigger 1 seconds pause"
- id: last
type: io.kestra.plugin.core.debug.Return
format: "{{task.id}} > {{taskrun.startDate}}"

View File

@@ -0,0 +1,17 @@
id: set-variables-duplicate
namespace: io.kestra.tests
variables:
name: World
tasks:
- id: set-vars
type: io.kestra.plugin.core.execution.SetVariables
variables:
message: Hello
name: Loïc
overwrite: false
- id: output
type: io.kestra.plugin.core.output.OutputValues
values:
message: "{{ vars.message }} {{ vars.name }}"

View File

@@ -0,0 +1,16 @@
id: set-variables
namespace: io.kestra.tests
variables:
name: World
tasks:
- id: set-vars
type: io.kestra.plugin.core.execution.SetVariables
variables:
message: Hello
name: Loïc
- id: output
type: io.kestra.plugin.core.output.OutputValues
values:
message: "{{ vars.message }} {{ vars.name }}"

View File

@@ -0,0 +1,8 @@
id: sla-parent-flow
namespace: io.kestra.tests
tasks:
- id: subflow
type: io.kestra.plugin.core.flow.Subflow
namespace: io.kestra.tests
flowId: sla-subflow

View File

@@ -0,0 +1,13 @@
id: sla-subflow
namespace: io.kestra.tests
sla:
- id: maxDuration
type: MAX_DURATION
duration: PT10S
behavior: FAIL
tasks:
- id: sleep
type: io.kestra.plugin.core.flow.Sleep
duration: PT60S

View File

@@ -0,0 +1,16 @@
id: unset-variables
namespace: io.kestra.tests
tasks:
- id: set-vars
type: io.kestra.plugin.core.execution.SetVariables
variables:
message: Hello World
- id: unset-variables
type: io.kestra.plugin.core.execution.UnsetVariables
variables:
- message
- id: output
type: io.kestra.plugin.core.output.OutputValues
values:
message: "{{ vars.message ??? 'default' }}"

View File

@@ -0,0 +1,15 @@
id: working-directory-invalid-runif
namespace: io.kestra.tests
tasks:
- id: workingDirectory
type: io.kestra.plugin.core.flow.WorkingDirectory
tasks:
- id: s1
type: io.kestra.plugin.core.debug.Return
format: 1
- id: s2
type: io.kestra.plugin.core.debug.Return
runIf: "{{ outputs.failed }}"
format: 2

View File

@@ -0,0 +1,200 @@
#!/bin/bash
#===============================================================================
# SCRIPT: update-plugin-kestra-version.sh
#
# DESCRIPTION:
# This script can be used to update the gradle 'kestraVersion' property on each kestra plugin repository.
# By default, if no `GITHUB_PAT` environment variable exist, the script will attempt to clone GitHub repositories using SSH_KEY.
#
#USAGE:
# ./dev-tools/update-plugin-kestra-version.sh --branch <branch> --version <version> [plugin-repositories...]
#
#OPTIONS:
# --branch <branch> Specify the branch on which to update the kestraCoreVersion (default: master).
# --version <version> Specify the Kestra core version (required).
# --plugin-file File containing the plugin list (default: .plugins)
# --dry-run Specify to run in DRY_RUN.
# -y, --yes Automatically confirm prompts (non-interactive).
# -h, --help Show this help message and exit.
# EXAMPLES:
# To release all plugins:
# ./update-plugin-kestra-version.sh --branch=releases/v0.23.x --version="[0.23,0.24)"
# To release a specific plugin:
# ./update-plugin-kestra-version.sh --branch=releases/v0.23.x --version="[0.23,0.24)" plugin-kubernetes
# To release specific plugins from file:
# ./update-plugin-kestra-version.sh --branch=releases/v0.23.x --version="[0.23,0.24)" --plugin-file .plugins
#===============================================================================
set -e;
###############################################################
# Global vars
###############################################################
BASEDIR=$(dirname "$(readlink -f $0)")
SCRIPT_NAME=$(basename "$0")
SCRIPT_NAME="${SCRIPT_NAME%.*}"
WORKING_DIR="/tmp/kestra-$SCRIPT_NAME-$(date +%s)"
PLUGIN_FILE="$BASEDIR/../.plugins"
GIT_BRANCH=master
###############################################################
# Functions
###############################################################
# Function to display the help message
usage() {
echo "Usage: $0 --branch <branch> --version <version> [plugin-repositories...]"
echo
echo "Options:"
echo " --branch <branch> Specify the branch on which to update the kestraCoreVersion (default: master)."
echo " --version <version> Specify the Kestra core version (required)."
echo " --plugin-file File containing the plugin list (default: .plugins)"
echo " --dry-run Specify to run in DRY_RUN."
echo " -y, --yes Automatically confirm prompts (non-interactive)."
echo " -h, --help Show this help message and exit."
exit 1
}
# Function to ask to continue
function askToContinue() {
read -p "Are you sure you want to continue? [y/N] " confirm
[[ "$confirm" =~ ^[Yy]$ ]] || { echo "Operation cancelled."; exit 1; }
}
###############################################################
# Options
###############################################################
PLUGINS_ARGS=()
AUTO_YES=false
DRY_RUN=false
# Get the options
while [[ "$#" -gt 0 ]]; do
case "$1" in
--branch)
GIT_BRANCH="$2"
shift 2
;;
--branch=*)
GIT_BRANCH="${1#*=}"
shift
;;
--version)
VERSION="$2"
shift 2
;;
--version=*)
VERSION="${1#*=}"
shift
;;
--plugin-file)
PLUGIN_FILE="$2"
shift 2
;;
--plugin-file=*)
PLUGIN_FILE="${1#*=}"
shift
;;
--dry-run)
DRY_RUN=true
shift
;;
-y|--yes)
AUTO_YES=true
shift
;;
-h|--help)
usage
;;
*)
PLUGINS_ARGS+=("$1")
shift
;;
esac
done
## Check options
if [[ -z "$VERSION" ]]; then
echo -e "Missing required argument: --version\n";
usage
fi
## Get plugin list
if [[ "${#PLUGINS_ARGS[@]}" -eq 0 ]]; then
if [ -f "$PLUGIN_FILE" ]; then
PLUGINS=$(cat "$PLUGIN_FILE" | grep "io\\.kestra\\." | sed -e '/#/s/^.//' | cut -d':' -f1 | uniq | sort);
PLUGINS_COUNT=$(echo "$PLUGINS" | wc -l);
PLUGINS_ARRAY=$(echo "$PLUGINS" | xargs || echo '');
PLUGINS_ARRAY=($PLUGINS_ARRAY);
fi
else
PLUGINS_ARRAY=("${PLUGINS_ARGS[@]}")
PLUGINS_COUNT="${#PLUGINS_ARGS[@]}"
fi
## Get plugin list
echo "VERSION=$RELEASE_VERSION"
echo "GIT_BRANCH=$GIT_BRANCH"
echo "DRY_RUN=$DRY_RUN"
echo "Found ($PLUGINS_COUNT) plugin repositories:";
for PLUGIN in "${PLUGINS_ARRAY[@]}"; do
echo "$PLUGIN"
done
if [[ "$AUTO_YES" == false ]]; then
askToContinue
fi
###############################################################
# Main
###############################################################
mkdir -p $WORKING_DIR
COUNTER=1;
for PLUGIN in "${PLUGINS_ARRAY[@]}"
do
cd $WORKING_DIR;
echo "---------------------------------------------------------------------------------------"
echo "[$COUNTER/$PLUGINS_COUNT] Update Plugin: $PLUGIN"
echo "---------------------------------------------------------------------------------------"
if [[ -z "${GITHUB_PAT}" ]]; then
git clone git@github.com:kestra-io/$PLUGIN
else
echo "Clone git repository using GITHUB PAT"
git clone https://${GITHUB_PAT}@github.com/kestra-io/$PLUGIN.git
fi
cd "$PLUGIN";
if [[ "$PLUGIN" == "plugin-transform" ]] && [[ "$GIT_BRANCH" == "master" ]]; then # quickfix
git checkout main;
else
git checkout "$GIT_BRANCH";
fi
CURRENT_BRANCH=$(git branch --show-current);
echo "Update kestraVersion for plugin: $PLUGIN on branch $CURRENT_BRANCH:";
# Update the kestraVersion property
sed -i "s/^kestraVersion=.*/kestraVersion=${VERSION}/" ./gradle.properties
# Display diff
git diff --exit-code --unified=0 ./gradle.properties | grep -E '^\+|^-' | grep -v -E '^\+\+\+|^---'
if [[ "$DRY_RUN" == false ]]; then
if [[ "$AUTO_YES" == false ]]; then
askToContinue
fi
git add ./gradle.properties
git commit -m"chore(deps): update kestraVersion to ${VERSION}."
git push
else
echo "Skip git commit/push [DRY_RUN=true]";
fi
COUNTER=$(( COUNTER + 1 ));
done;
exit 0;

View File

@@ -1,6 +1,6 @@
version=0.24.0-SNAPSHOT
version=0.23.2
org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError
org.gradle.parallel=true
org.gradle.caching=true
org.gradle.priority=low
org.gradle.priority=low

View File

@@ -39,4 +39,13 @@ public class H2TenantMigration extends AbstractJdbcTenantMigration {
return context.execute(query);
}
@Override
protected int deleteTutorialFlows(Table<?> table, DSLContext context) {
String query = """
DELETE FROM "%s"
WHERE JQ_STRING("value", '.namespace') = ?
""".formatted(table.getName());
return context.execute(query, "tutorial");
}
}

View File

@@ -7,9 +7,9 @@ import io.kestra.core.exceptions.DeserializationException;
import io.kestra.core.models.triggers.TriggerContext;
import io.kestra.core.queues.QueueFactoryInterface;
import io.kestra.core.queues.QueueInterface;
import io.kestra.core.runners.WorkerJobRunningStateStore;
import io.kestra.core.runners.WorkerTriggerResult;
import io.kestra.core.utils.Either;
import io.kestra.jdbc.repository.AbstractJdbcWorkerJobRunningRepository;
import io.kestra.jdbc.runner.JdbcQueue;
import io.micronaut.context.ApplicationContext;
import io.micronaut.inject.qualifiers.Qualifiers;
@@ -30,7 +30,8 @@ public class JdbcWorkerTriggerResultQueueService implements Closeable {
private final JdbcQueue<WorkerTriggerResult> workerTriggerResultQueue;
@Inject
private AbstractJdbcWorkerJobRunningRepository jdbcWorkerJobRunningRepository;
private WorkerJobRunningStateStore workerJobRunningStateStore;
private final AtomicReference<Runnable> disposable = new AtomicReference<>();
private final AtomicBoolean isClosed = new AtomicBoolean(false);
@@ -52,14 +53,14 @@ public class JdbcWorkerTriggerResultQueueService implements Closeable {
try {
JsonNode json = MAPPER.readTree(either.getRight().getRecord());
var triggerContext = MAPPER.treeToValue(json.get("triggerContext"), TriggerContext.class);
jdbcWorkerJobRunningRepository.deleteByKey(triggerContext.uid());
workerJobRunningStateStore.deleteByKey(triggerContext.uid());
} catch (JsonProcessingException | DeserializationException e) {
// ignore the message if we cannot do anything about it
log.error("Unexpected exception when trying to handle a deserialization error", e);
}
} else {
WorkerTriggerResult workerTriggerResult = either.getLeft();
jdbcWorkerJobRunningRepository.deleteByKey(workerTriggerResult.getTriggerContext().uid());
workerJobRunningStateStore.deleteByKey(workerTriggerResult.getTriggerContext().uid());
}
consumer.accept(either);
});

View File

@@ -45,6 +45,15 @@ public abstract class AbstractJdbcTenantMigration implements TenantMigrationInte
}
if (!dryRun) {
if ("flows".equalsIgnoreCase(table.getName()) || "triggers".equalsIgnoreCase(table.getName())){
log.info("🔸 Delete tutorial flows to prevent duplication");
int deleted = dslContextWrapper.transactionResult(configuration -> {
DSLContext context = DSL.using(configuration);
return deleteTutorialFlows(table, context);
});
log.info("✅ {} tutorial flows have been deleted", deleted);
}
int updated;
if (tableWithKey(table.getName())){
updated = dslContextWrapper.transactionResult(configuration -> {
@@ -93,4 +102,9 @@ public abstract class AbstractJdbcTenantMigration implements TenantMigrationInte
protected abstract int updateTenantIdFieldAndKey(Table<?> table, DSLContext context);
protected int deleteTutorialFlows(Table<?> table, DSLContext context){
String query = "DELETE FROM %s WHERE namespace = ?".formatted(table.getName());
return context.execute(query, "tutorial");
}
}

View File

@@ -3,6 +3,7 @@ package io.kestra.jdbc.repository;
import com.google.common.annotations.VisibleForTesting;
import io.kestra.core.repositories.WorkerJobRunningRepositoryInterface;
import io.kestra.core.runners.WorkerJobRunning;
import io.kestra.core.runners.WorkerJobRunningStateStore;
import lombok.extern.slf4j.Slf4j;
import org.jooq.DSLContext;
import org.jooq.Record1;
@@ -13,7 +14,7 @@ import java.util.List;
import java.util.Optional;
@Slf4j
public abstract class AbstractJdbcWorkerJobRunningRepository extends AbstractJdbcRepository implements WorkerJobRunningRepositoryInterface {
public abstract class AbstractJdbcWorkerJobRunningRepository extends AbstractJdbcRepository implements WorkerJobRunningRepositoryInterface, WorkerJobRunningStateStore {
protected io.kestra.jdbc.AbstractJdbcRepository<WorkerJobRunning> jdbcRepository;
public AbstractJdbcWorkerJobRunningRepository(io.kestra.jdbc.AbstractJdbcRepository<WorkerJobRunning> jdbcRepository) {
@@ -26,9 +27,15 @@ public abstract class AbstractJdbcWorkerJobRunningRepository extends AbstractJdb
}
@Override
public void deleteByKey(String uid) {
Optional<WorkerJobRunning> workerJobRunning = this.findByKey(uid);
workerJobRunning.ifPresent(jobRunning -> this.jdbcRepository.delete(jobRunning));
public void deleteByKey(String key) {
this.jdbcRepository.getDslContextWrapper()
.transaction(configuration ->
DSL
.using(configuration)
.deleteFrom(this.jdbcRepository.getTable())
.where(field("key").eq(key))
.execute()
);
}
@Override

View File

@@ -718,22 +718,6 @@ public class JdbcExecutor implements ExecutorInterface, Service {
try {
// process worker task result
executorService.addWorkerTaskResult(current, () -> findFlow(execution), message);
// send metrics on terminated
TaskRun taskRun = message.getTaskRun();
if (taskRun.getState().isTerminated()) {
metricRegistry
.counter(MetricRegistry.METRIC_EXECUTOR_TASKRUN_ENDED_COUNT, MetricRegistry.METRIC_EXECUTOR_TASKRUN_ENDED_COUNT_DESCRIPTION, metricRegistry.tags(message))
.increment();
metricRegistry
.timer(MetricRegistry.METRIC_EXECUTOR_TASKRUN_ENDED_DURATION, MetricRegistry.METRIC_EXECUTOR_TASKRUN_ENDED_DURATION_DESCRIPTION, metricRegistry.tags(message))
.record(taskRun.getState().getDuration());
log.trace("TaskRun terminated: {}", taskRun);
workerJobRunningRepository.deleteByKey(taskRun.getId());
}
// join worker result
return Pair.of(
current,
@@ -1166,7 +1150,7 @@ public class JdbcExecutor implements ExecutorInterface, Service {
try {
// Handle paused tasks
if (executionDelay.getDelayType().equals(ExecutionDelay.DelayType.RESUME_FLOW)) {
if (executionDelay.getDelayType().equals(ExecutionDelay.DelayType.RESUME_FLOW) && !pair.getLeft().getState().isTerminated()) {
FlowInterface flow = flowMetaStore.findByExecution(pair.getLeft()).orElseThrow();
if (executionDelay.getTaskRunId() == null) {
// if taskRunId is null, this means we restart a flow that was delayed at startup (scheduled on)
@@ -1224,8 +1208,8 @@ public class JdbcExecutor implements ExecutorInterface, Service {
slaMonitorStorage.processExpired(Instant.now(), slaMonitor -> {
Executor result = executionRepository.lock(slaMonitor.getExecutionId(), pair -> {
Executor executor = new Executor(pair.getLeft(), null);
FlowInterface flow = flowMetaStore.findByExecution(pair.getLeft()).orElseThrow();
FlowWithSource flow = findFlow(pair.getLeft());
Executor executor = new Executor(pair.getLeft(), null).withFlow(flow);
Optional<SLA> sla = flow.getSla().stream().filter(s -> s.getId().equals(slaMonitor.getSlaId())).findFirst();
if (sla.isEmpty()) {
// this can happen in case the flow has been updated and the SLA removed

View File

@@ -398,7 +398,7 @@ public class Docker extends TaskRunner<Docker.DockerTaskRunnerDetailResult> {
String remotePath = windowsToUnixPath(taskCommands.getWorkingDirectory().toString());
// first, create an archive
Path fileArchive = runContext.workingDir().createFile("inputFiles.tart");
Path fileArchive = runContext.workingDir().createFile("inputFiles.tar");
try (FileOutputStream fos = new FileOutputStream(fileArchive.toString());
TarArchiveOutputStream out = new TarArchiveOutputStream(fos)) {
out.setLongFileMode(TarArchiveOutputStream.LONGFILE_POSIX); // allow long file name
@@ -827,8 +827,23 @@ public class Docker extends TaskRunner<Docker.DockerTaskRunnerDetailResult> {
.longValue();
}
private String getImageNameWithoutTag(String fullImageName) {
if (fullImageName == null || fullImageName.isEmpty()) {
return fullImageName;
}
int lastColonIndex = fullImageName.lastIndexOf(':');
int firstSlashIndex = fullImageName.indexOf('/');
if (lastColonIndex > -1 && (firstSlashIndex == -1 || lastColonIndex > firstSlashIndex)) {
return fullImageName.substring(0, lastColonIndex);
} else {
return fullImageName; // No tag found or the colon is part of the registry host
}
}
private void pullImage(DockerClient dockerClient, String image, PullPolicy policy, Logger logger) {
NameParser.ReposTag imageParse = NameParser.parseRepositoryTag(image);
var imageNameWithoutTag = getImageNameWithoutTag(image);
var parsedTagFromImage = NameParser.parseRepositoryTag(image);
if (policy.equals(PullPolicy.IF_NOT_PRESENT)) {
try {
@@ -839,7 +854,9 @@ public class Docker extends TaskRunner<Docker.DockerTaskRunnerDetailResult> {
}
}
try (PullImageCmd pull = dockerClient.pullImageCmd(image)) {
// pullImageCmd without the tag (= repository) to avoid being redundant with withTag below
// and prevent errors with Podman trying to pull "image:tag:tag"
try (var pull = dockerClient.pullImageCmd(imageNameWithoutTag)) {
new RetryUtils().<Boolean, InternalServerErrorException>of(
Exponential.builder()
.delayFactor(2.0)
@@ -851,8 +868,8 @@ public class Docker extends TaskRunner<Docker.DockerTaskRunnerDetailResult> {
(bool, throwable) -> throwable instanceof InternalServerErrorException ||
throwable.getCause() instanceof ConnectionClosedException,
() -> {
String tag = !imageParse.tag.isEmpty() ? imageParse.tag : "latest";
String repository = pull.getRepository().contains(":") ? pull.getRepository().split(":")[0] : pull.getRepository();
var tag = !parsedTagFromImage.tag.isEmpty() ? parsedTagFromImage.tag : "latest";
var repository = pull.getRepository().contains(":") ? pull.getRepository().split(":")[0] : pull.getRepository();
pull
.withTag(tag)
.exec(new PullImageResultCallback())

View File

@@ -1,12 +1,40 @@
package io.kestra.plugin.scripts.runner.docker;
import io.kestra.core.models.property.Property;
import io.kestra.core.models.tasks.runners.AbstractTaskRunnerTest;
import io.kestra.core.models.tasks.runners.TaskRunner;
import io.kestra.plugin.scripts.exec.scripts.runners.CommandsWrapper;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import java.util.Collections;
import java.util.List;
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
class DockerTest extends AbstractTaskRunnerTest {
@Override
protected TaskRunner<?> taskRunner() {
return Docker.builder().image("rockylinux:9.3-minimal").build();
}
@Test
void shouldNotHaveTagInDockerPullButJustInWithTag() throws Exception {
var runContext = runContext(this.runContextFactory);
var docker = Docker.builder()
.image("ghcr.io/kestra-io/kestrapy:latest")
.pullPolicy(Property.ofValue(PullPolicy.ALWAYS))
.build();
var taskCommands = new CommandsWrapper(runContext).withCommands(Property.ofValue(List.of(
"/bin/sh", "-c",
"echo Hello World!"
)));
var result = docker.run(runContext, taskCommands, Collections.emptyList());
assertThat(result).isNotNull();
assertThat(result.getExitCode()).isZero();
Assertions.assertThat(result.getLogConsumer().getStdOutCount()).isEqualTo(1);
}
}

View File

@@ -32,7 +32,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
@KestraTest
public abstract class AbstractTaskRunnerTest {
@Inject private TestRunContextFactory runContextFactory;
@Inject protected TestRunContextFactory runContextFactory;
@Inject private StorageInterface storage;
@Test

View File

@@ -1,25 +1,33 @@
import type {StorybookConfig} from "@storybook/vue3-vite";
import path from "path";
const config: StorybookConfig = {
stories: [
"../tests/**/*.stories.@(js|jsx|mjs|ts|tsx)"
],
addons: [
"@storybook/addon-essentials",
"@storybook/addon-themes",
"@storybook/experimental-addon-test"
],
framework: {
name: "@storybook/vue3-vite",
options: {},
},
async viteFinal(config) {
const {default: viteJSXPlugin} = await import("@vitejs/plugin-vue-jsx")
config.plugins = [
...(config.plugins ?? []),
viteJSXPlugin(),
];
return config;
},
stories: [
"../tests/**/*.stories.@(js|jsx|mjs|ts|tsx)"
],
addons: [
"@storybook/addon-themes",
"@storybook/addon-vitest",
],
framework: {
name: "@storybook/vue3-vite",
options: {},
},
async viteFinal(config) {
const {default: viteJSXPlugin} = await import("@vitejs/plugin-vue-jsx")
config.plugins = [
...(config.plugins ?? []),
viteJSXPlugin(),
];
if (config.resolve) {
config.resolve.alias = {
"override/services/filterLanguagesProvider": path.resolve(__dirname, "../tests/storybook/mocks/services/filterLanguagesProvider.mock.ts"),
...config.resolve?.alias
};
}
return config;
},
};
export default config;

View File

@@ -1,4 +1,4 @@
import {setup} from "@storybook/vue3";
import {setup} from "@storybook/vue3-vite";
import {withThemeByClassName} from "@storybook/addon-themes";
import initApp from "../src/utils/init";
import stores from "../src/stores/store";
@@ -11,7 +11,7 @@ window.KESTRA_BASE_PATH = "/ui";
window.KESTRA_UI_PATH = "./";
/**
* @type {import('@storybook/vue3').Preview}
* @type {import('@storybook/vue3-vite').Preview}
*/
const preview = {
parameters: {

View File

@@ -0,0 +1,42 @@
import path from "node:path";
import {defineProject, mergeConfig} from "vitest/config";
import {storybookTest} from "@storybook/addon-vitest/vitest-plugin";
import initialConfig from "../vite.config.js"
// More info at: https://storybook.js.org/docs/writing-tests/test-addon
export default mergeConfig(
// We need to define a side first project to set up the alias for the filterLanguagesProvider mock because otherwise the `override` alias will take precedence over this one (first match rule)
defineProject({
resolve: {
alias: {
"override/services/filterLanguagesProvider": path.resolve(__dirname, "../tests/storybook/mocks/services/filterLanguagesProvider.mock.ts")
}
}
}),
mergeConfig(
initialConfig,
defineProject({
plugins: [
// The plugin will run tests for the stories defined in your Storybook config
// See options at: https://storybook.js.org/docs/writing-tests/test-addon#storybooktest
storybookTest({configDir: path.join(__dirname)}),
],
test: {
name: "storybook",
browser: {
enabled: true,
headless: true,
provider: "playwright",
instances: [{browser: "chromium"}],
},
setupFiles: ["vitest.setup.ts"],
},
define: {
"process.env.RUN_TEST_WITH_PERSISTENT": JSON.stringify("false"), // Disable persistent mode for tests
}
}),
),
);

View File

@@ -1,5 +1,5 @@
import {beforeAll} from "vitest";
import {setProjectAnnotations} from "@storybook/vue3";
import {setProjectAnnotations} from "@storybook/vue3-vite";
import * as projectAnnotations from "./preview";
// This is an important step to apply the right configuration when testing your stories.

View File

@@ -20,7 +20,7 @@ export default [
"**/*.spec.ts",
"vite.config.js",
"vitest.config.js",
"vitest.workspace.js",
".storybook/vitest.config.js",
],
languageOptions: {globals: globals.node},
},

3124
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -22,22 +22,22 @@
},
"dependencies": {
"@js-joda/core": "^5.6.5",
"@kestra-io/ui-libs": "^0.0.203",
"@kestra-io/ui-libs": "^0.0.205",
"@vue-flow/background": "^1.3.2",
"@vue-flow/controls": "^1.1.2",
"@vue-flow/core": "^1.44.0",
"@vueuse/core": "^13.2.0",
"@vue-flow/core": "^1.45.0",
"@vueuse/core": "^13.3.0",
"ansi-to-html": "^0.7.2",
"axios": "^1.9.0",
"bootstrap": "^5.3.6",
"buffer": "^6.0.3",
"chart.js": "^4.4.9",
"core-js": "^3.42.0",
"core-js": "^3.43.0",
"cronstrue": "^2.61.0",
"dagre": "^0.8.5",
"el-table-infinite-scroll": "^3.0.6",
"element-plus": "^2.9.10",
"humanize-duration": "^3.32.2",
"element-plus": "^2.10.2",
"humanize-duration": "^3.33.0",
"js-yaml": "^4.1.0",
"lodash": "^4.17.21",
"markdown-it": "^14.1.0",
@@ -50,21 +50,21 @@
"md5": "^2.3.0",
"moment": "^2.30.1",
"moment-range": "^4.0.2",
"moment-timezone": "^0.5.48",
"moment-timezone": "^0.5.46",
"nprogress": "^0.2.0",
"path-browserify": "^1.0.1",
"pdfjs-dist": "^5.2.133",
"posthog-js": "^1.245.1",
"pdfjs-dist": "^5.3.31",
"posthog-js": "^1.250.1",
"rapidoc": "^9.3.8",
"semver": "^7.7.2",
"shiki": "^3.4.2",
"shiki": "^3.6.0",
"splitpanes": "^3.2.0",
"throttle-debounce": "^5.0.2",
"vue": "^3.5.13",
"vue": "^3.5.16",
"vue-axios": "^3.5.2",
"vue-chartjs": "^5.3.2",
"vue-gtag": "^2.1.0",
"vue-i18n": "^11.1.3",
"vue-i18n": "^11.1.5",
"vue-material-design-icons": "^5.3.1",
"vue-router": "^4.5.1",
"vue-sidebar-menu": "^5.7.0",
@@ -80,60 +80,59 @@
"@esbuild-plugins/node-modules-polyfill": "^0.2.2",
"@eslint/js": "^9.27.0",
"@rushstack/eslint-patch": "^1.11.0",
"@shikijs/markdown-it": "^3.4.2",
"@storybook/addon-essentials": "^8.6.14",
"@storybook/addon-themes": "^8.6.14",
"@storybook/blocks": "^8.6.14",
"@storybook/experimental-addon-test": "^8.6.14",
"@storybook/test": "^8.6.14",
"@storybook/test-runner": "^0.22.0",
"@storybook/vue3": "^8.6.14",
"@storybook/vue3-vite": "^8.6.14",
"@shikijs/markdown-it": "^3.6.0",
"@storybook/addon-themes": "^9.0.8",
"@storybook/addon-vitest": "^9.0.8",
"@storybook/test-runner": "^0.22.1",
"@storybook/vue3-vite": "^9.0.8",
"@types/humanize-duration": "^3.27.4",
"@types/js-yaml": "^4.0.9",
"@types/moment": "^2.11.29",
"@types/path-browserify": "^1.0.3",
"@typescript-eslint/parser": "^8.32.1",
"@types/testing-library__jest-dom": "^5.14.9",
"@types/testing-library__user-event": "^4.1.1",
"@typescript-eslint/parser": "^8.34.0",
"@vitejs/plugin-vue": "^5.2.4",
"@vitejs/plugin-vue-jsx": "^4.2.0",
"@vitest/browser": "^3.1.4",
"@vitest/coverage-v8": "^3.1.4",
"@vitest/browser": "^3.2.3",
"@vitest/coverage-v8": "^3.2.3",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/test-utils": "^2.4.6",
"@vueuse/router": "^13.2.0",
"change-case": "4.1.2",
"@vueuse/router": "^13.3.0",
"change-case": "5.4.4",
"cross-env": "^7.0.3",
"decompress": "^4.2.1",
"eslint": "^9.27.0",
"eslint-plugin-storybook": "^0.12.0",
"eslint": "^9.28.0",
"eslint-plugin-storybook": "^9.0.8",
"eslint-plugin-vue": "^9.33.0",
"globals": "^16.1.0",
"globals": "^16.2.0",
"husky": "^9.1.7",
"jsdom": "^26.1.0",
"lint-staged": "^15.5.2",
"lint-staged": "^16.1.0",
"monaco-editor": "^0.52.2",
"monaco-yaml": "5.3.1",
"patch-package": "^8.0.0",
"playwright": "^1.52.0",
"playwright": "^1.53.0",
"prettier": "^3.5.3",
"rollup-plugin-copy": "^3.5.0",
"sass": "^1.89.0",
"storybook": "^8.6.14",
"sass": "^1.89.2",
"storybook": "^9.0.8",
"storybook-vue3-router": "^5.0.0",
"typescript": "^5.8.3",
"typescript-eslint": "^8.32.1",
"typescript-eslint": "^8.34.0",
"vite": "^6.3.5",
"vitest": "^3.1.4"
"vitest": "^3.2.3"
},
"optionalDependencies": {
"@esbuild/darwin-arm64": "^0.25.4",
"@esbuild/darwin-x64": "^0.25.4",
"@esbuild/linux-x64": "^0.25.4",
"@rollup/rollup-darwin-arm64": "^4.41.0",
"@rollup/rollup-darwin-x64": "^4.41.0",
"@rollup/rollup-linux-x64-gnu": "^4.41.0",
"@swc/core-darwin-arm64": "^1.11.24",
"@swc/core-darwin-x64": "^1.11.24",
"@swc/core-linux-x64-gnu": "^1.11.24"
"@esbuild/darwin-arm64": "^0.25.5",
"@esbuild/darwin-x64": "^0.25.5",
"@esbuild/linux-x64": "^0.25.5",
"@rollup/rollup-darwin-arm64": "^4.43.0",
"@rollup/rollup-darwin-x64": "^4.43.0",
"@rollup/rollup-linux-x64-gnu": "^4.43.0",
"@swc/core-darwin-arm64": "^1.12.0",
"@swc/core-darwin-x64": "^1.12.0",
"@swc/core-linux-x64-gnu": "^1.12.0"
},
"overrides": {
"bootstrap": {
@@ -141,7 +140,8 @@
},
"el-table-infinite-scroll": {
"vue": "$vue"
}
},
"storybook": "$storybook"
},
"lint-staged": {
"**/*.{js,mjs,cjs,ts,vue}": "eslint --fix"

View File

@@ -15,19 +15,15 @@
<slot v-else />
</span>
</template>
<script>
export default {
props:{
tooltip: {
type: String,
default: ""
},
placement:{
type: String,
default: "top"
},
},
}
<script lang="ts" setup>
withDefaults(
defineProps<{
tooltip?: string;
placement?: string;
}>(),{
tooltip: "",
placement: "",
});
</script>
<style lang="scss" scoped>

View File

@@ -1,5 +1,5 @@
<template>
<Splitpanes class="default-theme" @resize="onResize">
<Splitpanes class="default-theme" v-bind="$attrs" @resize="onResize">
<Pane
v-for="(panel, panelIndex) in panels"
min-size="10"
@@ -47,6 +47,7 @@
@dragleave.prevent
:data-tab-id="tab.value"
@click="panel.activeTab = tab"
@mouseup="middleMouseClose($event, panelIndex, tab)"
>
<component :is="tab.button.icon" class="tab-icon" />
{{ tab.button.label }}
@@ -131,10 +132,31 @@
</div>
</Pane>
</Splitpanes>
<div
v-if="showDropZones"
class="absolute-drop-zones-container"
>
<div
class="new-panel-drop-zone left-drop-zone"
:class="{'panel-dragover': leftPanelDragover}"
@dragover.prevent="leftPanelDragOver"
@dragleave.prevent="leftPanelDragLeave"
@drop.prevent="(e) => newPanelDrop(e, 'left')"
/>
<div
class="new-panel-drop-zone right-drop-zone"
:class="{'panel-dragover': rightPanelDragover}"
@dragover.prevent="rightPanelDragOver"
@dragleave.prevent="rightPanelDragLeave"
@drop.prevent="(e) => newPanelDrop(e, 'right')"
/>
</div>
</template>
<script lang="ts" setup>
import {nextTick, ref, watch, provide} from "vue";
import {nextTick, ref, watch, provide, computed} from "vue";
import {useI18n} from "vue-i18n";
import "splitpanes/dist/splitpanes.css"
@@ -206,6 +228,15 @@
const dragging = ref(false);
const tabContainerRefs = ref<HTMLDivElement[]>([]);
const draggingPanel = ref<number | null>(null);
const realDragging = ref(false);
const leftPanelDragover = ref(false);
const rightPanelDragover = ref(false);
const showDropZones = computed(() =>
realDragging.value &&
movedTabInfo.value &&
!draggingPanel.value
);
function onResize(e: {size:number}[]) {
let i = 0;
@@ -222,7 +253,10 @@
function cleanUp(){
dragging.value = false;
realDragging.value = false;
mouseXRef.value = -1;
leftPanelDragover.value = false;
rightPanelDragover.value = false;
nextTick(() => {
movedTabInfo.value = null
for(const panel of panels.value) {
@@ -244,6 +278,12 @@
}
function dragover(e: DragEvent) {
// Ensure we set the realDragging flag when a drag operation is in progress
if (movedTabInfo.value) {
realDragging.value = true;
dragging.value = true;
}
// if mouse has not moved vertically, stop the processing
// this will be triggered every few ms so perf and readability will be paramount
if(mouseXRef.value === e.clientX){
@@ -381,6 +421,49 @@
}
}
function newPanelDrop(e: DragEvent, direction: "left" | "right") {
if (!movedTabInfo.value) return;
const {tab: movedTab} = movedTabInfo.value;
// Create a new panel with the dragged tab
const newPanel = {
tabs: [movedTab],
activeTab: movedTab
};
// Add the new panel based on the drop direction, not relative to original panel
if (direction === "left") {
panels.value.splice(0, 0, newPanel);
} else {
panels.value.push(newPanel);
}
// Remove the tab from the original panel
// After adding the new panel, the original panel's index may have changed
// Find it again by looking for the tab in all panels
for (let i = 0; i < panels.value.length; i++) {
const panel = panels.value[i];
const tabIndex = panel.tabs.findIndex(t => t.value === movedTab.value);
if (i === 0 && direction === "left") continue;
if (i === panels.value.length - 1 && direction === "right") continue;
if (tabIndex !== -1) {
panel.tabs.splice(tabIndex, 1);
if (panel.activeTab.value === movedTab.value && panel.tabs.length > 0) {
panel.activeTab = tabIndex > 0
? panel.tabs[tabIndex - 1]
: panel.tabs[0];
}
break;
}
}
cleanUp();
}
function closeAllTabs(panelIndex: number){
panels.value[panelIndex].tabs = [];
}
@@ -463,6 +546,36 @@
panelsCopy.splice(newIndex, 0, movedPanel);
panels.value = panelsCopy;
}
function rightPanelDragOver() {
if (!movedTabInfo.value) return;
rightPanelDragover.value = true;
leftPanelDragover.value = false;
removeAllPotentialTabs();
}
function rightPanelDragLeave() {
rightPanelDragover.value = false;
}
function leftPanelDragOver() {
if (!movedTabInfo.value) return;
leftPanelDragover.value = true;
rightPanelDragover.value = false;
removeAllPotentialTabs();
}
function leftPanelDragLeave() {
leftPanelDragover.value = false;
}
function middleMouseClose(event:MouseEvent, panelIndex:number, tab: Tab) {
// Middle mouse button
if (event.button === 1) {
event.preventDefault();
destroyTab(panelIndex, tab);
}
}
</script>
<style lang="scss" scoped>
@@ -620,4 +733,46 @@
transition: background-color 0.2s ease;
}
.absolute-drop-zones-container {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
z-index: 100;
display: flex;
justify-content: space-between;
}
.new-panel-drop-zone {
position: relative;
width: 60px;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(30, 30, 30, 0.5);
transition: all 0.2s ease;
border: 2px dashed var(--ks-border-primary, #444);
border-radius: 4px;
margin: 8px;
pointer-events: auto;
height: calc(100% - 16px);
}
.new-panel-drop-zone:hover,
.new-panel-drop-zone.panel-dragover {
background-color: rgba(40, 40, 40, 0.8);
border-color: var(--ks-border-active, #888);
}
.left-drop-zone {
border-right-width: 2px;
}
.right-drop-zone {
border-left-width: 2px;
}
</style>

View File

@@ -21,8 +21,7 @@
</template>
</el-tab-pane>
</el-tabs>
<section v-if="isEditorActiveTab || activeTab.component" data-component="FILENAME_PLACEHOLDER#container" ref="container" v-bind="$attrs" :class="{...containerClass, 'd-flex flex-row': isEditorActiveTab, 'maximized': activeTab.maximized}">
<section v-if="isEditorActiveTab || activeTab.component" data-component="FILENAME_PLACEHOLDER#container" ref="container" v-bind="$attrs" :class="{...containerClass, 'maximized': activeTab.maximized}">
<EditorSidebar v-if="isEditorActiveTab" ref="sidebar" :style="`flex: 0 0 calc(${explorerWidth}% - 11px);`" :current-n-s="namespace" v-show="explorerVisible" />
<div v-if="isEditorActiveTab && explorerVisible" @mousedown.prevent.stop="dragSidebar" class="slider" />
<div v-if="isEditorActiveTab" :style="`flex: 1 1 ${100 - (isEditorActiveTab && explorerVisible ? explorerWidth : 0)}%;`">
@@ -246,7 +245,6 @@
padding: 0;
display: flex;
flex-grow: 1;
flex-direction: column;
}
:deep(.el-tabs__nav-next),

View File

@@ -1,5 +1,5 @@
<template>
<div class="h-100 overflow-y-auto no-code">
<div class="no-code">
<Breadcrumbs />
<hr class="m-0">
@@ -19,11 +19,11 @@
import {
BREADCRUMB_INJECTION_KEY, CLOSE_TASK_FUNCTION_INJECTION_KEY,
CREATE_TASK_FUNCTION_INJECTION_KEY, CREATING_TASK_INJECTION_KEY,
EDIT_TASK_FUNCTION_INJECTION_KEY, BLOCKTYPE_INJECT_KEY,
CREATING_TASK_INJECTION_KEY, BLOCKTYPE_INJECT_KEY,
PANEL_INJECTION_KEY, POSITION_INJECTION_KEY,
REF_PATH_INJECTION_KEY, PARENT_PATH_INJECTION_KEY,
FLOW_INJECTION_KEY,
EDITING_TASK_INJECTION_KEY,
} from "./injectionKeys";
import Breadcrumbs from "./components/Breadcrumbs.vue";
import Editor from "./segments/Editor.vue";
@@ -34,7 +34,7 @@
(e: "updateMetadata", value: {[key: string]: any}): void
(e: "reorder", yaml: string): void
(e: "createTask", blockType: string, parentPath: string, refPath: number | undefined, position?: "before" | "after"): boolean | void
(e: "editTask", blockType: string, parentPath: string, refPath: number): boolean | void
(e: "editTask", blockType: string, parentPath: string, refPath?: number): boolean | void
(e: "closeTask"): boolean | void
}>()
@@ -55,9 +55,11 @@
*/
refPath?: number;
creatingTask?: boolean;
editingTask?: boolean;
position?: "before" | "after";
}>(), {
creatingTask: false,
editingTask: false,
position: "after",
refPath: undefined,
blockType: undefined,
@@ -66,7 +68,6 @@
const metadata = computed(() => YAML_UTILS.getMetadata(props.flow));
const creatingTaskRef = ref(props.creatingTask)
const breadcrumbs = ref<Breadcrumb[]>([])
const panel = ref()
@@ -77,13 +78,9 @@
provide(BREADCRUMB_INJECTION_KEY, breadcrumbs);
provide(BLOCKTYPE_INJECT_KEY, props.blockType);
provide(POSITION_INJECTION_KEY, props.position);
provide(CREATING_TASK_INJECTION_KEY, computed(() => creatingTaskRef.value));
provide(CREATE_TASK_FUNCTION_INJECTION_KEY, (blockType, parentPath, refPath) => {
emit("createTask", blockType, parentPath, refPath)
});
provide(EDIT_TASK_FUNCTION_INJECTION_KEY, (blockType, parentPath, refPath) => {
emit("editTask", blockType, parentPath, refPath)
});
provide(CREATING_TASK_INJECTION_KEY, props.creatingTask);
provide(EDITING_TASK_INJECTION_KEY, props.editingTask);
provide(CLOSE_TASK_FUNCTION_INJECTION_KEY, () => {
if (breadcrumbs.value[breadcrumbs.value.length - 1].component) {
breadcrumbs.value.pop();
@@ -95,4 +92,13 @@
})
</script>
<style scoped lang="scss" src="./styles/code.scss" />
<style lang="scss" scoped>
.no-code {
height: 100%;
overflow-y: auto;
hr {
margin: 0;
}
}
</style>

View File

@@ -1,32 +1,31 @@
<template>
<div>
<NoCode
:flow="lastValidFlowYaml"
:parent-path="parentPath"
:ref-path="refPath"
:block-type="blockType"
:creating-task="creatingTask"
:position
@update-metadata="(e) => onUpdateMetadata(e)"
@update-task="(e) => editorUpdate(e)"
@reorder="(yaml) => handleReorder(yaml)"
@create-task="(blockType, parentPath, refPath) => emit('createTask', blockType, parentPath, refPath, 'after')"
@close-task="() => emit('closeTask')"
@edit-task="(blockType, parentPath, refPath) => emit('editTask', blockType, parentPath, refPath)"
/>
</div>
<NoCode
:flow="lastValidFlowYaml"
:parent-path="parentPath"
:ref-path="refPath"
:block-type="blockType"
:creating-task="creatingTask"
:editing-task="editingTask"
:position
@update-metadata="(e) => onUpdateMetadata(e)"
@update-task="(e) => editorUpdate(e)"
@reorder="(yaml) => handleReorder(yaml)"
@close-task="() => emit('closeTask')"
/>
</template>
<script setup lang="ts">
import {computed, ref} from "vue";
import {computed, provide, ref} from "vue";
import debounce from "lodash/debounce";
import {useStore} from "vuex";
import * as YAML_UTILS from "@kestra-io/ui-libs/flow-yaml-utils";
import NoCode from "./NoCode.vue";
import {BlockType} from "./utils/types";
import {CREATE_TASK_FUNCTION_INJECTION_KEY, EDIT_TASK_FUNCTION_INJECTION_KEY} from "./injectionKeys";
export interface NoCodeProps {
creatingTask?: boolean;
editingTask?: boolean;
blockType?: BlockType | "pluginDefaults";
parentPath?: string;
refPath?: number;
@@ -37,10 +36,17 @@
const emit = defineEmits<{
(e: "createTask", blockType: string, parentPath: string, refPath: number | undefined, position: "after" | "before"): boolean | void;
(e: "editTask", blockType: string, parentPath: string, refPath: number): boolean | void;
(e: "editTask", blockType: string, parentPath: string, refPath?: number): boolean | void;
(e: "closeTask"): boolean | void;
}>();
provide(CREATE_TASK_FUNCTION_INJECTION_KEY, (blockType, parentPath, refPath) => {
emit("createTask", blockType, parentPath, refPath, "after")
});
provide(EDIT_TASK_FUNCTION_INJECTION_KEY, (blockType, parentPath, refPath) => {
emit("editTask", blockType, parentPath, refPath)
});
const store = useStore();
const flowYaml = computed<string>(() => store.getters["flow/flowYaml"]);

View File

@@ -1,24 +1,34 @@
<template>
<div @click="emits('add', props.what)" class="py-2 adding">
<button @click="emit('add', what)" class="py-2 adding" type="button">
{{
props.what
? t("no_code.adding", {what: props.what})
what
? t("no_code.adding", {what})
: t("no_code.adding_default")
}}
</div>
</button>
</template>
<script setup lang="ts">
import {useI18n} from "vue-i18n";
const {t} = useI18n({useScope: "global"});
const emits = defineEmits(["add"]);
const props = defineProps({what: {type: String, default: undefined}});
const emit = defineEmits<{
(e: "add", what: string | undefined): void;
}>();
defineProps<{
what?: string;
}>();
</script>
<style scoped lang="scss">
@import "../styles/code.scss";
button {
background: transparent;
border: none;
}
.adding {
cursor: pointer;
color: var(--ks-content-secondary);

View File

@@ -28,7 +28,7 @@
const breadcrumbs = inject(BREADCRUMB_INJECTION_KEY, ref([]));
const flowYaml = inject(FLOW_INJECTION_KEY, ref(""));
const refPath = inject(REF_PATH_INJECTION_KEY, undefined);
const taskCreation = inject(CREATING_TASK_INJECTION_KEY, ref(false));
const taskCreation = inject(CREATING_TASK_INJECTION_KEY, false);
const blockType = inject(BLOCKTYPE_INJECT_KEY, undefined);
const parentPath = inject(PARENT_PATH_INJECTION_KEY, "");
@@ -49,11 +49,11 @@
label: parentPath,
}
}
if(taskCreation.value || (refPath?.length && refPath.length > 0)){
if(taskCreation || (refPath !== undefined && refPath >= 0)) {
breadcrumbs.value[index] = {
label: taskCreation.value
label: taskCreation
? t(`no_code.creation.${blockType}`)
: refPath ?? ""
: refPath?.toString() ?? ""
}
}
});

View File

@@ -61,13 +61,13 @@
const parentPath = inject(PARENT_PATH_INJECTION_KEY, "");
const refPath = inject(REF_PATH_INJECTION_KEY, undefined);
const creatingTask = inject(CREATING_TASK_INJECTION_KEY, ref(false));
const creatingTask = inject(CREATING_TASK_INJECTION_KEY, false);
const parentPathComplete = computed(() => {
return `${[
[
parentPath,
creatingTask.value && refPath !== undefined
creatingTask && refPath !== undefined
? `[${refPath + 1}]`
: refPath !== undefined
? `[${refPath}]`

View File

@@ -14,7 +14,7 @@
size="small"
class="border-0"
/>
<div v-if="blockType !== 'pluginDefaults'" class="d-flex flex-column">
<div v-if="blockType !== 'pluginDefaults' && elementIndex !== undefined" class="d-flex flex-column">
<ChevronUp @click.prevent.stop="emits('moveElement', 'up')" />
<ChevronDown @click.prevent.stop="emits('moveElement', 'down')" />
</div>
@@ -43,7 +43,7 @@
id: string;
type: string;
};
elementIndex: number;
elementIndex?: number;
}>();
import {useStore} from "vuex";

View File

@@ -1,27 +1,36 @@
<template>
<span v-if="required" class="me-1 text-danger">*</span>
<span v-if="label" class="label">{{ label }}</span>
<el-alert
v-if="alertState.visible"
:title="alertState.message"
type="error"
show-icon
:closable="false"
class="mb-2"
/>
<div class="mt-1 mb-2 w-100 wrapper">
<el-row
v-for="(value, key, index) in props.modelValue"
v-for="(pair, index) in internalPairs"
:key="index"
:gutter="10"
>
<el-col :span="8">
<InputText
:model-value="key"
:model-value="pair[0]"
:placeholder="t('key')"
@update:model-value="(changed) => updateKey(key, changed)"
@update:model-value="(changed) => handleKeyInput(index, changed)"
:have-error="duplicatedKeys.includes(pair[0])"
/>
</el-col>
<el-col :span="16" class="d-flex">
<InputText
:model-value="value"
:model-value="pair[1]"
:placeholder="t('value')"
@update:model-value="(changed) => updateValue(key, changed)"
@update:model-value="(changed) => updateValue(index, changed)"
class="w-100 me-2"
/>
<DeleteOutline @click="removePair(key)" class="delete" />
<DeleteOutline @click="removePair(index)" class="delete" />
</el-col>
</el-row>
@@ -30,8 +39,7 @@
</template>
<script setup lang="ts">
import {PropType} from "vue";
import {watch, computed, ref} from "vue";
import {PairField} from "../../utils/types";
import {DeleteOutline} from "../../utils/icons";
@@ -47,56 +55,78 @@
inheritAttrs: false,
});
const emits = defineEmits(["update:modelValue"]);
const props = defineProps({
modelValue: {
type: Object as PropType<PairField["value"]>,
default: undefined,
},
label: {type: String, default: undefined},
property: {type: String, default: undefined},
required: {type: Boolean, default: false},
const emit = defineEmits(["update:modelValue"]);
const props = defineProps<{
modelValue?: PairField["value"],
label?: string,
property?: string,
required?: boolean
}>();
const internalPairs = ref<[string, string | undefined][]>([])
// this flag will avoid updating the modelValue when the
// change was initiated in the component itself
const localEdit = ref(false);
const duplicatedKeys = computed(() => {
return internalPairs.value.map(pair => pair[0])
.filter((key, index, self) =>
self.indexOf(key) !== index
);
});
const addPair = () => {
emits("update:modelValue", {...props.modelValue, "": ""});
};
const removePair = (key: any) => {
const values = {...props.modelValue};
delete values[key];
emits("update:modelValue", values);
};
const updateKey = (old, changed) => {
const values = {...props.modelValue};
// Create an array of key-value pairs and preserve order
const entries = Object.entries(values);
// Find the index of the old key
const index = entries.findIndex(([key]) => key === old);
if (index !== -1) {
// Get the value of the old key
const [, value] = entries[index];
// Remove the old key from the entries
entries.splice(index, 1);
// Add the new key with the same value
entries.splice(index, 0, [changed, value]);
// Rebuild the object while keeping the order
const updatedValues = Object.fromEntries(entries);
// Emit the updated object
emits("update:modelValue", updatedValues);
const alertState = computed(() => {
if(duplicatedKeys.value.length > 0){
return {
visible: true,
message: t("duplicate-pair", {label: props.label ?? t("key"), key: duplicatedKeys.value[0]}),
}
}
return {
visible: false,
message: "",
};
});
watch(() => props.modelValue, (newValue) => {
// If the alert is visible, we don't want to update the pairs
// because it would delete problem line silently.
if (alertState.value.visible || localEdit.value) {
return;
}
localEdit.value = false;
internalPairs.value = Object.entries(newValue || {});
}, {
deep: true,
immediate: true
});
function updateModel() {
localEdit.value = true;
emit("update:modelValue", Object.fromEntries(internalPairs.value.filter(pair => pair[0] !== "" && pair[1] !== undefined)));
}
function handleKeyInput(index: number, newValue: string) {
internalPairs.value[index][0] = newValue.toString();
updateModel()
};
const updateValue = (key, value) => {
const values = {...props.modelValue};
values[key] = value;
emits("update:modelValue", values);
function addPair() {
internalPairs.value.push(["", undefined])
updateModel()
};
function removePair (pairId: number) {
internalPairs.value.splice(pairId, 1);
updateModel()
};
function updateValue (pairId: number, newValue: string){
internalPairs.value[pairId][1] = newValue;
updateModel()
};
</script>

View File

@@ -8,8 +8,9 @@
:placeholder
:disabled
:type="disabled ? '' : 'textarea'"
:suffix-icon="Lock"
:autosize="{minRows: 1}"
:input-style="haveError ? {boxShadow: '0 0 6px #ab0009'} : {}"
:suffix-icon="disabled ? Lock : undefined"
/>
</div>
</template>
@@ -31,18 +32,20 @@
disabled: {type: Boolean, default: false},
margin: {type: String, default: "mt-1 mb-2"},
class: {type: String, default: undefined},
haveError: {type: Boolean, default: false}
});
const input = computed({
get: () => props.modelValue,
set: (value) => {
emits("update:modelValue", value);
},
}
});
</script>
<style scoped lang="scss">
@import "../../styles/code.scss";
:deep(.el-input__icon) {
.lock-icon {
color: var(--ks-content-inactive);

View File

@@ -26,7 +26,8 @@ export const POSITION_INJECTION_KEY = Symbol("position-injection-key") as Inject
* Tells if the task is being created or edited. Used to discriminate when a section is specified
* NOTE: different from the `isCreating` flag coming from the store. `isCreating` refers to the Complete flow being in creation
*/
export const CREATING_TASK_INJECTION_KEY = Symbol("creating-injection-key") as InjectionKey<ComputedRef<boolean>>
export const CREATING_TASK_INJECTION_KEY = Symbol("creating-injection-key") as InjectionKey<boolean>
export const EDITING_TASK_INJECTION_KEY = Symbol("editing-injection-key") as InjectionKey<boolean>
/**
* Call this when starting to create a new task, when the user clicks on the add button
* to start the addition process
@@ -36,7 +37,7 @@ export const CREATE_TASK_FUNCTION_INJECTION_KEY = Symbol("creating-function-inje
* Call this when starting to edit a task, when the user clicks on the task line
* to start the edition process
*/
export const EDIT_TASK_FUNCTION_INJECTION_KEY = Symbol("edit-function-injection-key") as InjectionKey<(blockType: BlockType | "pluginDefaults", parentPath: string, refPath: number) => void>
export const EDIT_TASK_FUNCTION_INJECTION_KEY = Symbol("edit-function-injection-key") as InjectionKey<(blockType: BlockType | "pluginDefaults", parentPath: string, refPath?: number) => void>
/**
* Call this when closing a task, when the user clicks on the close button
*/

View File

@@ -1,25 +1,33 @@
<template>
<div class="p-4">
<template v-if="panel">
<component
:is="panel.type"
:model-value="panel.props.modelValue"
v-bind="panel.props"
@update:model-value="
<MetadataInputsContent
:inputs="metadata.inputs"
:label="t('inputs')"
:selected-index="panel.props.selectedIndex"
@update:inputs="
(value: any) => emits('updateMetadata', 'inputs', value)
"
/>
</template>
<template v-else-if="!creatingTask && refPath === undefined">
<el-form label-position="top">
<component
v-for="(v, k) in mainFields"
:key="k"
:is="v.component"
v-model="v.value"
v-bind="trimmed(v)"
@update:model-value="emits('updateMetadata', k, v.value)"
<template v-else-if="!creatingTask && !editingTask">
<el-form label-position="top" v-if="fieldsFromSchema.length">
<TaskWrapper :key="v.root" v-for="(v) in fieldsFromSchema.slice(0, 3)" :merge="shouldMerge(v.schema)">
<template #tasks>
<TaskObjectField
v-bind="v"
@update:model-value="updateMetadata(v.root, $event)"
/>
</template>
</TaskWrapper>
<MetadataInputs
v-if="flowSchemaProperties.inputs"
:label="t('no_code.fields.general.inputs')"
:model-value="metadata.inputs"
:required="flowSchema.required?.includes('inputs')"
@update:model-value="updateMetadata('inputs', $event)"
/>
<hr class="my-4">
@@ -34,15 +42,34 @@
<hr class="my-4">
<component
v-for="(v, k) in otherFields"
:key="k"
:is="v.component"
v-model="v.value"
v-bind="trimmed(v)"
@update:model-value="emits('updateMetadata', k, v.value)"
/>
<TaskWrapper :key="v.root" v-for="(v) in fieldsFromSchema.slice(4)" :merge="shouldMerge(v.schema)">
<template #tasks>
<TaskObjectField
v-bind="v"
@update:model-value="updateMetadata(v.root, $event)"
/>
</template>
</TaskWrapper>
</el-form>
<template v-else>
<el-skeleton
animated
:rows="4"
:throttle="{leading: 500, initVal: true}"
/>
<hr class="my-4">
<el-skeleton
animated
:rows="6"
:throttle="{leading: 500, initVal: true}"
/>
<hr class="my-4">
<el-skeleton
animated
:rows="5"
:throttle="{leading: 500, initVal: true}"
/>
</template>
</template>
<Task
@@ -56,34 +83,34 @@
import {onMounted, computed, inject, ref} from "vue";
import * as YAML_UTILS from "@kestra-io/ui-libs/flow-yaml-utils";
import {Field, Fields, CollapseItem, NoCodeElement, BlockType} from "../utils/types";
import {CollapseItem, NoCodeElement, BlockType} from "../utils/types";
import Collapse from "../components/collapse/Collapse.vue";
import InputText from "../components/inputs/InputText.vue";
import InputSwitch from "../components/inputs/InputSwitch.vue";
import InputPair from "../components/inputs/InputPair.vue";
import Editor from "../../inputs/Editor.vue";
import MetadataInputs from "../../flows/MetadataInputs.vue";
import MetadataRetry from "../../flows/MetadataRetry.vue";
import MetadataSLA from "../../flows/MetadataSLA.vue";
import TaskBasic from "../../flows/tasks/TaskBasic.vue";
import MetadataInputsContent from "../../flows/MetadataInputsContent.vue";
import TaskObjectField from "../../flows/tasks/TaskObjectField.vue";
import InitialSchema from "./flow-schema.json"
import {
CREATING_TASK_INJECTION_KEY, FLOW_INJECTION_KEY,
PANEL_INJECTION_KEY, REF_PATH_INJECTION_KEY,
CREATING_TASK_INJECTION_KEY, EDITING_TASK_INJECTION_KEY,
FLOW_INJECTION_KEY, PANEL_INJECTION_KEY,
} from "../injectionKeys";
import Task from "./Task.vue";
const panel = inject(PANEL_INJECTION_KEY, ref());
const refPath = inject(REF_PATH_INJECTION_KEY, undefined);
const editingTask = inject(EDITING_TASK_INJECTION_KEY, false);
import {useI18n} from "vue-i18n";
const {t} = useI18n({useScope: "global"});
import {useStore} from "vuex";
import TaskWrapper from "../../flows/tasks/TaskWrapper.vue";
import {removeNullAndUndefined} from "../utils/cleanUp";
const store = useStore();
const emits = defineEmits([
@@ -100,6 +127,22 @@
}
};
function shouldMerge(schema: any): boolean {
const complexObject = ["object", "array"].includes(schema?.type) || schema?.$ref || schema?.oneOf || schema?.anyOf || schema?.allOf;
return !complexObject
}
function updateMetadata(key: string, val: any) {
const realValue = val === null || val === undefined ? undefined :
// allow array to be created with null values (specifically for metadata)
// metadata do not use a buffer value, so each change needs to be reflected in the code,
// for TaskKvPair.vue (object) we added the buffer value in the input component
typeof val === "object" && !Array.isArray(val) ? removeNullAndUndefined(val) :
val; // Handle null values
emits("updateMetadata", key, realValue);
}
document.addEventListener("keydown", saveEvent);
const creatingFlow = computed(() => {
@@ -113,111 +156,76 @@
metadata: {type: Object, required: true},
});
const trimmed = (field: Field) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const {component, value, ...rest} = field;
return rest;
};
function onTaskUpdate(yaml: string) {
emits("updateTask", yaml)
}
const schema = ref({})
const schema = ref<{
definitions?: any,
$ref?: string,
}>(InitialSchema)
onMounted(async () => {
await store.dispatch("plugin/loadSchemaType").then((response) => {
schema.value = response;
})
});
const fields = computed<Fields>(() => {
return {
id: {
component: InputText,
value: props.metadata.id,
label: t("no_code.fields.main.flow_id"),
required: true,
disabled: !creatingFlow.value,
},
namespace: {
component: InputText,
value: props.metadata.namespace,
label: t("no_code.fields.main.namespace"),
required: true,
disabled: !creatingFlow.value,
},
description: {
component: InputText,
value: props.metadata.description,
label: t("no_code.fields.main.description"),
},
retry: {
component: MetadataRetry,
value: props.metadata.retry,
label: t("no_code.fields.general.retry")
},
labels: {
component: InputPair,
value: props.metadata.labels,
label: t("no_code.fields.general.labels"),
property: t("no_code.labels.label"),
},
inputs: {
component: MetadataInputs,
value: props.metadata.inputs,
label: t("no_code.fields.general.inputs"),
inputs: props.metadata.inputs ?? [],
},
outputs: {
component: Editor,
value: props.metadata.outputs,
label: t("no_code.fields.general.outputs"),
navbar: false,
input: true,
lang: "yaml",
shouldFocus: false,
showScroll: true,
style: {height: "100px"},
},
variables: {
component: InputPair,
value: props.metadata.variables,
label: t("no_code.fields.general.variables"),
property: t("no_code.labels.variable"),
},
concurrency: {
component: TaskBasic,
value: props.metadata.concurrency,
label: t("no_code.fields.general.concurrency"),
schema: schema.value?.definitions?.["io.kestra.core.models.flows.Concurrency"] ?? {},
root: "concurrency",
},
sla: {
component: MetadataSLA,
value: props.metadata.sla ?? [],
label: t("no_code.fields.general.sla")
},
disabled: {
component: InputSwitch,
value: props.metadata.disabled,
label: t("no_code.fields.general.disabled"),
},
}
const definitions = computed(() => {
return schema.value?.definitions ?? {};
});
function removeRefPrefix(ref?: string): string {
return ref?.replace(/^#\/definitions\//, "") ?? "";
}
const flowSchema = computed(() => {
const ref = removeRefPrefix(schema.value?.$ref);
return definitions.value?.[ref];
});
const mainFields = computed(() => {
const {id, namespace, description, inputs} = fields.value;
const flowSchemaProperties = computed(() => {
return flowSchema.value?.properties ?? {};
});
return {id, namespace, description, inputs};
})
const METADATA_KEYS = [
"id",
"namespace",
"description",
"inputs",
"retry",
"labels",
"outputs",
"variables",
"concurrency",
"sla",
"disabled"
] as const;
const otherFields = computed(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const {id, namespace, description, inputs, ...rest} = fields.value;
return rest;
})
const fieldsFromSchema = computed(() => {
if( !flowSchema.value || !flowSchemaProperties.value) {
return [];
}
// FIXME: some labels are not where you would expect them to be
const mainLabels: Record<string, string> = {
id: t("no_code.fields.main.flow_id"),
namespace: t("no_code.fields.main.namespace"),
description: t("no_code.fields.main.description"),
}
return METADATA_KEYS.map(f => ({
modelValue: props.metadata[f],
required: flowSchema.value?.required ?? [],
disabled: !creatingFlow.value && (f === "id" || f === "namespace"),
schema: flowSchemaProperties.value[f],
definitions: definitions.value,
label: mainLabels[f] ?? t(`no_code.fields.general.${f}`),
fieldKey: f,
task: props.metadata,
root: f,
}));
});
const SECTIONS_IDS = [
"tasks",

View File

@@ -55,7 +55,7 @@
const position = inject(POSITION_INJECTION_KEY, "after");
const creatingTask = inject(
CREATING_TASK_INJECTION_KEY,
ref(false),
false,
);
const exitTaskElement = inject(
CLOSE_TASK_FUNCTION_INJECTION_KEY,
@@ -90,11 +90,16 @@
const yaml = ref("");
function getPath(parentPath: string, refPath: number | undefined): string {
return refPath !== undefined && refPath !== null ? `${parentPath}[${refPath}]` : parentPath;
}
watch(flow, (source) => {
if(!creatingTask.value){
if(!creatingTask){
const path = getPath(parentPath, refPath);
const taskYaml = YAML_UTILS.extractBlockWithPath({
source,
path: `${parentPath}[${refPath}]`,
path,
}) ?? ""
if(taskYaml === yaml.value){
@@ -157,15 +162,16 @@
const saveTask = () => {
let result: string = flow.value;
if (!creatingTask.value) {
if (!creatingTask) {
if(yaml.value){
const path = getPath(parentPath, refPath);
result = YAML_UTILS.replaceBlockWithPath({
source: result,
path: `${parentPath}[${refPath}]`,
path,
newContent: yaml.value,
});
}
}else if(!hasMovedToEdit.value && blockType){
} else if(!hasMovedToEdit.value && blockType){
const currentSection = section.value as keyof typeof SECTIONS_MAP;
if(!currentSection) {

View File

@@ -0,0 +1,415 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$ref": "#/definitions/io.kestra.core.models.flows.Flow",
"definitions": {
"io.kestra.plugin.core.debug.Echo": {
"type": "object",
"properties": {
"allowFailure": {
"type": "boolean",
"default": false,
"$dynamic": false,
"$group": "core",
"markdownDescription": "Default value is : `false`"
},
"allowWarning": {
"type": "boolean",
"default": false,
"$dynamic": false,
"$group": "core",
"markdownDescription": "Default value is : `false`"
},
"description": {
"type": "string",
"$dynamic": false,
"$group": "core"
},
"disabled": {
"type": "boolean",
"default": false,
"$dynamic": false,
"$group": "core",
"markdownDescription": "Default value is : `false`"
},
"format": {
"type": "string",
"$dynamic": true
},
"id": {
"type": "string",
"minLength": 1,
"pattern": "^[a-zA-Z0-9][a-zA-Z0-9_-]*"
},
"level": {
"type": "string",
"enum": [
"ERROR",
"WARN",
"INFO",
"DEBUG",
"TRACE"
],
"default": "INFO",
"$dynamic": true,
"markdownDescription": "Default value is : `INFO`"
},
"logLevel": {
"type": "string",
"enum": [
"ERROR",
"WARN",
"INFO",
"DEBUG",
"TRACE"
],
"$dynamic": false,
"$group": "core"
},
"logToFile": {
"type": "boolean",
"default": false,
"$dynamic": false,
"$group": "core",
"markdownDescription": "Default value is : `false`"
},
"retry": {
"anyOf": [
{
"allOf": [
{
"$ref": "#/definitions/io.kestra.core.models.tasks.retrys.Constant-2"
},
{
"$dynamic": false,
"$group": "core"
}
]
},
{
"allOf": [
{
"$ref": "#/definitions/io.kestra.core.models.tasks.retrys.Exponential-2"
},
{
"$dynamic": false,
"$group": "core"
}
]
},
{
"allOf": [
{
"$ref": "#/definitions/io.kestra.core.models.tasks.retrys.Random-2"
},
{
"$dynamic": false,
"$group": "core"
}
]
}
]
},
"runIf": {
"type": "string",
"default": "true",
"$dynamic": false,
"$group": "core",
"markdownDescription": "Default value is : `\"true\"`"
},
"timeout": {
"type": "string",
"format": "duration",
"$dynamic": true,
"$group": "core"
},
"type": {
"const": "io.kestra.plugin.core.debug.Echo"
},
"version": {
"type": "string",
"title": "The version of the plugin to use.",
"pattern": "\\d+\\.\\d+\\.\\d+(-[a-zA-Z0-9-]+)?|([a-zA-Z0-9]+)",
"$dynamic": false,
"$group": "core"
},
"workerGroup": {
"allOf": [
{
"$ref": "#/definitions/io.kestra.core.models.tasks.WorkerGroup"
},
{
"$dynamic": false,
"$group": "core"
}
]
}
},
"required": [
"id",
"type"
],
"title": "Log a message in the task logs (Deprecated).",
"$deprecated": "true",
"markdownDescription": "This task is deprecated, please use the `io.kestra.plugin.core.log.Log` task instead.##### Examples\n> \n```yaml\nid: echo_flow\nnamespace: company.team\n\ntasks:\n - id: echo\n type: io.kestra.plugin.core.debug.Echo\n level: WARN\n format: \"{{ task.id }} > {{ taskrun.startDate }}\"\n\n```"
},
"io.kestra.core.models.flows.Flow": {
"type": "object",
"properties": {
"afterExecution": {
"type": "array",
"items": {
"anyOf": [
{
"$ref": "#/definitions/io.kestra.plugin.core.debug.Echo"
}
]
}
},
"concurrency": {
"$ref": "#/definitions/io.kestra.core.models.flows.Concurrency"
},
"deleted": {
"type": "boolean",
"default": false,
"markdownDescription": "Default value is : `false`"
},
"description": {
"type": "string"
},
"disabled": {
"type": "boolean",
"default": false,
"markdownDescription": "Default value is : `false`"
},
"errors": {
"type": "array",
"items": {
}
},
"finally": {
"type": "array",
"items": {
"anyOf": [
{
"$ref": "#/definitions/io.kestra.plugin.core.debug.Echo"
}
]
}
},
"id": {
"type": "string",
"minLength": 1,
"maxLength": 100,
"pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*"
},
"inputs": {
"type": "array",
"items": {
"anyOf": [
{
"$ref": "#/definitions/io.kestra.core.models.flows.input.ArrayInput-2"
},
{
"$ref": "#/definitions/io.kestra.core.models.flows.input.BooleanInput-2"
},
{
"$ref": "#/definitions/io.kestra.core.models.flows.input.BoolInput-2"
},
{
"$ref": "#/definitions/io.kestra.core.models.flows.input.DateInput-2"
},
{
"$ref": "#/definitions/io.kestra.core.models.flows.input.DateTimeInput-2"
},
{
"$ref": "#/definitions/io.kestra.core.models.flows.input.DurationInput-2"
},
{
"$ref": "#/definitions/io.kestra.core.models.flows.input.FileInput-2"
},
{
"$ref": "#/definitions/io.kestra.core.models.flows.input.FloatInput-2"
},
{
"$ref": "#/definitions/io.kestra.core.models.flows.input.IntInput-2"
},
{
"$ref": "#/definitions/io.kestra.core.models.flows.input.JsonInput-2"
},
{
"$ref": "#/definitions/io.kestra.core.models.flows.input.SecretInput-2"
},
{
"$ref": "#/definitions/io.kestra.core.models.flows.input.StringInput-2"
},
{
"$ref": "#/definitions/io.kestra.core.models.flows.input.EnumInput-2"
},
{
"$ref": "#/definitions/io.kestra.core.models.flows.input.SelectInput-2"
},
{
"$ref": "#/definitions/io.kestra.core.models.flows.input.TimeInput-2"
},
{
"$ref": "#/definitions/io.kestra.core.models.flows.input.URIInput-2"
},
{
"$ref": "#/definitions/io.kestra.core.models.flows.input.MultiselectInput-2"
},
{
"$ref": "#/definitions/io.kestra.core.models.flows.input.YamlInput-2"
},
{
"$ref": "#/definitions/io.kestra.core.models.flows.input.EmailInput-2"
}
]
}
},
"labels": {
"anyOf": [
{
"type": "array",
"items": {}
},
{
"type": "object"
}
]
},
"listeners": {
"$deprecated": true,
"type": "array",
"items": {
"allOf": [
{
"$ref": "#/definitions/io.kestra.core.models.listeners.Listener"
},
{
"$deprecated": true
}
]
}
},
"namespace": {
"type": "string",
"minLength": 1,
"maxLength": 150,
"pattern": "^[a-z0-9][a-z0-9._-]*"
},
"outputs": {
"title": "Output values available and exposes to other flows.",
"$dynamic": true,
"markdownDescription": "Output values make information about the execution of your Flow available and expose for other Kestra flows to use. Output values are similar to return values in programming languages.",
"type": "array",
"items": {
"allOf": [
{
"$ref": "#/definitions/io.kestra.core.models.flows.Output"
},
{
"$dynamic": true
}
]
}
},
"pluginDefaults": {
"type": "array",
"items": {
"$ref": "#/definitions/io.kestra.core.models.flows.PluginDefault"
}
},
"retry": {
"anyOf": [
{
"$ref": "#/definitions/io.kestra.core.models.tasks.retrys.Constant-2"
},
{
"$ref": "#/definitions/io.kestra.core.models.tasks.retrys.Exponential-2"
},
{
"$ref": "#/definitions/io.kestra.core.models.tasks.retrys.Random-2"
}
]
},
"revision": {
"type": "integer",
"minimum": 1
},
"sla": {
"$dynamic": false,
"$beta": true,
"type": "array",
"items": {
"anyOf": [
{
"allOf": [
{
"$ref": "#/definitions/io.kestra.core.models.flows.sla.types.MaxDurationSLA-2"
},
{
"$dynamic": false,
"$beta": true
}
]
},
{
"allOf": [
{
"$ref": "#/definitions/io.kestra.core.models.flows.sla.types.ExecutionAssertionSLA-2"
},
{
"$dynamic": false,
"$beta": true
}
]
}
]
}
},
"taskDefaults": {
"$deprecated": true,
"type": "array",
"items": {
"allOf": [
{
"$ref": "#/definitions/io.kestra.core.models.flows.PluginDefault"
},
{
"$deprecated": true
}
]
}
},
"tasks": {
"minItems": 1,
"type": "array",
"items": {
"anyOf": [{
"$ref": "#/definitions/io.kestra.plugin.core.debug.Echo"
}]
}
},
"tenantId": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9_-]*"
},
"triggers": {
"type": "array",
"items": {
}
},
"variables": {
"type": "object"
}
},
"required": [
"id",
"namespace",
"tasks"
]
}
}
}

View File

@@ -0,0 +1,31 @@
function isNullOrUndefined(value: any): boolean {
return value === null || value === undefined;
}
export function removeNullAndUndefined(obj: any): any {
if (Array.isArray(obj)) {
const ar = obj
.map(item => removeNullAndUndefined(item))
.filter(item => isNullOrUndefined(item) === false);
return ar.length > 0 ? ar : undefined;
}
if (typeof obj === "object") {
const newObj: any = {};
let hasValue = false;
for (const key in obj) {
const rawValue = obj[key]
if(isNullOrUndefined(rawValue)) {
continue;
}
const newVal = removeNullAndUndefined(rawValue);
if(isNullOrUndefined(newVal)) {
continue;
}
hasValue = true;
newObj[key] = newVal;
}
return hasValue ? newObj : undefined;
}
return obj;
}

View File

@@ -47,7 +47,7 @@
</el-button>
</div>
<div class="w-100 p-4" v-if="currentView === views.DASHBOARD">
<ChartsSection :charts="charts.map(chart => chart.data)" />
<ChartsSection :charts="charts.map(chart => chart.data)" show-default />
</div>
<div class="main-editor" v-else>
<div
@@ -100,7 +100,7 @@
:source="selectedChart.content"
:chart="selectedChart"
:identifier="selectedChart.id"
is-preview
show-default
/>
</div>
</div>

View File

@@ -134,7 +134,7 @@
<VarValue
v-if="displayVarValue()"
:value="selectedValue"
:value="selectedValue.uri ? selectedValue.uri : selectedValue"
:execution="execution"
/>
<SubFlowLink

View File

@@ -6,8 +6,8 @@
<MonacoEditor
ref="monacoEditor"
class="border flex-grow-1 position-relative"
:language="`${language?.domain === undefined ? '' : (language.domain + '-')}${legacyQuery ? 'legacy-' : ''}filter`"
:schema-type="language?.domain"
:language="`${language.domain === undefined ? '' : (language.domain + '-')}${legacyQuery ? 'legacy-' : ''}filter`"
:schema-type="language.domain"
:value="filter"
@change="filter = $event"
:theme="themeComputed"
@@ -15,6 +15,7 @@
@editor-did-mount="editorDidMount"
suggestions-on-focus
:placeholder="placeholder ?? t('filters.label')"
data-testid="monaco-filter"
/>
<el-button-group
class="d-inline-flex"
@@ -84,6 +85,8 @@
import {Comparators, getComparator} from "../../composables/monaco/languages/filters/filterCompletion.ts";
import {watchDebounced} from "@vueuse/core";
import {FilterLanguage} from "../../composables/monaco/languages/filters/filterLanguage.ts";
import DefaultFilterLanguage from "../../composables/monaco/languages/filters/impl/defaultFilterLanguage.ts";
import _isEqual from "lodash/isEqual";
const router = useRouter();
const route = useRoute();
@@ -91,7 +94,7 @@
const props = withDefaults(defineProps<{
prefix?: string | undefined;
language?: FilterLanguage | undefined,
language?: FilterLanguage,
propertiesWidth?: number,
buttons?: (Omit<Buttons, "settings"> & {
settings: Omit<Buttons["settings"], "charts"> & { charts?: Buttons["settings"]["charts"] }
@@ -104,7 +107,7 @@
legacyQuery?: boolean,
}>(), {
prefix: undefined,
language: undefined,
language: () => DefaultFilterLanguage,
propertiesWidth: 144,
buttons: () => ({
refresh: {
@@ -146,7 +149,7 @@
}
}));
const itemsPrefix = computed(() => props.prefix ?? route.name?.toString());
const itemsPrefix = computed(() => props.prefix ?? route.name?.toString() ?? "fallback-filters");
const emits = defineEmits(["dashboard", "updateProperties"]);
@@ -160,14 +163,9 @@
.map(([key, value]) => [value, key])
);
const EXCLUDED_QUERY_FIELDS = ["sort", "size", "page"];
const queryParamsToKeep = ref<string[]>([]);
const filteredRouteQuery = computed(() => route.query === undefined
? undefined
: Object.fromEntries(Object.entries(route.query).filter(([key]) => !EXCLUDED_QUERY_FIELDS.includes(key))) as LocationQuery
);
watch(filteredRouteQuery, (newVal) => {
watch(() => route.query, (newVal) => {
if (skipRouteWatcherOnce.value) {
skipRouteWatcherOnce.value = false;
return;
@@ -177,12 +175,19 @@
return;
}
queryParamsToKeep.value = [];
let query = newVal;
if (props.queryNamespace !== undefined) {
query = Object.fromEntries(
Object.entries(newVal)
.filter(([key]) => {
return key.startsWith(props.queryNamespace + "[");
if (key.startsWith(props.queryNamespace + "[")) {
return true;
}
queryParamsToKeep.value.push(key);
return false;
})
.map(([key, value]) =>
// We trim the queryNamespace from the key
@@ -198,17 +203,32 @@
*/
filter.value = Object.entries(query)
.flatMap(([key, values]) => {
const remappedFilterKey = queryRemapper[key] ?? key;
if (!props.language.keyMatchers()?.some(keyMatcher => keyMatcher.test(FilterLanguage.withNestedKeyPlaceholder(remappedFilterKey)))) {
queryParamsToKeep.value.push(key);
return [];
}
if (!Array.isArray(values)) {
values = [values];
}
return values.map(value => (queryRemapper?.[key] ?? key) + Comparators.EQUALS + value);
return values.map(value => remappedFilterKey + Comparators.EQUALS + value);
}).join(" ");
} else {
Object.keys(query).filter((key) => {
return !key.startsWith("filters[");
}).forEach((key) => {
queryParamsToKeep.value.push(key);
});
filter.value = Object.entries(query)
.filter(([key]) => key.startsWith("filters["))
.flatMap(([key, values]) => {
const [_, filterKey, comparator, subKey] = key.match(/filters\[([^\]]+)]\[([^\]]+)](?:\[([^\]]+)])?/) ?? [];
const remappedFilterKey = queryRemapper[filterKey] ?? filterKey;
let maybeSubKeyString;
if (subKey === undefined) {
maybeSubKeyString = "";
@@ -220,7 +240,7 @@
values = [values];
}
return values.map(value => (queryRemapper?.[filterKey] ?? filterKey) + maybeSubKeyString + getComparator(comparator as Parameters<typeof getComparator>[0]) + value);
return values.map(value => remappedFilterKey + maybeSubKeyString + getComparator(comparator as Parameters<typeof getComparator>[0]) + (value!.includes(" ") ? `"${value}"` : value));
})
.join(" ");
}
@@ -243,10 +263,10 @@
return {};
}
const KEY_MATCHER = "((?:(?!" + COMPARATORS_REGEX + ")\\S)+?)";
const KEY_MATCHER = "((?:(?!" + COMPARATORS_REGEX + ")(?:\\S|\"[^\"]*\"))+?)";
const COMPARATOR_MATCHER = "(" + COMPARATORS_REGEX + ")";
const MAYBE_PREVIOUS_VALUE = "(?:(?<=\\S),)?";
const VALUE_MATCHER = "((?:" + MAYBE_PREVIOUS_VALUE + "(?:(?:\"[^\\n,]*\")|(?:[^\\s,]*)))+)";
const VALUE_MATCHER = "((?:" + MAYBE_PREVIOUS_VALUE + "(?:(?:\"[^\"]*\")|(?:[^\\s,]*)))+)";
const filterMatcher = new RegExp("\\s*(?<!\\S)" +
"((?:" + KEY_MATCHER + COMPARATOR_MATCHER + VALUE_MATCHER + ")" +
"|\"([^\"]*)\"" +
@@ -259,7 +279,7 @@
// If we're not in a {key}{comparator}{value} format, we assume it's a text search
if (key === undefined) {
if (props.language?.textFilterSupported && (text === undefined || !props.language?.keyMatchers()?.some(keyMatcher => keyMatcher.test(text)))) {
if (props.language.textFilterSupported && (text === undefined || !props.language.keyMatchers()?.some(keyMatcher => keyMatcher.test(text)))) {
filters.push({
key: "text",
comparator: "EQUALS",
@@ -269,15 +289,17 @@
continue;
}
if (!props.language?.keyMatchers()?.some(keyMatcher => keyMatcher.test(key))) {
if (!props.language.keyMatchers()?.some(keyMatcher => keyMatcher.test(key))) {
continue; // Skip keys that don't match the language key matchers
}
if (!props.language?.comparatorsPerKey()[FilterLanguage.withNestedKeyPlaceholder(key)].some(c => Comparators[c] === comparator)) {
if (!props.language.comparatorsPerKey()[FilterLanguage.withNestedKeyPlaceholder(key)].some(c => Comparators[c] === comparator)) {
continue; // Skip comparators that are not valid for the key
}
const values = [...new Set(commaSeparatedValues?.split(",")?.filter(value => value !== "")?.map(value => value.replaceAll("\"", "")) ?? [])];
const values = [...new Set(
[...commaSeparatedValues?.matchAll(/,?(?:"([^"]*)"|([^",]+))/g) ?? []].map(([_, quotedValue, rawValue]) => quotedValue ?? rawValue) ?? [])
];
if (values.length === 0) {
continue; // Skip empty values
}
@@ -308,9 +330,9 @@
if (!props.legacyQuery) {
if (key.includes(".")) {
const keyAndSubKeyMatch = queryKey.match(/([^.]+)\.([^.]+)/);
const keyAndSubKeyMatch = queryKey.match(/([^.]+)\.(\S+)/);
const rootKey = keyAndSubKeyMatch?.[1];
const subKey = keyAndSubKeyMatch?.[2];
const subKey = keyAndSubKeyMatch?.[2].replace(/^"([^"]*)"$/, "$1");
if (rootKey === undefined || subKey === undefined) {
return [];
}
@@ -443,16 +465,23 @@
};
watchDebounced(filterQueryString, () => {
const newQuery = {
...Object.fromEntries(queryParamsToKeep.value.map(key => {
return [
key,
route.query[key]
];
})),
...filterQueryString.value
};
if (_isEqual(route.query, newQuery)) {
return; // Skip if the query hasn't changed
}
skipRouteWatcherOnce.value = true;
router.push({
query: {
sort: route.query.sort,
size: route.query.size,
page: route.query.page,
...filterQueryString.value
}
query: newQuery
});
}, {immediate: true, debounce: 500});
}, {immediate: true, debounce: 1000});
</script>
<style lang="scss" scoped>

View File

@@ -739,7 +739,7 @@
);
if (this.namespace) {
queryFilter["filters[namespace][EQUALS]"] = this.namespace;
queryFilter["filters[namespace][EQUALS]"] = this.$route.params.id || this.namespace;
}
return _merge(base, queryFilter);

View File

@@ -280,7 +280,7 @@
id: this.newMetadata.id,
namespace: this.newMetadata.namespace,
description: this.newMetadata.description,
retry: retry,
retry: retry && Object.keys(retry).length > 0 ? retry : undefined,
labels: this.arrayToObject(this.newMetadata.labels),
inputs: this.newMetadata.inputs.filter(e => e.id && e.type),
variables: this.arrayToObject(this.newMetadata.variables),

View File

@@ -19,127 +19,73 @@
</div>
</template>
<script setup>
<script setup lang="ts">
import {ref, watch, inject} from "vue";
import {useI18n} from "vue-i18n";
import InputText from "../code/components/inputs/InputText.vue";
import Add from "../code/components/Add.vue";
import {DeleteOutline} from "../code/utils/icons";
</script>
<script>
import {h} from "vue";
import MetadataInputsContent from "./MetadataInputsContent.vue";
import {mapState} from "vuex";
import {BREADCRUMB_INJECTION_KEY, PANEL_INJECTION_KEY} from "../code/injectionKeys";
export default {
emits: ["update:modelValue"],
props: {
modelValue: {
type: Array,
default: () => [],
},
inputs: {
type: Array,
default: () => [],
},
label: {type: String, required: true},
required: {type: Boolean, default: false},
disabled: {type: Boolean, default: false},
},
computed: {
...mapState("plugin", ["inputSchema", "inputsType"]),
},
mounted() {
this.newInputs = this.inputs;
interface InputType {
type: string;
id?: string;
cls?: string;
}
this.$store
.dispatch("plugin/loadInputsType")
.then((_) => (this.loading = false));
},
data() {
return {
newInputs: [],
selectedInput: undefined,
selectedIndex: undefined,
isEditOpen: false,
loading: false,
};
},
inject:{
panel: {from: PANEL_INJECTION_KEY},
breadcrumbs: {from: BREADCRUMB_INJECTION_KEY}
},
methods: {
selectInput(input, index) {
this.loading = true;
this.selectedInput = input;
this.selectedIndex = index;
const {t} = useI18n();
this.loadSchema(input.type);
const props = withDefaults(defineProps<{
modelValue: InputType[];
label: string;
required?: boolean;
disabled?: boolean;
}>(), {
modelValue: () => [],
required: false,
disabled: false
});
this.panel = h(MetadataInputsContent, {
modelValue: input,
inputs: this.inputs,
label: this.$t("inputs"),
selectedIndex: index,
"onUpdate:modelValue": this.updateSelected,
})
const emit = defineEmits<{
(e: "update:modelValue", value: InputType[]): void
}>();
this.breadcrumbs.push(
{
label: this.$t("inputs").toLowerCase(),
});
},
getCls(type) {
return this.inputsType.find((e) => e.type === type).cls;
},
getType(cls) {
return this.inputsType.find((e) => e.cls === cls).type;
},
loadSchema(type) {
this.$store
.dispatch("plugin/loadInputSchema", {type: type})
.then((_) => (this.loading = false));
},
update() {
if (
this.newInputs.map((e) => e.id).length !==
new Set(this.newInputs.map((e) => e.id)).size
) {
this.$store.dispatch("core/showMessage", {
variant: "error",
title: this.$t("error"),
message: this.$t("duplicate input id"),
});
} else {
this.isEditOpen = false;
this.$emit("update:modelValue", this.newInputs);
}
},
updateSelected(value) {
this.newInputs = value;
},
deleteInput(index) {
this.newInputs.splice(index, 1);
this.$emit("update:modelValue", this.newInputs);
},
addInput() {
this.newInputs.push({type: "STRING"});
this.selectInput(this.newInputs.at(-1), this.newInputs.length - 1);
},
onChangeType(value) {
this.loading = true;
this.selectedInput = {
type: value,
id: this.newInputs[this.selectedIndex].id,
};
this.newInputs[this.selectedIndex] = this.selectedInput;
this.loadSchema(value);
},
},
const panel = inject(PANEL_INJECTION_KEY, ref());
const breadcrumbs = inject(BREADCRUMB_INJECTION_KEY, ref([]));
const newInputs = ref<InputType[]>([]);
const selectedInput = ref<InputType | undefined>();
const selectedIndex = ref<number | undefined>();
const loading = ref(false);
watch(() => props.modelValue, (newValue) => {
newInputs.value = newValue;
}, {deep: true, immediate: true});
const selectInput = async (input: InputType, index: number) => {
loading.value = true;
selectedInput.value = input;
selectedIndex.value = index;
panel.value = {
props: {
selectedIndex: index,
}
};
breadcrumbs.value.push({
label: t("inputs".toLowerCase()),
});
};
const deleteInput = (index: number) => {
newInputs.value.splice(index, 1);
emit("update:modelValue", newInputs.value);
};
const addInput = () => {
newInputs.value.push({type: "STRING"});
selectInput(newInputs.value[newInputs.value.length - 1], newInputs.value.length - 1);
};
</script>

View File

@@ -1,6 +1,6 @@
<template>
<el-select
:model-value="selectedInput.type"
:model-value="selectedInput?.type"
@update:model-value="onChangeType"
class="mb-3"
>
@@ -25,116 +25,101 @@
<Save @click="update" what="input" class="w-100 mt-3" />
</template>
<script setup>
<script setup lang="ts">
import {ref, computed, watch, onMounted, inject} from "vue";
import {useStore} from "vuex";
import TaskObject from "./tasks/TaskObject.vue";
import Save from "../code/components/Save.vue";
</script>
<script>
import {mapState, mapGetters} from "vuex";
import {BREADCRUMB_INJECTION_KEY, PANEL_INJECTION_KEY} from "../code/injectionKeys";
export default {
emits: ["update:modelValue"],
props: {
modelValue: {
type: Object,
default: () => {},
},
inputs: {
type: Array,
default: () => [],
},
label: {type: String, required: true},
selectedIndex: {type: Number, required: true},
required: {type: Boolean, default: false},
disabled: {type: Boolean, default: false},
},
computed: {
...mapState("plugin", ["inputSchema", "inputsType"]),
...mapGetters("flow", ["flowYamlMetadata"]),
},
created() {
if (this.inputs && this.inputs.length > 0) {
this.newInputs = this.inputs;
interface InputType {
type: string;
id?: string;
}
const props = withDefaults(defineProps<{
inputs: InputType[];
label: string;
selectedIndex: number;
required?: boolean;
disabled?: boolean;
}>(), {
inputs: () => [],
required: false,
disabled: false
});
const store = useStore();
const inputSchema = computed(() => store.state.plugin.inputSchema);
const inputsType = computed(() => store.state.plugin.inputsType);
const emit = defineEmits<{
(e: "update:inputs", value?: InputType[]): void
}>();
const panel = inject(PANEL_INJECTION_KEY, ref());
const breadcrumbs = inject(BREADCRUMB_INJECTION_KEY, ref([]));
const newInputs = ref<InputType[]>([{type: "STRING"}]);
const loading = ref(false);
const loadSchema = async (type: string) => {
loading.value = true;
await store.dispatch("plugin/loadInputSchema", {type});
loading.value = false;
};
onMounted(() => {
loading.value = true;
store.dispatch("plugin/loadInputsType")
.then(() => loading.value = false);
if(selectedInput.value.type) {
loadSchema(selectedInput.value.type);
} else {
loadSchema("STRING");
}
});
watch(() => props.inputs, (val) => {
if (val?.length) {
newInputs.value = props.inputs;
}
}, {
immediate: true,
deep: true
});
const selectedInput = computed(() => {
return props.inputs[props.selectedIndex] ?? {type: "STRING"};
});
const update = () => {
panel.value = undefined;
breadcrumbs.value.pop();
const value = newInputs.value.filter(input => input?.id);
emit("update:inputs", value.length ? value : undefined);
};
const updateSelected = (value: InputType, index: number) => {
if (index >= 0) {
if (index >= 0) {
newInputs.value[index] = value;
emit("update:inputs", [...newInputs.value]);
}
}
};
this.selectedInput = this.modelValue ?? {type: "STRING"};
const onChangeType = (type: string) => {
// Resetting the selected input if the type changes, but keeping the ID if it exists
const id = selectedInput.value?.id;
const newInput = {...(id ? {id} : {}), type};
this.$store
.dispatch("plugin/loadInputsType")
.then((_) => (this.loading = false));
},
data() {
return {
newInputs: [{type: "STRING"}],
selectedInput: undefined,
loading: false,
};
},
inject:{
panel: {from: PANEL_INJECTION_KEY},
breadcrumbs: {from: BREADCRUMB_INJECTION_KEY}
},
methods: {
selectInput(input) {
this.selectedInput = input;
this.loadSchema(input.type);
},
getCls(type) {
return this.inputsType.find((e) => e.type === type).cls;
},
getType(cls) {
return this.inputsType.find((e) => e.cls === cls).type;
},
loadSchema(type) {
this.loading = true;
newInputs.value[props.selectedIndex] = newInput;
this.$store
.dispatch("plugin/loadInputSchema", {type: type})
.then((_) => (this.loading = false));
},
update() {
if (
this.newInputs.map((e) => e?.id).length !==
new Set(this.newInputs.map((e) => e?.id)).size
) {
this.$store.dispatch("core/showMessage", {
variant: "error",
title: this.$t("error"),
message: this.$t("duplicate input id"),
});
} else {
this.panel = undefined;
this.breadcrumbs.pop();
const value = this.newInputs.filter(input => input?.id);
this.$emit("update:modelValue", value.length ? value : undefined);
}
},
updateSelected(value) {
if (this.selectedIndex >= 0) {
this.newInputs[this.selectedIndex] = value;
this.$emit("update:modelValue", [...this.newInputs]);
}
},
deleteInput(index) {
this.newInputs.splice(index, 1);
},
addInput() {
this.newInputs.push({type: "STRING"});
},
onChangeType(type) {
// Resetting the selected input if the type changes, but keeping the ID if it exists
const id = this.selectedInput?.id || undefined;
this.selectedInput = {...(id ? {id} : {}), type};
this.newInputs[this.selectedIndex] = {...(id ? {id} : {}), type};
this.$emit("update:modelValue", [...this.newInputs]);
this.loadSchema(type);
},
},
emit("update:inputs", [...newInputs.value]);
loadSchema(type);
};
</script>

View File

@@ -1,165 +0,0 @@
<template>
<TaskWrapper>
<template #tasks>
<TaskObjectField
:field-key="label"
v-model="value"
:schema
:definitions
:task="{[label]: value}"
@update:model-value="(val) => emit('update:modelValue', val)"
/>
</template>
</TaskWrapper>
</template>
<script setup lang="ts">
import TaskWrapper from "./tasks/TaskWrapper.vue";
import TaskObjectField from "./tasks/TaskObjectField.vue";
const value = defineModel({
type: Object,
default: () => ({}),
});
const emit = defineEmits<{
(e: "update:modelValue", value: any): void;
}>();
defineProps({
label: {type: String, required: true},
});
// FIXME: Properly fetch and parse the schema and definitions
const schema = {
anyOf: [
{
$ref: "#/definitions/kestra_frontend.core.models.tasks.retrys.Constant-2",
},
{
$ref: "#/definitions/kestra_frontend.core.models.tasks.retrys.Exponential-2",
},
{
$ref: "#/definitions/kestra_frontend.core.models.tasks.retrys.Random-2",
},
],
};
const definitions = {
"kestra_frontend.core.models.tasks.retrys.Random-2": {
type: "object",
properties: {
behavior: {
type: "string",
enum: ["RETRY_FAILED_TASK", "CREATE_NEW_EXECUTION"],
default: "RETRY_FAILED_TASK",
markdownDescription: "Default value is : `RETRY_FAILED_TASK`",
},
maxAttempt: {
type: "integer",
minimum: 1,
},
maxDuration: {
type: "string",
format: "duration",
},
maxInterval: {
type: "string",
format: "duration",
},
minInterval: {
type: "string",
format: "duration",
},
type: {
type: "constant",
const: "random",
},
warningOnRetry: {
type: "boolean",
default: false,
markdownDescription: "Default value is : `false`",
},
},
required: ["type", "maxInterval", "minInterval"],
},
"kestra_frontend.core.models.tasks.retrys.Exponential-2": {
type: "object",
properties: {
behavior: {
type: "string",
enum: ["RETRY_FAILED_TASK", "CREATE_NEW_EXECUTION"],
default: "RETRY_FAILED_TASK",
markdownDescription: "Default value is : `RETRY_FAILED_TASK`",
},
delayFactor: {
type: "number",
},
interval: {
type: "string",
format: "duration",
},
maxAttempt: {
type: "integer",
minimum: 1,
},
maxDuration: {
type: "string",
format: "duration",
},
maxInterval: {
type: "string",
format: "duration",
},
type: {
type: "constant",
const: "exponential",
},
warningOnRetry: {
type: "boolean",
default: false,
markdownDescription: "Default value is : `false`",
},
},
required: ["type", "interval", "maxInterval"],
},
"kestra_frontend.core.models.tasks.retrys.Constant-2": {
type: "object",
properties: {
behavior: {
type: "string",
enum: ["RETRY_FAILED_TASK", "CREATE_NEW_EXECUTION"],
default: "RETRY_FAILED_TASK",
markdownDescription: "Default value is : `RETRY_FAILED_TASK`",
},
interval: {
type: "string",
format: "duration",
},
maxAttempt: {
type: "integer",
minimum: 1,
},
maxDuration: {
type: "string",
format: "duration",
},
type: {
type: "constant",
const: "constant",
},
warningOnRetry: {
type: "boolean",
default: false,
markdownDescription: "Default value is : `false`",
},
},
required: ["type", "interval"],
},
};
</script>
<style scoped lang="scss">
@import "../code/styles/code.scss";
</style>

View File

@@ -1,101 +0,0 @@
<template>
<TaskWrapper>
<template #tasks>
<TaskObjectField
v-model="value[0]"
:field-key="label"
:schema
:definitions
:task="{[label]: value}"
@update:model-value="(val) => emit('update:modelValue', val? [val] : undefined)"
/>
</template>
</TaskWrapper>
</template>
<script setup lang="ts">
import TaskWrapper from "./tasks/TaskWrapper.vue";
import TaskObjectField from "./tasks/TaskObjectField.vue";
const value = defineModel<any[]>({
type: Array,
default: () => ([]),
});
const emit = defineEmits<{
(e: "update:modelValue", value: any): void;
}>();
defineProps({
label: {type: String, required: true},
});
// FIXME: Properly fetch and parse the schema and definitions
const schema = {
anyOf: [
{
$ref: "#/definitions/io.kestra.core.models.flows.sla.types.ExecutionAssertionSLA-1",
},
{
$ref: "#/definitions/io.kestra.core.models.flows.sla.types.MaxDurationSLA-1",
},
],
};
const definitions = {
"io.kestra.core.models.flows.sla.types.ExecutionAssertionSLA-1": {
type: "object",
properties: {
id: {
type: "string",
minLength: 1,
},
type: {
type: "constant",
const: "EXECUTION_ASSERTION",
},
assert: {
type: "string",
minLength: 1,
},
behavior: {
type: "string",
enum: ["FAIL", "CANCEL", "NONE"],
},
labels: {
type: "object",
},
},
required: ["type", "id", "assert", "behavior"],
},
"io.kestra.core.models.flows.sla.types.MaxDurationSLA-1": {
type: "object",
properties: {
id: {
type: "string",
minLength: 1,
},
type: {
type: "constant",
const: "MAX_DURATION",
},
behavior: {
type: "string",
enum: ["FAIL", "CANCEL", "NONE"],
},
duration: {
type: "string",
format: "duration",
},
labels: {
type: "object",
},
},
required: ["type", "id", "behavior", "duration"],
},
};
</script>
<style scoped lang="scss">
@import "../code/styles/code.scss";
</style>

Some files were not shown because too many files have changed in this diff Show More