mirror of
https://github.com/kestra-io/kestra.git
synced 2025-12-26 14:00:23 -05:00
Compare commits
41 Commits
docs/retur
...
v0.23.0-rc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c8419b266 | ||
|
|
84e4c62c6d | ||
|
|
9aa605e23b | ||
|
|
faa77aed79 | ||
|
|
fdce552528 | ||
|
|
a028a61792 | ||
|
|
023a77a320 | ||
|
|
bfee04bca2 | ||
|
|
3756f01bdf | ||
|
|
c1240d7391 | ||
|
|
ac37ae6032 | ||
|
|
9e51b100b0 | ||
|
|
bc81e01608 | ||
|
|
9f2162c942 | ||
|
|
97992d99ee | ||
|
|
f90f6b8429 | ||
|
|
0f7360ae81 | ||
|
|
938590f31f | ||
|
|
b2d1c84a86 | ||
|
|
d7ca302830 | ||
|
|
8656e852cc | ||
|
|
cc72336350 | ||
|
|
316d89764e | ||
|
|
4873bf4d36 | ||
|
|
204bf7f5e1 | ||
|
|
1e0950fdf8 | ||
|
|
4cddc704f4 | ||
|
|
f2f0e29f93 | ||
|
|
95011e022e | ||
|
|
65503b708a | ||
|
|
876b8cb2e6 | ||
|
|
f3b7592dfa | ||
|
|
4dbeaf86bb | ||
|
|
f98e78399d | ||
|
|
71dac0f311 | ||
|
|
3077d0ac7a | ||
|
|
9504bbaffe | ||
|
|
159c9373ad | ||
|
|
55b9088b55 | ||
|
|
601d1a0abb | ||
|
|
4a1cf98f26 |
3
.github/workflows/main.yml
vendored
3
.github/workflows/main.yml
vendored
@@ -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
|
||||
|
||||
6
.github/workflows/setversion-tag.yml
vendored
6
.github/workflows/setversion-tag.yml
vendored
@@ -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
|
||||
38
.github/workflows/workflow-github-release.yml
vendored
38
.github/workflows/workflow-github-release.yml
vendored
@@ -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 }}"
|
||||
}
|
||||
9
.github/workflows/workflow-release.yml
vendored
9
.github/workflows/workflow-release.yml
vendored
@@ -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 }}
|
||||
1
.plugins
1
.plugins
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
200
dev-tools/update-plugin-kestra-version.sh
Executable file
200
dev-tools/update-plugin-kestra-version.sh
Executable 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;
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
39
ui/.storybook/vitest.config.js
Normal file
39
ui/.storybook/vitest.config.js
Normal 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"],
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -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.
|
||||
|
||||
@@ -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
44474
ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
291
ui/package.json
291
ui/package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -154,7 +154,7 @@
|
||||
task: this.modelValue,
|
||||
schema: schema,
|
||||
definitions: this.definitions,
|
||||
required: this.schema?.required,
|
||||
required: this.requiredProperties.map(([p]) => p),
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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([]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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: () => ({
|
||||
|
||||
@@ -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("");
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
529
ui/tests/storybook/components/filter/KestraFilter.stories.tsx
Normal file
529
ui/tests/storybook/components/filter/KestraFilter.stories.tsx
Normal 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"));
|
||||
}
|
||||
@@ -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}) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import loadFilterLanguages from "../../../../src/override/services/filterLanguagesProvider.ts";
|
||||
import {fn} from "storybook/test";
|
||||
|
||||
export default fn(loadFilterLanguages).mockName("loadFilterLanguages");
|
||||
@@ -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 />,
|
||||
|
||||
5
ui/tests/storybook/utils/monacoUtils.ts
Normal file
5
ui/tests/storybook/utils/monacoUtils.ts
Normal 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;
|
||||
}
|
||||
@@ -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.*",
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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/",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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\\([^)]+\\))(.*)");
|
||||
|
||||
|
||||
@@ -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.";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<>(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user