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_KEYID: ${{ secrets.SONATYPE_GPG_KEYID }}
SONATYPE_GPG_PASSWORD: ${{ secrets.SONATYPE_GPG_PASSWORD }} SONATYPE_GPG_PASSWORD: ${{ secrets.SONATYPE_GPG_PASSWORD }}
SONATYPE_GPG_FILE: ${{ secrets.SONATYPE_GPG_FILE }} SONATYPE_GPG_FILE: ${{ secrets.SONATYPE_GPG_FILE }}
GH_PERSONAL_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }}
SLACK_RELEASES_WEBHOOK_URL: ${{ secrets.SLACK_RELEASES_WEBHOOK_URL }}
end: end:
runs-on: ubuntu-latest 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)?$" echo "Invalid release version. Must match regex: ^[0-9]+(\.[0-9]+)(\.[0-9]+)-(rc[0-9])?(-SNAPSHOT)?$"
exit 1 exit 1
fi fi
# Extract the major and minor versions # Extract the major and minor versions
BASE_VERSION=$(echo "$RELEASE_VERSION" | sed -E 's/^([0-9]+\.[0-9]+)\..*/\1/') BASE_VERSION=$(echo "$RELEASE_VERSION" | sed -E 's/^([0-9]+\.[0-9]+)\..*/\1/')
RELEASE_BRANCH="refs/heads/releases/v${BASE_VERSION}.x" RELEASE_BRANCH="refs/heads/releases/v${BASE_VERSION}.x"
CURRENT_BRANCH="$GITHUB_REF" CURRENT_BRANCH="$GITHUB_REF"
if ! [[ "$CURRENT_BRANCH" == "$RELEASE_BRANCH" ]]; then if ! [[ "$CURRENT_BRANCH" == "$RELEASE_BRANCH" ]]; then
echo "Invalid release branch. Expected $RELEASE_BRANCH, was $CURRENT_BRANCH" 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 commit -m"chore(version): update to version '$RELEASE_VERSION'"
git push git push
git tag -a "v$RELEASE_VERSION" -m"v$RELEASE_VERSION" 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: GH_PERSONAL_TOKEN:
description: "The Github personal token." description: "The Github personal token."
required: true required: true
push: SLACK_RELEASES_WEBHOOK_URL:
tags: description: "The Slack webhook URL."
- '*' required: true
jobs: jobs:
publish: publish:
name: Github - Release name: Github - Release
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: 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 # Check out
- name: Checkout - Repository - name: Checkout - Repository
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -36,11 +28,20 @@ jobs:
with: with:
repository: kestra-io/actions repository: kestra-io/actions
sparse-checkout-cone-mode: true sparse-checkout-cone-mode: true
ref: fix/core-release
path: actions path: actions
sparse-checkout: | sparse-checkout: |
.github/actions .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 # GitHub Release
- name: Create GitHub release - name: Create GitHub release
uses: ./actions/.github/actions/github-release uses: ./actions/.github/actions/github-release
@@ -49,3 +50,16 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }} GITHUB_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }}
SLACK_RELEASES_WEBHOOK_URL: ${{ secrets.SLACK_RELEASES_WEBHOOK_URL }} 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: SONATYPE_GPG_FILE:
description: "The Sonatype GPG file." description: "The Sonatype GPG file."
required: true required: true
GH_PERSONAL_TOKEN:
description: "The Github personal token."
required: true
SLACK_RELEASES_WEBHOOK_URL:
description: "The Slack webhook URL."
required: true
jobs: jobs:
build-artifacts: build-artifacts:
name: Build - Artifacts name: Build - Artifacts
@@ -77,4 +83,5 @@ jobs:
if: startsWith(github.ref, 'refs/tags/v') if: startsWith(github.ref, 'refs/tags/v')
uses: ./.github/workflows/workflow-github-release.yml uses: ./.github/workflows/workflow-github-release.yml
secrets: 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-jenkins:io.kestra.plugin:plugin-jenkins:LATEST
#plugin-jira:io.kestra.plugin:plugin-jira:LATEST #plugin-jira:io.kestra.plugin:plugin-jira:LATEST
#plugin-kafka:io.kestra.plugin:plugin-kafka: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-kubernetes:io.kestra.plugin:plugin-kubernetes:LATEST
#plugin-langchain4j:io.kestra.plugin:plugin-langchain4j:LATEST #plugin-langchain4j:io.kestra.plugin:plugin-langchain4j:LATEST
#plugin-ldap:io.kestra.plugin:plugin-ldap: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.Requires;
import io.micronaut.context.annotation.Value; import io.micronaut.context.annotation.Value;
import io.micronaut.scheduling.io.watch.FileWatchConfiguration; import io.micronaut.scheduling.io.watch.FileWatchConfiguration;
import jakarta.inject.Inject;
import jakarta.annotation.Nullable; import jakarta.annotation.Nullable;
import jakarta.inject.Inject;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
import jakarta.validation.ConstraintViolationException; import jakarta.validation.ConstraintViolationException;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -26,6 +26,8 @@ import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import static io.kestra.core.tenant.TenantService.MAIN_TENANT;
@Singleton @Singleton
@Slf4j @Slf4j
@Requires(property = "micronaut.io.watch.enabled", value = "true") @Requires(property = "micronaut.io.watch.enabled", value = "true")
@@ -111,6 +113,8 @@ public class FileChangedEventListener {
} }
public void startListening(List<Path> paths) throws IOException, InterruptedException { public void startListening(List<Path> paths) throws IOException, InterruptedException {
String tenantId = this.tenantId != null ? this.tenantId : MAIN_TENANT;
for (Path path : paths) { for (Path path : paths) {
path.register(watchService, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_DELETE, StandardWatchEventKinds.ENTRY_MODIFY); path.register(watchService, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_DELETE, StandardWatchEventKinds.ENTRY_MODIFY);
} }
@@ -189,6 +193,8 @@ public class FileChangedEventListener {
} }
private void loadFlowsFromFolder(Path folder) { private void loadFlowsFromFolder(Path folder) {
String tenantId = this.tenantId != null ? this.tenantId : MAIN_TENANT;
try { try {
Files.walkFileTree(folder, new SimpleFileVisitor<Path>() { Files.walkFileTree(folder, new SimpleFileVisitor<Path>() {
@Override @Override
@@ -232,6 +238,8 @@ public class FileChangedEventListener {
} }
private Optional<FlowWithSource> parseFlow(String content, Path entry) { private Optional<FlowWithSource> parseFlow(String content, Path entry) {
String tenantId = this.tenantId != null ? this.tenantId : MAIN_TENANT;
try { try {
FlowWithSource flow = pluginDefaultService.parseFlowWithAllDefaults(tenantId, content, false); FlowWithSource flow = pluginDefaultService.parseFlowWithAllDefaults(tenantId, content, false);
modelValidator.validate(flow); modelValidator.validate(flow);

View File

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

View File

@@ -21,7 +21,7 @@ import static org.assertj.core.api.Assertions.assertThat;
class PluginDocCommandTest { 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 @Test
void run() throws IOException, URISyntaxException { void run() throws IOException, URISyntaxException {

View File

@@ -20,7 +20,7 @@ import static org.assertj.core.api.Assertions.assertThat;
class PluginListCommandTest { 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 @Test
void shouldListPluginsInstalledLocally() throws IOException, URISyntaxException { void shouldListPluginsInstalledLocally() throws IOException, URISyntaxException {

View File

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

View File

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

View File

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

View File

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

View File

@@ -116,4 +116,11 @@ public interface PluginRegistry {
default void clear() { 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 org.apache.commons.io.IOUtils;
import java.io.IOException; import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException; import java.net.URISyntaxException;
import java.net.URL; import java.net.URL;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.FileSystemNotFoundException;
import java.nio.file.FileSystems; import java.nio.file.FileSystems;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
@@ -202,19 +204,13 @@ public class PluginScanner {
var guidesDirectory = classLoader.getResource("doc/guides"); var guidesDirectory = classLoader.getResource("doc/guides");
if (guidesDirectory != null) { if (guidesDirectory != null) {
try (var fileSystem = FileSystems.newFileSystem(guidesDirectory.toURI(), Collections.emptyMap())) { try {
var root = fileSystem.getPath("/doc/guides"); var root = Path.of(guidesDirectory.toURI());
try (var stream = Files.walk(root, 1)) { addGuides(root, guides);
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('.')));
});
}
} catch (IOException | URISyntaxException e) { } catch (IOException | URISyntaxException e) {
// silently fail // silently fail
} catch (FileSystemNotFoundException e) {
addGuidesThroughNewFileSystem(guidesDirectory, guides);
} }
} }
@@ -243,6 +239,27 @@ public class PluginScanner {
.build(); .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) { public static Manifest getManifest(ClassLoader classLoader) {
try { try {
URL url = classLoader.getResource(JarFile.MANIFEST_NAME); 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 { DeserializationContext context) throws IOException {
Class<? extends Plugin> pluginType = null; Class<? extends Plugin> pluginType = null;
final String identifier = extractPluginRawIdentifier(node); final String identifier = extractPluginRawIdentifier(node, pluginRegistry.isVersioningSupported());
if (identifier != null) { if (identifier != null) {
log.trace("Looking for Plugin for: {}", log.trace("Looking for Plugin for: {}",
identifier identifier
@@ -103,7 +103,7 @@ public final class PluginDeserializer<T extends Plugin> extends JsonDeserializer
); );
if (DataChart.class.isAssignableFrom(pluginType)) { 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(); ParameterizedType genericDataFilterClass = (ParameterizedType) dataFilterClass.getGenericSuperclass();
Type dataFieldsEnum = genericDataFilterClass.getActualTypeArguments()[0]; Type dataFieldsEnum = genericDataFilterClass.getActualTypeArguments()[0];
TypeFactory typeFactory = JacksonMapper.ofJson().getTypeFactory(); 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 type = Optional.ofNullable(node.get(TYPE)).map(JsonNode::textValue).orElse(null);
String version = Optional.ofNullable(node.get(VERSION)).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 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 @Inject
private WorkerGroupExecutorInterface workerGroupExecutorInterface; private WorkerGroupExecutorInterface workerGroupExecutorInterface;
@Inject
private WorkerJobRunningStateStore workerJobRunningStateStore;
protected FlowMetaStoreInterface flowExecutorInterface; protected FlowMetaStoreInterface flowExecutorInterface;
@Inject @Inject
@@ -1072,6 +1075,25 @@ public class ExecutorService {
newExecution = executionService.killParentTaskruns(taskRun, newExecution); newExecution = executionService.killParentTaskruns(taskRun, newExecution);
} }
executor.withExecution(newExecution, "addWorkerTaskResult"); 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 // 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.amazon.ion.system.*;
import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.StreamReadConstraints;
import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
@@ -36,6 +37,8 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.TimeZone; import java.util.TimeZone;
import static com.fasterxml.jackson.core.StreamReadConstraints.DEFAULT_MAX_STRING_LEN;
public final class JacksonMapper { public final class JacksonMapper {
public static final TypeReference<Map<String, Object>> MAP_TYPE_REFERENCE = new TypeReference<>() {}; public static final TypeReference<Map<String, Object>> MAP_TYPE_REFERENCE = new TypeReference<>() {};
public static final TypeReference<List<Object>> LIST_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() {} private JacksonMapper() {}
static {
StreamReadConstraints.overrideDefaultStreamReadConstraints(
StreamReadConstraints.builder().maxNameLength(DEFAULT_MAX_STRING_LEN).build()
);
}
private static final ObjectMapper MAPPER = JacksonMapper.configure( private static final ObjectMapper MAPPER = JacksonMapper.configure(
new ObjectMapper() new ObjectMapper()
); );

View File

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

View File

@@ -5,16 +5,19 @@ import io.kestra.core.test.TestState;
import jakarta.annotation.Nullable; import jakarta.annotation.Nullable;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import java.net.URI;
import java.util.List; import java.util.List;
public record UnitTestResult( public record UnitTestResult(
@NotNull @NotNull
String unitTestId, String testId,
@NotNull @NotNull
String unitTestType, String testType,
@NotNull @NotNull
String executionId, String executionId,
@NotNull @NotNull
URI url,
@NotNull
TestState state, TestState state,
@NotNull @NotNull
List<AssertionResult> assertionResults, List<AssertionResult> assertionResults,
@@ -22,14 +25,13 @@ public record UnitTestResult(
List<AssertionRunError> errors, List<AssertionRunError> errors,
Fixtures fixtures Fixtures fixtures
) { ) {
public static UnitTestResult of(String unitTestId, String unitTestType, String executionId, URI url, List<AssertionResult> results, List<AssertionRunError> errors, @Nullable Fixtures fixtures) {
public static UnitTestResult of(String unitTestId, String unitTestType, String executionId, List<AssertionResult> results, List<AssertionRunError> errors, @Nullable Fixtures fixtures) {
TestState state; TestState state;
if(!errors.isEmpty()){ if(!errors.isEmpty()){
state = TestState.ERROR; state = TestState.ERROR;
} else { } else {
state = results.stream().anyMatch(assertion -> !assertion.isSuccess()) ? TestState.FAILED : TestState.SUCCESS; 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; 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.Environment;
import io.micronaut.context.env.PropertiesPropertySourceLoader; import io.micronaut.context.env.PropertiesPropertySourceLoader;
import io.micronaut.context.env.PropertySource; import io.micronaut.context.env.PropertySource;
@@ -29,6 +31,9 @@ public class VersionProvider {
@Inject @Inject
private Environment environment; private Environment environment;
@Inject
private Optional<SettingRepositoryInterface> settingRepository; // repositories are not always there on unit tests
@PostConstruct @PostConstruct
public void start() { public void start() {
final Optional<PropertySource> gitProperties = new PropertiesPropertySourceLoader() final Optional<PropertySource> gitProperties = new PropertiesPropertySourceLoader()
@@ -40,6 +45,18 @@ public class VersionProvider {
this.revision = loadRevision(gitProperties); this.revision = loadRevision(gitProperties);
this.date = loadTime(gitProperties); this.date = loadTime(gitProperties);
this.version = loadVersion(buildProperties, 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, private String loadVersion(final Optional<PropertySource> buildProperties,

View File

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

View File

@@ -49,7 +49,6 @@ class DocumentationGeneratorTest {
assertThat(render).contains("description: \"Short description for this task\""); assertThat(render).contains("description: \"Short description for this task\"");
assertThat(render).contains("`VALUE_1`"); assertThat(render).contains("`VALUE_1`");
assertThat(render).contains("`VALUE_2`"); assertThat(render).contains("`VALUE_2`");
assertThat(render).contains("This plugin is exclusively available on the Cloud and Enterprise editions of Kestra.");
} }
@SuppressWarnings({"rawtypes", "unchecked"}) @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.ObjectMapper;
import com.fasterxml.jackson.databind.exc.InvalidTypeIdException; import com.fasterxml.jackson.databind.exc.InvalidTypeIdException;
import com.fasterxml.jackson.databind.module.SimpleModule; import com.fasterxml.jackson.databind.module.SimpleModule;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.fasterxml.jackson.databind.node.TextNode; import com.fasterxml.jackson.databind.node.TextNode;
import io.kestra.core.models.Plugin; import io.kestra.core.models.Plugin;
import io.kestra.core.plugins.PluginRegistry; import io.kestra.core.plugins.PluginRegistry;
@@ -15,12 +16,14 @@ import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.stubbing.Answer; import org.mockito.stubbing.Answer;
import static org.assertj.core.api.Assertions.assertThat;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
class PluginDeserializerTest { class PluginDeserializerTest {
@Mock @Mock
private PluginRegistry registry; private PluginRegistry registry;
@Test @Test
void shouldSucceededDeserializePluginGivenValidType() throws JsonProcessingException { void shouldSucceededDeserializePluginGivenValidType() throws JsonProcessingException {
// Given // Given
@@ -38,8 +41,9 @@ class PluginDeserializerTest {
TestPluginHolder deserialized = om.readValue(input, TestPluginHolder.class); TestPluginHolder deserialized = om.readValue(input, TestPluginHolder.class);
// Then // Then
Assertions.assertEquals(TestPlugin.class.getCanonicalName(), deserialized.plugin().getType()); assertThat(TestPlugin.class.getCanonicalName()).isEqualTo(deserialized.plugin().getType());
Mockito.verify(registry, Mockito.only()).findClassByIdentifier(identifier); Mockito.verify(registry, Mockito.times(1)).isVersioningSupported();
Mockito.verify(registry, Mockito.times(1)).findClassByIdentifier(identifier);
} }
@Test @Test
@@ -57,17 +61,33 @@ class PluginDeserializerTest {
}); });
// Then // Then
Assertions.assertEquals("io.kestra.core.plugins.serdes.Unknown", exception.getTypeId()); assertThat("io.kestra.core.plugins.serdes.Unknown").isEqualTo(exception.getTypeId());
} }
@Test @Test
void shouldReturnNullPluginIdentifierGivenNullType() { void shouldReturnNullPluginIdentifierGivenNullType() {
Assertions.assertNull(PluginDeserializer.extractPluginRawIdentifier(new TextNode(null))); assertThat(PluginDeserializer.extractPluginRawIdentifier(new TextNode(null), true)).isNull();
} }
@Test @Test
void shouldReturnNullPluginIdentifierGivenEmptyType() { 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) { 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.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError
org.gradle.parallel=true org.gradle.parallel=true
org.gradle.caching=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); 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.models.triggers.TriggerContext;
import io.kestra.core.queues.QueueFactoryInterface; import io.kestra.core.queues.QueueFactoryInterface;
import io.kestra.core.queues.QueueInterface; import io.kestra.core.queues.QueueInterface;
import io.kestra.core.runners.WorkerJobRunningStateStore;
import io.kestra.core.runners.WorkerTriggerResult; import io.kestra.core.runners.WorkerTriggerResult;
import io.kestra.core.utils.Either; import io.kestra.core.utils.Either;
import io.kestra.jdbc.repository.AbstractJdbcWorkerJobRunningRepository;
import io.kestra.jdbc.runner.JdbcQueue; import io.kestra.jdbc.runner.JdbcQueue;
import io.micronaut.context.ApplicationContext; import io.micronaut.context.ApplicationContext;
import io.micronaut.inject.qualifiers.Qualifiers; import io.micronaut.inject.qualifiers.Qualifiers;
@@ -30,7 +30,8 @@ public class JdbcWorkerTriggerResultQueueService implements Closeable {
private final JdbcQueue<WorkerTriggerResult> workerTriggerResultQueue; private final JdbcQueue<WorkerTriggerResult> workerTriggerResultQueue;
@Inject @Inject
private AbstractJdbcWorkerJobRunningRepository jdbcWorkerJobRunningRepository; private WorkerJobRunningStateStore workerJobRunningStateStore;
private final AtomicReference<Runnable> disposable = new AtomicReference<>(); private final AtomicReference<Runnable> disposable = new AtomicReference<>();
private final AtomicBoolean isClosed = new AtomicBoolean(false); private final AtomicBoolean isClosed = new AtomicBoolean(false);
@@ -52,14 +53,14 @@ public class JdbcWorkerTriggerResultQueueService implements Closeable {
try { try {
JsonNode json = MAPPER.readTree(either.getRight().getRecord()); JsonNode json = MAPPER.readTree(either.getRight().getRecord());
var triggerContext = MAPPER.treeToValue(json.get("triggerContext"), TriggerContext.class); var triggerContext = MAPPER.treeToValue(json.get("triggerContext"), TriggerContext.class);
jdbcWorkerJobRunningRepository.deleteByKey(triggerContext.uid()); workerJobRunningStateStore.deleteByKey(triggerContext.uid());
} catch (JsonProcessingException | DeserializationException e) { } catch (JsonProcessingException | DeserializationException e) {
// ignore the message if we cannot do anything about it // ignore the message if we cannot do anything about it
log.error("Unexpected exception when trying to handle a deserialization error", e); log.error("Unexpected exception when trying to handle a deserialization error", e);
} }
} else { } else {
WorkerTriggerResult workerTriggerResult = either.getLeft(); WorkerTriggerResult workerTriggerResult = either.getLeft();
jdbcWorkerJobRunningRepository.deleteByKey(workerTriggerResult.getTriggerContext().uid()); workerJobRunningStateStore.deleteByKey(workerTriggerResult.getTriggerContext().uid());
} }
consumer.accept(either); consumer.accept(either);
}); });

View File

@@ -45,6 +45,15 @@ public abstract class AbstractJdbcTenantMigration implements TenantMigrationInte
} }
if (!dryRun) { 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; int updated;
if (tableWithKey(table.getName())){ if (tableWithKey(table.getName())){
updated = dslContextWrapper.transactionResult(configuration -> { updated = dslContextWrapper.transactionResult(configuration -> {
@@ -93,4 +102,9 @@ public abstract class AbstractJdbcTenantMigration implements TenantMigrationInte
protected abstract int updateTenantIdFieldAndKey(Table<?> table, DSLContext context); 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 com.google.common.annotations.VisibleForTesting;
import io.kestra.core.repositories.WorkerJobRunningRepositoryInterface; import io.kestra.core.repositories.WorkerJobRunningRepositoryInterface;
import io.kestra.core.runners.WorkerJobRunning; import io.kestra.core.runners.WorkerJobRunning;
import io.kestra.core.runners.WorkerJobRunningStateStore;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.jooq.DSLContext; import org.jooq.DSLContext;
import org.jooq.Record1; import org.jooq.Record1;
@@ -13,7 +14,7 @@ import java.util.List;
import java.util.Optional; import java.util.Optional;
@Slf4j @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; protected io.kestra.jdbc.AbstractJdbcRepository<WorkerJobRunning> jdbcRepository;
public AbstractJdbcWorkerJobRunningRepository(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 @Override
public void deleteByKey(String uid) { public void deleteByKey(String key) {
Optional<WorkerJobRunning> workerJobRunning = this.findByKey(uid); this.jdbcRepository.getDslContextWrapper()
workerJobRunning.ifPresent(jobRunning -> this.jdbcRepository.delete(jobRunning)); .transaction(configuration ->
DSL
.using(configuration)
.deleteFrom(this.jdbcRepository.getTable())
.where(field("key").eq(key))
.execute()
);
} }
@Override @Override

View File

@@ -718,22 +718,6 @@ public class JdbcExecutor implements ExecutorInterface, Service {
try { try {
// process worker task result // process worker task result
executorService.addWorkerTaskResult(current, () -> findFlow(execution), message); 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 // join worker result
return Pair.of( return Pair.of(
current, current,
@@ -1166,7 +1150,7 @@ public class JdbcExecutor implements ExecutorInterface, Service {
try { try {
// Handle paused tasks // 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(); FlowInterface flow = flowMetaStore.findByExecution(pair.getLeft()).orElseThrow();
if (executionDelay.getTaskRunId() == null) { if (executionDelay.getTaskRunId() == null) {
// if taskRunId is null, this means we restart a flow that was delayed at startup (scheduled on) // 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()); String remotePath = windowsToUnixPath(taskCommands.getWorkingDirectory().toString());
// first, create an archive // 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()); try (FileOutputStream fos = new FileOutputStream(fileArchive.toString());
TarArchiveOutputStream out = new TarArchiveOutputStream(fos)) { TarArchiveOutputStream out = new TarArchiveOutputStream(fos)) {
out.setLongFileMode(TarArchiveOutputStream.LONGFILE_POSIX); // allow long file name out.setLongFileMode(TarArchiveOutputStream.LONGFILE_POSIX); // allow long file name
@@ -827,8 +827,23 @@ public class Docker extends TaskRunner<Docker.DockerTaskRunnerDetailResult> {
.longValue(); .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) { 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)) { if (policy.equals(PullPolicy.IF_NOT_PRESENT)) {
try { 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( new RetryUtils().<Boolean, InternalServerErrorException>of(
Exponential.builder() Exponential.builder()
.delayFactor(2.0) .delayFactor(2.0)
@@ -851,8 +868,8 @@ public class Docker extends TaskRunner<Docker.DockerTaskRunnerDetailResult> {
(bool, throwable) -> throwable instanceof InternalServerErrorException || (bool, throwable) -> throwable instanceof InternalServerErrorException ||
throwable.getCause() instanceof ConnectionClosedException, throwable.getCause() instanceof ConnectionClosedException,
() -> { () -> {
String tag = !imageParse.tag.isEmpty() ? imageParse.tag : "latest"; var tag = !parsedTagFromImage.tag.isEmpty() ? parsedTagFromImage.tag : "latest";
String repository = pull.getRepository().contains(":") ? pull.getRepository().split(":")[0] : pull.getRepository(); var repository = pull.getRepository().contains(":") ? pull.getRepository().split(":")[0] : pull.getRepository();
pull pull
.withTag(tag) .withTag(tag)
.exec(new PullImageResultCallback()) .exec(new PullImageResultCallback())

View File

@@ -1,12 +1,40 @@
package io.kestra.plugin.scripts.runner.docker; 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.AbstractTaskRunnerTest;
import io.kestra.core.models.tasks.runners.TaskRunner; 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 { class DockerTest extends AbstractTaskRunnerTest {
@Override @Override
protected TaskRunner<?> taskRunner() { protected TaskRunner<?> taskRunner() {
return Docker.builder().image("rockylinux:9.3-minimal").build(); 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 @KestraTest
public abstract class AbstractTaskRunnerTest { public abstract class AbstractTaskRunnerTest {
@Inject private TestRunContextFactory runContextFactory; @Inject protected TestRunContextFactory runContextFactory;
@Inject private StorageInterface storage; @Inject private StorageInterface storage;
@Test @Test

View File

@@ -1,25 +1,33 @@
import type {StorybookConfig} from "@storybook/vue3-vite"; import type {StorybookConfig} from "@storybook/vue3-vite";
import path from "path";
const config: StorybookConfig = { const config: StorybookConfig = {
stories: [ stories: [
"../tests/**/*.stories.@(js|jsx|mjs|ts|tsx)" "../tests/**/*.stories.@(js|jsx|mjs|ts|tsx)"
], ],
addons: [ addons: [
"@storybook/addon-essentials", "@storybook/addon-themes",
"@storybook/addon-themes", "@storybook/addon-vitest",
"@storybook/experimental-addon-test" ],
], framework: {
framework: { name: "@storybook/vue3-vite",
name: "@storybook/vue3-vite", options: {},
options: {}, },
}, async viteFinal(config) {
async viteFinal(config) { const {default: viteJSXPlugin} = await import("@vitejs/plugin-vue-jsx")
const {default: viteJSXPlugin} = await import("@vitejs/plugin-vue-jsx") config.plugins = [
config.plugins = [ ...(config.plugins ?? []),
...(config.plugins ?? []), viteJSXPlugin(),
viteJSXPlugin(), ];
];
return config; 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; 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 {withThemeByClassName} from "@storybook/addon-themes";
import initApp from "../src/utils/init"; import initApp from "../src/utils/init";
import stores from "../src/stores/store"; import stores from "../src/stores/store";
@@ -11,7 +11,7 @@ window.KESTRA_BASE_PATH = "/ui";
window.KESTRA_UI_PATH = "./"; window.KESTRA_UI_PATH = "./";
/** /**
* @type {import('@storybook/vue3').Preview} * @type {import('@storybook/vue3-vite').Preview}
*/ */
const preview = { const preview = {
parameters: { 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 {beforeAll} from "vitest";
import {setProjectAnnotations} from "@storybook/vue3"; import {setProjectAnnotations} from "@storybook/vue3-vite";
import * as projectAnnotations from "./preview"; import * as projectAnnotations from "./preview";
// This is an important step to apply the right configuration when testing your stories. // This is an important step to apply the right configuration when testing your stories.

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
<template> <template>
<Splitpanes class="default-theme" @resize="onResize"> <Splitpanes class="default-theme" v-bind="$attrs" @resize="onResize">
<Pane <Pane
v-for="(panel, panelIndex) in panels" v-for="(panel, panelIndex) in panels"
min-size="10" min-size="10"
@@ -47,6 +47,7 @@
@dragleave.prevent @dragleave.prevent
:data-tab-id="tab.value" :data-tab-id="tab.value"
@click="panel.activeTab = tab" @click="panel.activeTab = tab"
@mouseup="middleMouseClose($event, panelIndex, tab)"
> >
<component :is="tab.button.icon" class="tab-icon" /> <component :is="tab.button.icon" class="tab-icon" />
{{ tab.button.label }} {{ tab.button.label }}
@@ -131,10 +132,31 @@
</div> </div>
</Pane> </Pane>
</Splitpanes> </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> </template>
<script lang="ts" setup> <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 {useI18n} from "vue-i18n";
import "splitpanes/dist/splitpanes.css" import "splitpanes/dist/splitpanes.css"
@@ -206,6 +228,15 @@
const dragging = ref(false); const dragging = ref(false);
const tabContainerRefs = ref<HTMLDivElement[]>([]); const tabContainerRefs = ref<HTMLDivElement[]>([]);
const draggingPanel = ref<number | null>(null); 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}[]) { function onResize(e: {size:number}[]) {
let i = 0; let i = 0;
@@ -222,7 +253,10 @@
function cleanUp(){ function cleanUp(){
dragging.value = false; dragging.value = false;
realDragging.value = false;
mouseXRef.value = -1; mouseXRef.value = -1;
leftPanelDragover.value = false;
rightPanelDragover.value = false;
nextTick(() => { nextTick(() => {
movedTabInfo.value = null movedTabInfo.value = null
for(const panel of panels.value) { for(const panel of panels.value) {
@@ -244,6 +278,12 @@
} }
function dragover(e: DragEvent) { 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 // if mouse has not moved vertically, stop the processing
// this will be triggered every few ms so perf and readability will be paramount // this will be triggered every few ms so perf and readability will be paramount
if(mouseXRef.value === e.clientX){ 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){ function closeAllTabs(panelIndex: number){
panels.value[panelIndex].tabs = []; panels.value[panelIndex].tabs = [];
} }
@@ -463,6 +546,36 @@
panelsCopy.splice(newIndex, 0, movedPanel); panelsCopy.splice(newIndex, 0, movedPanel);
panels.value = panelsCopy; 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> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -620,4 +733,46 @@
transition: background-color 0.2s ease; 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> </style>

View File

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

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="h-100 overflow-y-auto no-code"> <div class="no-code">
<Breadcrumbs /> <Breadcrumbs />
<hr class="m-0"> <hr class="m-0">
@@ -19,11 +19,11 @@
import { import {
BREADCRUMB_INJECTION_KEY, CLOSE_TASK_FUNCTION_INJECTION_KEY, BREADCRUMB_INJECTION_KEY, CLOSE_TASK_FUNCTION_INJECTION_KEY,
CREATE_TASK_FUNCTION_INJECTION_KEY, CREATING_TASK_INJECTION_KEY, CREATING_TASK_INJECTION_KEY, BLOCKTYPE_INJECT_KEY,
EDIT_TASK_FUNCTION_INJECTION_KEY, BLOCKTYPE_INJECT_KEY,
PANEL_INJECTION_KEY, POSITION_INJECTION_KEY, PANEL_INJECTION_KEY, POSITION_INJECTION_KEY,
REF_PATH_INJECTION_KEY, PARENT_PATH_INJECTION_KEY, REF_PATH_INJECTION_KEY, PARENT_PATH_INJECTION_KEY,
FLOW_INJECTION_KEY, FLOW_INJECTION_KEY,
EDITING_TASK_INJECTION_KEY,
} from "./injectionKeys"; } from "./injectionKeys";
import Breadcrumbs from "./components/Breadcrumbs.vue"; import Breadcrumbs from "./components/Breadcrumbs.vue";
import Editor from "./segments/Editor.vue"; import Editor from "./segments/Editor.vue";
@@ -34,7 +34,7 @@
(e: "updateMetadata", value: {[key: string]: any}): void (e: "updateMetadata", value: {[key: string]: any}): void
(e: "reorder", yaml: string): void (e: "reorder", yaml: string): void
(e: "createTask", blockType: string, parentPath: string, refPath: number | undefined, position?: "before" | "after"): boolean | 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 (e: "closeTask"): boolean | void
}>() }>()
@@ -55,9 +55,11 @@
*/ */
refPath?: number; refPath?: number;
creatingTask?: boolean; creatingTask?: boolean;
editingTask?: boolean;
position?: "before" | "after"; position?: "before" | "after";
}>(), { }>(), {
creatingTask: false, creatingTask: false,
editingTask: false,
position: "after", position: "after",
refPath: undefined, refPath: undefined,
blockType: undefined, blockType: undefined,
@@ -66,7 +68,6 @@
const metadata = computed(() => YAML_UTILS.getMetadata(props.flow)); const metadata = computed(() => YAML_UTILS.getMetadata(props.flow));
const creatingTaskRef = ref(props.creatingTask)
const breadcrumbs = ref<Breadcrumb[]>([]) const breadcrumbs = ref<Breadcrumb[]>([])
const panel = ref() const panel = ref()
@@ -77,13 +78,9 @@
provide(BREADCRUMB_INJECTION_KEY, breadcrumbs); provide(BREADCRUMB_INJECTION_KEY, breadcrumbs);
provide(BLOCKTYPE_INJECT_KEY, props.blockType); provide(BLOCKTYPE_INJECT_KEY, props.blockType);
provide(POSITION_INJECTION_KEY, props.position); provide(POSITION_INJECTION_KEY, props.position);
provide(CREATING_TASK_INJECTION_KEY, computed(() => creatingTaskRef.value)); provide(CREATING_TASK_INJECTION_KEY, props.creatingTask);
provide(CREATE_TASK_FUNCTION_INJECTION_KEY, (blockType, parentPath, refPath) => { provide(EDITING_TASK_INJECTION_KEY, props.editingTask);
emit("createTask", blockType, parentPath, refPath)
});
provide(EDIT_TASK_FUNCTION_INJECTION_KEY, (blockType, parentPath, refPath) => {
emit("editTask", blockType, parentPath, refPath)
});
provide(CLOSE_TASK_FUNCTION_INJECTION_KEY, () => { provide(CLOSE_TASK_FUNCTION_INJECTION_KEY, () => {
if (breadcrumbs.value[breadcrumbs.value.length - 1].component) { if (breadcrumbs.value[breadcrumbs.value.length - 1].component) {
breadcrumbs.value.pop(); breadcrumbs.value.pop();
@@ -95,4 +92,13 @@
}) })
</script> </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> <template>
<div> <NoCode
<NoCode :flow="lastValidFlowYaml"
:flow="lastValidFlowYaml" :parent-path="parentPath"
:parent-path="parentPath" :ref-path="refPath"
:ref-path="refPath" :block-type="blockType"
:block-type="blockType" :creating-task="creatingTask"
:creating-task="creatingTask" :editing-task="editingTask"
:position :position
@update-metadata="(e) => onUpdateMetadata(e)" @update-metadata="(e) => onUpdateMetadata(e)"
@update-task="(e) => editorUpdate(e)" @update-task="(e) => editorUpdate(e)"
@reorder="(yaml) => handleReorder(yaml)" @reorder="(yaml) => handleReorder(yaml)"
@create-task="(blockType, parentPath, refPath) => emit('createTask', blockType, parentPath, refPath, 'after')" @close-task="() => emit('closeTask')"
@close-task="() => emit('closeTask')" />
@edit-task="(blockType, parentPath, refPath) => emit('editTask', blockType, parentPath, refPath)"
/>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {computed, ref} from "vue"; import {computed, provide, ref} from "vue";
import debounce from "lodash/debounce"; import debounce from "lodash/debounce";
import {useStore} from "vuex"; import {useStore} from "vuex";
import * as YAML_UTILS from "@kestra-io/ui-libs/flow-yaml-utils"; import * as YAML_UTILS from "@kestra-io/ui-libs/flow-yaml-utils";
import NoCode from "./NoCode.vue"; import NoCode from "./NoCode.vue";
import {BlockType} from "./utils/types"; import {BlockType} from "./utils/types";
import {CREATE_TASK_FUNCTION_INJECTION_KEY, EDIT_TASK_FUNCTION_INJECTION_KEY} from "./injectionKeys";
export interface NoCodeProps { export interface NoCodeProps {
creatingTask?: boolean; creatingTask?: boolean;
editingTask?: boolean;
blockType?: BlockType | "pluginDefaults"; blockType?: BlockType | "pluginDefaults";
parentPath?: string; parentPath?: string;
refPath?: number; refPath?: number;
@@ -37,10 +36,17 @@
const emit = defineEmits<{ const emit = defineEmits<{
(e: "createTask", blockType: string, parentPath: string, refPath: number | undefined, position: "after" | "before"): boolean | void; (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; (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 store = useStore();
const flowYaml = computed<string>(() => store.getters["flow/flowYaml"]); const flowYaml = computed<string>(() => store.getters["flow/flowYaml"]);

View File

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

View File

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

View File

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

View File

@@ -14,7 +14,7 @@
size="small" size="small"
class="border-0" 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')" /> <ChevronUp @click.prevent.stop="emits('moveElement', 'up')" />
<ChevronDown @click.prevent.stop="emits('moveElement', 'down')" /> <ChevronDown @click.prevent.stop="emits('moveElement', 'down')" />
</div> </div>
@@ -43,7 +43,7 @@
id: string; id: string;
type: string; type: string;
}; };
elementIndex: number; elementIndex?: number;
}>(); }>();
import {useStore} from "vuex"; import {useStore} from "vuex";

View File

@@ -1,27 +1,36 @@
<template> <template>
<span v-if="required" class="me-1 text-danger">*</span> <span v-if="required" class="me-1 text-danger">*</span>
<span v-if="label" class="label">{{ label }}</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"> <div class="mt-1 mb-2 w-100 wrapper">
<el-row <el-row
v-for="(value, key, index) in props.modelValue" v-for="(pair, index) in internalPairs"
:key="index" :key="index"
:gutter="10" :gutter="10"
> >
<el-col :span="8"> <el-col :span="8">
<InputText <InputText
:model-value="key" :model-value="pair[0]"
:placeholder="t('key')" :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>
<el-col :span="16" class="d-flex"> <el-col :span="16" class="d-flex">
<InputText <InputText
:model-value="value" :model-value="pair[1]"
:placeholder="t('value')" :placeholder="t('value')"
@update:model-value="(changed) => updateValue(key, changed)" @update:model-value="(changed) => updateValue(index, changed)"
class="w-100 me-2" class="w-100 me-2"
/> />
<DeleteOutline @click="removePair(key)" class="delete" /> <DeleteOutline @click="removePair(index)" class="delete" />
</el-col> </el-col>
</el-row> </el-row>
@@ -30,8 +39,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {PropType} from "vue"; import {watch, computed, ref} from "vue";
import {PairField} from "../../utils/types"; import {PairField} from "../../utils/types";
import {DeleteOutline} from "../../utils/icons"; import {DeleteOutline} from "../../utils/icons";
@@ -47,56 +55,78 @@
inheritAttrs: false, inheritAttrs: false,
}); });
const emits = defineEmits(["update:modelValue"]); const emit = defineEmits(["update:modelValue"]);
const props = defineProps({ const props = defineProps<{
modelValue: { modelValue?: PairField["value"],
type: Object as PropType<PairField["value"]>, label?: string,
default: undefined, property?: string,
}, required?: boolean
label: {type: String, default: undefined}, }>();
property: {type: String, default: undefined},
required: {type: Boolean, default: false}, 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 = () => { const alertState = computed(() => {
emits("update:modelValue", {...props.modelValue, "": ""}); if(duplicatedKeys.value.length > 0){
}; return {
const removePair = (key: any) => { visible: true,
const values = {...props.modelValue}; message: t("duplicate-pair", {label: props.label ?? t("key"), key: duplicatedKeys.value[0]}),
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);
} }
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}; function addPair() {
values[key] = value; internalPairs.value.push(["", undefined])
emits("update:modelValue", values); updateModel()
};
function removePair (pairId: number) {
internalPairs.value.splice(pairId, 1);
updateModel()
};
function updateValue (pairId: number, newValue: string){
internalPairs.value[pairId][1] = newValue;
updateModel()
}; };
</script> </script>

View File

@@ -8,8 +8,9 @@
:placeholder :placeholder
:disabled :disabled
:type="disabled ? '' : 'textarea'" :type="disabled ? '' : 'textarea'"
:suffix-icon="Lock"
:autosize="{minRows: 1}" :autosize="{minRows: 1}"
:input-style="haveError ? {boxShadow: '0 0 6px #ab0009'} : {}"
:suffix-icon="disabled ? Lock : undefined"
/> />
</div> </div>
</template> </template>
@@ -31,18 +32,20 @@
disabled: {type: Boolean, default: false}, disabled: {type: Boolean, default: false},
margin: {type: String, default: "mt-1 mb-2"}, margin: {type: String, default: "mt-1 mb-2"},
class: {type: String, default: undefined}, class: {type: String, default: undefined},
haveError: {type: Boolean, default: false}
}); });
const input = computed({ const input = computed({
get: () => props.modelValue, get: () => props.modelValue,
set: (value) => { set: (value) => {
emits("update:modelValue", value); emits("update:modelValue", value);
}, }
}); });
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@import "../../styles/code.scss"; @import "../../styles/code.scss";
:deep(.el-input__icon) { :deep(.el-input__icon) {
.lock-icon { .lock-icon {
color: var(--ks-content-inactive); 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 * 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 * 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 * Call this when starting to create a new task, when the user clicks on the add button
* to start the addition process * 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 * Call this when starting to edit a task, when the user clicks on the task line
* to start the edition process * 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 * Call this when closing a task, when the user clicks on the close button
*/ */

View File

@@ -1,25 +1,33 @@
<template> <template>
<div class="p-4"> <div class="p-4">
<template v-if="panel"> <template v-if="panel">
<component <MetadataInputsContent
:is="panel.type" :inputs="metadata.inputs"
:model-value="panel.props.modelValue" :label="t('inputs')"
v-bind="panel.props" :selected-index="panel.props.selectedIndex"
@update:model-value=" @update:inputs="
(value: any) => emits('updateMetadata', 'inputs', value) (value: any) => emits('updateMetadata', 'inputs', value)
" "
/> />
</template> </template>
<template v-else-if="!creatingTask && refPath === undefined"> <template v-else-if="!creatingTask && !editingTask">
<el-form label-position="top"> <el-form label-position="top" v-if="fieldsFromSchema.length">
<component <TaskWrapper :key="v.root" v-for="(v) in fieldsFromSchema.slice(0, 3)" :merge="shouldMerge(v.schema)">
v-for="(v, k) in mainFields" <template #tasks>
:key="k" <TaskObjectField
:is="v.component" v-bind="v"
v-model="v.value" @update:model-value="updateMetadata(v.root, $event)"
v-bind="trimmed(v)" />
@update:model-value="emits('updateMetadata', k, v.value)" </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"> <hr class="my-4">
@@ -34,15 +42,34 @@
<hr class="my-4"> <hr class="my-4">
<component <TaskWrapper :key="v.root" v-for="(v) in fieldsFromSchema.slice(4)" :merge="shouldMerge(v.schema)">
v-for="(v, k) in otherFields" <template #tasks>
:key="k" <TaskObjectField
:is="v.component" v-bind="v"
v-model="v.value" @update:model-value="updateMetadata(v.root, $event)"
v-bind="trimmed(v)" />
@update:model-value="emits('updateMetadata', k, v.value)" </template>
/> </TaskWrapper>
</el-form> </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> </template>
<Task <Task
@@ -56,34 +83,34 @@
import {onMounted, computed, inject, ref} from "vue"; import {onMounted, computed, inject, ref} from "vue";
import * as YAML_UTILS from "@kestra-io/ui-libs/flow-yaml-utils"; 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 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 MetadataInputs from "../../flows/MetadataInputs.vue";
import MetadataRetry from "../../flows/MetadataRetry.vue"; import MetadataInputsContent from "../../flows/MetadataInputsContent.vue";
import MetadataSLA from "../../flows/MetadataSLA.vue"; import TaskObjectField from "../../flows/tasks/TaskObjectField.vue";
import TaskBasic from "../../flows/tasks/TaskBasic.vue"; import InitialSchema from "./flow-schema.json"
import { import {
CREATING_TASK_INJECTION_KEY, FLOW_INJECTION_KEY, CREATING_TASK_INJECTION_KEY, EDITING_TASK_INJECTION_KEY,
PANEL_INJECTION_KEY, REF_PATH_INJECTION_KEY, FLOW_INJECTION_KEY, PANEL_INJECTION_KEY,
} from "../injectionKeys"; } from "../injectionKeys";
import Task from "./Task.vue"; import Task from "./Task.vue";
const panel = inject(PANEL_INJECTION_KEY, ref()); 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"; import {useI18n} from "vue-i18n";
const {t} = useI18n({useScope: "global"}); const {t} = useI18n({useScope: "global"});
import {useStore} from "vuex"; import {useStore} from "vuex";
import TaskWrapper from "../../flows/tasks/TaskWrapper.vue";
import {removeNullAndUndefined} from "../utils/cleanUp";
const store = useStore(); const store = useStore();
const emits = defineEmits([ 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); document.addEventListener("keydown", saveEvent);
const creatingFlow = computed(() => { const creatingFlow = computed(() => {
@@ -113,111 +156,76 @@
metadata: {type: Object, required: true}, 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) { function onTaskUpdate(yaml: string) {
emits("updateTask", yaml) emits("updateTask", yaml)
} }
const schema = ref({}) const schema = ref<{
definitions?: any,
$ref?: string,
}>(InitialSchema)
onMounted(async () => { onMounted(async () => {
await store.dispatch("plugin/loadSchemaType").then((response) => { await store.dispatch("plugin/loadSchemaType").then((response) => {
schema.value = response; schema.value = response;
}) })
}); });
const fields = computed<Fields>(() => { const definitions = computed(() => {
return { return schema.value?.definitions ?? {};
id: { });
component: InputText, function removeRefPrefix(ref?: string): string {
value: props.metadata.id, return ref?.replace(/^#\/definitions\//, "") ?? "";
label: t("no_code.fields.main.flow_id"), }
required: true,
disabled: !creatingFlow.value, const flowSchema = computed(() => {
}, const ref = removeRefPrefix(schema.value?.$ref);
namespace: { return definitions.value?.[ref];
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 mainFields = computed(() => { const flowSchemaProperties = computed(() => {
const {id, namespace, description, inputs} = fields.value; 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 = [ const SECTIONS_IDS = [
"tasks", "tasks",

View File

@@ -55,7 +55,7 @@
const position = inject(POSITION_INJECTION_KEY, "after"); const position = inject(POSITION_INJECTION_KEY, "after");
const creatingTask = inject( const creatingTask = inject(
CREATING_TASK_INJECTION_KEY, CREATING_TASK_INJECTION_KEY,
ref(false), false,
); );
const exitTaskElement = inject( const exitTaskElement = inject(
CLOSE_TASK_FUNCTION_INJECTION_KEY, CLOSE_TASK_FUNCTION_INJECTION_KEY,
@@ -90,11 +90,16 @@
const yaml = ref(""); const yaml = ref("");
function getPath(parentPath: string, refPath: number | undefined): string {
return refPath !== undefined && refPath !== null ? `${parentPath}[${refPath}]` : parentPath;
}
watch(flow, (source) => { watch(flow, (source) => {
if(!creatingTask.value){ if(!creatingTask){
const path = getPath(parentPath, refPath);
const taskYaml = YAML_UTILS.extractBlockWithPath({ const taskYaml = YAML_UTILS.extractBlockWithPath({
source, source,
path: `${parentPath}[${refPath}]`, path,
}) ?? "" }) ?? ""
if(taskYaml === yaml.value){ if(taskYaml === yaml.value){
@@ -157,15 +162,16 @@
const saveTask = () => { const saveTask = () => {
let result: string = flow.value; let result: string = flow.value;
if (!creatingTask.value) { if (!creatingTask) {
if(yaml.value){ if(yaml.value){
const path = getPath(parentPath, refPath);
result = YAML_UTILS.replaceBlockWithPath({ result = YAML_UTILS.replaceBlockWithPath({
source: result, source: result,
path: `${parentPath}[${refPath}]`, path,
newContent: yaml.value, newContent: yaml.value,
}); });
} }
}else if(!hasMovedToEdit.value && blockType){ } else if(!hasMovedToEdit.value && blockType){
const currentSection = section.value as keyof typeof SECTIONS_MAP; const currentSection = section.value as keyof typeof SECTIONS_MAP;
if(!currentSection) { 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> </el-button>
</div> </div>
<div class="w-100 p-4" v-if="currentView === views.DASHBOARD"> <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>
<div class="main-editor" v-else> <div class="main-editor" v-else>
<div <div
@@ -100,7 +100,7 @@
:source="selectedChart.content" :source="selectedChart.content"
:chart="selectedChart" :chart="selectedChart"
:identifier="selectedChart.id" :identifier="selectedChart.id"
is-preview show-default
/> />
</div> </div>
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
<template> <template>
<el-select <el-select
:model-value="selectedInput.type" :model-value="selectedInput?.type"
@update:model-value="onChangeType" @update:model-value="onChangeType"
class="mb-3" class="mb-3"
> >
@@ -25,116 +25,101 @@
<Save @click="update" what="input" class="w-100 mt-3" /> <Save @click="update" what="input" class="w-100 mt-3" />
</template> </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 TaskObject from "./tasks/TaskObject.vue";
import Save from "../code/components/Save.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"; import {BREADCRUMB_INJECTION_KEY, PANEL_INJECTION_KEY} from "../code/injectionKeys";
export default { interface InputType {
emits: ["update:modelValue"], type: string;
props: { id?: string;
modelValue: { }
type: Object,
default: () => {}, const props = withDefaults(defineProps<{
}, inputs: InputType[];
inputs: { label: string;
type: Array, selectedIndex: number;
default: () => [], required?: boolean;
}, disabled?: boolean;
label: {type: String, required: true}, }>(), {
selectedIndex: {type: Number, required: true}, inputs: () => [],
required: {type: Boolean, default: false}, required: false,
disabled: {type: Boolean, default: false}, disabled: false
}, });
computed: {
...mapState("plugin", ["inputSchema", "inputsType"]), const store = useStore();
...mapGetters("flow", ["flowYamlMetadata"]),
}, const inputSchema = computed(() => store.state.plugin.inputSchema);
created() { const inputsType = computed(() => store.state.plugin.inputsType);
if (this.inputs && this.inputs.length > 0) {
this.newInputs = this.inputs; 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 newInputs.value[props.selectedIndex] = newInput;
.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;
this.$store emit("update:inputs", [...newInputs.value]);
.dispatch("plugin/loadInputSchema", {type: type}) loadSchema(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);
},
},
}; };
</script> </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 {useStorage} from "@vueuse/core";
import {useStore} from "vuex"; import {useStore} from "vuex";
import {useI18n} from "vue-i18n"; import {useI18n} from "vue-i18n";
import {useRoute} from "vue-router";
import MultiPanelTabs, {Panel, Tab} from "../MultiPanelTabs.vue"; import MultiPanelTabs, {Panel, Tab} from "../MultiPanelTabs.vue";
import EditorButtonsWrapper from "../inputs/EditorButtonsWrapper.vue"; import EditorButtonsWrapper from "../inputs/EditorButtonsWrapper.vue";
@@ -42,7 +41,6 @@
} }
const store = useStore() const store = useStore()
const route = useRoute();
const flow = computed(() => store.state.flow.flow) const flow = computed(() => store.state.flow.flow)
onMounted(() => { onMounted(() => {
@@ -145,11 +143,19 @@
const {setupInitialCodeTab} = useInitialCodeTabs() const {setupInitialCodeTab} = useInitialCodeTabs()
const isTourRunning = computed(() => store.state.core.guidedProperties?.tourStarted) 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>( const panels: Ref<Panel[]> = useStorage<any>(
`panel-${flow.value.namespace}-${flow.value.id}`, `panel-${flow.value.namespace}-${flow.value.id}`,
DEAFULT_TABS DEFAULT_ACTIVE_TABS
.map((t):Panel => getPanelFromValue(t).panel), .map((t):Panel => getPanelFromValue(t).panel),
undefined, undefined,
{ {
@@ -157,13 +163,13 @@
write(v: Panel[]){ write(v: Panel[]){
return JSON.stringify(v.map(p => ({ return JSON.stringify(v.map(p => ({
tabs: p.tabs.map(t => t.value), tabs: p.tabs.map(t => t.value),
activeTab: p.activeTab?.value, activeTab: cleanupNoCodeTabKey(p.activeTab?.value),
size: p.size, size: p.size,
}))) })))
}, },
read(v?: string) { read(v?: string) {
if(v){ 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 return panels
.filter((p) => p.tabs.length) .filter((p) => p.tabs.length)
.map((p):Panel => { .map((p):Panel => {
@@ -174,7 +180,7 @@
) )
// filter out any tab that may have disappeared // filter out any tab that may have disappeared
.filter(Boolean) .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 { return {
activeTab, activeTab,
tabs, tabs,
@@ -224,11 +230,10 @@
} }
.editor-wrapper{ .editor-wrapper{
flex: 1;
position: relative; position: relative;
} }
.editor-panels{ :deep(.editor-panels){
position: absolute; position: absolute;
} }

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,9 @@
:schema :schema
:definitions :definitions
:properties="computedProperties" :properties="computedProperties"
:root="root"
:task="task"
:required="required"
merge merge
@update:model-value="onInput" @update:model-value="onInput"
/> />
@@ -16,21 +19,25 @@
<script> <script>
import Task from "./Task"; import Task from "./Task";
import {
BREADCRUMB_INJECTION_KEY,
PANEL_INJECTION_KEY,
} from "../../code/injectionKeys";
export default { export default {
inheritAttrs: false,
mixins: [Task], mixins: [Task],
inject: {
panel: {from: PANEL_INJECTION_KEY},
breadcrumbs: {from: BREADCRUMB_INJECTION_KEY},
},
computed: { computed: {
computedProperties() { computedProperties() {
const type = this.schema.$ref.split("/").pop(); if(!this.schema?.allOf && !this.schema?.$ref) {
return this.definitions[type]?.properties; 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> <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"> <el-col :span="6">
<InputText <InputText
:model-value="item[0]" :model-value="item[0]"
@update:model-value="onKey(index, $event)" @update:model-value="onKey(index, $event)"
@change="onKeyChange(index, $event)"
margin="m-0" margin="m-0"
placeholder="Key"
:have-error="duplicatedKeys.includes(item[0])"
/> />
</el-col> </el-col>
<el-col :span="16"> <el-col :span="16">
<component <component
:is="schema.additionalProperties ? getTaskComponent(schema.additionalProperties) : TaskExpression"
:is="schema.additionalProperties ? getTaskComponent(schema.additionalProperties, key, properties) : TaskExpression"
:model-value="item[1]" :model-value="item[1]"
@update:model-value="onValueChange(index, $event)" @update:model-value="onValueChange(index, $event)"
:root="getKey(item[0])" :root="getKey(item[0])"
:schema="schema.additionalProperties" :schema="schema.additionalProperties"
:required="isRequired(item[0])" :required="isRequired(item[0])"
:definitions="definitions" :definitions="definitions"
:disabled
/> />
</el-col> </el-col>
<el-col :span="2" class="col align-self-center delete"> <el-col :span="2" class="col align-self-center delete">
@@ -27,107 +36,120 @@
<Add v-if="!disabledAdding" @add="addItem()" /> <Add v-if="!disabledAdding" @add="addItem()" />
</template> </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 {DeleteOutline} from "../../code/utils/icons";
import InputText from "../../code/components/inputs/InputText.vue"; import InputText from "../../code/components/inputs/InputText.vue";
import TaskExpression from "./TaskExpression.vue"; import TaskExpression from "./TaskExpression.vue";
import Add from "../../code/components/Add.vue"; import Add from "../../code/components/Add.vue";
</script>
<script>
import {toRaw} from "vue";
import Task from "./Task";
import getTaskComponent from "./getTaskComponent"; import getTaskComponent from "./getTaskComponent";
import debounce from "lodash/debounce";
function emptyValueObjectProvider() { const {t} = useI18n();
return {"": undefined};
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() { function isRequired(key: string) {
return ["", undefined]; return props.schema?.required?.includes(key);
} }
export default { function onKey(key: number, val: string) {
mixins: [Task], currentValue.value[key][0] = val;
emits: ["update:modelValue"], emitUpdate()
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();
}
return this.modelValue; function onValueChange(key: number, val: any) {
}, currentValue.value[key][1] = val;
}, emitUpdate()
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;
}, {});
this.$emit("update:modelValue", local); function removeItem(index: number) {
}, currentValue.value.splice(index, 1);
onValueChange(key, value) { emitUpdate()
const local = this.currentValue || []; }
local[key][1] = value;
this.currentValue = local;
this.emitLocal(); function addItem() {
}, currentValue.value.push(["", undefined]);
onKey(key, value) { emitUpdate()
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]);
this.currentValue = local; const disabledAdding = computed(() => {
return props.disabled || currentValue.value.at(-1)?.[0] === "" && currentValue.value.at(-1)?.[1] === undefined;
this.emitLocal(); });
},
removeItem(x) {
let local = this.currentValue || [];
if (local.length === 1) {
local = [emptyValueEntriesProvider()];
} else {
local.splice(x, 1);
}
this.currentValue = local;
this.emitLocal();
},
},
};
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">

View File

@@ -10,36 +10,50 @@
:large-suggestions="false" :large-suggestions="false"
/> />
</template> </template>
<script> <script lang="ts" setup>
import Task from "./Task"; import {collapseEmptyValues} from "./Task";
import Editor from "../../../components/inputs/Editor.vue"; 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 { const props = defineProps({
mixins: [Task], modelValue: {
components: {Editor}, type: [String, Object],
data() { default: undefined
return {
localEditorValue: undefined
}
}, },
created() { root: {
this.localEditorValue = this.editorValue; type: String,
}, default: undefined
methods: {
editorInput(value) {
this.localEditorValue = value;
this.onInput(this.parseValue(value));
},
parseValue(value) {
if(value.match(/^\s*{{/)) {
return value;
}
return YAML_UTILS.parse(value);
}
} }
}; });
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> </script>
<style lang="scss" scoped> <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> <template>
<namespace-select <NamespaceSelect
data-type="flow" data-type="flow"
:value="modelValue" :value="modelValue"
allow-create allow-create

View File

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

View File

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

View File

@@ -1,15 +1,37 @@
<template> <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 <el-time-picker
v-if="!pebble && schema.format === 'duration'"
:model-value="durationValue" :model-value="durationValue"
type="time" type="time"
:default-value="defaultDuration" :default-value="defaultDuration"
:placeholder="`Choose a${/^[aeiou]/i.test(root || '') ? 'n' : ''} ${root || 'duration'}`" :placeholder="`Choose a${/^[aeiou]/i.test(root || '') ? 'n' : ''} ${root || 'duration'}`"
@update:model-value="onInputDuration" @update:model-value="onInputDuration"
/> />
</template> <el-date-picker
<template v-else> 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 <editor
v-else
:model-value="editorValue" :model-value="editorValue"
:navbar="false" :navbar="false"
:full-height="false" :full-height="false"
@@ -17,20 +39,44 @@
schema-type="flow" schema-type="flow"
lang="plaintext" lang="plaintext"
input input
:placeholder="`Your ${root || 'value'} here...`"
@update:model-value="onInput" @update:model-value="onInput"
:large-suggestions="false" :large-suggestions="false"
/> />
</template> </div>
</template> </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> <script>
import Task from "./Task"; import Task from "./Task";
import Editor from "../../../components/inputs/Editor.vue";
export default { export default {
inheritAttrs: false,
mixins: [Task], mixins: [Task],
components: {Editor}, components: {Editor},
props:{
disabled: {
type: Boolean,
default: false,
},
},
data() {
return {
pebble: false,
};
},
emits: ["update:modelValue"], 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: { computed: {
isValid() { isValid() {
if (this.required && !this.modelValue) { if (this.required && !this.modelValue) {
@@ -94,4 +140,38 @@
:deep(.placeholder) { :deep(.placeholder) {
top: -7px !important; 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> </style>

View File

@@ -1,71 +1,60 @@
<template> <template>
<el-input :model-value="JSON.stringify(values)"> <div class="w-100">
<template #append> <Element
<el-button :icon="TextSearch" @click="isOpen = true" /> :section="root"
</template> block-type="tasks"
</el-input> :parent-path-complete="parentPathComplete"
:element="{
<drawer id: model?.id ?? 'Set a task',
v-if="isOpen" type: model?.type,
v-model="isOpen" }"
> @remove-element="removeElement()"
<template #header> />
<code>{{ root }}</code> </div>
</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>
</template> </template>
<script setup> <script setup lang="ts">
import {SECTIONS} from "@kestra-io/ui-libs"; import {computed, inject} from "vue";
import * as YAML_UTILS from "@kestra-io/ui-libs/flow-yaml-utils"; 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"; const model = defineModel({
import ContentSave from "vue-material-design-icons/ContentSave.vue"; type: Object,
import TaskEditor from "../TaskEditor.vue" default: () => ({})
import Drawer from "../../Drawer.vue" });
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>
<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 <Collapse
title="tasks" title="tasks"
:elements="items" :elements="items"
section="tasks" :section
block-type="tasks" block-type="tasks"
@remove="(yaml) => store.commit('flow/setFlowYaml', yaml)" @remove="(yaml) => store.commit('flow/setFlowYaml', yaml)"
@reorder="(yaml) => store.commit('flow/setFlowYaml', yaml)" @reorder="(yaml) => store.commit('flow/setFlowYaml', yaml)"
@@ -16,21 +16,32 @@
import {useStore} from "vuex"; import {useStore} from "vuex";
import Collapse from "../../code/components/collapse/Collapse.vue"; import Collapse from "../../code/components/collapse/Collapse.vue";
defineOptions({inheritAttrs: false}); defineOptions({
inheritAttrs: false
});
const store = useStore(); const store = useStore();
interface Task {id:string, type:string} interface Task {
id:string,
type:string
}
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
modelValue?: Task[] modelValue?: Task[],
root?: string;
}>(), { }>(), {
modelValue: () => [] modelValue: () => [],
root: undefined
}); });
const items = computed(() => const items = computed(() =>
!Array.isArray(props.modelValue) ? [props.modelValue] : props.modelValue, !Array.isArray(props.modelValue) ? [props.modelValue] : props.modelValue,
); );
const section = computed(() => {
return props.root ?? "tasks";
});
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">

View File

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

View File

@@ -1,5 +1,4 @@
import {pascalCase} from "change-case"; import {pascalCase} from "change-case";
import InputPair from "../../code/components/inputs/InputPair.vue";
const TasksComponents = import.meta.glob<{default: any}>("./Task*.vue", {eager: true}); 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"; 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 (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"; return "any-of";
} }
@@ -37,7 +47,7 @@ function getType(property: any, key?: string, schema?: any): string {
} }
if (key === "namespace") { if (key === "namespace") {
return "subflow-namespace"; return "namespace";
} }
const properties = Object.keys(schema?.properties ?? {}); const properties = Object.keys(schema?.properties ?? {});
@@ -55,7 +65,8 @@ function getType(property: any, key?: string, schema?: any): string {
return "tasks"; 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"; return "conditions";
} }
@@ -67,7 +78,7 @@ function getType(property: any, key?: string, schema?: any): string {
} }
if( property.type === "object" && !property.properties) { if( property.type === "object" && !property.properties) {
return "input-pair"; return "KV-pairs";
} }
return property.type || "expression"; 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) { export default function getTaskComponent(property: any, key?: string, schema?: any) {
const typeString = getType(property, key, schema); const typeString = getType(property, key, schema);
if( typeString === "input-pair") {
return InputPair;
}
const type = pascalCase(typeString); const type = pascalCase(typeString);
const component = TasksComponents[`./Task${type}.vue`]?.default; const component = TasksComponents[`./Task${type}.vue`]?.default;
if (component) { if (component) {
component.ksTaskName = typeString; 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 {useStore} from "vuex";
import {useI18n} from "vue-i18n"; import {useI18n} from "vue-i18n";
import MouseRightClickIcon from "vue-material-design-icons/MouseRightClick.vue"; import MouseRightClickIcon from "vue-material-design-icons/MouseRightClick.vue";
import * as YAML_UTILS from "@kestra-io/ui-libs/flow-yaml-utils"; import * as YAML_UTILS from "@kestra-io/ui-libs/flow-yaml-utils";
import type {Panel, Tab} from "../MultiPanelTabs.vue"; import type {Panel, Tab} from "../MultiPanelTabs.vue";
import {BlockType} from "../code/utils/types"; import {BlockType} from "../code/utils/types";
import NoCodeWrapper from "../code/NoCodeWrapper.vue"
import type {NoCodeProps} from "../code/NoCodeWrapper.vue"; import type {NoCodeProps} from "../code/NoCodeWrapper.vue";
const NoCodeWrapper = markRaw(defineAsyncComponent(() => import("../code/NoCodeWrapper.vue")))
const NOCODE_PREFIX = "nocode" const NOCODE_PREFIX = "nocode"
@@ -20,7 +20,7 @@ interface Opener {
interface Handlers { interface Handlers {
onCreateTask: (opener: Opener, blockType: BlockType | "pluginDefaults", parentPath: string, refPath?: number, position?: "before" | "after") => boolean, 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 onCloseTask: (opener: Opener) => boolean
} }
@@ -67,10 +67,15 @@ export function getTabFromNoCodeTab(tab: NoCodeTabWithAction, t: (key: string) =
}, },
} }
} else if (tab.action === "edit") { } 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({ const currentBlock: any = tab.parentPath ? YAML_UTILS.parse(YAML_UTILS.extractBlockWithPath({
source: flow, source: flow,
path: `${tab.parentPath}[${tab.refPath}]`, path,
})) : {} })) : {}
return { return {
value: getEditTabKey(tab, keepAliveCacheBuster++), value: getEditTabKey(tab, keepAliveCacheBuster++),
button: { button: {
@@ -102,6 +107,7 @@ export function getTabFromNoCodeTab(tab: NoCodeTabWithAction, t: (key: string) =
[h(NoCodeWrapper, { [h(NoCodeWrapper, {
...restOfTab, ...restOfTab,
creatingTask: tab.action === "create", creatingTask: tab.action === "create",
editingTask: tab.action === "edit",
onCloseTask: onCloseTask?.bind({}, props), onCloseTask: onCloseTask?.bind({}, props),
onCreateTask: onCreateTask?.bind({}, props) as any, onCreateTask: onCreateTask?.bind({}, props) as any,
onEditTask: onEditTask?.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 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({ const tab = getTabFromNoCodeTab({
action: "edit", action: "edit",
blockType, blockType,

View File

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

View File

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

View File

@@ -8,7 +8,7 @@
</template> </template>
<script setup lang="ts"> <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 {useTabs} from "override/components/namespaces/useTabs";
import {useHelpers} from "./utils/useHelpers"; import {useHelpers} from "./utils/useHelpers";
@@ -25,11 +25,17 @@
const route = useRoute(); const route = useRoute();
const context = ref({title: details.title}); const context = ref({title: details.value.title});
useRouteContext(context); useRouteContext(context);
const namespace = computed(() => route.params?.id) as Ref<string>; const namespace = computed(() => route.params?.id) as Ref<string>;
watch(namespace, (newID) => {
if (newID) {
store.dispatch("namespace/load", newID);
}
});
const store = useStore(); const store = useStore();
onMounted(() => { onMounted(() => {
if (namespace.value) { 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 {useRoute} from "vue-router";
import {useI18n} from "vue-i18n"; import {useI18n} from "vue-i18n";
@@ -45,30 +45,30 @@ export function useHelpers() {
const route = useRoute(); const route = useRoute();
const {t} = useI18n({useScope: "global"}); 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 parts = computed(() => namespace.value?.split(".") ?? []);
const details: Details = { const details: Ref<Details> = computed(() => ({
title: parts.at(-1) || t("namespaces"), title: parts.value.at(-1) || t("namespaces"),
breadcrumb: [ breadcrumb: [
{label: t("namespaces"), link: {name: "namespaces/list"}}, {label: t("namespaces"), link: {name: "namespaces/list"}},
...parts.map((_: string, index: number) => ({ ...parts.value.map((_: string, index: number) => ({
label: parts[index], label: parts.value[index],
link: { link: {
name: "namespaces/update", name: "namespaces/update",
params: { params: {
id: parts.slice(0, index + 1).join("."), id: parts.value.slice(0, index + 1).join("."),
tab: "overview", tab: "overview",
}, },
}, },
disabled: index === parts.length - 1, disabled: index === parts.value.length - 1,
})), })),
], ],
}; }));
const tabs: Tab[] = [ const tabs: Tab[] = [
// If it's a system namespace, include the blueprints tab // If it's a system namespace, include the blueprints tab
...(namespace === "system" ...(namespace.value === "system"
? [ ? [
{ {
name: "blueprints", name: "blueprints",
@@ -88,26 +88,34 @@ export function useHelpers() {
name: "flows", name: "flows",
title: t("flows"), title: t("flows"),
component: Flows, component: Flows,
props: {namespace, topbar: false}, props: {namespace: namespace.value, topbar: false},
}, },
{ {
name: "executions", name: "executions",
title: t("executions"), title: t("executions"),
component: Executions, component: Executions,
props: {namespace, topbar: false, visibleCharts: true}, props: {
namespace: namespace.value,
topbar: false,
visibleCharts: true,
},
}, },
{ {
name: "dependencies", name: "dependencies",
title: t("dependencies"), title: t("dependencies"),
component: Dependencies, component: Dependencies,
props: {namespace, type: "dependencies"}, props: {namespace: namespace.value, type: "dependencies"},
}, },
{ {
maximized: true, maximized: true,
name: "files", name: "files",
title: t("files"), title: t("files"),
component: EditorView, component: EditorView,
props: {namespace, isNamespace: true, isReadOnly: false}, props: {
namespace: namespace.value,
isNamespace: true,
isReadOnly: false,
},
}, },
]; ];

View File

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

View File

@@ -60,11 +60,18 @@ export class FilterKeyCompletions {
private readonly _comparators: Comparators[]; private readonly _comparators: Comparators[];
private readonly _valuesFetcher: (store: Store<Record<string, any>>, hardcodedValues: ReturnType<typeof useValues>["VALUES"]) => Promise<ValueCompletions>; private readonly _valuesFetcher: (store: Store<Record<string, any>>, hardcodedValues: ReturnType<typeof useValues>["VALUES"]) => Promise<ValueCompletions>;
private readonly _allowMultipleValues: boolean; 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._comparators = comparators;
this._valuesFetcher = valuesFetcher; this._valuesFetcher = valuesFetcher;
this._allowMultipleValues = allowMultipleValues ?? false; this._allowMultipleValues = allowMultipleValues ?? false;
this._forbiddenConcurrentKeys = forbiddenConcurrentKeys;
} }
get comparators(): Comparators[] { get comparators(): Comparators[] {
@@ -78,4 +85,8 @@ export class FilterKeyCompletions {
get allowMultipleValues(): boolean { get allowMultipleValues(): boolean {
return this._allowMultipleValues; 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); return this._filterKeyCompletions.map(([{regex}]) => regex);
} }
async keyCompletion(): Promise<Completion[]> { async keyCompletion(usedKeys: string[] = []): Promise<Completion[]> {
return this._filterKeyCompletions return this._filterKeyCompletions
.map(([{key}, {comparators}]) => { .filter(([_, {forbiddenConcurrentKeys}]) => {
return !usedKeys.some(usedKey => forbiddenConcurrentKeys.includes(usedKey));
}).map(([{key}, {comparators}]) => {
return new Completion( return new Completion(
key.replaceAll(/\$(\{[^}]*})/g, "$1"), key.replaceAll(/\$(\{[^}]*})/g, "$1"),
key.replaceAll(/\$?\{([^}]*)}/g, "") + (key.includes("{") ? "" : comparators[0]) key.replaceAll(/\$?\{([^}]*)}/g, "") + (key.includes("{") ? "" : comparators[0])
@@ -96,4 +98,4 @@ export abstract class FilterLanguage {
multipleValuesAllowed(key: string): boolean { multipleValuesAllowed(key: string): boolean {
return this.completionForKey(key)?.allowMultipleValues ?? false; return this.completionForKey(key)?.allowMultipleValues ?? false;
} }
} }

View File

@@ -60,7 +60,7 @@ export default class FilterLanguageConfigurator extends AbstractLanguageConfigur
const keyLabelToRegex = (keyLabel: string) => { const keyLabelToRegex = (keyLabel: string) => {
return new RegExp(keyLabel return new RegExp(keyLabel
.replaceAll(".", "\\.") .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) { 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, includeLF: true,
tokenizer: { tokenizer: {
root: [ root: [
[/[\w."]+/, { [/[\w.]*(?:"[^"]*")?[\w.]*/, {
cases: { cases: {
...keysTokenizerCases, ...keysTokenizerCases,
"@default": {token: "@rematch", next: "@rawText"} "@default": {token: "@rematch", next: "@rawText"}
@@ -123,7 +123,7 @@ export default class FilterLanguageConfigurator extends AbstractLanguageConfigur
], ],
value: [ value: [
[/"[^"]+(?![^"]*")/, "invalid"], [/"[^"]+(?![^"]*")/, "invalid"],
[new RegExp("\"[^\\n,\"]*\""), { [new RegExp("\"[^\\n\"]*\""), {
token: "variable.value", token: "variable.value",
next: "@separator" next: "@separator"
}], }],
@@ -186,7 +186,6 @@ export default class FilterLanguageConfigurator extends AbstractLanguageConfigur
}) })
}; };
}; };
const KEY_COMPLETIONS: Promise<Completion[]> = filterLanguage.keyCompletion();
const filterLanguageConfiguratorInstance = this; const filterLanguageConfiguratorInstance = this;
return [ return [
monaco.languages.registerCompletionItemProvider({ monaco.languages.registerCompletionItemProvider({
@@ -259,6 +258,9 @@ export default class FilterLanguageConfigurator extends AbstractLanguageConfigur
null, null,
true true
); );
const usedKeys = [...modelValue.matchAll(new RegExp(`\\s?(\\S+?)${COMPARATORS_REGEX}`, "g"))]
.map(([_, key]) => FilterLanguage.withNestedKeyPlaceholder(key));
if (offset === 0 if (offset === 0
|| (SEPARATOR_CHARS.includes(previousChar) && !inQuotedString) || (SEPARATOR_CHARS.includes(previousChar) && !inQuotedString)
|| (!lastWordIsComparator && comparatorsAfterCurrentWord?.matches?.[1] !== undefined)) { || (!lastWordIsComparator && comparatorsAfterCurrentWord?.matches?.[1] !== undefined)) {
@@ -268,7 +270,7 @@ export default class FilterLanguageConfigurator extends AbstractLanguageConfigur
...wordAtPosition, ...wordAtPosition,
endColumn: wordAtPosition.endColumn + (comparatorsAfterCurrentWord?.matches?.[1]?.length ?? 0) 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) { if (currentFilterMatch === null) {
return TO_SUGGESTIONS(position, wordAtPosition, await KEY_COMPLETIONS); return TO_SUGGESTIONS(position, wordAtPosition, await filterLanguage.keyCompletion(usedKeys));
} else { } else {
const [, key, comparator, commaSeparatedValues] = currentFilterMatch?.matches ?? []; 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( timeRange: new FilterKeyCompletions(
[Comparators.EQUALS], [Comparators.EQUALS],
async (_, hardcodedValues) => hardcodedValues.RELATIVE_DATE async (_, hardcodedValues) => hardcodedValues.RELATIVE_DATE,
false,
["timeRange", "startDate", "endDate"]
), ),
startDate: new FilterKeyCompletions( 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], [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( 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], [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( "labels.{key}": new FilterKeyCompletions(
[Comparators.EQUALS, Comparators.NOT_EQUALS], [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( scope: new FilterKeyCompletions(
[Comparators.EQUALS, Comparators.NOT_EQUALS], [Comparators.EQUALS, Comparators.NOT_EQUALS],
async (_, hardcodedValues) => hardcodedValues.SCOPES async (_, hardcodedValues) => hardcodedValues.SCOPES,
undefined,
["scope"]
), ),
childFilter: new FilterKeyCompletions( childFilter: new FilterKeyCompletions(
[Comparators.EQUALS, Comparators.NOT_EQUALS], [Comparators.EQUALS, Comparators.NOT_EQUALS],
@@ -43,15 +45,21 @@ const executionFilterKeys: Record<string, FilterKeyCompletions> = {
), ),
timeRange: new FilterKeyCompletions( timeRange: new FilterKeyCompletions(
[Comparators.EQUALS], [Comparators.EQUALS],
async (_, hardcodedValues) => hardcodedValues.RELATIVE_DATE async (_, hardcodedValues) => hardcodedValues.RELATIVE_DATE,
false,
["timeRange", "startDate", "endDate"]
), ),
startDate: new FilterKeyCompletions( 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], [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( 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], [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( "labels.{key}": new FilterKeyCompletions(
[Comparators.EQUALS, Comparators.NOT_EQUALS], [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> = { const flowDashboardFilterKeys: Record<string, FilterKeyCompletions> = {
timeRange: new FilterKeyCompletions( timeRange: new FilterKeyCompletions(
[Comparators.EQUALS], [Comparators.EQUALS],
async (_, hardcodedValues) => hardcodedValues.RELATIVE_DATE async (_, hardcodedValues) => hardcodedValues.RELATIVE_DATE,
false,
["timeRange", "startDate", "endDate"]
), ),
startDate: new FilterKeyCompletions( 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], [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( 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], [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( "labels.{key}": new FilterKeyCompletions(
[Comparators.EQUALS, Comparators.NOT_EQUALS], [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