Compare commits

...

41 Commits

Author SHA1 Message Date
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
91 changed files with 24061 additions and 22842 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1166,7 +1166,7 @@ public class JdbcExecutor implements ExecutorInterface, Service {
try {
// Handle paused tasks
if (executionDelay.getDelayType().equals(ExecutionDelay.DelayType.RESUME_FLOW)) {
if (executionDelay.getDelayType().equals(ExecutionDelay.DelayType.RESUME_FLOW) && !pair.getLeft().getState().isTerminated()) {
FlowInterface flow = flowMetaStore.findByExecution(pair.getLeft()).orElseThrow();
if (executionDelay.getTaskRunId() == null) {
// if taskRunId is null, this means we restart a flow that was delayed at startup (scheduled on)

View File

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

View File

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

View File

@@ -0,0 +1,39 @@
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"],
},
}),
),
);

View File

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

View File

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

44474
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,149 +1,150 @@
{
"name": "kestra",
"version": "0.0.0",
"private": true,
"type": "module",
"packageManager": "npm@10.9.0",
"scripts": {
"dev": "vite --host",
"build": "vite build --emptyOutDir",
"preview": "vite preview",
"test:unit": "vitest run",
"test:lint": "eslint",
"test:cicd": "vitest run --coverage",
"test:storybook": "vitest run --project=storybook",
"translations:check": "node ./src/translations/check.js",
"lint": "eslint --fix",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"prepare": "cd .. && husky ui/.husky && rm -f .git/hooks/*",
"notes": "node ./scripts/ci/generateReleaseNotes",
"postinstall": "patch-package"
},
"dependencies": {
"@js-joda/core": "^5.6.5",
"@kestra-io/ui-libs": "^0.0.203",
"@vue-flow/background": "^1.3.2",
"@vue-flow/controls": "^1.1.2",
"@vue-flow/core": "^1.44.0",
"@vueuse/core": "^13.2.0",
"ansi-to-html": "^0.7.2",
"axios": "^1.9.0",
"bootstrap": "^5.3.6",
"buffer": "^6.0.3",
"chart.js": "^4.4.9",
"core-js": "^3.42.0",
"cronstrue": "^2.61.0",
"dagre": "^0.8.5",
"el-table-infinite-scroll": "^3.0.6",
"element-plus": "^2.9.10",
"humanize-duration": "^3.32.2",
"js-yaml": "^4.1.0",
"lodash": "^4.17.21",
"markdown-it": "^14.1.0",
"markdown-it-anchor": "^9.2.0",
"markdown-it-container": "^4.0.0",
"markdown-it-link-attributes": "^4.0.1",
"markdown-it-mark": "^4.0.0",
"markdown-it-meta": "^0.0.1",
"material-file-icons": "^2.4.0",
"md5": "^2.3.0",
"moment": "^2.30.1",
"moment-range": "^4.0.2",
"moment-timezone": "^0.5.48",
"nprogress": "^0.2.0",
"path-browserify": "^1.0.1",
"pdfjs-dist": "^5.2.133",
"posthog-js": "^1.245.1",
"rapidoc": "^9.3.8",
"semver": "^7.7.2",
"shiki": "^3.4.2",
"splitpanes": "^3.2.0",
"throttle-debounce": "^5.0.2",
"vue": "^3.5.13",
"vue-axios": "^3.5.2",
"vue-chartjs": "^5.3.2",
"vue-gtag": "^2.1.0",
"vue-i18n": "^11.1.3",
"vue-material-design-icons": "^5.3.1",
"vue-router": "^4.5.1",
"vue-sidebar-menu": "^5.7.0",
"vue-virtual-scroller": "^2.0.0-beta.8",
"vue3-popper": "^1.5.0",
"vue3-tour": "github:kestra-io/vue3-tour",
"vuex": "^4.1.0",
"xss": "^1.0.15",
"yaml": "^2.7.1"
},
"devDependencies": {
"@codecov/vite-plugin": "^1.9.1",
"@esbuild-plugins/node-modules-polyfill": "^0.2.2",
"@eslint/js": "^9.27.0",
"@rushstack/eslint-patch": "^1.11.0",
"@shikijs/markdown-it": "^3.4.2",
"@storybook/addon-essentials": "^8.6.14",
"@storybook/addon-themes": "^8.6.14",
"@storybook/blocks": "^8.6.14",
"@storybook/experimental-addon-test": "^8.6.14",
"@storybook/test": "^8.6.14",
"@storybook/test-runner": "^0.22.0",
"@storybook/vue3": "^8.6.14",
"@storybook/vue3-vite": "^8.6.14",
"@types/humanize-duration": "^3.27.4",
"@types/js-yaml": "^4.0.9",
"@types/path-browserify": "^1.0.3",
"@typescript-eslint/parser": "^8.32.1",
"@vitejs/plugin-vue": "^5.2.4",
"@vitejs/plugin-vue-jsx": "^4.2.0",
"@vitest/browser": "^3.1.4",
"@vitest/coverage-v8": "^3.1.4",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/test-utils": "^2.4.6",
"@vueuse/router": "^13.2.0",
"change-case": "4.1.2",
"cross-env": "^7.0.3",
"decompress": "^4.2.1",
"eslint": "^9.27.0",
"eslint-plugin-storybook": "^0.12.0",
"eslint-plugin-vue": "^9.33.0",
"globals": "^16.1.0",
"husky": "^9.1.7",
"jsdom": "^26.1.0",
"lint-staged": "^15.5.2",
"monaco-editor": "^0.52.2",
"monaco-yaml": "5.3.1",
"patch-package": "^8.0.0",
"playwright": "^1.52.0",
"prettier": "^3.5.3",
"rollup-plugin-copy": "^3.5.0",
"sass": "^1.89.0",
"storybook": "^8.6.14",
"storybook-vue3-router": "^5.0.0",
"typescript": "^5.8.3",
"typescript-eslint": "^8.32.1",
"vite": "^6.3.5",
"vitest": "^3.1.4"
},
"optionalDependencies": {
"@esbuild/darwin-arm64": "^0.25.4",
"@esbuild/darwin-x64": "^0.25.4",
"@esbuild/linux-x64": "^0.25.4",
"@rollup/rollup-darwin-arm64": "^4.41.0",
"@rollup/rollup-darwin-x64": "^4.41.0",
"@rollup/rollup-linux-x64-gnu": "^4.41.0",
"@swc/core-darwin-arm64": "^1.11.24",
"@swc/core-darwin-x64": "^1.11.24",
"@swc/core-linux-x64-gnu": "^1.11.24"
},
"overrides": {
"bootstrap": {
"@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7"
"name": "kestra",
"version": "0.0.0",
"private": true,
"type": "module",
"packageManager": "npm@10.9.0",
"scripts": {
"dev": "vite --host",
"build": "vite build --emptyOutDir",
"preview": "vite preview",
"test:unit": "vitest run",
"test:lint": "eslint",
"test:cicd": "vitest run --coverage",
"test:storybook": "vitest run --project=storybook",
"translations:check": "node ./src/translations/check.js",
"lint": "eslint --fix",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"prepare": "cd .. && husky ui/.husky && rm -f .git/hooks/*",
"notes": "node ./scripts/ci/generateReleaseNotes",
"postinstall": "patch-package"
},
"el-table-infinite-scroll": {
"vue": "$vue"
"dependencies": {
"@js-joda/core": "^5.6.5",
"@kestra-io/ui-libs": "^0.0.203",
"@vue-flow/background": "^1.3.2",
"@vue-flow/controls": "^1.1.2",
"@vue-flow/core": "^1.44.0",
"@vueuse/core": "^13.2.0",
"ansi-to-html": "^0.7.2",
"axios": "^1.9.0",
"bootstrap": "^5.3.6",
"buffer": "^6.0.3",
"chart.js": "^4.4.9",
"core-js": "^3.42.0",
"cronstrue": "^2.61.0",
"dagre": "^0.8.5",
"el-table-infinite-scroll": "^3.0.6",
"element-plus": "^2.9.10",
"humanize-duration": "^3.32.2",
"js-yaml": "^4.1.0",
"lodash": "^4.17.21",
"markdown-it": "^14.1.0",
"markdown-it-anchor": "^9.2.0",
"markdown-it-container": "^4.0.0",
"markdown-it-link-attributes": "^4.0.1",
"markdown-it-mark": "^4.0.0",
"markdown-it-meta": "^0.0.1",
"material-file-icons": "^2.4.0",
"md5": "^2.3.0",
"moment": "^2.30.1",
"moment-range": "^4.0.2",
"moment-timezone": "^0.5.48",
"nprogress": "^0.2.0",
"path-browserify": "^1.0.1",
"pdfjs-dist": "^5.2.133",
"posthog-js": "^1.245.1",
"rapidoc": "^9.3.8",
"semver": "^7.7.2",
"shiki": "^3.4.2",
"splitpanes": "^3.2.0",
"throttle-debounce": "^5.0.2",
"vue": "^3.5.13",
"vue-axios": "^3.5.2",
"vue-chartjs": "^5.3.2",
"vue-gtag": "^2.1.0",
"vue-i18n": "^11.1.3",
"vue-material-design-icons": "^5.3.1",
"vue-router": "^4.5.1",
"vue-sidebar-menu": "^5.7.0",
"vue-virtual-scroller": "^2.0.0-beta.8",
"vue3-popper": "^1.5.0",
"vue3-tour": "github:kestra-io/vue3-tour",
"vuex": "^4.1.0",
"xss": "^1.0.15",
"yaml": "^2.7.1"
},
"devDependencies": {
"@codecov/vite-plugin": "^1.9.1",
"@esbuild-plugins/node-modules-polyfill": "^0.2.2",
"@eslint/js": "^9.27.0",
"@rushstack/eslint-patch": "^1.11.0",
"@shikijs/markdown-it": "^3.4.2",
"@storybook/addon-essentials": "^8.6.14",
"@storybook/addon-themes": "^9.0.5",
"@storybook/addon-vitest": "^9.0.5",
"@storybook/test-runner": "^0.22.0",
"@storybook/types": "^8.6.14",
"@storybook/vue3-vite": "^9.0.5",
"@types/humanize-duration": "^3.27.4",
"@types/js-yaml": "^4.0.9",
"@types/path-browserify": "^1.0.3",
"@types/testing-library__jest-dom": "^5.14.9",
"@types/testing-library__user-event": "^4.1.1",
"@typescript-eslint/parser": "^8.33.1",
"@vitejs/plugin-vue": "^5.2.4",
"@vitejs/plugin-vue-jsx": "^4.2.0",
"@vitest/browser": "^3.2.3",
"@vitest/coverage-v8": "^3.2.3",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/test-utils": "^2.4.6",
"@vueuse/router": "^13.2.0",
"change-case": "4.1.2",
"cross-env": "^7.0.3",
"decompress": "^4.2.1",
"eslint": "^9.28.0",
"eslint-plugin-storybook": "^9.0.5",
"eslint-plugin-vue": "^9.33.0",
"globals": "^16.1.0",
"husky": "^9.1.7",
"jsdom": "^26.1.0",
"lint-staged": "^15.5.2",
"monaco-editor": "^0.52.2",
"monaco-yaml": "5.3.1",
"patch-package": "^8.0.0",
"playwright": "^1.52.0",
"prettier": "^3.5.3",
"rollup-plugin-copy": "^3.5.0",
"sass": "^1.89.0",
"storybook": "^9.0.5",
"storybook-vue3-router": "^5.0.0",
"typescript": "^5.8.3",
"typescript-eslint": "^8.33.1",
"vite": "^6.3.5",
"vitest": "^3.2.3"
},
"optionalDependencies": {
"@esbuild/darwin-arm64": "^0.25.4",
"@esbuild/darwin-x64": "^0.25.4",
"@esbuild/linux-x64": "^0.25.4",
"@rollup/rollup-darwin-arm64": "^4.41.0",
"@rollup/rollup-darwin-x64": "^4.41.0",
"@rollup/rollup-linux-x64-gnu": "^4.41.0",
"@swc/core-darwin-arm64": "^1.11.24",
"@swc/core-darwin-x64": "^1.11.24",
"@swc/core-linux-x64-gnu": "^1.11.24"
},
"overrides": {
"bootstrap": {
"@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7"
},
"el-table-infinite-scroll": {
"vue": "$vue"
},
"storybook": "$storybook"
},
"lint-staged": {
"**/*.{js,mjs,cjs,ts,vue}": "eslint --fix"
}
},
"lint-staged": {
"**/*.{js,mjs,cjs,ts,vue}": "eslint --fix"
}
}

View File

@@ -47,6 +47,7 @@
@dragleave.prevent
:data-tab-id="tab.value"
@click="panel.activeTab = tab"
@mouseup="middleMouseClose($event, panelIndex, tab)"
>
<component :is="tab.button.icon" class="tab-icon" />
{{ tab.button.label }}
@@ -463,6 +464,14 @@
panelsCopy.splice(newIndex, 0, movedPanel);
panels.value = panelsCopy;
}
function middleMouseClose(event:MouseEvent, panelIndex:number, tab: Tab) {
// Middle mouse button
if (event.button === 1) {
event.preventDefault();
destroyTab(panelIndex, tab);
}
}
</script>
<style lang="scss" scoped>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -154,7 +154,7 @@
task: this.modelValue,
schema: schema,
definitions: this.definitions,
required: this.schema?.required,
required: this.requiredProperties.map(([p]) => p),
};
},
},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -25,7 +25,9 @@ const flowFilterKeys: Record<string, FilterKeyCompletions> = {
),
scope: new FilterKeyCompletions(
[Comparators.EQUALS, Comparators.NOT_EQUALS],
async (_, hardcodedValues) => hardcodedValues.SCOPES
async (_, hardcodedValues) => hardcodedValues.SCOPES,
undefined,
["scope"]
),
"labels.{key}": new FilterKeyCompletions(
[Comparators.EQUALS, Comparators.NOT_EQUALS],
@@ -34,15 +36,21 @@ const flowFilterKeys: Record<string, FilterKeyCompletions> = {
),
timeRange: new FilterKeyCompletions(
[Comparators.EQUALS],
async (_, hardcodedValues) => hardcodedValues.RELATIVE_DATE
async (_, hardcodedValues) => hardcodedValues.RELATIVE_DATE,
false,
["timeRange", "startDate", "endDate"]
),
startDate: new FilterKeyCompletions(
[Comparators.GREATER_THAN_OR_EQUAL_TO, Comparators.GREATER_THAN, Comparators.LESS_THAN_OR_EQUAL_TO, Comparators.LESS_THAN, Comparators.EQUALS, Comparators.NOT_EQUALS],
async () => PICK_DATE_VALUE
async () => PICK_DATE_VALUE,
false,
["timeRange"]
),
endDate: new FilterKeyCompletions(
[Comparators.LESS_THAN_OR_EQUAL_TO, Comparators.LESS_THAN, Comparators.GREATER_THAN_OR_EQUAL_TO, Comparators.GREATER_THAN, Comparators.EQUALS, Comparators.NOT_EQUALS],
async () => PICK_DATE_VALUE
async () => PICK_DATE_VALUE,
false,
["timeRange"]
),
};
@@ -54,4 +62,4 @@ class FlowFilterLanguage extends FilterLanguage {
}
}
export default FlowFilterLanguage.INSTANCE as FilterLanguage;
export default FlowFilterLanguage.INSTANCE as FilterLanguage;

