Compare commits

...

81 Commits

Author SHA1 Message Date
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
166 changed files with 4895 additions and 3387 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

@@ -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

@@ -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

@@ -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

@@ -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
@@ -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

@@ -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

@@ -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()
);

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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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.1
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)

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>

View File

@@ -25,7 +25,6 @@
import {useStorage} from "@vueuse/core";
import {useStore} from "vuex";
import {useI18n} from "vue-i18n";
import {useRoute} from "vue-router";
import MultiPanelTabs, {Panel, Tab} from "../MultiPanelTabs.vue";
import EditorButtonsWrapper from "../inputs/EditorButtonsWrapper.vue";
@@ -42,7 +41,6 @@
}
const store = useStore()
const route = useRoute();
const flow = computed(() => store.state.flow.flow)
onMounted(() => {
@@ -145,11 +143,19 @@
const {setupInitialCodeTab} = useInitialCodeTabs()
const isTourRunning = computed(() => store.state.core.guidedProperties?.tourStarted)
const DEAFULT_TABS = route.name === "flows/create" && isTourRunning.value ? ["code", "topology"] : DEFAULT_ACTIVE_TABS
const DEFAULT_TOUR_TABS = [
{tabs: ["code"], activeTab: "code", size: 1},
{tabs: ["topology"], activeTab: "topology", size: 1}
];
function cleanupNoCodeTabKey(key: string): string {
// remove the number for "nocode-1234-" prefix from the key
return /^nocode-\d{4}/.test(key) ? key.slice(0, 6) + key.slice(11) : key
}
const panels: Ref<Panel[]> = useStorage<any>(
`panel-${flow.value.namespace}-${flow.value.id}`,
DEAFULT_TABS
DEFAULT_ACTIVE_TABS
.map((t):Panel => getPanelFromValue(t).panel),
undefined,
{
@@ -157,13 +163,13 @@
write(v: Panel[]){
return JSON.stringify(v.map(p => ({
tabs: p.tabs.map(t => t.value),
activeTab: p.activeTab?.value,
activeTab: cleanupNoCodeTabKey(p.activeTab?.value),
size: p.size,
})))
},
read(v?: string) {
if(v){
const panels: {tabs: string[], activeTab: string, size: number}[] = JSON.parse(v)
const panels: {tabs: string[], activeTab: string, size: number}[] = isTourRunning.value ? DEFAULT_TOUR_TABS : JSON.parse(v)
return panels
.filter((p) => p.tabs.length)
.map((p):Panel => {
@@ -174,7 +180,7 @@
)
// filter out any tab that may have disappeared
.filter(Boolean)
const activeTab = tabs.find(t => t.value === p.activeTab) ?? tabs[0]
const activeTab = tabs.find(t => cleanupNoCodeTabKey(t.value) === p.activeTab) ?? tabs[0]
return {
activeTab,
tabs,
@@ -224,11 +230,10 @@
}
.editor-wrapper{
flex: 1;
position: relative;
}
.editor-panels{
:deep(.editor-panels){
position: absolute;
}

View File

@@ -16,16 +16,18 @@
</el-form-item>
</el-form>
<TaskObject
v-loading="isLoading"
v-if="selectedTaskType && schema"
name="root"
:model-value="taskObject"
@update:model-value="onTaskInput"
:schema="schemaProp"
:properties="properties"
:definitions="schema.definitions"
/>
<div @click="store.dispatch('plugin/updateDocumentation', {task: selectedTaskType});">
<TaskObject
v-loading="isLoading"
v-if="selectedTaskType && schema"
name="root"
:model-value="taskObject"
@update:model-value="onTaskInput"
:schema="schemaProp"
:properties="properties"
:definitions="schema.definitions"
/>
</div>
</template>
<script lang="ts" setup>
@@ -36,6 +38,7 @@
import PluginSelect from "../../components/plugins/PluginSelect.vue";
import {NoCodeElement, Schemas} from "../code/utils/types";
import {BLOCKTYPE_INJECT_KEY, PARENT_PATH_INJECTION_KEY} from "../code/injectionKeys";
import {removeNullAndUndefined} from "../code/utils/cleanUp";
defineOptions({
name: "TaskEditor",
@@ -115,8 +118,6 @@
taskObject.value = parsed;
}
selectedTaskType.value = taskObject.value?.type;
}
// when tab is clicked, load the documentation
@@ -165,7 +166,7 @@
};
}
}
modelValue.value = YAML_UTILS.stringify(toRaw(val));
modelValue.value = YAML_UTILS.stringify(removeNullAndUndefined(toRaw(val)));
}
function onTaskTypeSelect() {
@@ -180,7 +181,6 @@
<style lang="scss" scoped>
.type-div {
display: flex;
justify-content: space-between;
text-transform: lowercase;
align-items: center;
gap: 0.25rem;

View File

@@ -31,6 +31,7 @@
:properties="Object.fromEntries(filteredProperties)"
:definitions="definitions"
@update:model-value="onAnyOfInput"
merge
/>
</el-form>
</template>
@@ -41,6 +42,52 @@
import {TaskIcon} from "@kestra-io/ui-libs";
import getTaskComponent from "./getTaskComponent";
/**
* merge allOf schemas if they exist
* @param schema
*/
function consolidateAllOfSchemas(schema, definitions) {
if(schema?.allOf?.length) {
return {
...schema,
type: "object",
...schema.allOf.reduce((acc, item) => {
if(item.$ref) {
const refSchema = definitions[item.$ref.split("/").pop()];
if(refSchema) {
return {
required: [
...acc.required,
...(refSchema.required ?? [])
],
properties: {
...acc.properties,
...refSchema.properties,
}
};
}
} else {
return {
required: [
...acc.required,
...(item.required ?? [])
],
properties: {
...acc.properties,
...item.properties,
}
};
}
return acc;
}, {
properties: {},
required: [],
})
}
}
return schema;
}
export default {
components: {
TaskIcon,
@@ -51,22 +98,28 @@
data() {
return {
isOpen: false,
schemas: [],
selectedSchema: undefined,
finishedMounting: false,
};
},
created() {
this.schemas = this.schema?.anyOf ?? [];
const schema = this.schemaOptions.find((item) =>
typeof item.value === this.modelValue?.type ||
(this.modelValue === "string" && item.value === "string") ||
(this.modelValue === "number" && item.value === "integer") ||
item.value === this.modelValue?.type ||
(typeof this.modelValue === "string" && item.value === "string") ||
(typeof this.modelValue === "number" && item.value === "integer") ||
(Array.isArray(this.modelValue) && item.value === "array"),
);
this.selectedSchema = schema?.value || this.schemaOptions[0]?.value;
this.selectedSchema = schema?.value;
// only default selector to required values
if(!this.selectedSchema && this.schemas.length > 0 && this.required) {
this.selectedSchema = this.schemas[0].type;
}
if (schema) {
this.onSelectType(schema.value);
}
},
mounted() {
this.$nextTick(() => {
@@ -124,18 +177,37 @@
this.onInput(value);
},
resetSelectType() {
this.selectedSchema = this.schemaOptions[0]?.value;
this.selectedSchema = undefined;
this.$nextTick(() => {
this.onInput(undefined);
});
},
},
expose: [
"resetSelectType",
],
computed: {
...mapState("plugin", ["icons"]),
schemas() {
if(!this.schema?.anyOf || !Array.isArray(this.schema.anyOf)) {
return [];
}
return this.schema.anyOf.map((schema) => {
if(schema.allOf && Array.isArray(schema.allOf)) {
if(schema.allOf.length === 2 && schema.allOf[0].$ref && !schema.allOf[1].$ref) {
return {
...schema.allOf[1],
$ref: schema.allOf[0].$ref,
};
}
}
return schema;
});
},
constantType() {
return this.currentSchema?.properties?.type?.const;
},
@@ -145,10 +217,8 @@
}) : [];
},
currentSchema() {
return (
this.definitions[this.selectedSchema] ??
this.schemaByType[this.selectedSchema]
);
const rawSchema = this.definitions[this.selectedSchema] ?? this.schemaByType[this.selectedSchema]
return consolidateAllOfSchemas(rawSchema, this.definitions);
},
schemaByType() {
return this.schemas.reduce((acc, schema) => {
@@ -160,19 +230,23 @@
return this.selectedSchema ? getTaskComponent(this.currentSchema) : undefined;
},
isSelectingPlugins() {
return this.schemaOptions.some((schema) => schema.label.startsWith("io.kestra"));
return this.schemaOptions.some((schema) => schema.label.startsWith("io.kestra")) || this.schemas.length > 3;
},
schemaOptions() {
if (!this.schemas?.length || !this.definitions) {
return [];
}
// find the part of the prefix to schema references that is common to all schemas
const schemaRefsArray = this.schemas
.map((schema) => schema.$ref?.split("/").pop() ?? schema.type)
?.map((schema) => schema.$ref?.split("/").pop() ?? schema.type)
.filter((schemaRef) => schemaRef)
.map((schemaRef) => this.definitions[schemaRef]?.type?.const ?? schemaRef)
.map((schemaRef) => schemaRef.split("."))
let mismatch = false
const commonPart = schemaRefsArray[0]
.filter((schemaRef, index) => {
?.filter((schemaRef, index) => {
if(!mismatch && schemaRefsArray.every((item) => item[index] === schemaRef)){
return true;
} else {
@@ -188,6 +262,14 @@
? schema.$ref.split("/").pop()
: schema.type;
if (!schemaRef) {
return {
label: "Unknown Schema",
value: "",
id: "",
};
}
const cleanSchemaRef = schemaRef.replace(/-\d+$/, "");
const lastPartOfValue = cleanSchemaRef.slice(
@@ -199,6 +281,8 @@
value: schemaRef,
id: cleanSchemaRef,
};
}).filter((schema) => {
return schema.value
});
},
},

View File

@@ -16,26 +16,21 @@
/>
</el-col>
<el-col :span="items.length > 1 ? 20 : 22" class="pe-2">
<el-select
v-if="$attrs?.schema?.items?.enum"
:model-value="element"
@update:model-value="(v) => handleInput(v, index)"
:placeholder="$t('value')"
>
<el-option
v-for="item in $attrs.schema.items.enum.filter((i) => !items.includes(i))"
:key="item"
:label="item"
:value="item"
/>
</el-select>
<InputText
v-else
:model-value="element"
@update:model-value="(v) => handleInput(v, index)"
:placeholder="$t('value')"
class="w-100"
/>
<TaskWrapper :merge="!needWrapper">
<template #tasks>
<component
:key="'array-' + index"
:is="componentType"
:model-value="element"
:task="modelValue"
:root="`${root}[${index}]`"
:properties="{}"
:schema="props.schema.items"
:definitions="props.definitions"
@update:model-value="handleInput($event, index)"
/>
</template>
</TaskWrapper>
</el-col>
<el-col :span="2" class="d-flex align-items-center justify-content-center delete">
<DeleteOutline @click="removeItem(index)" />
@@ -45,52 +40,90 @@
</template>
<script setup lang="ts">
import {ref} from "vue";
import {computed} from "vue";
import {DeleteOutline, ChevronUp, ChevronDown} from "../../code/utils/icons";
import InputText from "../../code/components/inputs/InputText.vue";
import Add from "../../code/components/Add.vue";
import getTaskComponent from "./getTaskComponent";
import TaskWrapper from "./TaskWrapper.vue";
defineOptions({inheritAttrs: false});
const emits = defineEmits(["update:modelValue"]);
const props = withDefaults(defineProps<{
schema: any;
definitions: any;
modelValue?: (string | number | boolean | undefined)[] | string | number | boolean;
required?: boolean;
root?: string;
}>(), {
modelValue: undefined
modelValue: undefined,
schema: () => ({}),
definitions: () => ({}),
required: false,
root: undefined,
});
const items = ref(
!Array.isArray(props.modelValue) ? [props.modelValue] : props.modelValue,
const componentType = computed(() => {
return getTaskComponent(props.schema.items, "", props.definitions);
});
const needWrapper = computed(() => {
return ![
"string",
"number",
"boolean",
"expression",
].includes(componentType.value.ksTaskName)
});
const items = computed(() =>
props.modelValue === undefined && !props.required
// we want to avoid displaying an item when
// modelValue is undefined
// if field is required though it invites users to fill it in
? []
: !Array.isArray(props.modelValue) ? [props.modelValue] : props.modelValue,
);
const handleInput = (value: string, index: number) => {
items.value[index] = value;
emits("update:modelValue", items.value);
emits("update:modelValue", items.value.toSpliced(index, 1, value));
};
const newEmptyValue = computed(() => {
if (props.schema.items?.type === "string") {
return "";
}
return props.schema.items?.default ?? undefined;
})
const addItem = () => {
items.value.push(undefined);
emits("update:modelValue", items.value);
emits("update:modelValue", [...items.value, newEmptyValue.value]);
};
const removeItem = (index: number) => {
items.value.splice(index, 1);
emits("update:modelValue", items.value);
if (items.value.length <= 1) {
emits("update:modelValue", undefined);
return;
}
emits("update:modelValue", items.value.toSpliced(index, 1));
};
const moveItem = (index: number, direction: "up" | "down") => {
const tempValue = items.value
if (direction === "up" && index > 0) {
[items.value[index - 1], items.value[index]] = [
items.value[index],
items.value[index - 1],
[tempValue[index - 1], tempValue[index]] = [
tempValue[index],
tempValue[index - 1],
];
} else if (direction === "down" && index < items.value.length - 1) {
[items.value[index + 1], items.value[index]] = [
items.value[index],
items.value[index + 1],
} else if (direction === "down" && index < tempValue.length - 1) {
[tempValue[index + 1], tempValue[index]] = [
tempValue[index],
tempValue[index + 1],
];
}
emits("update:modelValue", items.value);
emits("update:modelValue", tempValue);
};
</script>

View File

@@ -4,6 +4,9 @@
:schema
:definitions
:properties="computedProperties"
:root="root"
:task="task"
:required="required"
merge
@update:model-value="onInput"
/>
@@ -16,21 +19,25 @@
<script>
import Task from "./Task";
import {
BREADCRUMB_INJECTION_KEY,
PANEL_INJECTION_KEY,
} from "../../code/injectionKeys";
export default {
inheritAttrs: false,
mixins: [Task],
inject: {
panel: {from: PANEL_INJECTION_KEY},
breadcrumbs: {from: BREADCRUMB_INJECTION_KEY},
},
computed: {
computedProperties() {
const type = this.schema.$ref.split("/").pop();
return this.definitions[type]?.properties;
if(!this.schema?.allOf && !this.schema?.$ref) {
return this.schema?.properties || {};
}
const schemas = this.schema.allOf ?? [this.schema];
return schemas.reduce((acc, item) => {
if (item.$ref) {
const type = item.$ref.split("/").pop();
return {
...acc,
...this.definitions[type]?.properties
};
}
return {...acc, ...item.properties};
}, {});
},
},
};

View File

@@ -1,23 +1,32 @@
<template>
<el-row v-for="(item, index) in currentValue" :key="index" :gutter="10" class="w-100">
<el-alert
v-if="duplicatedKeys?.length"
:title="t('duplicate-pair', {label: t('key'), key: duplicatedKeys[0]})"
type="error"
show-icon
:closable="false"
class="mb-2"
/>
<el-row v-for="(item, index) in currentValue" :key="index" :gutter="10" class="w-100" :data-testid="`task-dict-item-${item[0]}-${index}`">
<el-col :span="6">
<InputText
:model-value="item[0]"
@update:model-value="onKey(index, $event)"
@change="onKeyChange(index, $event)"
margin="m-0"
placeholder="Key"
:have-error="duplicatedKeys.includes(item[0])"
/>
</el-col>
<el-col :span="16">
<component
:is="schema.additionalProperties ? getTaskComponent(schema.additionalProperties, key, properties) : TaskExpression"
:is="schema.additionalProperties ? getTaskComponent(schema.additionalProperties) : TaskExpression"
:model-value="item[1]"
@update:model-value="onValueChange(index, $event)"
:root="getKey(item[0])"
:schema="schema.additionalProperties"
:required="isRequired(item[0])"
:definitions="definitions"
:disabled
/>
</el-col>
<el-col :span="2" class="col align-self-center delete">
@@ -27,107 +36,120 @@
<Add v-if="!disabledAdding" @add="addItem()" />
</template>
<script setup>
<script lang="ts" setup>
import {computed, ref, watch} from "vue";
import {useI18n} from "vue-i18n";
import {DeleteOutline} from "../../code/utils/icons";
import InputText from "../../code/components/inputs/InputText.vue";
import TaskExpression from "./TaskExpression.vue";
import Add from "../../code/components/Add.vue";
</script>
<script>
import {toRaw} from "vue";
import Task from "./Task";
import getTaskComponent from "./getTaskComponent";
import debounce from "lodash/debounce";
function emptyValueObjectProvider() {
return {"": undefined};
const {t} = useI18n();
defineOptions({
name: "TaskDict",
inheritAttrs: false,
});
const props = defineProps({
modelValue: {
type: Object,
default: () => ({}),
},
schema: {
type: Object,
required: true,
},
definitions: {
type: Object,
default: () => ({}),
},
root: {
type: String,
default: undefined,
},
disabled: {
type: Boolean,
default: false,
},
});
const currentValue = ref<[string, any][]>([])
// this flag will avoid updating the modelValue when the
// change was initiated in the component itself
const localEdit = ref(false);
watch(
() => props.modelValue,
(newValue) => {
if(localEdit.value) {
return;
}
localEdit.value = false;
if(newValue === undefined || newValue === null) {
currentValue.value = [];
return;
}
currentValue.value = Object.entries(newValue ?? {});
},
{
immediate: true,
deep: true
},
);
const duplicatedKeys = computed(() => {
return currentValue.value.map(pair => pair[0])
.filter((key, index, self) =>
self.indexOf(key) !== index
);
});
const emitUpdate = debounce(function () {
if(duplicatedKeys.value?.length > 0) {
return;
}
localEdit.value = true;
emit("update:modelValue", Object.fromEntries(currentValue.value.filter(pair => pair[0] !== "" && pair[1] !== undefined)));
}, 200);
const emit = defineEmits(["update:modelValue"]);
function getKey(key: string) {
return props.root ? `${props.root}.${key}` : key;
}
function emptyValueEntriesProvider() {
return ["", undefined];
function isRequired(key: string) {
return props.schema?.required?.includes(key);
}
export default {
mixins: [Task],
emits: ["update:modelValue"],
props: {
class: {
type: String,
default: undefined
},
},
data() {
return {
currentValue: undefined,
};
},
created() {
this.currentValue = Object.entries(toRaw(this.values));
},
computed: {
disabledAdding() {
return !this.currentValue.at(-1)[0] || !this.currentValue.at(-1)[1];
},
values() {
if (this.modelValue === undefined) {
return emptyValueObjectProvider();
}
function onKey(key: number, val: string) {
currentValue.value[key][0] = val;
emitUpdate()
}
return this.modelValue;
},
},
watch: {
modelValue() {
this.currentValue = Object.entries(toRaw(this.values));
},
},
methods: {
emitLocal(index, value) {
const local = this.currentValue.reduce(function (acc, cur, i) {
acc[i === index ? value : cur[0]] = cur[1];
return acc;
}, {});
function onValueChange(key: number, val: any) {
currentValue.value[key][1] = val;
emitUpdate()
}
this.$emit("update:modelValue", local);
},
onValueChange(key, value) {
const local = this.currentValue || [];
local[key][1] = value;
this.currentValue = local;
function removeItem(index: number) {
currentValue.value.splice(index, 1);
emitUpdate()
}
this.emitLocal();
},
onKey(key, value) {
const local = this.currentValue || [];
local[key][0] = value;
this.currentValue = local;
},
onKeyChange(index, value) {
this.emitLocal(index, value);
},
addItem() {
const local = this.currentValue || [];
local.push(["", undefined]);
function addItem() {
currentValue.value.push(["", undefined]);
emitUpdate()
}
this.currentValue = local;
this.emitLocal();
},
removeItem(x) {
let local = this.currentValue || [];
if (local.length === 1) {
local = [emptyValueEntriesProvider()];
} else {
local.splice(x, 1);
}
this.currentValue = local;
this.emitLocal();
},
},
};
const disabledAdding = computed(() => {
return props.disabled || currentValue.value.at(-1)?.[0] === "" && currentValue.value.at(-1)?.[1] === undefined;
});
</script>
<style scoped lang="scss">

View File

@@ -10,36 +10,50 @@
:large-suggestions="false"
/>
</template>
<script>
import Task from "./Task";
<script lang="ts" setup>
import {collapseEmptyValues} from "./Task";
import Editor from "../../../components/inputs/Editor.vue";
import {YamlUtils as YAML_UTILS} from "@kestra-io/ui-libs";
import * as YAML_UTILS from "@kestra-io/ui-libs/flow-yaml-utils";
import {computed, ref} from "vue";
export default {
mixins: [Task],
components: {Editor},
data() {
return {
localEditorValue: undefined
}
const props = defineProps({
modelValue: {
type: [String, Object],
default: undefined
},
created() {
this.localEditorValue = this.editorValue;
},
methods: {
editorInput(value) {
this.localEditorValue = value;
this.onInput(this.parseValue(value));
},
parseValue(value) {
if(value.match(/^\s*{{/)) {
return value;
}
return YAML_UTILS.parse(value);
}
root: {
type: String,
default: undefined
}
};
});
function editorInput(value: string) {
localEditorValue.value = value;
onInput(parseValue(value));
}
const emit = defineEmits(["update:modelValue"]);
function onInput(value: any) {
emit("update:modelValue", collapseEmptyValues(value));
}
const editorValue = computed(() => {
if (typeof props.modelValue === "string") {
return props.modelValue;
}
return YAML_UTILS.stringify(props.modelValue);
})
const localEditorValue = ref(editorValue.value)
function parseValue(value: string) {
if(value.match(/^\s*{{/)) {
return value;
}
return YAML_UTILS.parse(value);
}
</script>
<style lang="scss" scoped>

View File

@@ -0,0 +1,7 @@
<template>
<InputPair />
</template>
<script lang="ts" setup>
import InputPair from "../../code/components/inputs/InputPair.vue";
</script>

View File

@@ -1,5 +1,5 @@
<template>
<namespace-select
<NamespaceSelect
data-type="flow"
:value="modelValue"
allow-create

View File

@@ -58,6 +58,8 @@
import TaskDict from "./TaskDict.vue";
import TaskWrapper from "./TaskWrapper.vue";
import TaskObjectField from "./TaskObjectField.vue";
defineEmits(["update:modelValue"]);
</script>
<script>
@@ -114,7 +116,6 @@
merge: {type: Boolean, default: false},
metadataInputs: {type: Boolean, default: false}
},
emits: ["update:modelValue"],
data() {
return {
activeNames: [],
@@ -150,11 +151,12 @@
"onUpdate:modelValue": (value) => {
this.onObjectInput(key, value);
},
root: this.root,
fieldKey: key,
task: this.modelValue,
schema: schema,
definitions: this.definitions,
required: this.schema?.required,
required: this.requiredProperties.map(([p]) => p),
};
},
},

View File

@@ -12,7 +12,7 @@
{{ props.fieldKey }}
</span>
<ClearButton
v-if="isAnyOf && !required"
v-if="isAnyOf && !required && modelValue && Object.keys(modelValue).length > 0"
@click="$emit('update:modelValue', undefined); taskComponent?.resetSelectType?.();"
/>
</div>
@@ -48,6 +48,7 @@
ref="taskComponent"
:is="type"
v-bind="{...componentProps}"
:disabled
class="mt-1 mb-2 wrapper"
/>
</el-form-item>
@@ -65,10 +66,12 @@
const props = defineProps<{
schema: any;
definitions: any;
root?: string;
fieldKey: string;
task: any;
modelValue?: Record<string, any> | string | number | boolean | Array<any>,
required?: string[];
disabled?: boolean;
}>()
const emit = defineEmits<{
@@ -78,7 +81,7 @@
const taskComponent = templateRef<{resetSelectType?: () => void}>("taskComponent");
const required = computed(() => {
return props.required?.includes(props.fieldKey);
return props.required?.includes(props.fieldKey) && props.schema.$required
})
const componentProps = computed(() => {
@@ -88,7 +91,7 @@
emit("update:modelValue", value);
},
task: props.task,
root: props.fieldKey,
root: props.root ? `${props.root}.${props.fieldKey}` : props.fieldKey,
schema: props.schema,
required: required.value,
definitions: props.definitions

View File

@@ -1,15 +1,37 @@
<template>
<template v-if="schema.format === 'duration'">
<div class="wrapper">
<el-checkbox-button
v-if="['duration', 'date-time'].includes(schema.format)"
v-model="pebble"
:label="$t('no_code.toggle_pebble')"
:title="$t('no_code.toggle_pebble')"
class="ks-pebble"
>
<IconCodeBracesBox />
</el-checkbox-button>
<el-time-picker
v-if="!pebble && schema.format === 'duration'"
:model-value="durationValue"
type="time"
:default-value="defaultDuration"
:placeholder="`Choose a${/^[aeiou]/i.test(root || '') ? 'n' : ''} ${root || 'duration'}`"
@update:model-value="onInputDuration"
/>
</template>
<template v-else>
<el-date-picker
v-else-if="!pebble && schema.format === 'date-time'"
:model-value="modelValue"
type="date"
:placeholder="`Choose a${/^[aeiou]/i.test(root || '') ? 'n' : ''} ${root || 'date'}`"
@update:model-value="onInput($event.toISOString())"
/>
<InputText
v-else-if="disabled"
:model-value="modelValue"
disabled
class="w-100 disabled-field"
/>
<editor
v-else
:model-value="editorValue"
:navbar="false"
:full-height="false"
@@ -17,20 +39,44 @@
schema-type="flow"
lang="plaintext"
input
:placeholder="`Your ${root || 'value'} here...`"
@update:model-value="onInput"
:large-suggestions="false"
/>
</template>
</div>
</template>
<script setup>
import Editor from "../../../components/inputs/Editor.vue";
import InputText from "../../code/components/inputs/InputText.vue";
import IconCodeBracesBox from "vue-material-design-icons/CodeBracesBox.vue";
</script>
<script>
import Task from "./Task";
import Editor from "../../../components/inputs/Editor.vue";
export default {
inheritAttrs: false,
mixins: [Task],
components: {Editor},
props:{
disabled: {
type: Boolean,
default: false,
},
},
data() {
return {
pebble: false,
};
},
emits: ["update:modelValue"],
mounted(){
if(!["duration", "date-time"].includes(this.schema.format) || !this.modelValue){
this.pebble = false;
} else if( this.schema.format === "duration" && this.values) {
this.pebble = !this.$moment.duration(this.modelValue).isValid();
} else if (this.schema.format === "date-time" && this.values) {
this.pebble = isNaN(Date.parse(this.modelValue));
}
},
computed: {
isValid() {
if (this.required && !this.modelValue) {
@@ -94,4 +140,38 @@
:deep(.placeholder) {
top: -7px !important;
}
.wrapper {
display: flex;
align-items: stretch;
justify-content: stretch;
border-radius: 0.25rem;
border: 1px solid var(--ks-border-primary);
width: 100%;
:deep(.disabled-field) {
margin: 0!important;
border-radius: 4px;
}
:deep(.el-input__wrapper),
:deep(.editor-container) {
box-shadow: none;
}
:deep(.el-checkbox-button__inner) {
padding: 4px;
border: none;
}
.ks-pebble:deep(span:hover){
color: var(--ks-content-link-hover) ;
}
.ks-pebble * {
font-size: 24px;
vertical-align: top;
}
}
</style>

View File

@@ -1,71 +1,60 @@
<template>
<el-input :model-value="JSON.stringify(values)">
<template #append>
<el-button :icon="TextSearch" @click="isOpen = true" />
</template>
</el-input>
<drawer
v-if="isOpen"
v-model="isOpen"
>
<template #header>
<code>{{ root }}</code>
</template>
<el-form label-position="top">
<task-editor
ref="editor"
:model-value="taskYaml"
:section="section"
@update:model-value="onInput"
/>
</el-form>
<template #footer>
<el-button :icon="ContentSave" @click="isOpen = false" type="primary">
{{ $t('save') }}
</el-button>
</template>
</drawer>
<div class="w-100">
<Element
:section="root"
block-type="tasks"
:parent-path-complete="parentPathComplete"
:element="{
id: model?.id ?? 'Set a task',
type: model?.type,
}"
@remove-element="removeElement()"
/>
</div>
</template>
<script setup>
import {SECTIONS} from "@kestra-io/ui-libs";
import * as YAML_UTILS from "@kestra-io/ui-libs/flow-yaml-utils";
<script setup lang="ts">
import {computed, inject} from "vue";
import {
PARENT_PATH_INJECTION_KEY,
REF_PATH_INJECTION_KEY,
CREATING_TASK_INJECTION_KEY
} from "../../code/injectionKeys";
import Element from "../../code/components/collapse/Element.vue";
import TextSearch from "vue-material-design-icons/TextSearch.vue";
import ContentSave from "vue-material-design-icons/ContentSave.vue";
import TaskEditor from "../TaskEditor.vue"
import Drawer from "../../Drawer.vue"
const model = defineModel({
type: Object,
default: () => ({})
});
const props = defineProps({
root: {
type: String,
required: true
},
});
const parentPath = inject(PARENT_PATH_INJECTION_KEY, "");
const refPath = inject(REF_PATH_INJECTION_KEY, undefined);
const creatingTask = inject(CREATING_TASK_INJECTION_KEY, false);
const parentPathComplete = computed(() => {
return `${[
[
parentPath,
creatingTask && refPath !== undefined
? `[${refPath + 1}]`
: refPath !== undefined
? `[${refPath}]`
: undefined,
].filter(Boolean).join(""),
props.root,
].filter(p => p.length).join(".")}`;
});
function removeElement() {
model.value = undefined;
}
</script>
<script>
import Task from "./Task"
export default {
inheritAttrs: false,
mixins: [Task],
emits: ["update:modelValue"],
props: {
section: {
type: String,
default: SECTIONS.TASKS
},
},
data() {
return {
isOpen: false,
};
},
computed: {
taskYaml() {
return YAML_UTILS.stringify(this.modelValue);
}
},
methods: {
onInput(value) {
this.$emit("update:modelValue", YAML_UTILS.parse(value));
},
}
};
</script>

View File

@@ -3,7 +3,7 @@
<Collapse
title="tasks"
:elements="items"
section="tasks"
:section
block-type="tasks"
@remove="(yaml) => store.commit('flow/setFlowYaml', yaml)"
@reorder="(yaml) => store.commit('flow/setFlowYaml', yaml)"
@@ -16,21 +16,32 @@
import {useStore} from "vuex";
import Collapse from "../../code/components/collapse/Collapse.vue";
defineOptions({inheritAttrs: false});
defineOptions({
inheritAttrs: false
});
const store = useStore();
interface Task {id:string, type:string}
interface Task {
id:string,
type:string
}
const props = withDefaults(defineProps<{
modelValue?: Task[]
modelValue?: Task[],
root?: string;
}>(), {
modelValue: () => []
modelValue: () => [],
root: undefined
});
const items = computed(() =>
!Array.isArray(props.modelValue) ? [props.modelValue] : props.modelValue,
);
const section = computed(() => {
return props.root ?? "tasks";
});
</script>
<style scoped lang="scss">

View File

@@ -13,13 +13,14 @@
<style lang="scss" scoped>
.schema-wrapper {
width: 100%;
padding: 1rem;
padding-bottom: 1rem;
border-radius: 8px;
margin: 1rem 0;
background: var(--ks-background-box);
}
.bordered {
background: var(--ks-background-box);
border: 1px solid var(--ks-border-secondary);
box-shadow: 0 0 0 1px var(--ks-border-primary) inset;
margin: 1rem 0;
padding: 1rem;
}
</style>

View File

@@ -1,5 +1,4 @@
import {pascalCase} from "change-case";
import InputPair from "../../code/components/inputs/InputPair.vue";
const TasksComponents = import.meta.glob<{default: any}>("./Task*.vue", {eager: true});
@@ -24,7 +23,18 @@ function getType(property: any, key?: string, schema?: any): string {
return "complex";
}
if( Object.prototype.hasOwnProperty.call(property, "allOf")) {
if (property.allOf.length === 2
&& property.allOf[0].$ref && !property.allOf[1].properties) {
return "complex";
}
}
if (Object.prototype.hasOwnProperty.call(property, "anyOf")) {
if( key === "labels" && property.anyOf.length === 2
&& property.anyOf[0].type === "array" && property.anyOf[1].type === "object") {
return "KV-pairs";
}
return "any-of";
}
@@ -37,7 +47,7 @@ function getType(property: any, key?: string, schema?: any): string {
}
if (key === "namespace") {
return "subflow-namespace";
return "namespace";
}
const properties = Object.keys(schema?.properties ?? {});
@@ -55,7 +65,8 @@ function getType(property: any, key?: string, schema?: any): string {
return "tasks";
}
if (property.items?.$ref?.includes("conditions.Condition")) {
if (property.items?.$ref?.includes("conditions.Condition")
|| property.items.anyOf?.every((item: any) => item.$ref?.includes("io.kestra.plugin.core.condition"))) {
return "conditions";
}
@@ -67,7 +78,7 @@ function getType(property: any, key?: string, schema?: any): string {
}
if( property.type === "object" && !property.properties) {
return "input-pair";
return "KV-pairs";
}
return property.type || "expression";
@@ -75,13 +86,10 @@ function getType(property: any, key?: string, schema?: any): string {
export default function getTaskComponent(property: any, key?: string, schema?: any) {
const typeString = getType(property, key, schema);
if( typeString === "input-pair") {
return InputPair;
}
const type = pascalCase(typeString);
const component = TasksComponents[`./Task${type}.vue`]?.default;
if (component) {
component.ksTaskName = typeString;
}
return component
return component ?? {}
}

View File

@@ -1,14 +1,14 @@
import {defineAsyncComponent, h, markRaw, Ref, Suspense} from "vue"
import {h, markRaw, Ref, Suspense} from "vue"
import {useStore} from "vuex";
import {useI18n} from "vue-i18n";
import MouseRightClickIcon from "vue-material-design-icons/MouseRightClick.vue";
import * as YAML_UTILS from "@kestra-io/ui-libs/flow-yaml-utils";
import type {Panel, Tab} from "../MultiPanelTabs.vue";
import {BlockType} from "../code/utils/types";
import NoCodeWrapper from "../code/NoCodeWrapper.vue"
import type {NoCodeProps} from "../code/NoCodeWrapper.vue";
const NoCodeWrapper = markRaw(defineAsyncComponent(() => import("../code/NoCodeWrapper.vue")))
const NOCODE_PREFIX = "nocode"
@@ -20,7 +20,7 @@ interface Opener {
interface Handlers {
onCreateTask: (opener: Opener, blockType: BlockType | "pluginDefaults", parentPath: string, refPath?: number, position?: "before" | "after") => boolean,
onEditTask: (opener: Opener, blockType: BlockType | "pluginDefaults", parentPath: string, refPath: number) => boolean
onEditTask: (opener: Opener, blockType: BlockType | "pluginDefaults", parentPath: string, refPath?: number) => boolean
onCloseTask: (opener: Opener) => boolean
}
@@ -67,10 +67,15 @@ export function getTabFromNoCodeTab(tab: NoCodeTabWithAction, t: (key: string) =
},
}
} else if (tab.action === "edit") {
const path = tab.refPath !== undefined
? `${tab.parentPath}[${tab.refPath}]`
: tab.parentPath ?? ""
const currentBlock: any = tab.parentPath ? YAML_UTILS.parse(YAML_UTILS.extractBlockWithPath({
source: flow,
path: `${tab.parentPath}[${tab.refPath}]`,
path,
})) : {}
return {
value: getEditTabKey(tab, keepAliveCacheBuster++),
button: {
@@ -102,6 +107,7 @@ export function getTabFromNoCodeTab(tab: NoCodeTabWithAction, t: (key: string) =
[h(NoCodeWrapper, {
...restOfTab,
creatingTask: tab.action === "create",
editingTask: tab.action === "edit",
onCloseTask: onCloseTask?.bind({}, props),
onCreateTask: onCreateTask?.bind({}, props) as any,
onEditTask: onEditTask?.bind({}, props) as any,
@@ -178,7 +184,7 @@ export function useNoCodePanels(panels: Ref<Panel[]>, handlers: Handlers) {
openerPanel.activeTab = tab
}
function openEditTaskTab(opener: { panelIndex: number, tabIndex: number }, blockType: BlockType | "pluginDefaults", parentPath: string, refPath: number, dirty: boolean = false) {
function openEditTaskTab(opener: { panelIndex: number, tabIndex: number }, blockType: BlockType | "pluginDefaults", parentPath: string, refPath?: number, dirty: boolean = false) {
const tab = getTabFromNoCodeTab({
action: "edit",
blockType,

View File

@@ -75,7 +75,7 @@
</template>
<script>
import {defineAsyncComponent, shallowRef} from "vue";
import {shallowRef} from "vue";
import UnfoldLessHorizontal from "vue-material-design-icons/UnfoldLessHorizontal.vue";
import UnfoldMoreHorizontal from "vue-material-design-icons/UnfoldMoreHorizontal.vue";
import Help from "vue-material-design-icons/Help.vue";
@@ -83,8 +83,7 @@
import BookMultipleOutline from "vue-material-design-icons/BookMultipleOutline.vue";
import Close from "vue-material-design-icons/Close.vue";
import {TabFocus} from "monaco-editor/esm/vs/editor/browser/config/tabFocus.js";
const MonacoEditor = defineAsyncComponent(() => import("./MonacoEditor.vue"));
import MonacoEditor from "./MonacoEditor.vue";
import Utils from "../../utils/utils";
@@ -142,6 +141,7 @@
plugin: undefined,
taskType: undefined,
themeComputed: Utils.getTheme(),
preventCursorChange: false,
};
},
mounted() {
@@ -154,6 +154,13 @@
},
immediate: true,
},
modelValue(value) {
if (this.editor?.getValue() !== value) {
this.preventCursorChange = true;
} else {
this.preventCursorChange = false;
}
},
},
computed: {
...mapState({mappedTheme: state => state.misc.theme}),
@@ -427,9 +434,13 @@
});
this.editor.onDidChangeCursorPosition?.(() => {
clearTimeout(this.lastTimeout);
if(this.preventCursorChange) {
this.preventCursorChange = false;
return;
}
let position = this.editor.getPosition();
let model = this.editor.getModel();
clearTimeout(this.lastTimeout);
this.lastTimeout = setTimeout(() => {
this.$emit("cursor", {
position: position,
@@ -555,7 +566,7 @@
padding-top: 7px;
&.custom-dark-vs-theme {
background-color: var(--ks-background-input);
background-color: var(--ks-background-input);
}
&.theme-light {

View File

@@ -9,7 +9,7 @@
:teleported="false"
:default-value="nowMoment.toDate()"
@change="datePickerCallback"
@keydown.esc.prevent="editorResolved.focus()"
@keydown.esc.prevent="editorResolved?.focus()"
@keydown.enter.prevent="datePickerCallback"
:clearable="false"
class="z-3"
@@ -56,6 +56,7 @@
import {Moment} from "moment";
import PlaceholderContentWidget from "../../composables/monaco/PlaceholderContentWidget.ts";
import ICodeEditor = editor.ICodeEditor;
import debounce from "lodash/debounce";
import {hashCode} from "../../utils/global.ts";
const store = useStore();
@@ -168,7 +169,7 @@
base: kestraBaseTheme.base
}
: theme as Partial<editor.IStandaloneThemeData> & { base: editor.BuiltinTheme };
const themeId = hashCode(JSON.stringify(theme)).toString();
monaco.editor.defineTheme(themeId, {
inherit: true,
@@ -176,7 +177,7 @@
colors: {},
...base
});
return themeId;
}
@@ -244,7 +245,7 @@
watch(() => props.theme, (newTheme) => {
if (typeof newTheme === "object") {
const themeId = defineCustomTheme(newTheme);
if (editorResolved.value) {
monaco.editor.setTheme(themeId);
}
@@ -303,7 +304,7 @@
node.querySelector(`.${KESTRA_ICON_WRAPPER_CLASS}`)?.remove();
if (completionValue.includes(".") && !completionValue.includes("{")) {
if (store.state.plugin.icons[completionValue] !== undefined) {
if (store.state.plugin?.icons?.[completionValue] !== undefined) {
replaceRowIcon(vsCodeIcon, h(TaskIcon, {
cls: completionValue,
"only-icon": true,
@@ -464,6 +465,12 @@
(window as any).clearEditor = () => {
localEditor?.getModel()?.setValue("")
};
(window as any).acceptSuggestion = () => {
localEditor?.trigger("acceptSelectedSuggestion", "acceptSelectedSuggestion", {});
};
(window as any).nextSuggestion = () => {
localEditor?.trigger("selectNextSuggestion", "selectNextSuggestion", {});
};
})
onBeforeUnmount(function () {
@@ -657,12 +664,12 @@
}
});
localEditor.onDidChangeCursorPosition(() => {
localEditor.onDidChangeCursorPosition(debounce(() => {
if (suggestController.model.state !== 0) {
suggestController.cancelSuggestWidget();
localEditor!.trigger("refreshSuggestionsOnCursorMove", "editor.action.triggerSuggest", {});
}
})
}, 300))
}
if (!props.input) {
@@ -797,4 +804,4 @@
}
}
}
</style>
</style>

View File

@@ -4,7 +4,7 @@
<component :is="autoRefresh ? 'auto-renew' : 'auto-renew-off'" class="auto-refresh-icon" />
</kicon>
</el-button>
<el-button @click="triggerRefresh" data-test-id="trigger-refresh-button">
<el-button @click="triggerRefresh" data-test-id="trigger-refresh-button" data-testid="trigger-refresh-button">
<kicon :tooltip="$t('trigger refresh')" placement="bottom">
<refresh />
</kicon>

View File

@@ -8,7 +8,7 @@
</template>
<script setup lang="ts">
import {ref, computed, Ref, onMounted} from "vue";
import {ref, computed, Ref, watch, onMounted} from "vue";
import {useTabs} from "override/components/namespaces/useTabs";
import {useHelpers} from "./utils/useHelpers";
@@ -25,11 +25,17 @@
const route = useRoute();
const context = ref({title: details.title});
const context = ref({title: details.value.title});
useRouteContext(context);
const namespace = computed(() => route.params?.id) as Ref<string>;
watch(namespace, (newID) => {
if (newID) {
store.dispatch("namespace/load", newID);
}
});
const store = useStore();
onMounted(() => {
if (namespace.value) {

View File

@@ -1,4 +1,4 @@
import {Component} from "vue";
import {Component, computed, Ref} from "vue";
import {useRoute} from "vue-router";
import {useI18n} from "vue-i18n";
@@ -45,30 +45,30 @@ export function useHelpers() {
const route = useRoute();
const {t} = useI18n({useScope: "global"});
const namespace = route.params?.id as string;
const namespace = computed(() => route.params?.id) as Ref<string>;
const parts = namespace?.split(".") ?? [];
const details: Details = {
title: parts.at(-1) || t("namespaces"),
const parts = computed(() => namespace.value?.split(".") ?? []);
const details: Ref<Details> = computed(() => ({
title: parts.value.at(-1) || t("namespaces"),
breadcrumb: [
{label: t("namespaces"), link: {name: "namespaces/list"}},
...parts.map((_: string, index: number) => ({
label: parts[index],
...parts.value.map((_: string, index: number) => ({
label: parts.value[index],
link: {
name: "namespaces/update",
params: {
id: parts.slice(0, index + 1).join("."),
id: parts.value.slice(0, index + 1).join("."),
tab: "overview",
},
},
disabled: index === parts.length - 1,
disabled: index === parts.value.length - 1,
})),
],
};
}));
const tabs: Tab[] = [
// If it's a system namespace, include the blueprints tab
...(namespace === "system"
...(namespace.value === "system"
? [
{
name: "blueprints",
@@ -88,26 +88,34 @@ export function useHelpers() {
name: "flows",
title: t("flows"),
component: Flows,
props: {namespace, topbar: false},
props: {namespace: namespace.value, topbar: false},
},
{
name: "executions",
title: t("executions"),
component: Executions,
props: {namespace, topbar: false, visibleCharts: true},
props: {
namespace: namespace.value,
topbar: false,
visibleCharts: true,
},
},
{
name: "dependencies",
title: t("dependencies"),
component: Dependencies,
props: {namespace, type: "dependencies"},
props: {namespace: namespace.value, type: "dependencies"},
},
{
maximized: true,
name: "files",
title: t("files"),
component: EditorView,
props: {namespace, isNamespace: true, isReadOnly: false},
props: {
namespace: namespace.value,
isNamespace: true,
isReadOnly: false,
},
},
];

View File

@@ -1,6 +1,6 @@
<template>
<div class="plugin-doc">
<template v-if="editorPlugin">
<template v-if="fetchPluginDocumentation && editorPlugin">
<div class="d-flex gap-3 mb-3 align-items-center">
<task-icon
class="plugin-icon"
@@ -42,6 +42,10 @@
absolute: {
type: Boolean,
default: false
},
fetchPluginDocumentation: {
type: Boolean,
default: true
}
},
computed: {

View File

@@ -60,11 +60,18 @@ export class FilterKeyCompletions {
private readonly _comparators: Comparators[];
private readonly _valuesFetcher: (store: Store<Record<string, any>>, hardcodedValues: ReturnType<typeof useValues>["VALUES"]) => Promise<ValueCompletions>;
private readonly _allowMultipleValues: boolean;
private readonly _forbiddenConcurrentKeys: string[];
constructor(comparators: Comparators[], valuesFetcher: (store: Store<Record<string, any>>, hardcodedValues: ReturnType<typeof useValues>["VALUES"]) => Promise<ValueCompletions> = async () => [], allowMultipleValues?: boolean) {
constructor(
comparators: Comparators[],
valuesFetcher: (store: Store<Record<string, any>>, hardcodedValues: ReturnType<typeof useValues>["VALUES"]) => Promise<ValueCompletions> = async () => [],
allowMultipleValues?: boolean,
forbiddenConcurrentKeys: string[] = []
) {
this._comparators = comparators;
this._valuesFetcher = valuesFetcher;
this._allowMultipleValues = allowMultipleValues ?? false;
this._forbiddenConcurrentKeys = forbiddenConcurrentKeys;
}
get comparators(): Comparators[] {
@@ -78,4 +85,8 @@ export class FilterKeyCompletions {
get allowMultipleValues(): boolean {
return this._allowMultipleValues;
}
}
get forbiddenConcurrentKeys(): string[] {
return this._forbiddenConcurrentKeys;
}
}

View File

@@ -58,9 +58,11 @@ export abstract class FilterLanguage {
return this._filterKeyCompletions.map(([{regex}]) => regex);
}
async keyCompletion(): Promise<Completion[]> {
async keyCompletion(usedKeys: string[] = []): Promise<Completion[]> {
return this._filterKeyCompletions
.map(([{key}, {comparators}]) => {
.filter(([_, {forbiddenConcurrentKeys}]) => {
return !usedKeys.some(usedKey => forbiddenConcurrentKeys.includes(usedKey));
}).map(([{key}, {comparators}]) => {
return new Completion(
key.replaceAll(/\$(\{[^}]*})/g, "$1"),
key.replaceAll(/\$?\{([^}]*)}/g, "") + (key.includes("{") ? "" : comparators[0])
@@ -96,4 +98,4 @@ export abstract class FilterLanguage {
multipleValuesAllowed(key: string): boolean {
return this.completionForKey(key)?.allowMultipleValues ?? false;
}
}
}

View File

@@ -60,7 +60,7 @@ export default class FilterLanguageConfigurator extends AbstractLanguageConfigur
const keyLabelToRegex = (keyLabel: string) => {
return new RegExp(keyLabel
.replaceAll(".", "\\.")
.replaceAll(/\{[^}]*}/g, "(?:\"[^,\"]*\"|[^\\s,\"]*?(?=" + COMPARATORS_REGEX + "|\\s|$))"));
.replaceAll(/\{[^}]*}/g, "(?:\"[^\"]*\"|[^\\s,\"]*?(?=" + COMPARATORS_REGEX + "|\\s|$))"));
};
if (this._filterLanguage && monaco.languages.getLanguages().find(l => l.id === this.language) === undefined) {
@@ -102,7 +102,7 @@ export default class FilterLanguageConfigurator extends AbstractLanguageConfigur
includeLF: true,
tokenizer: {
root: [
[/[\w."]+/, {
[/[\w.]*(?:"[^"]*")?[\w.]*/, {
cases: {
...keysTokenizerCases,
"@default": {token: "@rematch", next: "@rawText"}
@@ -123,7 +123,7 @@ export default class FilterLanguageConfigurator extends AbstractLanguageConfigur
],
value: [
[/"[^"]+(?![^"]*")/, "invalid"],
[new RegExp("\"[^\\n,\"]*\""), {
[new RegExp("\"[^\\n\"]*\""), {
token: "variable.value",
next: "@separator"
}],
@@ -186,7 +186,6 @@ export default class FilterLanguageConfigurator extends AbstractLanguageConfigur
})
};
};
const KEY_COMPLETIONS: Promise<Completion[]> = filterLanguage.keyCompletion();
const filterLanguageConfiguratorInstance = this;
return [
monaco.languages.registerCompletionItemProvider({
@@ -259,6 +258,9 @@ export default class FilterLanguageConfigurator extends AbstractLanguageConfigur
null,
true
);
const usedKeys = [...modelValue.matchAll(new RegExp(`\\s?(\\S+?)${COMPARATORS_REGEX}`, "g"))]
.map(([_, key]) => FilterLanguage.withNestedKeyPlaceholder(key));
if (offset === 0
|| (SEPARATOR_CHARS.includes(previousChar) && !inQuotedString)
|| (!lastWordIsComparator && comparatorsAfterCurrentWord?.matches?.[1] !== undefined)) {
@@ -268,7 +270,7 @@ export default class FilterLanguageConfigurator extends AbstractLanguageConfigur
...wordAtPosition,
endColumn: wordAtPosition.endColumn + (comparatorsAfterCurrentWord?.matches?.[1]?.length ?? 0)
},
await KEY_COMPLETIONS
await filterLanguage.keyCompletion(usedKeys)
);
}
@@ -309,7 +311,7 @@ export default class FilterLanguageConfigurator extends AbstractLanguageConfigur
);
if (currentFilterMatch === null) {
return TO_SUGGESTIONS(position, wordAtPosition, await KEY_COMPLETIONS);
return TO_SUGGESTIONS(position, wordAtPosition, await filterLanguage.keyCompletion(usedKeys));
} else {
const [, key, comparator, commaSeparatedValues] = currentFilterMatch?.matches ?? [];
@@ -344,4 +346,4 @@ export default class FilterLanguageConfigurator extends AbstractLanguageConfigur
}
})];
}
}
}

View File

@@ -25,15 +25,21 @@ const dashboardFilterKeys: Record<string, FilterKeyCompletions> = {
),
timeRange: new FilterKeyCompletions(
[Comparators.EQUALS],
async (_, hardcodedValues) => hardcodedValues.RELATIVE_DATE
async (_, hardcodedValues) => hardcodedValues.RELATIVE_DATE,
false,
["timeRange", "startDate", "endDate"]
),
startDate: new FilterKeyCompletions(
[Comparators.GREATER_THAN_OR_EQUAL_TO, Comparators.GREATER_THAN, Comparators.LESS_THAN_OR_EQUAL_TO, Comparators.LESS_THAN, Comparators.EQUALS, Comparators.NOT_EQUALS],
async () => PICK_DATE_VALUE
async () => PICK_DATE_VALUE,
false,
["timeRange"]
),
endDate: new FilterKeyCompletions(
[Comparators.LESS_THAN_OR_EQUAL_TO, Comparators.LESS_THAN, Comparators.GREATER_THAN_OR_EQUAL_TO, Comparators.GREATER_THAN, Comparators.EQUALS, Comparators.NOT_EQUALS],
async () => PICK_DATE_VALUE
async () => PICK_DATE_VALUE,
false,
["timeRange"]
),
"labels.{key}": new FilterKeyCompletions(
[Comparators.EQUALS, Comparators.NOT_EQUALS],
@@ -50,4 +56,4 @@ class DashboardFilterLanguage extends FilterLanguage {
}
}
export default DashboardFilterLanguage.INSTANCE as FilterLanguage;
export default DashboardFilterLanguage.INSTANCE as FilterLanguage;

View File

@@ -35,7 +35,9 @@ const executionFilterKeys: Record<string, FilterKeyCompletions> = {
),
scope: new FilterKeyCompletions(
[Comparators.EQUALS, Comparators.NOT_EQUALS],
async (_, hardcodedValues) => hardcodedValues.SCOPES
async (_, hardcodedValues) => hardcodedValues.SCOPES,
undefined,
["scope"]
),
childFilter: new FilterKeyCompletions(
[Comparators.EQUALS, Comparators.NOT_EQUALS],
@@ -43,15 +45,21 @@ const executionFilterKeys: Record<string, FilterKeyCompletions> = {
),
timeRange: new FilterKeyCompletions(
[Comparators.EQUALS],
async (_, hardcodedValues) => hardcodedValues.RELATIVE_DATE
async (_, hardcodedValues) => hardcodedValues.RELATIVE_DATE,
false,
["timeRange", "startDate", "endDate"]
),
startDate: new FilterKeyCompletions(
[Comparators.GREATER_THAN_OR_EQUAL_TO, Comparators.GREATER_THAN, Comparators.LESS_THAN_OR_EQUAL_TO, Comparators.LESS_THAN, Comparators.EQUALS, Comparators.NOT_EQUALS],
async () => PICK_DATE_VALUE
async () => PICK_DATE_VALUE,
false,
["timeRange"]
),
endDate: new FilterKeyCompletions(
[Comparators.LESS_THAN_OR_EQUAL_TO, Comparators.LESS_THAN, Comparators.GREATER_THAN_OR_EQUAL_TO, Comparators.GREATER_THAN, Comparators.EQUALS, Comparators.NOT_EQUALS],
async () => PICK_DATE_VALUE
async () => PICK_DATE_VALUE,
false,
["timeRange"]
),
"labels.{key}": new FilterKeyCompletions(
[Comparators.EQUALS, Comparators.NOT_EQUALS],
@@ -73,4 +81,4 @@ class ExecutionFilterLanguage extends FilterLanguage {
}
}
export default ExecutionFilterLanguage.INSTANCE as FilterLanguage;
export default ExecutionFilterLanguage.INSTANCE as FilterLanguage;

View File

@@ -4,15 +4,21 @@ import {FilterLanguage} from "../filterLanguage.ts";
const flowDashboardFilterKeys: Record<string, FilterKeyCompletions> = {
timeRange: new FilterKeyCompletions(
[Comparators.EQUALS],
async (_, hardcodedValues) => hardcodedValues.RELATIVE_DATE
async (_, hardcodedValues) => hardcodedValues.RELATIVE_DATE,
false,
["timeRange", "startDate", "endDate"]
),
startDate: new FilterKeyCompletions(
[Comparators.GREATER_THAN_OR_EQUAL_TO, Comparators.GREATER_THAN, Comparators.LESS_THAN_OR_EQUAL_TO, Comparators.LESS_THAN, Comparators.EQUALS, Comparators.NOT_EQUALS],
async () => PICK_DATE_VALUE
async () => PICK_DATE_VALUE,
false,
["timeRange"]
),
endDate: new FilterKeyCompletions(
[Comparators.LESS_THAN_OR_EQUAL_TO, Comparators.LESS_THAN, Comparators.GREATER_THAN_OR_EQUAL_TO, Comparators.GREATER_THAN, Comparators.EQUALS, Comparators.NOT_EQUALS],
async () => PICK_DATE_VALUE
async () => PICK_DATE_VALUE,
false,
["timeRange"]
),
"labels.{key}": new FilterKeyCompletions(
[Comparators.EQUALS, Comparators.NOT_EQUALS],
@@ -29,4 +35,4 @@ class FlowDashboardFilterLanguage extends FilterLanguage {
}
}
export default FlowDashboardFilterLanguage.INSTANCE as FilterLanguage;
export default FlowDashboardFilterLanguage.INSTANCE as FilterLanguage;

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