View File

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

View File

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

View File

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

View File

@@ -57,19 +57,49 @@ export class YamlLanguageConfigurator extends AbstractLanguageConfigurator {
(defaultCompletion.suggestions as {
label: string,
filterText: string,
insertText: string
insertText: string,
sortText?: string
}[]).forEach(suggestion => {
if (suggestion.label.endsWith("...") && suggestion.insertText.includes(suggestion.label.substring(0, suggestion.label.length - 3))) {
suggestion.label = suggestion.insertText;
}
if (suggestion.label.includes(".")) {
const dotSplit = suggestion.label.split(/\.(?=\w)/);
const taskName = dotSplit.pop();
suggestion.filterText = [taskName, ...dotSplit, taskName].join(".");
}
});
const wordAtPosition = model.getWordAtPosition(position)?.word?.toLowerCase();
if (wordAtPosition !== undefined) {
const sortBumperText = "a1".repeat(10);
if (suggestion.label.includes(".")) {
const dotSplit = suggestion.label.toLowerCase().split(/\.(?=\w)/);
if (dotSplit[dotSplit.length - 1].startsWith(wordAtPosition)) {
suggestion.sortText = sortBumperText.repeat(5) + suggestion.label;
} else if (dotSplit[dotSplit.length - 1].includes(wordAtPosition)) {
suggestion.sortText = sortBumperText.repeat(4) + suggestion.label;
} else {
suggestion.sortText = dotSplit.splice(dotSplit.length - 1, 1).reduceRight((prefix, part) => {
let sortBumperPrefixForPart;
if (part.startsWith(wordAtPosition)) {
sortBumperPrefixForPart = sortBumperText.repeat(3)
} else if (part.includes(wordAtPosition)) {
sortBumperPrefixForPart = sortBumperText.repeat(2);
}
if (sortBumperPrefixForPart === undefined || prefix.length >= sortBumperPrefixForPart.length) {
return prefix;
}
return sortBumperPrefixForPart;
}, "") + suggestion.label;
}
suggestion.filterText = (suggestion.label.includes(wordAtPosition) ? wordAtPosition + " " : "") + suggestion.label.toLowerCase();
}
if (suggestion.sortText === undefined && suggestion.label.includes(wordAtPosition)) {
suggestion.sortText = sortBumperText + suggestion.label;
}
}
suggestion.sortText = suggestion.sortText?.toLowerCase();
});
return defaultCompletion;
};
@@ -258,4 +288,4 @@ export class YamlLanguageConfigurator extends AbstractLanguageConfigurator {
return autoCompletionProviders;
}
}
}

View File

@@ -3,6 +3,7 @@ import type {JSONSchema} from "@kestra-io/ui-libs";
import {YamlElement, YamlUtils as YAML_UTILS} from "@kestra-io/ui-libs";
import {QUOTE, YamlAutoCompletion} from "../../services/autoCompletionProvider";
import RegexProvider from "../../utils/regex";
import {State} from "@kestra-io/ui-libs";
function distinct<T>(val: T[] | undefined): T[] {
return Array.from(new Set(val ?? []));
@@ -54,7 +55,8 @@ export class FlowAutoCompletion extends YamlAutoCompletion {
"id()",
"now()",
"randomInt(lower=${1:0}, upper=${2:10})",
"randomPort()"
"randomPort()",
"tasksWithState(state=${1:'FAILED'})",
]);
}
@@ -161,7 +163,7 @@ export class FlowAutoCompletion extends YamlAutoCompletion {
.map(input => `${input}:`);
}
async valueAutoCompletion(source: string, parsed: any | undefined, yamlElement: YamlElement | undefined): Promise<string[]> {
async valueAutoCompletion(_: string, parsed: any | undefined, yamlElement: YamlElement | undefined): Promise<string[]> {
if (yamlElement === undefined) {
return Promise.resolve([]);
}
@@ -197,7 +199,7 @@ export class FlowAutoCompletion extends YamlAutoCompletion {
return Promise.resolve([]);
}
private extractArgValue(arg) {
private extractArgValue(arg: string | undefined) {
if (arg === undefined) {
return undefined;
}
@@ -221,19 +223,22 @@ export class FlowAutoCompletion extends YamlAutoCompletion {
if (namespace === undefined) {
return Promise.resolve([]);
}
return Array.from(Object.entries(await this.store.dispatch("namespace/inheritedSecrets", {id: namespace})).reduce((acc, [_, nsSecrets]: [string, string[]]) => {
return Array.from(Object.entries<string[]>(await this.store.dispatch("namespace/inheritedSecrets", {id: namespace})).reduce((acc: Set<string>, [_, nsSecrets]: [string, string[]]) => {
nsSecrets.forEach(secret => acc.add(QUOTE + secret + QUOTE));
return acc;
}, new Set()));
}, new Set<string>()));
}
case "kv": {
const namespace = this.extractArgValue(namespaceArg);
if (namespace === undefined) {
return Promise.resolve([]);
}
return (await this.store.dispatch("namespace/kvsList", {id: namespace})).map(kv => QUOTE + kv.key + QUOTE);
return (await this.store.dispatch("namespace/kvsList", {id: namespace})).map((kv: {key: string}) => QUOTE + kv.key + QUOTE);
}
case "tasksWithState": {
return State.arrayAllStates().map(({name}) => QUOTE + name + QUOTE);
}
}
return Promise.resolve([]);
}
}
}

View File

@@ -19,19 +19,43 @@ export default {
actions: {
list({commit}) {
return this.$http.get(`${apiUrl(this)}/plugins`, {}).then(response => {
commit("setPlugins", response.data)
commit("setPluginSingleList", response.data.map(plugin => plugin.tasks.concat(plugin.triggers, plugin.conditions, plugin.controllers, plugin.storages, plugin.taskRunners, plugin.charts, plugin.dataFilters, plugin.aliases, plugin.logExporters)).flat())
commit("setPlugins", response.data);
commit("setPluginSingleList", response.data.map(plugin =>
plugin.tasks.concat(
plugin.triggers,
plugin.conditions,
plugin.controllers,
plugin.storages,
plugin.taskRunners,
plugin.charts,
plugin.dataFilters,
plugin.aliases,
plugin.logExporters,
plugin.additionalPlugins
)).flat());
return response.data;
})
});
},
listWithSubgroup({commit}, options) {
return this.$http.get(`${apiUrl(this)}/plugins/groups/subgroups`, {
params: options
}).then(response => {
commit("setPlugins", response.data)
commit("setPluginSingleList", response.data.map(plugin => plugin.tasks.concat(plugin.triggers, plugin.conditions, plugin.controllers, plugin.storages, plugin.taskRunners, plugin.charts, plugin.dataFilters, plugin.aliases, plugin.logExporters)).flat())
commit("setPlugins", response.data);
commit("setPluginSingleList", response.data.map(plugin =>
plugin.tasks.concat(
plugin.triggers,
plugin.conditions,
plugin.controllers,
plugin.storages,
plugin.taskRunners,
plugin.charts,
plugin.dataFilters,
plugin.aliases,
plugin.logExporters,
plugin.additionalPlugins
)).flat());
return response.data;
})
});
},
load({commit, state}, options) {
if (options.cls === undefined) {
@@ -63,7 +87,7 @@ export default {
}
return response.data;
})
});
},
loadVersions({commit}, options) {
const promise = this.$http.get(
@@ -74,7 +98,7 @@ export default {
commit("setVersions", response.data.versions);
}
return response.data;
})
});
},
icons({commit}) {
return Promise.all([
@@ -85,7 +109,7 @@ export default {
for (const [key, plugin] of Object.entries(responses[1].data)) {
if (icons[key] === undefined) {
icons[key] = plugin
icons[key] = plugin;
}
}
@@ -96,29 +120,29 @@ export default {
},
groupIcons(_) {
return Promise.all([
this.$http.get(`${apiUrl(this)}/plugins/icons/groups`, {}),
this.$http.get(`${apiUrl(this)}/plugins/icons/groups`, {})
]).then(responses => {
return responses[0].data
return responses[0].data;
});
},
loadInputsType({commit}) {
return this.$http.get(`${apiUrl(this)}/plugins/inputs`, {}).then(response => {
commit("setInputsType", response.data)
commit("setInputsType", response.data);
return response.data;
})
});
},
loadInputSchema({commit}, options) {
return this.$http.get(`${apiUrl(this)}/plugins/inputs/${options.type}`, {}).then(response => {
commit("setInputSchema", response.data)
commit("setInputSchema", response.data);
return response.data;
})
});
},
loadSchemaType(_, options = {type: "flow"}) {
return this.$http.get(`${apiUrlWithoutTenants()}/plugins/schemas/${options.type}`, {}).then(response => {
return response.data;
})
});
},
updateDocumentation({commit, dispatch, getters}, options) {
const taskType = options.task !== undefined ? options.task : YamlUtils.getTaskType(
@@ -129,9 +153,9 @@ export default {
const taskVersion = options.event
? YamlUtils.getVersionAtPosition(
options?.event?.model?.getValue(),
options?.event?.position,
)
options?.event?.model?.getValue(),
options?.event?.position
)
: undefined;
if (taskType) {
@@ -156,37 +180,37 @@ export default {
},
mutations: {
setPlugin(state, plugin) {
state.plugin = plugin
state.plugin = plugin;
},
setVersions(state, versions) {
state.versions = versions
state.versions = versions;
},
setPluginAllProps(state, pluginAllProps) {
state.pluginAllProps = pluginAllProps
state.pluginAllProps = pluginAllProps;
},
setPlugins(state, plugins) {
state.plugins = plugins
state.plugins = plugins;
},
setPluginSingleList(state, pluginSingleList) {
state.pluginSingleList = pluginSingleList
state.pluginSingleList = pluginSingleList;
},
setIcons(state, icons) {
state.icons = icons
state.icons = icons;
},
setPluginsDocumentation(state, pluginsDocumentation) {
state.pluginsDocumentation = pluginsDocumentation
state.pluginsDocumentation = pluginsDocumentation;
},
addPluginDocumentation(state, pluginDocumentation) {
state.pluginsDocumentation = {...state.pluginsDocumentation, ...pluginDocumentation}
state.pluginsDocumentation = {...state.pluginsDocumentation, ...pluginDocumentation};
},
setEditorPlugin(state, editorPlugin) {
state.editorPlugin = editorPlugin
state.editorPlugin = editorPlugin;
},
setInputsType(state, inputsType) {
state.inputsType = inputsType
state.inputsType = inputsType;
},
setInputSchema(state, schema) {
state.inputSchema = schema
state.inputSchema = schema;
}
},
getters: {
@@ -194,5 +218,5 @@ export default {
getPluginsDocumentation: state => state.pluginsDocumentation,
getIcons: state => state.icons
}
}
};

View File

@@ -1352,6 +1352,7 @@
"kv_pairs": "No Key-Value pairs Found",
"secrets": "No Secrets Found",
"templates": "No Templates Found"
}
},
"duplicate-pair": "{label} {key} is duplicated, second key ignored."
}
}

View File

@@ -3,6 +3,7 @@ import NProgress from "nprogress"
import {storageKeys} from "./constants";
// nprogress
let pendingRoute = false
let requestsTotal = 0
let requestsCompleted = 0
let latencyThreshold = 0
@@ -10,17 +11,22 @@ let latencyThreshold = 0
const JWT_REFRESHED_QUERY = "__jwt_refreshed__";
const progressComplete = () => {
pendingRoute = false
requestsTotal = 0
requestsCompleted = 0
NProgress.done()
}
const initProgress = () => {
requestsTotal++;
if (0 === requestsTotal) {
setTimeout(() => NProgress.start(), latencyThreshold)
setTimeout(() => {
NProgress.start();
NProgress.set(requestsCompleted / requestsTotal);
}, latencyThreshold);
} else {
NProgress.set(requestsCompleted / requestsTotal);
}
requestsTotal++
NProgress.set(requestsCompleted / requestsTotal)
}
const increaseProgress = () => {
@@ -114,7 +120,7 @@ export default (callback, store, router) => {
if (errorResponse.response.status === 401 &&
store.getters["auth/isLogged"] &&
!document.cookie.split("; ").map(cookie => cookie.split("=")[0]).includes("JWT")
&& !impersonate) {
&& !impersonate) {
// Keep original request
const originalRequest = errorResponse.config
@@ -125,7 +131,7 @@ export default (callback, store, router) => {
// if we already tried refreshing the token,
// the user simply does not have access to this feature
if(originalRequestData[JWT_REFRESHED_QUERY] === 1) {
if (originalRequestData[JWT_REFRESHED_QUERY] === 1) {
return Promise.reject(errorResponse)
}
@@ -133,7 +139,11 @@ export default (callback, store, router) => {
try {
await instance.post("/oauth/access_token?grant_type=refresh_token", null, {headers: {"Content-Type": "application/json"}});
toRefreshQueue.forEach(({config, resolve, reject}) => {
instance.request(config).then(response => { resolve(response) }).catch(error => { reject(error) })
instance.request(config).then(response => {
resolve(response)
}).catch(error => {
reject(error)
})
})
toRefreshQueue = [];
refreshing = false;
@@ -184,13 +194,20 @@ export default (callback, store, router) => {
}
router.beforeEach((to, from, next) => {
if (pendingRoute) {
requestsTotal--;
}
pendingRoute = true;
initProgress();
next()
})
router.afterEach(() => {
increaseProgress();
if (pendingRoute) {
increaseProgress();
pendingRoute = false;
}
})
callback(instance);

View File

@@ -185,14 +185,10 @@ export function backgroundFromState(state, alpha = 1) {
}
export function getConsistentHEXColor(theme, value) {
// if (!value) {
// return "#ffffff";
// }
let hex;
hex = getSchemeValue(value, "executions");
if (hex) {
if (hex && hex !== "transparent") {
return hex;
}

View File

@@ -1,6 +1,6 @@
import {markRaw, ref, StyleValue} from "vue";
import {within, userEvent, expect, fireEvent, waitFor} from "@storybook/test";
import type {Meta, StoryObj} from "@storybook/vue3";
import {within, userEvent, expect, fireEvent, waitFor} from "storybook/test";
import type {Meta, StoryObj} from "@storybook/vue3-vite";
import CodeTagsIcon from "vue-material-design-icons/CodeTags.vue";
import MouseRightClickIcon from "vue-material-design-icons/MouseRightClick.vue";
import FileTreeOutlineIcon from "vue-material-design-icons/FileTreeOutline.vue";

View File

@@ -24,7 +24,7 @@ const tabs = [
]
/**
* @type {import('@storybook/vue3').StoryObj<typeof ShowCase>}
* @type {import('@storybook/vue3-vite').StoryObj<typeof ShowCase>}
*/
export const Default = {
render: () => ({

View File

@@ -1,7 +1,7 @@
import {ref} from "vue";
import BarChart from "../../../../../../src/components/dashboard/components/charts/executions/BarChart.vue";
import {vueRouter} from "storybook-vue3-router";
import {within, expect, fireEvent, waitFor} from "@storybook/test";
import {within, expect, fireEvent, waitFor} from "storybook/test";
const hasNavigated = ref("");

View File

@@ -1,5 +1,5 @@
import {markRaw} from "vue";
import type {Meta, StoryObj} from "@storybook/vue3";
import type {Meta, StoryObj} from "@storybook/vue3-vite";
import {vueRouter} from "storybook-vue3-router";
import Card from "../../../../../src/components/dashboard/components/Card.vue";
import AccountMultiple from "vue-material-design-icons/AccountMultiple.vue";

View File

@@ -0,0 +1,214 @@
import {useStore} from "vuex";
import {vueRouter} from "storybook-vue3-router";
import Executions from "../../../../src/components/executions/Executions.vue";
import fixtureS from "./Executions-s.fixture.json";
import {expect, userEvent, waitFor, within} from "storybook/test";
import * as monaco from "monaco-editor/esm/vs/editor/editor.api";
import {isColoredAsError} from "../../utils/monacoUtils.js";
function getDecorators(executionsSearchData) {
return [
() => {
return {
setup() {
const store = useStore();
store.commit("auth/setUser", {
id: "123",
firstName: "John",
lastName: "Doe",
email: "john.doe@example.com",
isAllowed: () => true,
hasAnyActionOnAnyNamespace: () => true,
});
store.commit("misc/setConfigs", {
hiddenLabelsPrefixes: ["system_"],
});
store.$http = {
get: async (uri, _params) => {
if (uri.endsWith("executions/search")) {
console.log("uri", uri);
// query params are available here if we want to make tests with them
// console.log("params", params);
return Promise.resolve({
data: executionsSearchData,
});
}
return Promise.resolve({data: []});
},
post: async (uri) => {
console.log("post request", uri);
if (uri.includes("/dashboards/charts/preview")) {
return Promise.resolve({}); // empty chart
}
throw new Error(
"Unhandled fixture Request POST: " + uri,
);
},
};
},
template: "<div style='margin:2rem'><story /></div>",
};
},
vueRouter(
[
{
path: "/",
name: "home",
component: {template: "<div>home</div>"},
},
{
path: "/flows/update/:namespace/:id?/:flowId?",
name: "flows/update",
component: {template: "<div>updateflows</div>"},
},
{
path: "/executions/update/:namespace/:id?/:flowId?",
name: "executions/update",
component: {template: "<div>executions</div>"},
},
{
path: "/executions/:id?/:flowId?",
name: "executions/list",
component: {template: "<div>executions</div>"},
},
],
{
initialRoute: "/executions/123/645",
},
),
];
}
// Story configuration
export default {
title: "Components/Executions",
component: Executions,
};
// Stories
export const FilterExecutions = {
decorators: getDecorators(fixtureS),
args: {
hidden: [],
statuses: [],
isReadOnly: false,
topbar: false,
filter: true,
},
};
FilterExecutions.play = async ({canvasElement, step}) => {
const canvas = within(canvasElement);
const user = userEvent.setup();
await step("filter should contains \"timeRange\" by default", async () => {
await waitFor(() => expect(getMonacoFilter(canvas)).toHaveTextContent("timeRange="), {timeout: 5000});
});
await step(
"clearing and adding a namespace filter with keyboard",
async () => {
await user.click(getMonacoFilterInput(canvas));
await clearMonacoInput();
await userEvent.keyboard("namespace=io.kestra");
await refreshMonacoFilter(canvas);
await waitFor(() =>
expect(getMonacoFilter(canvas)).toHaveTextContent(
"namespace=io.kestra",
),
);
},
);
await step("adding an additional flowId filter with keyboard", async () => {
await waitFor(() =>
expect(getMonacoFilter(canvas)).toHaveTextContent(
"namespace=io.kestra",
),
);
await user.click(getMonacoFilterInput(canvas));
await userEvent.keyboard("{End}");
await userEvent.keyboard(spaceBarKey);
await userEvent.keyboard("flowId=123");
await refreshMonacoFilter(canvas);
await waitFor(() =>
expect(getMonacoFilter(canvas)).toHaveTextContent(
"namespace=io.kestra",
),
);
await waitFor(() =>
expect(getMonacoFilter(canvas)).toHaveTextContent("flowId=123"),
);
});
await step("unknown field should be displayed red", async () => {
await user.click(getMonacoFilterInput(canvas));
await clearMonacoInput();
await userEvent.keyboard("an-unknown-field=q2132");
await refreshMonacoFilter(canvas);
await waitFor(() =>
expect(getMonacoFilter(canvas)).toHaveTextContent(
"an-unknown-field=q2132",
),
);
await waitFor(() =>
expect(
isColoredAsError(within(getMonacoFilter(canvas)).getByText(
"an-unknown-field=q2132",
))
).toBeTruthy(),
);
});
await step(
"unknown field should be marked as invalid internally by Monaco",
async () => {
await user.click(getMonacoFilterInput(canvas));
await clearMonacoInput();
await userEvent.keyboard("an-unknown-field=q2222222222");
await refreshMonacoFilter(canvas);
await waitFor(() =>
expect(getMonacoFilter(canvas)).toHaveTextContent(
"an-unknown-field=q2222222222",
),
);
const model = monaco.editor.getModels()[0];
const tokens = monaco.editor.tokenize(
model.getValue(),
"executions-filter",
);
await expect(tokens).toBeDefined();
await expect(tokens[0][0].type).toContain("executions-filter");
await expect(tokens[0][0].type).toContain("invalid");
},
);
};
// TODO test from query route !!!!
// Helpers and constants
const spaceBarKey = "{ }";
const triggerRefreshButton = "trigger-refresh-button";
const monacoFilter = "monaco-filter";
function getMonacoFilter(canvas) {
return canvas.getByTestId(monacoFilter);
}
function getMonacoFilterInput(canvas) {
const monacoFilter = getMonacoFilter(canvas);
return within(monacoFilter).getByRole("textbox");
}
async function clearMonacoInput() {
await userEvent.keyboard("{Control>}a{/Control}{Delete}");
}
async function refreshMonacoFilter(canvas) {
await userEvent.click(canvas.getByTestId(triggerRefreshButton));
}

View File

@@ -0,0 +1,529 @@
import {vueRouter} from "storybook-vue3-router";
import {expect, userEvent, waitFor, within} from "storybook/test";
import KestraFilter from "../../../../src/components/filter/KestraFilter.vue";
import {type LocationQuery, stringifyQuery, useRoute} from "vue-router";
import {Meta, StoryObj} from "@storybook/vue3-vite";
import {FilterLanguage} from "../../../../src/composables/monaco/languages/filters/filterLanguage.ts";
import {
Comparators,
Completion,
FilterKeyCompletions
} from "../../../../src/composables/monaco/languages/filters/filterCompletion.ts";
import loadFilterLanguages from "../../mocks/services/filterLanguagesProvider.mock.ts";
import DefaultFilterLanguage from "../../../../src/composables/monaco/languages/filters/impl/defaultFilterLanguage.ts";
import {isColoredAsError} from "../../utils/monacoUtils.ts";
const meta = {
title: "Components/KestraFilter",
component: KestraFilter
} satisfies Meta<typeof KestraFilter>;
export default meta;
type Story = StoryObj<typeof meta>;
declare global {
interface Window {
// Hack from Monaco editor to allow navigating through suggestions
acceptSuggestion: () => void;
nextSuggestion: () => void;
}
}
let suggestionWidgetController: {
accept: () => void,
next: () => void
} = {
accept() {
},
next() {
}
};
function getDecorators(routeQuery?: LocationQuery) {
return [
() => {
return {
setup() {
const route = useRoute();
loadFilterLanguages.mockReturnValue(Promise.resolve([TestFilterLanguage.INSTANCE, DefaultFilterLanguage]));
return {route};
},
template: "<div><span>ROUTE QUERY: </span><span data-testid='routeQuery'>{{route.query}}</span><story /></div>",
};
},
vueRouter(
[
{
path: "/",
name: "home",
component: {template: "<div>home</div>"},
}
],
{
initialRoute: "/" + (routeQuery === undefined ? "" : `?${stringifyQuery(routeQuery!)}`),
},
),
];
}
async function parseRouteQuery(canvas: any): Promise<LocationQuery> {
return JSON.parse(canvas.getByTestId("routeQuery").textContent);
}
function waitForFilterToBeReady(user: ReturnType<typeof userEvent.setup>, canvas: ReturnType<typeof within>): Promise<void> {
return waitFor(async () => {
await user.click(await getMonacoFilterInput(canvas));
await assertSuggestions(canvas, (assertion) => assertion.not.toHaveLength(0));
}, {timeout: 5000});
}
// Stories
export const KestraFilterDefault: Story = {
decorators: getDecorators()
};
KestraFilterDefault.play = async ({canvasElement, step}) => {
const canvas = within(canvasElement);
const user = userEvent.setup();
await step("filter is empty with default placeholder", async () => {
await expect(await getMonacoFilterInput(canvas)).toBeEmptyDOMElement();
await expect(getMonacoFilter(canvas).querySelector("[widgetid=\"editor.widget.placeholderHint\"]"))
.toHaveTextContent(/^Choose filters$/)
});
await new Promise(resolve => setTimeout(resolve, 1000));
await step(
"autocompletion pops upon clicking and show only text because no language is set",
async () => {
await waitFor(async () => {
await user.click(await getMonacoFilterInput(canvas));
await assertSuggestionsValues(canvas, ["text"]);
}, {timeout: 5000});
},
);
await step(
"add some text in the filter",
async () => {
await user.click(await getMonacoFilterInput(canvas));
await userEvent.keyboard("test");
await waitFor(() => assertMonacoFilterContentToBe(canvas, "test"));
await assertRouteQuery(canvas, {"filters[q][EQUALS]": "test"});
},
);
};
export const KestraFilterPlaceholder: Story = {
decorators: getDecorators(),
args: {
placeholder: "Hello Filter"
}
};
KestraFilterPlaceholder.play = async ({canvasElement, step}) => {
const canvas = within(canvasElement);
await step("placeholder should be 'Hello Filter'", async () => {
await expect(await getMonacoFilterInput(canvas)).toBeEmptyDOMElement();
await expect(getMonacoFilter(canvas).querySelector("[widgetid=\"editor.widget.placeholderHint\"]"))
.toHaveTextContent(new RegExp(`^${KestraFilterPlaceholder.args!.placeholder}$`));
});
};
export const KestraFilterLegacyQuery: Story = {
decorators: getDecorators(),
args: {
legacyQuery: true
}
};
async function assertRouteQuery(canvas: ReturnType<typeof within>, expectedQuery: LocationQuery = {}) {
await waitFor(async () => {
const routeQuery = await parseRouteQuery(canvas);
return expect(routeQuery).toStrictEqual(expectedQuery)
}, {timeout: 5000});
}
KestraFilterLegacyQuery.play = async ({canvasElement, step}) => {
const canvas = within(canvasElement);
const user = userEvent.setup();
await step(
"add some text in the filter",
async () => {
await user.click(await getMonacoFilterInput(canvas));
await userEvent.keyboard("test");
await waitFor(() => assertMonacoFilterContentToBe(canvas, "test"));
await assertRouteQuery(canvas, {q: "test"});
},
);
};
class TestFilterLanguage extends FilterLanguage {
static readonly FILTER_KEYS = {
singleValue: new FilterKeyCompletions(
[Comparators.EQUALS, Comparators.NOT_EQUALS, Comparators.STARTS_WITH],
async () => [
new Completion("First value", "value1"),
new Completion("Second value", "value2")
],
false,
["notCompatibleWithSingleAndNestedAndSelf"]
),
multiValue: new FilterKeyCompletions(
[Comparators.NOT_EQUALS, Comparators.EQUALS, Comparators.STARTS_WITH],
async () => [
new Completion("Another first value", "anotherValue1"),
new Completion("Another second value", "anotherValue2")
],
true
),
"nested.{key}": new FilterKeyCompletions(
[Comparators.EQUALS],
undefined,
false,
["notCompatibleWithSingleAndNestedAndSelf"]
),
notCompatibleWithSingleAndNestedAndSelf: new FilterKeyCompletions(
[Comparators.EQUALS],
undefined,
false,
["singleValue", FilterLanguage.withNestedKeyPlaceholder("nested.{key}"), "notCompatibleWithSingleAndNestedAndSelf", "text"]
)
};
static readonly INSTANCE = new TestFilterLanguage();
private constructor() {
super("test", TestFilterLanguage.FILTER_KEYS);
}
}
async function assertSuggestions(canvas: ReturnType<typeof within>, assertion: (assertion: ReturnType<typeof expect<string[]>>) => Promise<void>): Promise<void> {
const suggestWidget = getMonacoFilter(canvas).querySelector(".suggest-widget");
if (suggestWidget === null) {
throw new Error("Waiting for suggest widget to be shown");
}
await expect(suggestWidget).toBeVisible();
const suggestions = [...getMonacoFilter(canvas).querySelectorAll(".monaco-list-row")].map(({textContent}) => textContent);
return assertion(expect(suggestions));
}
async function assertSuggestionsValues(canvas: ReturnType<typeof within>, expectedSuggestions: string[]) {
return assertSuggestions(canvas, (assertion) => assertion.toEqual(expectedSuggestions));
}
export const KestraFilterWithLanguage: Story = {
decorators: getDecorators(),
args: {
language: TestFilterLanguage.INSTANCE as FilterLanguage
}
};
KestraFilterWithLanguage.play = async ({canvasElement, step}) => {
const canvas = within(canvasElement);
const user = userEvent.setup();
await step(
"autocompletion pops upon clicking and show available keys",
async () => {
await waitFor(async () => {
await user.click(await getMonacoFilterInput(canvas));
await assertSuggestionsValues(canvas, [...Object.keys(TestFilterLanguage.FILTER_KEYS), "text"]);
}, {timeout: 5000});
},
);
suggestionWidgetController = {
accept: window.acceptSuggestion,
next: window.nextSuggestion
}
await step(
"accepting suggestion should insert the key followed by the first comparator in the filter and proceed to value completion",
async () => {
let highlightedSuggest = getMonacoFilter(canvas).querySelector(".monaco-list-row.focused");
await expect(highlightedSuggest).toHaveTextContent(/^singleValue$/);
suggestionWidgetController.accept();
await waitFor(() => assertMonacoFilterContentToBe(canvas, "singleValue="));
await assertRouteQuery(canvas, {});
await waitFor(async () => {
await assertSuggestionsValues(canvas, ["First value", "Second value"]);
}, {timeout: 5000});
highlightedSuggest = getMonacoFilter(canvas).querySelector(".monaco-list-row.focused");
await expect(highlightedSuggest).toHaveTextContent(/^First value$/)
suggestionWidgetController.next();
highlightedSuggest = getMonacoFilter(canvas).querySelector(".monaco-list-row.focused");
await expect(highlightedSuggest).toHaveTextContent(/^Second value$/);
suggestionWidgetController.accept();
await assertRouteQuery(canvas, {"filters[singleValue][EQUALS]": "value2"});
// Back to the initial suggestions as a space is automatically added after the value
await waitFor(() => assertMonacoFilterContentToBe(canvas, "singleValue=value2 "));
await waitFor(() => assertSuggestionsValues(canvas, [...Object.keys(TestFilterLanguage.FILTER_KEYS).filter(k => k !== "notCompatibleWithSingleAndNestedAndSelf"), "text"]), {timeout: 5000});
},
);
};
export const KestraFilterWithLanguage_MultiValueAnotherComparator: Story = {
decorators: getDecorators(),
args: {
language: TestFilterLanguage.INSTANCE as FilterLanguage
}
};
KestraFilterWithLanguage_MultiValueAnotherComparator.play = async ({canvasElement, step}) => {
const canvas = within(canvasElement);
const user = userEvent.setup();
await waitForFilterToBeReady(user, canvas);
suggestionWidgetController = {
accept: window.acceptSuggestion,
next: window.nextSuggestion
}
await step(
"accepting suggestion should insert the key followed by the first comparator in the filter and proceed to value completion",
async () => {
suggestionWidgetController.next();
let highlightedSuggest = getMonacoFilter(canvas).querySelector(".monaco-list-row.focused");
await expect(highlightedSuggest).toHaveTextContent(/^multiValue$/);
suggestionWidgetController.accept();
await waitFor(() => assertMonacoFilterContentToBe(canvas, "multiValue!="));
await assertRouteQuery(canvas, {});
await waitFor(async () => {
await assertSuggestionsValues(canvas, ["Another first value", "Another second value"]);
}, {timeout: 5000});
highlightedSuggest = getMonacoFilter(canvas).querySelector(".monaco-list-row.focused");
await expect(highlightedSuggest).toHaveTextContent(/^Another first value$/)
suggestionWidgetController.accept();
await waitFor(() => assertMonacoFilterContentToBe(canvas, "multiValue!=anotherValue1,"));
await assertRouteQuery(canvas, {"filters[multiValue][NOT_EQUALS]": "anotherValue1"});
highlightedSuggest = getMonacoFilter(canvas).querySelector(".monaco-list-row.focused");
await expect(highlightedSuggest).toHaveTextContent(/^Another second value$/);
suggestionWidgetController.accept();
// No more suggestions as all the values are taken so we add a space
await waitFor(() => assertMonacoFilterContentToBe(canvas, "multiValue!=anotherValue1,anotherValue2 "));
// Back to the initial suggestions
await waitFor(() => assertSuggestionsValues(canvas, [...Object.keys(TestFilterLanguage.FILTER_KEYS), "text"]), {timeout: 5000});
await assertRouteQuery(canvas, {"filters[multiValue][NOT_IN]": "anotherValue1,anotherValue2"});
},
);
};
export const KestraFilterWithLanguage_PopulateValueFromQuery: Story = {
name: "Keys from query that are not compliant with language should not be added to filter",
decorators: getDecorators({
"filters[unknownKey][EQUALS]": "whatever",
"filters[singleValue][EQUALS]": "unknownValue StillShouldBeAdded",
"filters[nested][EQUALS][specialKey]": "someValue",
}),
args: {
language: TestFilterLanguage.INSTANCE as FilterLanguage
}
};
KestraFilterWithLanguage_PopulateValueFromQuery.play = async ({canvasElement, step}) => {
const canvas = within(canvasElement);
await step(
"value should be populated from query at initialization",
async () => {
await waitFor(() => assertMonacoFilterContentToBe(canvas, "singleValue=\"unknownValue StillShouldBeAdded\" nested.specialKey=someValue "));
// verify we kept the unknown value in the query parameters even though we didn't add it to the filter
await new Promise(resolve => {
setInterval(resolve, 1100);
});
assertRouteQuery(canvas, {
"filters[unknownKey][EQUALS]": "whatever",
"filters[singleValue][EQUALS]": "unknownValue StillShouldBeAdded",
"filters[nested][EQUALS][specialKey]": "someValue"
});
}
);
};
export const KestraFilterWithLanguage_NestedKey: Story = {
decorators: getDecorators(),
args: {
language: TestFilterLanguage.INSTANCE as FilterLanguage
}
};
KestraFilterWithLanguage_NestedKey.play = async ({canvasElement, step}) => {
const canvas = within(canvasElement);
const user = userEvent.setup();
await step(
"nested key autocompletion should output `nested.`",
async () => {
await waitForFilterToBeReady(user, canvas);
suggestionWidgetController = {
accept: window.acceptSuggestion,
next: window.nextSuggestion
}
suggestionWidgetController.next();
suggestionWidgetController.next();
const highlightedSuggest = getMonacoFilter(canvas).querySelector(".monaco-list-row.focused");
await expect(highlightedSuggest).toHaveTextContent(/^nested\.\{key}$/);
suggestionWidgetController.accept();
await waitFor(() => assertMonacoFilterContentToBe(canvas, "nested."));
await assertRouteQuery(canvas, {});
}
);
await step(
"adding a nested key with a value should add not be colored as error and add the nested key as an extra [...] in the query",
async () => {
await userEvent.keyboard("deep.key=\"[[And Value],[[With Spaces]\"");
await waitFor(() => expect(
([...getMonacoFilter(canvas).querySelectorAll(".view-lines .view-line span")] as HTMLElement[])
.map(el => isColoredAsError(el))
).toSatisfy<boolean[]>(areErrors => areErrors.every(isError => !isError)), {timeout: 5000});
await assertRouteQuery(canvas, {
"filters[nested][EQUALS][deep.key]": "[And Value],[With Spaces]"
});
},
);
// NOTE We can see a bug here as we have no way to distinguish between multiple values and a single value with a comma (because allowed when having quoted value) as of now
await step(
"adding a comma and another value should switch the comparator to IN and add the value to the query",
async () => {
await userEvent.keyboard(",anotherValue");
await waitFor(() => expect(
([...getMonacoFilter(canvas).querySelectorAll(".view-lines .view-line span")] as HTMLElement[])
.map(el => isColoredAsError(el))
).toSatisfy<boolean[]>(areErrors => areErrors.every(isError => !isError)), {timeout: 5000});
await assertRouteQuery(canvas, {
"filters[nested][IN][deep.key]": "[And Value],[With Spaces],anotherValue"
});
},
);
};
export const KestraFilterWithLanguage_ForbiddenConcurrentKeys: Story = {
decorators: getDecorators(),
args: {
language: TestFilterLanguage.INSTANCE as FilterLanguage
}
};
function assertNoErrorsInFilter(canvas: ReturnType<typeof within>, expectedFilterContent: string): Promise<void> {
return waitFor(async () => {
await assertMonacoFilterContentToBe(canvas, expectedFilterContent);
return expect(
([...getMonacoFilter(canvas).querySelectorAll(".view-lines .view-line span")] as HTMLElement[])
.map(el => isColoredAsError(el))
).toSatisfy<boolean[]>(areErrors => areErrors.every(isError => !isError))
}, {timeout: 5000});
}
KestraFilterWithLanguage_ForbiddenConcurrentKeys.play = async ({canvasElement, step}) => {
const canvas = within(canvasElement);
const user = userEvent.setup();
await step(
"adding singleValue filter",
async () => {
await waitForFilterToBeReady(user, canvas);
const filterValue = "singleValue=\"some value\" ";
await userEvent.keyboard(filterValue);
await assertNoErrorsInFilter(canvas, filterValue);
},
);
await step(
"notCompatibleWithSingleAndNestedAndSelf should not show up in autocompletion",
async () => {
await waitFor(() => assertSuggestions(canvas, (assertion) => assertion.not.toContain("notCompatibleWithSingleAndNestedAndSelf")));
},
);
await step(
"cleaning and adding nested.{key} to filter",
async () => {
await clearMonacoInput(user, canvas);
const filterValue = "nested.some.key=\"some value\" ";
await userEvent.keyboard(filterValue);
await assertNoErrorsInFilter(canvas, filterValue);
},
);
await step(
"notCompatibleWithSingleAndNestedAndSelf should not show up in autocompletion",
async () => {
await waitFor(() => assertSuggestions(canvas, (assertion) => assertion.not.toContain("notCompatibleWithSingleAndNestedAndSelf")));
},
);
await step(
"cleaning and asserting that notCompatibleWithSingleAndNestedAndSelf is in autocompletion",
async () => {
await waitFor(async () => {
await clearMonacoInput(user, canvas);
await user.click(await getMonacoFilterInput(canvas));
return assertSuggestions(canvas, (assertion) => assertion.toContain("notCompatibleWithSingleAndNestedAndSelf"));
}, {timeout: 5000})
},
);
await step(
"adding notCompatibleWithSingleAndNestedAndSelf and assert it's no longer showing in autocompletion",
async () => {
const filterValue = "notCompatibleWithSingleAndNestedAndSelf=\"some value\" ";
await userEvent.keyboard(filterValue);
await waitFor(() => assertSuggestions(canvas, (assertion) => assertion.not.toContain("notCompatibleWithSingleAndNestedAndSelf")));
},
);
};
const monacoFilter = "monaco-filter";
function getMonacoFilter(canvas: ReturnType<typeof within>) {
return canvas.getByTestId(monacoFilter);
}
async function clearMonacoInput(user: ReturnType<typeof userEvent.setup>, canvas: ReturnType<typeof within>): Promise<void> {
return user.clear(await getMonacoFilterInput(canvas))
}
function assertMonacoFilterContentToBe(canvas: ReturnType<typeof within>, expectedText: string): Promise<void> {
// We need to replace non-breaking spaces with regular spaces because Monaco editor uses non-breaking spaces
return expect(getMonacoFilter(canvas)).toHaveTextContent(expectedText, {normalizeWhitespace: true});
}
function getMonacoFilterInput(canvas: ReturnType<typeof within>): Promise<HTMLElement> {
return waitFor(() => within(getMonacoFilter(canvas)).getByRole("textbox"));
}

View File

@@ -1,5 +1,5 @@
import {defineComponent, ref} from "vue";
import {expect, userEvent, waitFor, within} from "@storybook/test";
import {expect, userEvent, waitFor, within} from "storybook/test";
import InputsForm from "../../../../src/components/inputs/InputsForm.vue";
const meta = {
@@ -26,7 +26,7 @@ const Sut = defineComponent((props) => {
});
/**
* @type {import("@storybook/vue3").StoryObj<typeof InputsForm>}
* @type {import("@storybook/vue3-vite").StoryObj<typeof InputsForm>}
*/
export const InputTypes = {
async play({canvasElement}) {

View File

@@ -5,7 +5,7 @@ import {
within,
expect,
waitFor
} from "@storybook/test";
} from "storybook/test";
import LogLine from "../../../../src/components/logs/LogLine.vue";
import {ElCard} from "element-plus";

View File

@@ -0,0 +1,4 @@
import loadFilterLanguages from "../../../../src/override/services/filterLanguagesProvider.ts";
import {fn} from "storybook/test";
export default fn(loadFilterLanguages).mockName("loadFilterLanguages");

View File

@@ -8,7 +8,7 @@ const meta = {
export default meta;
/**
* @type {import('@storybook/vue3').StoryObj<typeof ShowCase>}
* @type {import('@storybook/vue3-vite').StoryObj<typeof ShowCase>}
*/
export const ElementPlusPlayground = {
render: () => <ShowCase />,

View File

@@ -0,0 +1,5 @@
export function isColoredAsError(element: HTMLElement): boolean {
const rgb = window.getComputedStyle(element)!.color!.match(/\d+/g)!.map(Number);
// Assert red is dominant (e.g., red > 200, green & blue < 100)
return rgb[0] > 150 && rgb[1] < 60 && rgb[2] < 60;
}

View File

@@ -7,6 +7,7 @@
"lib": ["ES2020", "DOM", "DOM.Iterable", "ES2021.String"],
"skipLibCheck": true,
"incremental": true,
"types": ["vitest/globals"],
/* Bundler mode */
"moduleResolution": "bundler",
@@ -26,7 +27,7 @@
"baseUrl": "./",
"paths": {
"override/*": ["src/override/*"]
},
}
},
"include": [
".storybook/preview.*",

View File

@@ -36,7 +36,7 @@ export default defineConfig({
output: {
manualChunks
}
},
}
},
resolve: {
alias: {
@@ -47,6 +47,7 @@ export default defineConfig({
"#mdc-configs": path.resolve(__dirname, "node_modules/@kestra-io/ui-libs/stub-mdc-imports.js"),
"shiki": path.resolve(__dirname, "node_modules/shiki/dist"),
"vuex": path.resolve(__dirname, "node_modules/vuex/dist/vuex.esm-bundler.js"),
"@storybook/addon-actions": "storybook/actions",
},
},
plugins: [

View File

@@ -9,7 +9,15 @@ export default defineConfig({
],
resolve: {
alias: {
"override/services/filterLanguagesProvider": path.resolve(__dirname, "tests/storybook/mocks/services/filterLanguagesProvider.mock.ts"),
"override": path.resolve(__dirname, "src/override/"),
"#imports": path.resolve(__dirname, "node_modules/@kestra-io/ui-libs/stub-mdc-imports.js"),
"#build/mdc-image-component.mjs": path.resolve(__dirname, "node_modules/@kestra-io/ui-libs/stub-mdc-imports.js"),
"#mdc-imports": path.resolve(__dirname, "node_modules/@kestra-io/ui-libs/stub-mdc-imports.js"),
"#mdc-configs": path.resolve(__dirname, "node_modules/@kestra-io/ui-libs/stub-mdc-imports.js"),
"shiki": path.resolve(__dirname, "node_modules/shiki/dist"),
"vuex": path.resolve(__dirname, "node_modules/vuex/dist/vuex.esm-bundler.js"),
"@storybook/addon-actions": "storybook/actions"
},
},
test: {
@@ -33,9 +41,10 @@ export default defineConfig({
"**/*.stories.*",
"**/*.d.ts",
]
}
},
projects: [".storybook/vitest.config.js"]
},
define: {
"window.KESTRA_BASE_PATH": "/ui/",
},
})
})

View File

@@ -1,28 +0,0 @@
import path from "node:path";
import {defineWorkspace} from "vitest/config";
import {storybookTest} from "@storybook/experimental-addon-test/vitest-plugin";
// More info at: https://storybook.js.org/docs/writing-tests/test-addon
export default defineWorkspace([
"vitest.config.js",
{
extends: "vite.config.js",
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, ".storybook")}),
],
test: {
name: "storybook",
browser: {
enabled: true,
headless: true,
provider: "playwright",
instances: [{browser: "chromium"}],
},
setupFiles: [".storybook/vitest.setup.ts"],
},
},
]);

View File

@@ -34,7 +34,7 @@ import java.util.Map;
import java.util.Optional;
@Validated
@Controller("/api/v1/main/blueprints/community")
@Controller("/api/v1/{tenant}/blueprints/community")
public class BlueprintController {
@Inject
@Client("api")

View File

@@ -11,7 +11,7 @@ import io.micronaut.scheduling.annotation.ExecuteOn;
import io.swagger.v3.oas.annotations.Operation;
import jakarta.inject.Inject;
@Controller("/api/v1/main/cluster")
@Controller("/api/v1/{tenant}/cluster")
@Requires(bean = ServiceInstanceRepositoryInterface.class)
public class ClusterController {

View File

@@ -50,7 +50,7 @@ import java.util.Optional;
import static io.kestra.core.utils.DateUtils.validateTimeline;
@Validated
@Controller("/api/v1/main/dashboards")
@Controller("/api/v1/{tenant}/dashboards")
@Slf4j
public class DashboardController {
protected static final YamlParser YAML_PARSER = new YamlParser();

View File

@@ -110,7 +110,7 @@ import static io.kestra.core.utils.Rethrow.throwFunction;
@Slf4j
@Validated
@Controller("/api/v1/main/executions")
@Controller("/api/v1/{tenant}/executions")
public class ExecutionController {
private static final Pattern SECRET_FUNCTION = Pattern.compile("(.*)(secret\\([^)]+\\))(.*)");

View File

@@ -69,7 +69,7 @@ import java.util.stream.Collectors;
import java.util.stream.Stream;
@Validated
@Controller("/api/v1/main/flows")
@Controller("/api/v1/{tenant}/flows")
@Slf4j
public class FlowController {
private static final String WARNING_JSON_FLOW_ENDPOINT = "This endpoint is deprecated. Handling flows as 'application/json' is no longer supported and will be removed in a future release. Please use the same endpoint with an 'application/x-yaml' content type.";

View File

@@ -28,7 +28,7 @@ import java.util.NoSuchElementException;
import java.util.Optional;
@Validated
@Controller("/api/v1/main/namespaces/{namespace}/kv")
@Controller("/api/v1/{tenant}/namespaces/{namespace}/kv")
public class KVController {
@Inject
private StorageInterface storageInterface;

View File

@@ -37,7 +37,7 @@ import java.util.UUID;
@Validated
@Controller("/api/v1/main/logs")
@Controller("/api/v1/{tenant}/logs")
@Requires(beans = LogRepositoryInterface.class)
public class LogController {
@Inject

View File

@@ -30,7 +30,7 @@ import java.util.List;
import static io.kestra.core.utils.DateUtils.validateTimeline;
@Validated
@Controller("/api/v1/main/metrics")
@Controller("/api/v1/{tenant}/metrics")
@Requires(beans = MetricRepositoryInterface.class)
public class MetricController {
@Inject

View File

@@ -32,7 +32,7 @@ import java.util.Locale;
import java.util.stream.Collectors;
@Validated
@Controller("/api/v1/main/namespaces")
@Controller("/api/v1/{tenant}/namespaces")
public class NamespaceController implements NamespaceControllerInterface<Namespace, NamespaceWithDisabled> {
@Inject
private TenantService tenantService;

View File

@@ -38,7 +38,7 @@ import java.util.zip.ZipOutputStream;
@Slf4j
@Validated
@Controller("/api/v1/main/namespaces")
@Controller("/api/v1/{tenant}/namespaces")
public class NamespaceFileController {
public static final String FLOWS_FOLDER = "_flows";
@Inject

View File

@@ -28,7 +28,7 @@ import java.util.Set;
import java.util.function.Function;
@Validated
@Controller("/api/v1/main/namespaces")
@Controller("/api/v1/{tenant}/namespaces")
public class NamespaceSecretController {
@Inject
protected TenantService tenantService;

View File

@@ -30,7 +30,7 @@ import java.util.stream.Stream;
import static io.kestra.core.utils.Rethrow.throwFunction;
@Validated
@Controller("/api/v1/main/plugins/")
@Controller("/api/v1/{tenant}/plugins/")
public class PluginController {
private static final String CACHE_DIRECTIVE = "public, max-age=3600";
@@ -125,7 +125,8 @@ public class PluginController {
plugin.getTaskRunners().stream(),
plugin.getLogExporters().stream(),
plugin.getApps().stream(),
plugin.getAppBlocks().stream()
plugin.getAppBlocks().stream(),
plugin.getAdditionalPlugins().stream()
)
.flatMap(i -> i)
.map(e -> new AbstractMap.SimpleEntry<>(

View File

@@ -34,7 +34,7 @@ import java.util.List;
import java.util.Map;
@Validated
@Controller("/api/v1/main/stats")
@Controller("/api/v1/{tenant}/stats")
@Deprecated(forRemoval = true)
@Hidden
public class StatsController {

View File

@@ -28,7 +28,7 @@ import java.time.Duration;
import java.time.ZonedDateTime;
import java.util.List;
@Controller("/api/v1/main/taskruns")
@Controller("/api/v1/{tenant}/taskruns")
@Requires(property = "kestra.repository.type", value = "elasticsearch")
public class TaskRunController {
@Inject

View File

@@ -43,7 +43,7 @@ import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream;
@Validated
@Controller("/api/v1/main/templates")
@Controller("/api/v1/{tenant}/templates")
@TemplateEnabled
@Deprecated(forRemoval = true)
@Hidden

View File

@@ -46,7 +46,7 @@ import java.util.concurrent.atomic.AtomicInteger;
import static io.kestra.core.utils.Rethrow.throwConsumer;
import static io.kestra.core.utils.Rethrow.throwFunction;
@Controller("/api/v1/main/triggers")
@Controller("/api/v1/{tenant}/triggers")
@Slf4j
public class TriggerController {
@Inject

View File

@@ -85,10 +85,10 @@ public class QueryFilterFormatBinder implements AnnotatedRequestArgumentBinder<Q
private static List<Object> parseValues(List<String> values, QueryFilter.Field field, QueryFilter.Op operation) {
return values.stream().map(value -> switch (field) {
case SCOPE -> RequestUtils.toFlowScopes(values);
case SCOPE -> RequestUtils.toFlowScopes(value);
default -> (operation == QueryFilter.Op.IN || operation == QueryFilter.Op.NOT_IN)
? Arrays.asList(URLDecoder.decode(value, StandardCharsets.UTF_8).replaceAll("[\\[\\]]", "").split(","))
: value;
}).toList();
}
}
}

View File

@@ -16,11 +16,11 @@ import java.util.regex.Pattern;
import lombok.SneakyThrows;
@Singleton
@Requires(missingClasses = "io.kestra.ee.webserver.rooting.DefaultTenantAliasingRooter")
@Requires(missingClasses = "io.kestra.ee.webserver.rooting.TenantAliasingRooterEE")
@Replaces(DefaultRouter.class)
public class TenantAliasingRooter extends DefaultRouter {
private static final List<Pattern> EXCLUDED_ROUTES = List.of(
protected static final List<Pattern> EXCLUDED_ROUTES = List.of(
Pattern.compile("/api/v1/main/.*"),
Pattern.compile("/api/v1/configs")
);
@@ -34,6 +34,10 @@ public class TenantAliasingRooter extends DefaultRouter {
@Override
public <T, R> UriRouteMatch<T, R> findClosest(HttpRequest<?> request) {
String path = request.getUri().getPath();
UriRouteMatch<T, R> closest = super.findClosest(request);
if (closest != null || bypassRooting()){
return closest;
}
boolean excluded = EXCLUDED_ROUTES.stream().anyMatch(route -> route.matcher(path).matches());
if (path.startsWith("/api/v1/") && !excluded){
@@ -43,12 +47,25 @@ public class TenantAliasingRooter extends DefaultRouter {
originalUri.getUserInfo(),
request.getServerAddress().getHostName(),
request.getServerAddress().getPort(),
originalUri.getPath().replace("/api/v1", "/api/v1/main"),
originalUri.getPath().replace("/api/v1", "/api/v1/" + getTenantId()),
originalUri.getQuery(),
originalUri.getFragment()
);
return super.findClosest(request.toMutableRequest().uri(updatedUri));
}
return super.findClosest(request);
return null;
}
protected String getTenantId(){
return "main";
}
/**
* For override purpose. This method is here to allow EE version
* to bypass rooting when some condition are met
* @return
*/
protected boolean bypassRooting(){
return false;
}
}

View File

@@ -0,0 +1,28 @@
package io.kestra.webserver.tenants;
import static io.kestra.core.tenant.TenantService.MAIN_TENANT;
import io.micronaut.core.order.Ordered;
import io.micronaut.http.BasicHttpAttributes;
import io.micronaut.http.HttpRequest;
import io.micronaut.http.HttpStatus;
import io.micronaut.http.annotation.RequestFilter;
import io.micronaut.http.annotation.ServerFilter;
import io.micronaut.http.exceptions.HttpStatusException;
import io.micronaut.http.uri.UriMatchInfo;
@ServerFilter("/**")
public class TenantValidationFilter implements Ordered {
public static final String TENANT_PATH_ATTRIBUTES = "tenant";
@RequestFilter
public void filterRequest(HttpRequest<?> request) {
UriMatchInfo routeMatch = BasicHttpAttributes.getRouteMatchInfo(request).orElse(null);
if (routeMatch != null && routeMatch.getVariableValues().containsKey(TENANT_PATH_ATTRIBUTES)) {
String tenant = (String) routeMatch.getVariableValues().get(TENANT_PATH_ATTRIBUTES);
if (tenant != null && !MAIN_TENANT.equals(tenant)) {
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Tenant must be 'main' for OSS version");
}
}
}
}

View File

@@ -245,8 +245,8 @@ public class RequestUtils {
return filters;
}
public static List<FlowScope> toFlowScopes(List<String> values) {
return Arrays.stream(values.getFirst().split(","))
public static List<FlowScope> toFlowScopes(String value) {
return Arrays.stream(value.split(","))
.map(valueStr -> {
try {
return FlowScope.valueOf(valueStr.toUpperCase());

View File

@@ -71,7 +71,7 @@ class QueryFilterFormatBinderTest {
// THEN
assertEquals(1, filters.size());
assertEquals(QueryFilter.Field.SCOPE, filters.getFirst().field());
assertEquals(RequestUtils.toFlowScopes(List.of("USER,SYSTEM")), filters.getFirst().value());
assertEquals(RequestUtils.toFlowScopes("USER,SYSTEM"), filters.getFirst().value());
}
@Test

View File

@@ -0,0 +1,63 @@
package io.kestra.webserver.tenants;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowableOfType;
import io.kestra.core.junit.annotations.KestraTest;
import io.kestra.core.junit.annotations.LoadFlows;
import io.kestra.core.models.flows.Flow;
import io.micronaut.http.HttpRequest;
import io.micronaut.http.HttpStatus;
import io.micronaut.http.client.annotation.Client;
import io.micronaut.http.client.exceptions.HttpClientResponseException;
import io.micronaut.reactor.http.client.ReactorHttpClient;
import jakarta.inject.Inject;
import org.junit.jupiter.api.Test;
@KestraTest
public class TenantValidationFilterTest {
private static final String NAMESPACE = "io.kestra.tests";
@Inject
@Client("/")
ReactorHttpClient client;
@Test
@LoadFlows({"flows/valids/inputs.yaml"})
void should_find_flow_for_no_tenant() {
Flow flow = client.toBlocking()
.retrieve(
HttpRequest.GET("/api/v1/flows/" + NAMESPACE + "/inputs"),
Flow.class
);
assertThat(flow).isNotNull();
assertThat(flow.getId()).isEqualTo("inputs");
}
@Test
@LoadFlows({"flows/valids/inputs.yaml"})
void should_find_flow_for_main_tenant() {
Flow flow = client.toBlocking()
.retrieve(
HttpRequest.GET("/api/v1/main/flows/" + NAMESPACE + "/inputs"),
Flow.class
);
assertThat(flow).isNotNull();
assertThat(flow.getId()).isEqualTo("inputs");
}
@Test
@LoadFlows({"flows/valids/inputs.yaml"})
void should_return_bad_request_for_flow_with_incorrect_tenant() {
HttpClientResponseException excetpion = catchThrowableOfType(
HttpClientResponseException.class,
() -> client.toBlocking()
.retrieve(
HttpRequest.GET("/api/v1/non_main_tenant/flows/" + NAMESPACE + "/inputs"),
Flow.class
));
assertThat(excetpion.code()).isEqualTo(HttpStatus.BAD_REQUEST.getCode());
assertThat(excetpion.getMessage()).isEqualTo("Bad Request: Tenant must be 'main' for OSS version");
}
}

View File

@@ -107,7 +107,7 @@ class RequestUtilsTest {
@Test
void testToFlowScopesValid() {
List<FlowScope> result = RequestUtils.toFlowScopes(List.of("USER,SYSTEM"));
List<FlowScope> result = RequestUtils.toFlowScopes("USER,SYSTEM");
assertEquals(2, result.size());
assertTrue(result.contains(FlowScope.USER));
@@ -117,10 +117,10 @@ class RequestUtilsTest {
@Test
void testToFlowScopesInvalidValue() {
Exception exception = assertThrows(IllegalArgumentException.class, () ->
RequestUtils.toFlowScopes(List.of("INVALID_SCOPE"))
RequestUtils.toFlowScopes("INVALID_SCOPE")
);
assertTrue(exception.getMessage().contains("Invalid FlowScope value"));
}
}
}