Compare commits

...

41 Commits

Author SHA1 Message Date
Loïc Mathieu
08579cf555 chore: upgrade to v0.22.0-rc3-SNAPSHOT 2025-03-31 14:18:00 +02:00
Roman Acevedo
2b5d08c9f2 chore(deps): add bouncycastle:bcpkix-jdk18on to platform 2025-03-31 11:53:15 +02:00
Loïc Mathieu
822a3b438a chore: disable tests that are too flaky
An issue will be open to track them and re-enabled them later.
2025-03-31 10:47:52 +02:00
Nicolas K.
f6db013142 feat(core-ee): #7501 split file log exporter to multiple files (#8138)
Co-authored-by: nKwiatkowski <nkwiatkowski@kestra.io>
2025-03-31 09:49:27 +02:00
Miloš Paunović
a031bfc129 chore(ui): show blueprint id field in case of missing title (#8154) 2025-03-31 09:40:54 +02:00
Loïc Mathieu
1613dee76b fix(core): add missing docker plugin subgroup icon 2025-03-28 16:43:36 +01:00
Florian Hussonnois
a884708862 chore: upgrade to version 'v0.22.0-rc2-SNAPSHOT' 2025-03-28 15:54:38 +01:00
Florian Hussonnois
91bf3207f4 fix(cli): properly register plugins uninstall cmd 2025-03-28 15:41:00 +01:00
Florian Hussonnois
95b1f8dfcc fix(core): allow dash in plugin version qualifier 2025-03-28 15:40:47 +01:00
brian.mulier
4e7c6e87be fix(ui): search bars are properly working in secrets & KV pages
closes #8110
closes kestra-io/kestra-ee#3290
2025-03-28 15:15:42 +01:00
Miloš Paunović
8ac089de1d chore(ui): add padlock icon to secrets menu item (#8129) 2025-03-28 12:46:25 +01:00
Miloš Paunović
3d7c891b95 chore(ui): amend file tree context menu link colors (#8123) 2025-03-28 12:46:15 +01:00
brian.mulier
54eccac637 fix(ui): global secret page design
closes kestra-io/kestra-ee#3268
2025-03-28 11:19:46 +01:00
brian.mulier
b3799cc039 fix(ui): repair tenant translation 2025-03-28 11:19:46 +01:00
brian.mulier
143ebc061f fix(ui): add routeContext where it was missing 2025-03-28 11:19:46 +01:00
brian.mulier
224026c399 fix(webserver): handle out-of-bounds (>) namespaces fetch 2025-03-28 11:19:45 +01:00
brian.mulier
a093198004 fix(core): namespace service now properly detects namespaces with flows inside 2025-03-28 11:19:45 +01:00
Loïc Mathieu
380e329e97 fix(core): compilation issue 2025-03-28 09:26:12 +01:00
Loïc Mathieu
6ee206f5f3 Revert "fix(core): require condition in Flow trigger (#7494)"
This reverts commit c5767fd313.
2025-03-28 09:12:58 +01:00
Miloš Paunović
6c43f9c7c3 chore(ui): handle editor blueprint loading problem (#8113) 2025-03-28 08:58:54 +01:00
Roman Acevedo
ec067e1a06 fix: doc and deprecated field was not showing for dynamic non-string properties (#8006)
fix: doc and deprecated field was not showing for dynamic non-string properties (#8006)
2025-03-27 18:36:24 +01:00
Florian Hussonnois
8ebc3fbba7 fix(core): avoid flow validation error on plugin alias duplicates 2025-03-27 17:59:53 +01:00
Nicolas K.
7e087c696c fix(kafka runner): #2709 filter child forEach tasks before merging th… (#8095)
* fix(kafka runner): #2709 filter child forEach tasks before merging the output, and add sleep before restarting flows to ensure failure is persisted

* feat(kafka runner): #2709 wait until executions are persisted as Failed in the database before restarting

* fix(runner): put back the sleep instead of the wait

* clean(runner): remove unused variables

---------

Co-authored-by: nKwiatkowski <nkwiatkowski@kestra.io>
2025-03-27 17:09:26 +01:00
YannC
04b84df6ea feat(): add new crudeventtype "account_locked" (#8103) 2025-03-27 17:08:34 +01:00
Loïc Mathieu
b8e8333f62 fix(core): properly fix the issue with MapUtils.flattenToNestedMap 2025-03-27 16:28:47 +01:00
Loïc Mathieu
54aa935702 fix(core): HttpClient log the URL even if it's a secret
Fixes https://github.com/kestra-io/kestra/issues/8092
2025-03-27 16:02:00 +01:00
brian.mulier
8be17827c7 fix(ui): properly detect yaml to inject json schema into MonacoEditor
closes #8090
2025-03-27 13:39:45 +01:00
Miloš Paunović
9d83d9b6eb chore(ui): pass custom height property to execution output debug editors (#8100) 2025-03-27 13:36:56 +01:00
Miloš Paunović
ccd47f14ae chore(ui): include labels of saved search filter on page reload (#8099) 2025-03-27 13:17:36 +01:00
brian.mulier
8f4ce5fc18 fix(webserver): first eval without masking secret function to error in case of missing secret
closes #8094
2025-03-27 13:10:11 +01:00
Miloš Paunović
acb305dfdb chore(ui): make app & dashboard editors re-sizable (#8096) 2025-03-27 12:02:42 +01:00
Loïc Mathieu
4c93a2b0e9 fix(core): flatten map should not throw an exception
As it is called inside the Executor, it must be fail-safe.
2025-03-27 10:58:29 +01:00
Florian Hussonnois
dea66ca259 fix(cli): make worker args available through static KestraContext
part-of: kestra-io/kestra-ee#3259
2025-03-26 16:41:38 +01:00
Loïc Mathieu
c965f2f64c feat(*): add new methods findAllAsync for the backup 2025-03-26 14:04:58 +01:00
Miloš Paunović
6516f7fc60 chore(ui): improve saved search filtering functionality (#8073)
* chore(ui): passing prefix down  to saved search label component

* chore(ui): check if filters have operation field on encoding

* chore(ui): trigger search automatically on choosing the saved item
2025-03-26 11:18:29 +01:00
github-actions[bot]
2dd61fc194 chore(translations): localize to languages other than English (#8071) 2025-03-26 11:18:16 +01:00
YannC
771e841d78 chore(ci): pass plugin version to docker workflow 2025-03-26 10:39:29 +01:00
YannC
4448203031 fix(): add missing command to flow subcommand 2025-03-26 10:24:13 +01:00
yuri
e083163583 feat(ui): improve styling of saved filter searches (#8040)
Co-authored-by: MilosPaunovic <paun992@hotmail.com>
2025-03-26 09:58:22 +01:00
Florian Hussonnois
8617eb0c7b chore: upgrade to version 'v0.22.0-rc1-SNAPSHOT' 2025-03-25 17:32:59 +01:00
YannC
2a002e9531 chore(ci): lower build-artifacts workflow so github release can use it 2025-03-25 17:32:59 +01:00
88 changed files with 1132 additions and 459 deletions

View File

@@ -24,15 +24,9 @@ on:
required: true
jobs:
build-artifacts:
name: Build - Artifacts
uses: ./.github/workflows/workflow-build-artifacts.yml
with:
plugin-version: ${{ github.event.inputs.plugin-version != null && github.event.inputs.plugin-version || 'LATEST' }}
publish:
name: Publish - Docker
needs: build-artifacts
runs-on: ubuntu-latest
strategy:
matrix:

View File

@@ -38,9 +38,18 @@ on:
description: "The Sonatype GPG file."
required: true
jobs:
build-artifacts:
name: Build - Artifacts
uses: ./.github/workflows/workflow-build-artifacts.yml
with:
plugin-version: ${{ github.event.inputs.plugin-version != null && github.event.inputs.plugin-version || 'LATEST' }}
Docker:
name: Publish Docker
needs: build-artifacts
uses: ./.github/workflows/workflow-publish-docker.yml
with:
plugin-version: ${{ github.event.inputs.plugin-version != null && github.event.inputs.plugin-version || 'LATEST' }}
secrets:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKERHUB_PASSWORD: ${{ secrets.DOCKERHUB_PASSWORD }}
@@ -57,6 +66,7 @@ jobs:
Github:
name: Github Release
needs: build-artifacts
if: startsWith(github.ref, 'refs/tags/v')
uses: ./.github/workflows/workflow-github-release.yml
secrets:

View File

@@ -18,6 +18,8 @@ import picocli.CommandLine;
FlowNamespaceCommand.class,
FlowDotCommand.class,
FlowExportCommand.class,
FlowUpdateCommand.class,
FlowUpdatesCommand.class
}
)
@Slf4j

View File

@@ -12,6 +12,7 @@ import picocli.CommandLine.Command;
mixinStandardHelpOptions = true,
subcommands = {
PluginInstallCommand.class,
PluginUninstallCommand.class,
PluginListCommand.class,
PluginDocCommand.class,
PluginSearchCommand.class

View File

@@ -17,10 +17,10 @@ import java.util.List;
@CommandLine.Command(
name = "uninstall",
description = "uninstall a plugin"
description = "Uninstall plugins"
)
public class PluginUninstallCommand extends AbstractCommand {
@Parameters(index = "0..*", description = "the plugins to uninstall")
@Parameters(index = "0..*", description = "The plugins to uninstall. Represented as Maven artifact coordinates (i.e., <groupId>:<artifactId>:(<version>|LATEST)")
List<String> dependencies = new ArrayList<>();
@Spec

View File

@@ -2,6 +2,7 @@ package io.kestra.cli.commands.servers;
import com.google.common.collect.ImmutableMap;
import io.kestra.cli.services.FileChangedEventListener;
import io.kestra.core.contexts.KestraContext;
import io.kestra.core.models.ServerType;
import io.kestra.core.repositories.LocalFlowRepositoryLoader;
import io.kestra.core.runners.StandAloneRunner;
@@ -88,9 +89,10 @@ public class StandAloneCommand extends AbstractServerCommand {
this.skipExecutionService.setSkipFlows(skipFlows);
this.skipExecutionService.setSkipNamespaces(skipNamespaces);
this.skipExecutionService.setSkipTenants(skipTenants);
this.startExecutorService.applyOptions(startExecutors, notStartExecutors);
KestraContext.getContext().injectWorkerConfigs(workerThread, null);
super.call();
if (flowPath != null) {

View File

@@ -1,6 +1,7 @@
package io.kestra.cli.commands.servers;
import com.google.common.collect.ImmutableMap;
import io.kestra.core.contexts.KestraContext;
import io.kestra.core.models.ServerType;
import io.kestra.core.runners.Worker;
import io.kestra.core.utils.Await;
@@ -36,7 +37,11 @@ public class WorkerCommand extends AbstractServerCommand {
@Override
public Integer call() throws Exception {
KestraContext.getContext().injectWorkerConfigs(thread, workerGroupKey);
super.call();
if (this.workerGroupKey != null && !this.workerGroupKey.matches("[a-zA-Z0-9_-]+")) {
throw new IllegalArgumentException("The --worker-group option must match the [a-zA-Z0-9_-]+ pattern");
}

View File

@@ -10,6 +10,8 @@ import io.micronaut.context.env.Environment;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
@@ -27,6 +29,10 @@ public abstract class KestraContext {
// Properties
public static final String KESTRA_SERVER_TYPE = "kestra.server-type";
// Those properties are injected bases on the CLI args.
private static final String KESTRA_WORKER_MAX_NUM_THREADS = "kestra.worker.max-num-threads";
private static final String KESTRA_WORKER_GROUP_KEY = "kestra.worker.group-key";
/**
* Gets the current {@link KestraContext}.
*
@@ -54,6 +60,12 @@ public abstract class KestraContext {
*/
public abstract ServerType getServerType();
public abstract Optional<Integer> getWorkerMaxNumThreads();
public abstract Optional<String> getWorkerGroupKey();
public abstract void injectWorkerConfigs(Integer maxNumThreads, String workerGroupKey);
/**
* Returns the Kestra Version.
*
@@ -110,6 +122,34 @@ public abstract class KestraContext {
.orElse(ServerType.STANDALONE);
}
/** {@inheritDoc} **/
@Override
public Optional<Integer> getWorkerMaxNumThreads() {
return Optional.ofNullable(environment)
.flatMap(env -> env.getProperty(KESTRA_WORKER_MAX_NUM_THREADS, Integer.class));
}
/** {@inheritDoc} **/
@Override
public Optional<String> getWorkerGroupKey() {
return Optional.ofNullable(environment)
.flatMap(env -> env.getProperty(KESTRA_WORKER_GROUP_KEY, String.class));
}
/** {@inheritDoc} **/
@Override
public void injectWorkerConfigs(Integer maxNumThreads, String workerGroupKey) {
final Map<String, Object> configs = new HashMap<>();
Optional.ofNullable(maxNumThreads)
.ifPresent(val -> configs.put(KESTRA_WORKER_MAX_NUM_THREADS, val));
Optional.ofNullable(workerGroupKey)
.ifPresent(val -> configs.put(KESTRA_WORKER_GROUP_KEY, val));
if (!configs.isEmpty()) {
environment.addPropertySource("kestra-runtime", configs);
}
}
/** {@inheritDoc} **/
@Override
public void shutdown() {

View File

@@ -81,7 +81,7 @@ public class JsonSchemaGenerator {
objectNode.put("type", "array");
}
replaceAnyOfWithOneOf(objectNode);
pullOfDefaultFromOneOf(objectNode);
pullDocumentationAndDefaultFromOneOf(objectNode);
removeRequiredOnPropsWithDefaults(objectNode);
return JacksonMapper.toMap(objectNode);
@@ -122,22 +122,35 @@ public class JsonSchemaGenerator {
// This hack exists because for Property we generate a oneOf for properties that are not strings.
// By default, the 'default' is in each oneOf which Monaco editor didn't take into account.
// So, we pull off the 'default' from any of the oneOf to the parent.
private void pullOfDefaultFromOneOf(ObjectNode objectNode) {
// same thing for documentation fields: 'title', 'description', '$deprecated'
private void pullDocumentationAndDefaultFromOneOf(ObjectNode objectNode) {
objectNode.findParents("oneOf").forEach(jsonNode -> {
if (jsonNode instanceof ObjectNode oNode) {
JsonNode oneOf = oNode.get("oneOf");
if (oneOf instanceof ArrayNode arrayNode) {
Iterator<JsonNode> it = arrayNode.elements();
JsonNode defaultNode = null;
while (it.hasNext() && defaultNode == null) {
var nodesToPullUp = new HashMap<String, Optional<JsonNode>>(Map.ofEntries(
Map.entry("default", Optional.empty()),
Map.entry("title", Optional.empty()),
Map.entry("description", Optional.empty()),
Map.entry("$deprecated", Optional.empty())
));
// find nodes to pull up
while (it.hasNext() && nodesToPullUp.containsValue(Optional.<JsonNode>empty())) {
JsonNode next = it.next();
if (next instanceof ObjectNode nextAsObj) {
defaultNode = nextAsObj.get("default");
nodesToPullUp.entrySet().stream()
.filter(node -> node.getValue().isEmpty())
.forEach(node -> node
.setValue(Optional.ofNullable(
nextAsObj.get(node.getKey())
)));
}
}
if (defaultNode != null) {
oNode.set("default", defaultNode);
}
// create nodes on parent
nodesToPullUp.entrySet().stream()
.filter(node -> node.getValue().isPresent())
.forEach(node -> oNode.set(node.getKey(), node.getValue().get()));
}
}
});
@@ -629,7 +642,7 @@ public class JsonSchemaGenerator {
try {
ObjectNode objectNode = generator.generateSchema(cls);
replaceAnyOfWithOneOf(objectNode);
pullOfDefaultFromOneOf(objectNode);
pullDocumentationAndDefaultFromOneOf(objectNode);
removeRequiredOnPropsWithDefaults(objectNode);
return JacksonMapper.toMap(extractMainRef(objectNode));

View File

@@ -8,6 +8,7 @@ public enum CrudEventType {
LOGIN,
LOGOUT,
IMPERSONATE,
LOGIN_FAILURE
LOGIN_FAILURE,
ACCOUNT_LOCKED
}

View File

@@ -23,9 +23,9 @@ public class RunContextResponseInterceptor implements HttpResponseInterceptor {
response instanceof BasicClassicHttpResponse httpResponse
) {
try {
// FIXME temporary fix for https://github.com/kestra-io/kestra/issues/8092
runContext.logger().debug(
"Request '{}' from '{}' with the response code '{}'",
httpClientContext.getRequest().getUri(),
"Request " + httpClientContext.getRequest().getUri() + " from '{}' with the response code '{}'",
httpClientContext.getEndpointDetails().getRemoteAddress(),
response.getCode()
);

View File

@@ -19,6 +19,7 @@ public record Label(@NotNull String key, @NotNull String value) {
public static final String RESTARTED = SYSTEM_PREFIX + "restarted";
public static final String REPLAY = SYSTEM_PREFIX + "replay";
public static final String REPLAYED = SYSTEM_PREFIX + "replayed";
public static final String SIMULATED_EXECUTION = SYSTEM_PREFIX + "simulatedExecution";
/**
* Static helper method for converting a list of labels to a nested map.

View File

@@ -10,7 +10,7 @@ import jakarta.validation.constraints.Pattern;
*/
public interface PluginVersioning {
@Pattern(regexp="\\d+\\.\\d+\\.\\d+(-[a-zA-Z0-9]+)?|([a-zA-Z0-9]+)")
@Pattern(regexp="\\d+\\.\\d+\\.\\d+(-[a-zA-Z0-9-]+)?|([a-zA-Z0-9]+)")
@Schema(title = "The version of the plugin to use.")
String getVersion();
}

View File

@@ -38,7 +38,7 @@ public record PluginArtifact(
"([^: ]+):([^: ]+)(:([^: ]*)(:([^: ]+))?)?:([^: ]+)"
);
private static final Pattern FILENAME_PATTERN = Pattern.compile(
"^(?<groupId>[\\w_]+)__(?<artifactId>[\\w-_]+)(?:__(?<classifier>[\\w-_]+))?__(?<version>\\d+_\\d+_\\d+(-[a-zA-Z0-9]+)?|([a-zA-Z0-9]+))\\.jar$"
"^(?<groupId>[\\w_]+)__(?<artifactId>[\\w-_]+)(?:__(?<classifier>[\\w-_]+))?__(?<version>\\d+_\\d+_\\d+(-[a-zA-Z0-9-]+)?|([a-zA-Z0-9]+))\\.jar$"
);
public static final String JAR_EXTENSION = "jar";

View File

@@ -94,6 +94,7 @@ public interface ExecutionRepositoryInterface extends SaveRepositoryInterface<Ex
boolean allowDeleted
);
Flux<Execution> findAllAsync(@Nullable String tenantId);
ArrayListTotal<TaskRun> findTaskRun(
Pageable pageable,

View File

@@ -88,6 +88,8 @@ public interface LogRepositoryInterface extends SaveRepositoryInterface<LogEntry
ZonedDateTime startDate
);
Flux<LogEntry> findAllAsync(@Nullable String tenantId);
List<LogStatistics> statistics(
@Nullable String query,
@Nullable String tenantId,

View File

@@ -6,6 +6,7 @@ import io.kestra.core.models.executions.metrics.MetricAggregations;
import io.kestra.plugin.core.dashboard.data.Metrics;
import io.micronaut.core.annotation.Nullable;
import io.micronaut.data.model.Pageable;
import reactor.core.publisher.Flux;
import java.time.ZonedDateTime;
import java.util.List;
@@ -28,6 +29,8 @@ public interface MetricRepositoryInterface extends SaveRepositoryInterface<Metri
Integer purge(Execution execution);
Flux<MetricEntry> findAllAsync(@Nullable String tenantId);
default Function<String, String> sortMapping() throws IllegalArgumentException {
return s -> s;
}

View File

@@ -201,7 +201,7 @@ public final class FileSerde {
}
}
private static <T> SequenceWriter createSequenceWriter(ObjectMapper objectMapper, Writer writer, TypeReference<T> type) throws IOException {
public static <T> SequenceWriter createSequenceWriter(ObjectMapper objectMapper, Writer writer, TypeReference<T> type) throws IOException {
return objectMapper.writerFor(type).writeValues(writer);
}

View File

@@ -132,6 +132,15 @@ public class FlowService {
}
List<String> warnings = new ArrayList<>(checkValidSubflows(flow, tenantId));
List<io.kestra.plugin.core.trigger.Flow> flowTriggers = ListUtils.emptyOnNull(flow.getTriggers()).stream()
.filter(io.kestra.plugin.core.trigger.Flow.class::isInstance)
.map(io.kestra.plugin.core.trigger.Flow.class::cast)
.toList();
flowTriggers.forEach(flowTrigger -> {
if (ListUtils.emptyOnNull(flowTrigger.getConditions()).isEmpty() && flowTrigger.getPreconditions() == null) {
warnings.add("This flow will be triggered for EVERY execution of EVERY flow on your instance. We recommend adding the preconditions property to the Flow trigger '" + flowTrigger.getId() + "'.");
}
});
return warnings;
}
@@ -140,7 +149,11 @@ public class FlowService {
try {
Map<String, Class<?>> aliases = pluginRegistry.plugins().stream()
.flatMap(plugin -> plugin.getAliases().values().stream())
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
.collect(Collectors.toMap(
Map.Entry::getKey,
Map.Entry::getValue,
(existing, duplicate) -> existing
));
Map<String, Object> stringObjectMap = JacksonMapper.ofYaml().readValue(flowSource, JacksonMapper.MAP_TYPE_REFERENCE);
return relocations(aliases, stringObjectMap);
} catch (JsonProcessingException e) {

View File

@@ -1,9 +1,11 @@
package io.kestra.core.services;
import io.kestra.core.repositories.FlowRepositoryInterface;
import io.kestra.core.utils.NamespaceUtils;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import java.util.Collection;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
@@ -19,7 +21,7 @@ public class NamespaceService {
}
/**
* Checks whether a given namespace exists.
* Checks whether a given namespace exists. A namespace is considered existing if at least one Flow is within the namespace or a parent namespace
*
* @param tenant The tenant ID
* @param namespace The namespace - cannot be null.
@@ -29,7 +31,10 @@ public class NamespaceService {
Objects.requireNonNull(namespace, "namespace cannot be null");
if (flowRepository.isPresent()) {
List<String> namespaces = flowRepository.get().findDistinctNamespace(tenant);
List<String> namespaces = flowRepository.get().findDistinctNamespace(tenant).stream()
.map(NamespaceUtils::asTree)
.flatMap(Collection::stream)
.toList();
return namespaces.stream().anyMatch(ns -> ns.equals(namespace) || ns.startsWith(namespace));
}
return false;

View File

@@ -29,7 +29,7 @@ import java.util.stream.Stream;
@Slf4j
@Singleton
public class FlowTopologyService {
public static final Label SIMULATED_EXECUTION = new Label(Label.SYSTEM_PREFIX + "simulatedExecution", "true");
public static final Label SIMULATED_EXECUTION = new Label(Label.SIMULATED_EXECUTION, "true");
@Inject
protected ConditionService conditionService;

View File

@@ -1,11 +1,15 @@
package io.kestra.core.utils;
import jakarta.validation.constraints.NotNull;
import lombok.extern.slf4j.Slf4j;
import java.util.*;
@SuppressWarnings({"rawtypes", "unchecked"})
@Slf4j
public class MapUtils {
private static final String CONFLICT_AT_KEY_MSG = "Conflict at key: '{}', ignoring it. Map keys are: {}";
public static Map<String, Object> merge(Map<String, Object> a, Map<String, Object> b) {
if (a == null && b == null) {
return null;
@@ -136,9 +140,9 @@ public class MapUtils {
}
/**
* Utility method nested a flatten map.
* Utility method nested a flattened map.
*
* @param flatMap the flatten map.
* @param flatMap the flattened map.
* @return the nested map.
*
* @throws IllegalArgumentException if the given map contains conflicting keys.
@@ -156,13 +160,15 @@ public class MapUtils {
currentMap.put(key, new HashMap<>());
} else if (!(currentMap.get(key) instanceof Map)) {
var invalidKey = String.join(",", Arrays.copyOfRange(keys, 0, i));
throw new IllegalArgumentException("Conflict at key: '" + invalidKey + "'. Map keys are: " + flatMap.keySet());
log.warn(CONFLICT_AT_KEY_MSG, invalidKey, flatMap.keySet());
continue;
}
currentMap = (Map<String, Object>) currentMap.get(key);
}
String lastKey = keys[keys.length - 1];
if (currentMap.containsKey(lastKey)) {
throw new IllegalArgumentException("Conflict at key: '" + lastKey + "', Map keys are: " + flatMap.keySet());
log.warn("Conflict at key: '{}', ignoring it. Map keys are: {}", lastKey, flatMap.keySet());
continue;
}
currentMap.put(lastKey, entry.getValue());
}

View File

@@ -1,16 +0,0 @@
package io.kestra.core.validations;
import io.kestra.core.validations.validator.ConditionValidator;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = ConditionValidator.class)
public @interface ConditionValidation {
String message() default "one condition must be set";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}

View File

@@ -1,22 +0,0 @@
package io.kestra.core.validations.validator;
import io.kestra.core.validations.ConditionValidation;
import io.kestra.plugin.core.trigger.Flow;
import io.micronaut.core.annotation.AnnotationValue;
import io.micronaut.core.annotation.NonNull;
import io.micronaut.core.annotation.Nullable;
import io.micronaut.validation.validator.constraints.ConstraintValidator;
import io.micronaut.validation.validator.constraints.ConstraintValidatorContext;
import jakarta.inject.Singleton;
@Singleton
public class ConditionValidator implements ConstraintValidator<ConditionValidation, Flow> {
@Override
public boolean isValid(@Nullable Flow value, @NonNull AnnotationValue<ConditionValidation> annotationMetadata, @NonNull ConstraintValidatorContext context) {
if (value == null) {
return true;
}
return value.getConditions() != null || value.getPreconditions() != null;
}
}

View File

@@ -4,47 +4,51 @@ import com.fasterxml.jackson.annotation.JsonIgnore;
import io.kestra.core.exceptions.IllegalVariableEvaluationException;
import io.kestra.core.exceptions.InternalException;
import io.kestra.core.models.Label;
import io.kestra.core.models.annotations.Example;
import io.kestra.core.models.annotations.Plugin;
import io.kestra.core.models.annotations.PluginProperty;
import io.kestra.core.models.conditions.Condition;
import io.kestra.core.models.conditions.ConditionContext;
import io.kestra.core.models.executions.Execution;
import io.kestra.core.models.executions.ExecutionTrigger;
import io.kestra.core.models.flows.State;
import io.kestra.core.models.triggers.AbstractTrigger;
import io.kestra.core.models.triggers.TimeWindow;
import io.kestra.core.models.triggers.TriggerOutput;
import io.kestra.core.models.triggers.multipleflows.MultipleCondition;
import io.kestra.core.models.triggers.multipleflows.MultipleConditionStorageInterface;
import io.kestra.core.models.triggers.multipleflows.MultipleConditionWindow;
import io.kestra.core.runners.RunContext;
import io.kestra.core.services.LabelService;
import io.kestra.core.utils.IdUtils;
import io.kestra.core.utils.ListUtils;
import io.kestra.core.utils.MapUtils;
import io.kestra.core.utils.TruthUtils;
import io.kestra.core.validations.ConditionValidation;
import io.kestra.core.validations.PreconditionFilterValidation;
import io.micronaut.core.annotation.Nullable;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.*;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import lombok.*;
import lombok.experimental.SuperBuilder;
import io.kestra.core.models.annotations.Example;
import io.kestra.core.models.annotations.Plugin;
import io.kestra.core.models.executions.Execution;
import io.kestra.core.models.executions.ExecutionTrigger;
import io.kestra.core.models.flows.State;
import io.kestra.core.models.triggers.AbstractTrigger;
import io.kestra.core.models.triggers.TriggerOutput;
import io.kestra.core.runners.RunContext;
import io.kestra.core.utils.IdUtils;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.stream.Streams;
import org.slf4j.Logger;
import java.util.*;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import io.micronaut.core.annotation.Nullable;
import jakarta.validation.constraints.NotNull;
import static io.kestra.core.topologies.FlowTopologyService.SIMULATED_EXECUTION;
import static io.kestra.core.utils.Rethrow.throwPredicate;
@@ -194,7 +198,6 @@ import static io.kestra.core.utils.Rethrow.throwPredicate;
aliases = "io.kestra.core.models.triggers.types.Flow"
)
@Slf4j
@ConditionValidation
public class Flow extends AbstractTrigger implements TriggerOutput<Flow.Output> {
private static final String TRIGGER_VAR = "trigger";
private static final String OUTPUTS_VAR = "outputs";

View File

@@ -0,0 +1,37 @@
package io.kestra.core.contexts;
import io.kestra.core.junit.annotations.KestraTest;
import io.micronaut.context.ApplicationContext;
import jakarta.inject.Inject;
import org.junit.jupiter.api.Test;
import java.util.Map;
import java.util.Optional;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;
@KestraTest
class KestraContextTest {
@Inject
KestraContext context;
@Test
void shouldGetWorkerMaxNumThreads() {
// When
context.injectWorkerConfigs(16, null);
// Then
assertThat(KestraContext.getContext().getWorkerMaxNumThreads(), is(Optional.of(16)));
}
@Test
void shouldGetWorkerGroupKey() {
// When
context.injectWorkerConfigs(null, "my-key");
// Then
assertThat(KestraContext.getContext().getWorkerGroupKey(), is(Optional.of("my-key")));
}
}

View File

@@ -249,6 +249,30 @@ class JsonSchemaGeneratorTest {
assertThat((List<String>) generate.get("required"), containsInAnyOrder("requiredWithNoDefault"));
}
@SuppressWarnings("unchecked")
@Test
void testDocumentation() {
Map<String, Object> generate = jsonSchemaGenerator.properties(Task.class, TaskWithDynamicDocumentedFields.class);
assertThat(generate, is(not(nullValue())));
assertThat(((Map<String, Map<String, Object>>) generate.get("properties")).get("stringProperty").get("title"), is("stringProperty title"));
assertThat(((Map<String, Map<String, Object>>) generate.get("properties")).get("stringProperty").get("description"), is("stringProperty description"));
assertThat(((Map<String, Map<String, Object>>) generate.get("properties")).get("stringProperty").get("$deprecated"), is(true));
assertThat(((Map<String, Map<String, Object>>) generate.get("properties")).get("integerProperty").get("title"), is("integerProperty title"));
assertThat(((Map<String, Map<String, Object>>) generate.get("properties")).get("integerProperty").get("description"), is("integerProperty description"));
assertThat(((Map<String, Map<String, Object>>) generate.get("properties")).get("integerProperty").get("$deprecated"), is(true));
assertThat(((Map<String, Map<String, Object>>) generate.get("properties")).get("stringPropertyWithDefault").get("title"), is("stringPropertyWithDefault title"));
assertThat(((Map<String, Map<String, Object>>) generate.get("properties")).get("stringPropertyWithDefault").get("description"), is("stringPropertyWithDefault description"));
assertThat(((Map<String, Map<String, Object>>) generate.get("properties")).get("stringPropertyWithDefault").get("$deprecated"), is(true));
assertThat(((Map<String, Map<String, Object>>) generate.get("properties")).get("stringPropertyWithDefault").get("default"), is("my string"));
assertThat(((Map<String, Map<String, Object>>) generate.get("properties")).get("integerPropertyWithDefault").get("title"), is("integerPropertyWithDefault title"));
assertThat(((Map<String, Map<String, Object>>) generate.get("properties")).get("integerPropertyWithDefault").get("description"), is("integerPropertyWithDefault description"));
assertThat(((Map<String, Map<String, Object>>) generate.get("properties")).get("integerPropertyWithDefault").get("$deprecated"), is(true));
assertThat(((Map<String, Map<String, Object>>) generate.get("properties")).get("integerPropertyWithDefault").get("default"), is("10000"));
}
@SuppressWarnings("unchecked")
@Test
void dashboard() throws URISyntaxException {
@@ -314,10 +338,11 @@ class JsonSchemaGeneratorTest {
private TestEnum testEnum;
@PluginProperty
@Schema(title = "Title from the attribute")
@Schema(title = "Title from the attribute", description = "Description from the attribute")
private TestClass testClass;
@PluginProperty(internalStorageURI = true)
@Schema(title = "Title from the attribute", description = "Description from the attribute")
private String uri;
@PluginProperty
@@ -392,4 +417,49 @@ class JsonSchemaGeneratorTest {
@NotNull
private Property<TaskWithEnum.TestClass> requiredWithNoDefault;
}
@SuperBuilder
@ToString
@EqualsAndHashCode
@Getter
@NoArgsConstructor
public static class TaskWithDynamicDocumentedFields extends Task implements RunnableTask<VoidOutput> {
@Deprecated(since="deprecation_version_1", forRemoval=true)
@Schema(
title = "integerPropertyWithDefault title",
description = "integerPropertyWithDefault description"
)
@Builder.Default
protected Property<Integer> integerPropertyWithDefault = Property.of(10000);
@Deprecated(since="deprecation_version_1", forRemoval=true)
@Schema(
title = "stringPropertyWithDefault title",
description = "stringPropertyWithDefault description"
)
@Builder.Default
protected Property<String> stringPropertyWithDefault = Property.of("my string");
@Deprecated(since="deprecation_version_1", forRemoval=true)
@Schema(
title = "stringProperty title",
description = "stringProperty description"
)
protected Property<String> stringProperty;
@Deprecated(since="deprecation_version_1", forRemoval=true)
@Schema(
title = "integerProperty title",
description = "integerProperty description"
)
protected Property<Integer> integerProperty;
@Override
public VoidOutput run(RunContext runContext) throws Exception {
return null;
}
}
}

View File

@@ -33,6 +33,32 @@ class PluginArtifactTest {
assertNull(artifact.uri());
}
@Test
void shouldParseGivenValidFilenameWithQualifier() {
String fileName = "io_kestra_plugin__plugin-serdes__custom-classifier__0_20_0-SNAPSHOT.jar";
PluginArtifact artifact = PluginArtifact.fromFileName(fileName);
assertEquals("io.kestra.plugin", artifact.groupId());
assertEquals("plugin-serdes", artifact.artifactId());
assertEquals("jar", artifact.extension());
assertEquals("custom-classifier", artifact.classifier());
assertEquals("0.20.0-SNAPSHOT", artifact.version());
assertNull(artifact.uri());
}
@Test
void shouldParseGivenValidFilenameWithNonStandardQualifier() {
String fileName = "io_kestra_plugin__plugin-serdes__custom-classifier__0_20_0-RC1-SNAPSHOT.jar";
PluginArtifact artifact = PluginArtifact.fromFileName(fileName);
assertEquals("io.kestra.plugin", artifact.groupId());
assertEquals("plugin-serdes", artifact.artifactId());
assertEquals("jar", artifact.extension());
assertEquals("custom-classifier", artifact.classifier());
assertEquals("0.20.0-RC1-SNAPSHOT", artifact.version());
assertNull(artifact.uri());
}
@Test
void shouldThrowIllegalArgumentExceptionGivenInvalidFilenameMissingVersion() {
String fileName = "io_kestra_plugin__plugin-serdes__custom-classifier.jar";

View File

@@ -784,4 +784,12 @@ public abstract class AbstractExecutionRepositoryTest {
.taskRunList(List.of())
.build();
}
@Test
protected void findAllAsync() {
inject();
List<Execution> executions = executionRepository.findAllAsync(null).collectList().block();
assertThat(executions, hasSize(28));
}
}

View File

@@ -17,6 +17,7 @@ import java.util.List;
import reactor.core.publisher.Flux;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.is;
@KestraTest
@@ -205,7 +206,7 @@ public abstract class AbstractLogRepositoryTest {
}
@Test
void findAsych() {
void findAsync() {
logRepository.save(logEntry(Level.INFO).build());
logRepository.save(logEntry(Level.ERROR).build());
logRepository.save(logEntry(Level.WARN).build());
@@ -214,18 +215,29 @@ public abstract class AbstractLogRepositoryTest {
Flux<LogEntry> find = logRepository.findAsync(null, "io.kestra.unittest", Level.INFO, startDate);
List<LogEntry> logEntries = find.collectList().block();
assertThat(logEntries.size(), is(3));
assertThat(logEntries, hasSize(3));
find = logRepository.findAsync(null, null, Level.ERROR, startDate);
logEntries = find.collectList().block();
assertThat(logEntries.size(), is(1));
assertThat(logEntries, hasSize(1));
find = logRepository.findAsync(null, "io.kestra.unused", Level.INFO, startDate);
logEntries = find.collectList().block();
assertThat(logEntries.size(), is(0));
assertThat(logEntries, hasSize(0));
find = logRepository.findAsync(null, null, Level.INFO, startDate.plusSeconds(2));
logEntries = find.collectList().block();
assertThat(logEntries.size(), is(0));
assertThat(logEntries, hasSize(0));
}
@Test
void findAllAsync() {
logRepository.save(logEntry(Level.INFO).build());
logRepository.save(logEntry(Level.ERROR).build());
logRepository.save(logEntry(Level.WARN).build());
Flux<LogEntry> find = logRepository.findAllAsync(null);
List<LogEntry> logEntries = find.collectList().block();
assertThat(logEntries, hasSize(3));
}
}

View File

@@ -9,7 +9,6 @@ import io.kestra.core.models.executions.metrics.Timer;
import io.micronaut.data.model.Pageable;
import io.kestra.core.junit.annotations.KestraTest;
import jakarta.inject.Inject;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import java.time.Duration;
@@ -17,6 +16,7 @@ import java.time.ZonedDateTime;
import java.util.List;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.is;
@KestraTest
@@ -95,6 +95,20 @@ public abstract class AbstractMetricRepositoryTest {
assertThat(tasksWithMetrics.size(), is(2));
}
@Test
void findAllAsync() {
String executionId = FriendlyId.createFriendlyId();
TaskRun taskRun1 = taskRun(executionId, "task");
MetricEntry counter = MetricEntry.of(taskRun1, counter("counter"));
TaskRun taskRun2 = taskRun(executionId, "task");
MetricEntry timer = MetricEntry.of(taskRun2, timer());
metricRepository.save(counter);
metricRepository.save(timer);
List<MetricEntry> results = metricRepository.findAllAsync(null).collectList().block();
assertThat(results, hasSize(2));
}
private Counter counter(String metricName) {
return Counter.of(metricName, 1);
}

View File

@@ -2,7 +2,6 @@ package io.kestra.core.runners;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.*;
import static org.junit.jupiter.api.Assertions.assertThrows;
import io.kestra.core.junit.annotations.ExecuteFlow;
import io.kestra.core.junit.annotations.KestraTest;
@@ -10,11 +9,9 @@ import io.kestra.core.junit.annotations.LoadFlows;
import io.kestra.core.models.executions.Execution;
import io.kestra.core.models.executions.LogEntry;
import io.kestra.core.models.flows.State;
import io.kestra.core.queues.MessageTooBigException;
import io.kestra.core.queues.QueueException;
import io.kestra.core.queues.QueueFactoryInterface;
import io.kestra.core.queues.QueueInterface;
import io.kestra.core.utils.TestsUtils;
import io.kestra.plugin.core.flow.EachSequentialTest;
import io.kestra.plugin.core.flow.FlowCaseTest;
import io.kestra.plugin.core.flow.ForEachItemCaseTest;
@@ -23,20 +20,12 @@ import io.kestra.plugin.core.flow.WaitForCaseTest;
import io.kestra.plugin.core.flow.WorkingDirectoryTest;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import java.time.Duration;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.TimeoutException;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import org.junitpioneer.jupiter.RetryingTest;
import org.slf4j.event.Level;
import reactor.core.publisher.Flux;
@KestraTest(startRunner = true)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@@ -370,7 +359,7 @@ public abstract class AbstractRunnerTest {
forEachItemCaseTest.forEachItemWithSubflowOutputs();
}
@RetryingTest(5) // Flaky on CI but never locally even with 100 repetitions
@Test
@LoadFlows({"flows/valids/restart-for-each-item.yaml", "flows/valids/restart-child.yaml"})
void restartForEachItem() throws Exception {
forEachItemCaseTest.restartForEachItem();

View File

@@ -21,6 +21,7 @@ import io.kestra.core.runners.FlowListeners;
import io.kestra.core.utils.Await;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Flux;
@@ -525,6 +526,7 @@ public class SchedulerScheduleTest extends AbstractSchedulerTest {
}
@Test
@Disabled("too flaky on CI")
void recoverLASTLongRunningExecution() throws Exception {
// mock flow listeners
FlowListeners flowListenersServiceSpy = spy(this.flowListenersService);
@@ -596,6 +598,7 @@ public class SchedulerScheduleTest extends AbstractSchedulerTest {
}
@Test
@Disabled("too flaky on CI")
void recoverNONELongRunningExecution() throws Exception {
// mock flow listeners
FlowListeners flowListenersServiceSpy = spy(this.flowListenersService);

View File

@@ -205,6 +205,26 @@ class FlowServiceTest {
assertThat(collect.stream().filter(flow -> flow.getId().equals("test3")).findFirst().orElseThrow().getRevision(), is(3));
}
@Test
void warnings() {
Flow flow = create("test", "test", 1).toBuilder()
.namespace("system")
.triggers(List.of(
io.kestra.plugin.core.trigger.Flow.builder()
.id("flow-trigger")
.type(io.kestra.plugin.core.trigger.Flow.class.getName())
.build()
))
.build();
List<String> warnings = flowService.warnings(flow, null);
assertThat(warnings.size(), is(1));
assertThat(warnings, containsInAnyOrder(
"This flow will be triggered for EVERY execution of EVERY flow on your instance. We recommend adding the preconditions property to the Flow trigger 'flow-trigger'."
));
}
@Test
void aliases() {
List<FlowService.Relocation> warnings = flowService.relocations("""

View File

@@ -125,12 +125,13 @@ class MapUtilsTest {
}
@Test
void shouldThrowExceptionWhenNestingMapGivenFlattenMapWithConflicts() {
Assertions.assertThrows(IllegalArgumentException.class, () -> {
MapUtils.flattenToNestedMap(Map.of(
"k1.k2", "v1",
"k1.k2.k3", "v2"
));
});
void shouldReturnMapAndIgnoreConflicts() {
Map<String, Object> results = MapUtils.flattenToNestedMap(Map.of(
"k1.k2", "v1",
"k1.k2.k3", "v2"
));
assertThat(results, aMapWithSize(1));
// due to ordering change on each JVM restart, the result map would be different as different entries will be skipped
}
}

View File

@@ -37,6 +37,7 @@ import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.IntStream;
import static io.kestra.core.models.flows.State.Type.FAILED;
import static io.kestra.core.utils.Rethrow.throwRunnable;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.greaterThan;
@@ -136,7 +137,6 @@ public class ForEachItemCaseTest {
Flux<Execution> receive = TestsUtils.receive(executionQueue, either -> {
Execution execution = either.getLeft();
if (execution.getFlowId().equals("for-each-item-subflow")) {
log.info("Received sub-execution " + execution.getId() + " with status " + execution.getState().getCurrent());
if (execution.getState().getCurrent().isTerminated()) {
triggered.set(execution);
countDownLatch.countDown();
@@ -204,8 +204,9 @@ public class ForEachItemCaseTest {
// assert on the main flow execution
assertThat(execution.getTaskRunList(), hasSize(3));
assertThat(execution.getTaskRunList().get(2).getAttempts(), hasSize(1));
assertThat(execution.getTaskRunList().get(2).getAttempts().getFirst().getState().getCurrent(), is(State.Type.FAILED));
assertThat(execution.getState().getCurrent(), is(State.Type.FAILED));
assertThat(execution.getTaskRunList().get(2).getAttempts().getFirst().getState().getCurrent(), is(
FAILED));
assertThat(execution.getState().getCurrent(), is(FAILED));
Map<String, Object> outputs = execution.getTaskRunList().get(2).getOutputs();
assertThat(outputs.get("numberOfBatches"), is(26));
assertThat(outputs.get("iterations"), notNullValue());
@@ -215,7 +216,7 @@ public class ForEachItemCaseTest {
assertThat(iterations.get("FAILED"), is(26));
// assert on the last subflow execution
assertThat(triggered.get().getState().getCurrent(), is(State.Type.FAILED));
assertThat(triggered.get().getState().getCurrent(), is(FAILED));
assertThat(triggered.get().getFlowId(), is("for-each-item-subflow-failed"));
assertThat((String) triggered.get().getInputs().get("items"), matchesRegex("kestra:///io/kestra/tests/for-each-item-failed/executions/.*/tasks/each-split/.*\\.txt"));
assertThat(triggered.get().getTaskRunList(), hasSize(1));
@@ -290,7 +291,7 @@ public class ForEachItemCaseTest {
(flow, execution1) -> flowIO.readExecutionInputs(flow, execution1, inputs),
Duration.ofSeconds(30));
assertThat(execution.getTaskRunList(), hasSize(3));
assertThat(execution.getState().getCurrent(), is(State.Type.FAILED));
assertThat(execution.getState().getCurrent(), is(FAILED));
// here we must have 1 failed subflows
assertTrue(countDownLatch.await(1, TimeUnit.MINUTES));
@@ -303,11 +304,15 @@ public class ForEachItemCaseTest {
successLatch.countDown();
}
});
//Wait before restarting until the failed execution tasks are persisted.
Thread.sleep(1000L);
Execution restarted = executionService.restart(execution, null);
execution = runnerUtils.awaitExecution(
e -> e.getState().getCurrent() == State.Type.SUCCESS && e.getFlowId().equals("restart-for-each-item"),
throwRunnable(() -> executionQueue.emit(restarted)),
Duration.ofSeconds(10)
Duration.ofSeconds(20)
);
assertThat(execution.getTaskRunList(), hasSize(4));
assertTrue(successLatch.await(1, TimeUnit.MINUTES));

View File

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

View File

@@ -48,6 +48,7 @@ import java.util.*;
import java.util.Comparator;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public abstract class AbstractJdbcExecutionRepository extends AbstractJdbcRepository implements ExecutionRepositoryInterface, JdbcQueueIndexerInterface<Execution> {
private static final int FETCH_SIZE = 100;
@@ -343,6 +344,28 @@ public abstract class AbstractJdbcExecutionRepository extends AbstractJdbcReposi
return select;
}
@Override
public Flux<Execution> findAllAsync(@Nullable String tenantId) {
return Flux.create(emitter -> this.jdbcRepository
.getDslContextWrapper()
.transaction(configuration -> {
DSLContext context = DSL.using(configuration);
SelectConditionStep<Record1<Object>> select = context
.select(field("value"))
.hint(context.configuration().dialect().supports(SQLDialect.MYSQL) ? "SQL_CALC_FOUND_ROWS" : null)
.from(this.jdbcRepository.getTable())
.where(this.defaultFilter(tenantId));
try (Stream<Record1<Object>> stream = select.fetchSize(FETCH_SIZE).stream()){
stream.map((Record record) -> jdbcRepository.map(record))
.forEach(emitter::next);
} finally {
emitter.complete();
}
}), FluxSink.OverflowStrategy.BUFFER);
}
@Override
public ArrayListTotal<Execution> findByFlowId(String tenantId, String namespace, String id, Pageable pageable) {
return this.jdbcRepository

View File

@@ -183,6 +183,28 @@ public abstract class AbstractJdbcLogRepository extends AbstractJdbcRepository i
}), FluxSink.OverflowStrategy.BUFFER);
}
@Override
public Flux<LogEntry> findAllAsync(@Nullable String tenantId) {
return Flux.create(emitter -> this.jdbcRepository
.getDslContextWrapper()
.transaction(configuration -> {
DSLContext context = DSL.using(configuration);
SelectConditionStep<Record1<Object>> select = context
.select(field("value"))
.hint(context.configuration().dialect().supports(SQLDialect.MYSQL) ? "SQL_CALC_FOUND_ROWS" : null)
.from(this.jdbcRepository.getTable())
.where(this.defaultFilter(tenantId));
try (Stream<Record1<Object>> stream = select.fetchSize(FETCH_SIZE).stream()){
stream.map((Record record) -> jdbcRepository.map(record))
.forEach(emitter::next);
} finally {
emitter.complete();
}
}), FluxSink.OverflowStrategy.BUFFER);
}
@Override
public List<LogStatistics> statistics(
@Nullable String query,

View File

@@ -18,6 +18,8 @@ import lombok.Getter;
import org.jooq.Record;
import org.jooq.*;
import org.jooq.impl.DSL;
import reactor.core.publisher.Flux;
import reactor.core.publisher.FluxSink;
import java.time.Duration;
import java.time.ZoneId;
@@ -27,6 +29,7 @@ import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public abstract class AbstractJdbcMetricRepository extends AbstractJdbcRepository implements MetricRepositoryInterface {
protected io.kestra.jdbc.AbstractJdbcRepository<MetricEntry> jdbcRepository;
@@ -92,6 +95,28 @@ public abstract class AbstractJdbcMetricRepository extends AbstractJdbcRepositor
);
}
@Override
public Flux<MetricEntry> findAllAsync(@io.micronaut.core.annotation.Nullable String tenantId) {
return Flux.create(emitter -> this.jdbcRepository
.getDslContextWrapper()
.transaction(configuration -> {
DSLContext context = DSL.using(configuration);
SelectConditionStep<Record1<Object>> select = context
.select(field("value"))
.hint(context.configuration().dialect().supports(SQLDialect.MYSQL) ? "SQL_CALC_FOUND_ROWS" : null)
.from(this.jdbcRepository.getTable())
.where(this.defaultFilter(tenantId));
try (Stream<Record1<Object>> stream = select.fetchSize(FETCH_SIZE).stream()){
stream.map((Record record) -> jdbcRepository.map(record))
.forEach(emitter::next);
} finally {
emitter.complete();
}
}), FluxSink.OverflowStrategy.BUFFER);
}
@Override
public List<String> flowMetrics(
String tenantId,

View File

@@ -49,6 +49,7 @@ dependencies {
// ugly hack on crypto plugin
api("org.bouncycastle:bcprov-jdk18on:$bouncycastleVersion")
api("org.bouncycastle:bcpg-jdk18on:$bouncycastleVersion")
api("org.bouncycastle:bcpkix-jdk18on:$bouncycastleVersion")
// ugly hack for jackson: as enforcing platform didn't work (it didn't enforce everywhere, not in plugins), we had to force all jackson libs individually.
api("com.fasterxml.jackson.core:jackson-core:$jacksonVersion")
api("com.fasterxml.jackson.core:jackson-databind:$jacksonVersion")

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 27.1.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 439 309" style="enable-background:new 0 0 439 309;" xml:space="preserve">
<style type="text/css">
.st0{fill:#1D63ED;}
</style>
<path class="st0" d="M379.6,111.7c-2.3-16.7-11.5-31.2-28.1-44.3l-9.6-6.5l-6.4,9.7c-8.2,12.5-12.3,29.9-11,46.6
c0.6,5.8,2.5,16.4,8.4,25.5c-5.9,3.3-17.6,7.7-33.2,7.4H1.7l-0.6,3.5c-2.8,16.7-2.8,69,30.7,109.1c25.5,30.5,63.6,46,113.4,46
c108,0,187.8-50.3,225.3-141.9c14.7,0.3,46.4,0.1,62.7-31.4c0.4-0.7,1.4-2.6,4.2-8.6l1.6-3.3l-9.1-6.2
C419.9,110.8,397.2,108.3,379.6,111.7L379.6,111.7z M240,0h-45.3v41.7H240V0z M240,50.1h-45.3v41.7H240V50.1z M186.4,50.1h-45.3
v41.7h45.3V50.1z M132.9,50.1H87.6v41.7h45.3V50.1z M79.3,100.2H34v41.7h45.3V100.2z M132.9,100.2H87.6v41.7h45.3V100.2z
M186.4,100.2h-45.3v41.7h45.3V100.2z M240,100.2h-45.3v41.7H240V100.2z M293.6,100.2h-45.3v41.7h45.3V100.2z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -16,9 +16,6 @@
import Utils from "../utils/utils";
export default {
components: {
},
props: {
value: {
type: String,

View File

@@ -112,11 +112,12 @@
>
<PluginDocumentation
v-if="currentView === views.DOC"
class="plugin-doc combined-right-view enhance-readability"
class="combined-right-view enhance-readability"
:override-intro="intro"
absolute
/>
<div
class="d-flex justify-content-center align-items-center w-100 p-5"
class="d-flex justify-content-center align-items-center w-100 p-3"
v-else-if="currentView === views.CHART"
>
<div v-if="selectedChart" class="w-100">
@@ -129,7 +130,7 @@
<small>{{ selectedChart.chartOptions.description }}</small>
</p>
<div class="w-100">
<div :style="`position: relative; width:calc(${100}% - 10px)`">
<component
:key="selectedChart.id"
:is="types[selectedChart.type]"
@@ -171,6 +172,8 @@
defineEmits(["save"])
</script>
<script>
import {shallowRef} from "vue";
import Editor from "../../inputs/Editor.vue";
import yaml from "yaml";
import ContentSave from "vue-material-design-icons/ContentSave.vue";
@@ -312,11 +315,11 @@
charts: [],
chartError: null,
types: {
"io.kestra.plugin.core.dashboard.chart.TimeSeries": TimeSeries,
"io.kestra.plugin.core.dashboard.chart.Bar": Bar,
"io.kestra.plugin.core.dashboard.chart.Markdown": Markdown,
"io.kestra.plugin.core.dashboard.chart.Table": Table,
"io.kestra.plugin.core.dashboard.chart.Pie": Pie,
"io.kestra.plugin.core.dashboard.chart.TimeSeries": shallowRef(TimeSeries),
"io.kestra.plugin.core.dashboard.chart.Bar": shallowRef(Bar),
"io.kestra.plugin.core.dashboard.chart.Markdown": shallowRef(Markdown),
"io.kestra.plugin.core.dashboard.chart.Table": shallowRef(Table),
"io.kestra.plugin.core.dashboard.chart.Pie": shallowRef(Pie),
}
}
},

View File

@@ -54,6 +54,7 @@
import Pencil from "vue-material-design-icons/Pencil.vue";
import Plus from "vue-material-design-icons/Plus.vue";
import ViewDashboardEdit from "vue-material-design-icons/ViewDashboardEdit.vue";
import useRouteContext from "../../../mixins/useRouteContext.js";
const store = useStore();
const {t} = useI18n({useScope: "global"});
@@ -72,4 +73,6 @@
const routeInfo = computed(() => ({
title: props.title ?? t("homeDashboard.title"),
}));
useRouteContext(routeInfo);
</script>

View File

@@ -35,7 +35,7 @@
defineOptions({inheritAttrs: false});
const props = defineProps({
identifier: {type: Number, required: true},
identifier: {type: [Number, String], required: true},
chart: {type: Object, required: true},
isPreview: {type: Boolean, required: false, default: false}
});

View File

@@ -46,7 +46,7 @@
defineOptions({inheritAttrs: false});
const props = defineProps({
identifier: {type: Number, required: true},
identifier: {type: [Number, String], required: true},
chart: {type: Object, required: true},
isPreview: {type: Boolean, required: false, default: false}
});

View File

@@ -37,7 +37,7 @@
defineOptions({inheritAttrs: false});
const props = defineProps({
identifier: {type: Number, required: true},
identifier: {type: [Number, String], required: true},
chart: {type: Object, required: true},
isPreview: {type: Boolean, required: false, default: false}
});

View File

@@ -46,7 +46,7 @@
defineOptions({inheritAttrs: false});
const props = defineProps({
identifier: {type: Number, required: true},
identifier: {type: [Number, String], required: true},
chart: {type: Object, required: true},
isPreview: {type: Boolean, required: false, default: false}
});

View File

@@ -0,0 +1,34 @@
<template>
<div>
<a class="el-button el-button--large el-button--primary" target="_blank" :href="getADemoUrl.href">
{{ $t("demos.get_a_demo_button") }}
</a>
<el-button size="large" @click="store.commit('misc/setContextInfoBarOpenTab', 'docs')">
Learn More
<el-icon class="el-icon--right">
<ArrowRightIcon />
</el-icon>
</el-button>
</div>
</template>
<script setup lang="ts">
import ArrowRightIcon from "vue-material-design-icons/ArrowRight.vue";
import {useRoute} from "vue-router";
import {useStore} from "vuex";
import {computed} from "vue";
const store = useStore();
const route = useRoute();
const getADemoUrl = computed(() => {
const demoUrl = new URL("https://kestra.io/demo");
// set all utm params from the route query
for (const [key, value] of Object.entries(route.query)) {
if (key.startsWith("utm_")) {
demoUrl.searchParams.set(key, value as string);
}
}
return demoUrl;
});
</script>

View File

@@ -8,39 +8,14 @@
</div>
<h2>{{ title }}</h2>
<p><slot name="message" /></p>
<a class="el-button el-button--large el-button--primary" target="_blank" :href="getADemoUrl.href">
{{ $t("demos.get_a_demo_button") }}
</a>
<el-button size="large" @click="store.commit('misc/setContextInfoBarOpenTab', 'docs')">
Learn More
<el-icon class="el-icon--right">
<ArrowRightIcon />
</el-icon>
</el-button>
<DemoButtons />
</div>
</EmptyTemplate>
</template>
<script lang="ts" setup>
import {computed} from "vue";
import {useStore} from "vuex";
import {useRoute} from "vue-router";
import ArrowRightIcon from "vue-material-design-icons/ArrowRight.vue";
import EmptyTemplate from "../layout/EmptyTemplate.vue";
const store = useStore();
const route = useRoute();
const getADemoUrl = computed(() => {
const demoUrl = new URL("https://kestra.io/demo");
// set all utm params from the route query
for (const [key, value] of Object.entries(route.query)) {
if (key.startsWith("utm_")) {
demoUrl.searchParams.set(key, value as string);
}
}
return demoUrl;
});
import DemoButtons from "./DemoButtons.vue";
defineProps<{
title: string;

View File

@@ -76,6 +76,7 @@
<editor
ref="debugEditor"
:full-height="false"
:custom-height="20"
:input="true"
:navbar="false"
:model-value="computedDebugValue"
@@ -100,6 +101,7 @@
:read-only="true"
:input="true"
:full-height="false"
:custom-height="20"
:navbar="false"
:model-value="debugExpression"
:lang="isJSON ? 'json' : ''"

View File

@@ -201,7 +201,7 @@
import {useI18n} from "vue-i18n";
import {useStore} from "vuex";
import {useRoute, useRouter} from "vue-router";
import {useRoute, useRouter, LocationQueryRaw} from "vue-router";
import {useFilters} from "./composables/useFilters";
import action from "../../models/action";
import permission from "../../models/permission";
@@ -632,21 +632,9 @@
watch(
() => route.query,
(q: any) => {
// Handling change of label filters from direct click events
if (
Object.keys(q).length === 0 ||
Object.keys(q).some((key) => key.startsWith("filters[labels]"))
) {
const routeFilters = decodeParams(
route.name,
q,
props.include,
OPTIONS,
props.isDefaultDashboard
);
currentFilters.value = routeFilters;
}
(q: LocationQueryRaw) => {
const routeFilters = decodeParams(route.name, q, props.include, OPTIONS, props.isDefaultDashboard) as CurrentItem[];
currentFilters.value = routeFilters;
},
{immediate: true},
);
@@ -736,7 +724,7 @@
const handleClickedItems = (value) => {
if (value) currentFilters.value = value;
select.value?.focus();
triggerSearch();
};
const triggerSearch = () => {

View File

@@ -41,14 +41,20 @@
class="me-2"
>
<span class="small">
<Label :option="value" />
<Label :option="value" :prefix="props.prefix" />
</span>
</el-tag>
</div>
</div>
<div class="col-auto">
<DeleteOutline @click.stop="remove(index)" />
<KestraIcon
@click.stop="remove(index)"
:tooltip="$t('filters.save.remove')"
placement="right"
>
<DeleteOutline />
</KestraIcon>
</div>
</div>
</el-dropdown-item>
@@ -96,10 +102,15 @@
@import "../styles/filter";
.dropdown {
width: 400px;
width: 800px;
&:hover {
border-radius: 0;
}
}
.items {
background-color: var(--el-bg-color-overlay);
max-height: 170px !important; // 5 visible items
}
</style>

View File

@@ -15,8 +15,9 @@ $filters-font-xs: var(--el-font-size-extra-small);
.items {
.el-tag {
background: $filters-border-color;
color: $filters-gray-900;
padding: 0;
color: var(--ks-tag-content);
background: var(--ks-tag-background-active);
}
.small {

View File

@@ -121,22 +121,22 @@ export const encodeSearchParams = (filters, OPTIONS) => {
};
return filters.reduce((query, filter) => {
if(filter.field === "labels" && filter.operation) {
Object.assign(query, encode(filter.value, "labels", "EQUALS"));
}
if(filter.operation) {
Object.assign(query, encode(filter.value, filter.field, filter.operation));
} else {
const match = OPTIONS.find((o) => o.value.label === filter.label);
const key = match ? match.key : filter.label === "text" ? "q" : null;
const operation = filter.comparator?.value || match?.comparators?.find(c => c.value === filter.operation)?.value || "EQUALS";
const match = OPTIONS.find((o) => o.value.label === filter.label);
const key = match ? match.key : filter.label === "text" ? "q" : null;
const operation = filter.comparator?.value || match?.comparators?.find(c => c.value === filter.operation)?.value || "EQUALS";
if (key) {
if (key !== "date") {
Object.assign(query, encode(filter.value, key, operation));
} else if (filter.value?.length > 0) {
const {startDate, endDate} = filter.value[0];
if(startDate && endDate) {
query["filters[startDate][GREATER_THAN_OR_EQUAL_TO]"] = startDate;
query["filters[endDate][LESS_THAN_OR_EQUAL_TO]"] = endDate;
if (key) {
if (key !== "date") {
Object.assign(query, encode(filter.value, key, operation));
} else if (filter.value?.length > 0) {
const {startDate, endDate} = filter.value[0];
if(startDate && endDate) {
query["filters[startDate][GREATER_THAN_OR_EQUAL_TO]"] = startDate;
query["filters[endDate][LESS_THAN_OR_EQUAL_TO]"] = endDate;
}
}
}
}

View File

@@ -57,7 +57,7 @@
</el-card>
<template v-if="blueprint.description">
<h4>{{ $t('about_this_blueprint') }}</h4>
<div v-if="!system" class="tags text-uppercase">
<div class="tags text-uppercase">
<div v-for="(tag, index) in blueprint.tags" :key="index" class="tag-box">
<el-tag type="info" size="small">
{{ tag }}
@@ -130,6 +130,10 @@
type: String,
default: "flow",
},
combinedView: {
type: Boolean,
default: false
},
},
methods: {
goBack() {
@@ -153,7 +157,11 @@
}
},
async created() {
this.$store.dispatch("blueprints/getBlueprint", {type: this.$route.params.tab, kind: this.blueprintKind, id: this.blueprintId})
this.$store.dispatch("blueprints/getBlueprint", {
type: this.combinedView ? this.blueprintType : this.$route.params.tab,
kind: this.blueprintKind,
id: this.blueprintId
})
.then(data => {
this.blueprint = data;
if (this.kind === "flow") {

View File

@@ -1148,7 +1148,7 @@
height: 30px;
padding: 16px;
font-size: var(--el-font-size-small);
color: $gray-900;
color: var(--ks-content-primary);
&:hover {
color: var(--ks-content-secondary);

View File

@@ -250,7 +250,7 @@
v-if="viewType === editorViewTypes.SOURCE_BLUEPRINTS"
class="combined-right-view enhance-readability"
>
<Blueprints @loaded="blueprintsLoaded = true" embed kind="flow" />
<Blueprints @loaded="blueprintsLoaded = true" embed kind="flow" combined-view />
</div>
<div

View File

@@ -209,7 +209,7 @@
this.initMonaco(monaco)
})
if (!this.monacoYamlConfigured && (this.creating || this.current?.flow)) {
if (!this.monacoYamlConfigured && this.language === "yaml") {
this.$store.commit("core/setMonacoYamlConfigured", true);
configureMonacoYaml(monaco, {
enableSchemaRequest: true,

View File

@@ -1,5 +1,7 @@
<template>
<KestraFilter :placeholder="$t('search')" :search-callback="(input) => search = input" :decode="false" />
<section class="d-inline-flex mb-3 filters">
<el-input v-model="search" :placeholder="$t('search')" />
</section>
<select-table
:data="filteredKvs"
@@ -190,7 +192,6 @@
import ContentSave from "vue-material-design-icons/ContentSave.vue";
import TimeSelect from "../executions/date-select/TimeSelect.vue";
import Check from "vue-material-design-icons/Check.vue";
import KestraFilter from "../filter/KestraFilter.vue";
import NamespaceSelect from "../namespace/NamespaceSelect.vue";
import Utils from "../../utils/utils";
@@ -258,6 +259,13 @@
if (this.$refs.form) {
this.$refs.form.clearValidate("value");
}
},
search(newValue) {
if (newValue !== undefined) {
this.$router.push({query: {
q: newValue
}})
}
}
},
data() {
@@ -272,7 +280,7 @@
},
kvs: undefined,
namespaceIterator: undefined,
search: "",
search: this.$route.query?.q ?? "",
rules: {
key: [
{required: true, trigger: "change"},
@@ -411,13 +419,15 @@
});
});
},
reloadKvs() {
async reloadKvs() {
this.namespaceIterator = undefined;
const previousLength = this.secrets?.length ?? 0;
await this.$refs.selectTable.resetInfiniteScroll();
this.kvs = [];
this.$refs.selectTable.resetInfiniteScroll();
// If we are in the global KV view we let the infinite scroll handling the fetch
if (this.namespace !== undefined) {
if (this.namespace !== undefined || previousLength === 0) {
this.fetchKvs();
}
},

View File

@@ -24,9 +24,11 @@
import Navbar from "../layout/TopNavBar.vue";
import {useI18n} from "vue-i18n";
import {computed, ref} from "vue";
import useRouteContext from "../../mixins/useRouteContext.js";
const addKvModalVisible = ref(false);
const {t} = useI18n({useScope: "global"});
const routeInfo = computed(() => ({title: t("kv.name")}));
useRouteContext(routeInfo);
</script>

View File

@@ -25,12 +25,9 @@
</div>
</template>
<script setup>
import {default as vElTableInfiniteScroll} from "el-table-infinite-scroll";
</script>
<script>
import NoData from "./NoData.vue";
import elTableInfiniteScroll from "el-table-infinite-scroll";
export default {
components: {NoData},
@@ -61,9 +58,13 @@
return this.infiniteScrollDisabled === false;
}
},
directives: {
elTableInfiniteScroll
},
methods: {
async resetInfiniteScroll() {
this.infiniteScrollDisabled = false;
this.tableHeight = await this.computeTableHeight();
},
async waitTableRender() {
if (this.tableView === undefined) {
@@ -82,19 +83,9 @@
}
});
observer.observe(this.tableView.querySelector(".el-table__body > tbody"), {childList: true,});
observer.observe(this.tableView.querySelector(".el-table__body > tbody"), {childList: true});
});
},
async hasScrollbar() {
const scrollEl = this.scrollWrapper;
if (scrollEl === undefined) {
return false;
}
await this.waitTableRender();
return scrollEl.clientHeight < scrollEl.scrollHeight;
},
selectionChanged(selection) {
this.hasSelection = selection.length > 0;
this.$emit("selection-change", selection);
@@ -114,6 +105,10 @@
return "auto";
}
if (!this.stillHaveDataToFetch && this.data.length === 0) {
return "calc(var(--table-header-height) + 60px)";
}
return this.stillHaveDataToFetch || this.tableView === undefined ? "100%" : `min(${this.tableView.scrollHeight}px, 100%)`;
},
async infiniteScrollLoadWithDisableHandling() {

View File

@@ -60,6 +60,7 @@
import {useRoute} from "vue-router";
import useNamespaces, {Namespace} from "../../composables/useNamespaces";
import {useI18n} from "vue-i18n";
import useRouteContext from "../../mixins/useRouteContext.ts";
const {t} = useI18n({useScope: "global"});
@@ -74,6 +75,8 @@
}
const routeInfo = computed(() => ({title: t("namespaces")}));
useRouteContext(routeInfo);
const user = computed(() => store.state.auth.user);
const isUserEmpty = computed(() => Object.keys(user.value).length === 0);

View File

@@ -20,7 +20,7 @@
</schema-to-html>
</Suspense>
</template>
<markdown v-else :source="introContent" />
<markdown v-else :source="introContent" :class="{'position-absolute': absolute}" />
</div>
</template>
@@ -38,6 +38,10 @@
overrideIntro: {
type: String,
default: null
},
absolute: {
type: Boolean,
default: false
}
},
computed: {

View File

@@ -10,23 +10,57 @@
</ul>
</template>
</Navbar>
<section data-component="FILENAME_PLACEHOLDER" class="d-flex flex-column fill-height container padding-bottom" :class="configs?.secretsEnabled === undefined ? 'min-w-auto ms-auto me-auto' : ''">
<template v-if="configs?.secretsEnabled === undefined">
<Layout
:title="$t('demos.secrets.title')"
:image="{source: sourceImg, alt: $t('demos.secrets.title')}"
>
<template #message>
{{ $t('demos.secrets.message') }}
</template>
</Layout>
<el-divider />
<p>Here are secret-type environment variables identified at instance start-time:</p>
</template>
<section
data-component="FILENAME_PLACEHOLDER"
class="d-flex flex-column fill-height padding-bottom"
:class="configs?.secretsEnabled === undefined ? 'mt-0 p-0' : 'container'"
>
<EmptyTemplate v-if="configs?.secretsEnabled === undefined" class="d-flex flex-column text-start m-0 p-0 mw-100">
<div class="no-secret-manager-block d-flex flex-column gap-6">
<div class="header-block d-flex align-items-center">
<div class="d-flex flex-column">
<h5 class="mb-3">
{{ $t('demos.secrets.title') }}
</h5>
<p>{{ $t('demos.secrets.message') }}</p>
<DemoButtons />
</div>
<div class="img-wrapper">
<img :src="sourceImg" :alt="$t('demos.secrets.title')">
</div>
</div>
<p class="mb-0">
{{ $t('demos.secrets.detected_env') }}
</p>
<div v-if="hasData === false">
<p class="text-tertiary mb-4">
{{ $t('demos.secrets.empty_env') }}
</p>
<div class="text-secondary">
<p class="bold mb-0">
{{ $t('demos.secrets.add_env.intro') }}
</p>
<ul>
<li v-html="$t('demos.secrets.add_env.first')" />
<li v-html="$t('demos.secrets.add_env.second')" />
<li v-html="$t('demos.secrets.add_env.third')" />
</ul>
</div>
</div>
<SecretsTable
v-show="hasData === true"
:filterable="false"
key-only
:namespace="configs?.systemNamespace ?? 'system'"
:add-secret-modal-visible="addSecretModalVisible"
@update:add-secret-modal-visible="addSecretModalVisible = $event"
@has-data="hasData = $event"
/>
</div>
</EmptyTemplate>
<SecretsTable
:filterable="configs?.secretsEnabled !== undefined"
:key-only="configs?.secretsEnabled === undefined"
:namespace="configs?.secretsEnabled === true ? undefined : (configs?.systemNamespace ?? 'system')"
v-else
filterable
:add-secret-modal-visible="addSecretModalVisible"
@update:add-secret-modal-visible="addSecretModalVisible = $event"
/>
@@ -40,21 +74,50 @@
import {useI18n} from "vue-i18n";
import {computed, ref} from "vue";
import {useStore} from "vuex";
import useRouteContext from "../../mixins/useRouteContext.js";
import sourceImg from "../../assets/demo/secrets.png";
import Layout from "../demo/Layout.vue";
import DemoButtons from "../demo/DemoButtons.vue";
import EmptyTemplate from "../layout/EmptyTemplate.vue";
const store = useStore();
const configs = computed(() => store.getters["misc/configs"]);
const addSecretModalVisible = ref(false);
const hasData = ref(undefined);
const {t} = useI18n({useScope: "global"});
const routeInfo = computed(() => ({title: t("secret.names")}));
useRouteContext(routeInfo);
</script>
<style lang="scss" scoped>
:deep(.message-block) {
width: 100%;
.no-secret-manager-block {
padding: 0 10.75rem;
*[style*="display: none"] { display: none !important }
.header-block {
border-bottom: 1px solid var(--ks-border-primary);
p {
font-size: .875rem;
}
.img-wrapper {
width: 350px;
overflow: visible;
direction: rtl;
}
}
.text-secondary {
color: var(--ks-content-secondary) !important;
.bold {
font-weight: bold;
}
}
}
</style>

View File

@@ -1,148 +1,155 @@
<template>
<KestraFilter v-if="filterable" :placeholder="$t('search')" :decode="false" />
<select-table
:data="secrets"
v-bind="$attrs"
ref="selectTable"
:default-sort="{prop: 'key', order: 'ascending'}"
table-layout="auto"
fixed
:selectable="false"
@sort-change="handleSort"
:infinite-scroll-load="namespace === undefined ? fetchSecrets : undefined"
class="fill-height"
>
<el-table-column
v-if="namespace === undefined"
prop="namespace"
sortable="custom"
:sort-orders="['ascending', 'descending']"
:label="$t('namespace')"
/>
<el-table-column prop="key" sortable="custom" :sort-orders="['ascending', 'descending']" :label="keyOnly ? $t('secret.names') : $t('key')">
<template #default="scope">
<id v-if="scope.row.key !== undefined" :value="scope.row.key" :shrink="false" />
</template>
</el-table-column>
<el-table-column v-if="!keyOnly" prop="description" :label="$t('description')">
<template #default="scope">
{{ scope.row.description }}
</template>
</el-table-column>
<el-table-column v-if="!keyOnly" prop="tags" :label="$t('tags')">
<template #default="scope">
<labels v-if="scope.row.tags !== undefined" :labels="scope.row.tags" read-only />
</template>
</el-table-column>
<el-table-column column-key="locked" class-name="row-action">
<template #default="scope">
<el-tooltip v-if="scope.row.namespace !== undefined && areNamespaceSecretsReadOnly?.[scope.row.namespace]" transition="" :hide-after="0" :persistent="false" effect="light">
<template #content>
<span v-html="$t('secret.isReadOnly')" />
</template>
<Lock />
</el-tooltip>
</template>
</el-table-column>
<el-table-column column-key="copy" class-name="row-action">
<template #default="scope">
<el-tooltip :content="$t('copy_to_clipboard')">
<el-button :icon="ContentCopy" link @click="Utils.copy(`\{\{ secret('${scope.row.key}') \}\}`)" />
</el-tooltip>
</template>
</el-table-column>
<el-table-column column-key="update" class-name="row-action">
<template #default="scope">
<el-button v-if="canUpdate(scope.row)" :icon="FileDocumentEdit" link @click="updateSecretModal(scope.row)" />
</template>
</el-table-column>
<el-table-column column-key="delete" class-name="row-action">
<template #default="scope">
<el-button v-if="canDelete(scope.row)" :icon="Delete" link @click="removeSecret(scope.row.key)" />
</template>
</el-table-column>
</select-table>
<drawer
v-if="addSecretDrawerVisible"
v-model="addSecretDrawerVisible"
:title="secretModalTitle"
>
<el-form class="ks-horizontal" :model="secret" :rules="rules" ref="form">
<el-form-item v-if="namespace === undefined" :label="$t('namespace')" prop="namespace" required>
<!-- TODO ADD FILTER ON NAMESPACES WITH READ-ONLY SECRETS, FOR NOW IT WOULD BE TOO COSTFUL AS IT REQUIRES 1 CALL PER NAMESPACE -->
<namespace-select
v-model="secret.namespace"
:readonly="secret.update"
data-type="flow"
:include-system-namespace="true"
/>
</el-form-item>
<el-form-item :label="$t('secret.key')" prop="key">
<el-input v-model="secret.key" :readonly="secret.update" required />
</el-form-item>
<el-form-item v-if="!secret.update" :label="$t('secret.name')" prop="value">
<el-input v-model="secret.value" :placeholder="secretModalTitle" autosize type="textarea" required />
</el-form-item>
<el-form-item v-if="secret.update" :label="$t('secret.name')" prop="value">
<el-col :span="20">
<el-input
v-model="secret.value"
:placeholder="secretModalTitle"
autosize
type="textarea"
required
:disabled="!secret.updateValue"
/>
</el-col>
<el-col class="px-2" :span="4">
<el-switch
size="large"
inline-prompt
v-model="secret.updateValue"
:active-icon="PencilOutline"
:inactive-icon="PencilOff"
/>
</el-col>
</el-form-item>
<el-form-item :label="$t('secret.description')" prop="description">
<el-input v-model="secret.description" :placeholder="$t('secret.descriptionPlaceholder')" required />
</el-form-item>
<el-form-item :label="$t('secret.tags')" prop="tags">
<el-row :gutter="20" v-for="(tag, index) in secret.tags" :key="index">
<el-col :span="8">
<el-input required v-model="tag.key" :placeholder="$t('key')" />
</el-col>
<el-col :span="12">
<el-input required v-model="tag.value" :placeholder="$t('value')" />
</el-col>
<el-button-group class="d-flex flex-nowrap">
<el-button
:icon="Delete"
@click="removeSecretTag(index)"
:disabled="secret.tags.length === 1"
/>
</el-button-group>
</el-row>
<el-button :icon="Plus" @click="addSecretTag" type="primary">
{{ $t('secret.addTag') }}
</el-button>
</el-form-item>
</el-form>
<template #footer>
<el-button :icon="ContentSave" @click="saveSecret($refs.form)" type="primary">
{{ $t('save') }}
</el-button>
<div class="d-flex flex-column fill-height">
<template v-if="filterable">
<KestraFilter v-if="namespace" :placeholder="$t('search')" :decode="false" />
<section v-else class="d-inline-flex mb-3 filters">
<el-input v-model="search" :placeholder="$t('search')" />
</section>
</template>
</drawer>
<select-table
:data="filteredSecrets"
ref="selectTable"
:default-sort="{prop: 'key', order: 'ascending'}"
table-layout="auto"
fixed
:selectable="false"
@sort-change="handleSort"
:infinite-scroll-load="namespace === undefined ? fetchSecrets : undefined"
class="fill-height"
>
<el-table-column
v-if="namespace === undefined"
prop="namespace"
sortable="custom"
:sort-orders="['ascending', 'descending']"
:label="$t('namespace')"
/>
<el-table-column prop="key" sortable="custom" :sort-orders="['ascending', 'descending']" :label="keyOnly ? $t('secret.names') : $t('key')">
<template #default="scope">
<id v-if="scope.row.key !== undefined" :value="scope.row.key" :shrink="false" />
</template>
</el-table-column>
<el-table-column v-if="!keyOnly" prop="description" :label="$t('description')">
<template #default="scope">
{{ scope.row.description }}
</template>
</el-table-column>
<el-table-column v-if="!keyOnly" prop="tags" :label="$t('tags')">
<template #default="scope">
<labels v-if="scope.row.tags !== undefined" :labels="scope.row.tags" read-only />
</template>
</el-table-column>
<el-table-column column-key="locked" class-name="row-action">
<template #default="scope">
<el-tooltip v-if="scope.row.namespace !== undefined && areNamespaceSecretsReadOnly?.[scope.row.namespace]" transition="" :hide-after="0" :persistent="false" effect="light">
<template #content>
<span v-html="$t('secret.isReadOnly')" />
</template>
<el-icon class="d-flex justify-content-center text-base">
<Lock />
</el-icon>
</el-tooltip>
</template>
</el-table-column>
<el-table-column column-key="copy" class-name="row-action">
<template #default="scope">
<el-tooltip :content="$t('copy_to_clipboard')">
<el-button :icon="ContentCopy" link @click="Utils.copy(`\{\{ secret('${scope.row.key}') \}\}`)" />
</el-tooltip>
</template>
</el-table-column>
<el-table-column v-if="!keyOnly" column-key="update" class-name="row-action">
<template #default="scope">
<el-button v-if="canUpdate(scope.row)" :icon="FileDocumentEdit" link @click="updateSecretModal(scope.row)" />
</template>
</el-table-column>
<el-table-column v-if="!keyOnly" column-key="delete" class-name="row-action">
<template #default="scope">
<el-button v-if="canDelete(scope.row)" :icon="Delete" link @click="removeSecret(scope.row)" />
</template>
</el-table-column>
</select-table>
<drawer
v-if="addSecretDrawerVisible"
v-model="addSecretDrawerVisible"
:title="secretModalTitle"
>
<el-form class="ks-horizontal" :model="secret" :rules="rules" ref="form">
<el-form-item v-if="namespace === undefined" :label="$t('namespace')" prop="namespace" required>
<namespace-select
v-model="secret.namespace"
:readonly="secret.update"
data-type="flow"
:include-system-namespace="true"
/>
</el-form-item>
<el-form-item :label="$t('secret.key')" prop="key">
<el-input v-model="secret.key" :readonly="secret.update" required />
</el-form-item>
<el-form-item v-if="!secret.update" :label="$t('secret.name')" prop="value">
<el-input v-model="secret.value" :placeholder="secretModalTitle" autosize type="textarea" required />
</el-form-item>
<el-form-item v-if="secret.update" :label="$t('secret.name')" prop="value">
<el-col :span="20">
<el-input
v-model="secret.value"
:placeholder="secretModalTitle"
autosize
type="textarea"
required
:disabled="!secret.updateValue"
/>
</el-col>
<el-col class="px-2" :span="4">
<el-switch
size="large"
inline-prompt
v-model="secret.updateValue"
:active-icon="PencilOutline"
:inactive-icon="PencilOff"
/>
</el-col>
</el-form-item>
<el-form-item :label="$t('secret.description')" prop="description">
<el-input v-model="secret.description" :placeholder="$t('secret.descriptionPlaceholder')" required />
</el-form-item>
<el-form-item :label="$t('secret.tags')" prop="tags">
<el-row :gutter="20" v-for="(tag, index) in secret.tags" :key="index">
<el-col :span="8">
<el-input required v-model="tag.key" :placeholder="$t('key')" />
</el-col>
<el-col :span="12">
<el-input required v-model="tag.value" :placeholder="$t('value')" />
</el-col>
<el-button-group class="d-flex flex-nowrap">
<el-button
:icon="Delete"
@click="removeSecretTag(index)"
:disabled="secret.tags.length === 1"
/>
</el-button-group>
</el-row>
<el-button :icon="Plus" @click="addSecretTag" type="primary">
{{ $t('secret.addTag') }}
</el-button>
</el-form-item>
</el-form>
<template #footer>
<el-button :icon="ContentSave" @click="saveSecret($refs.form)" type="primary">
{{ $t('save') }}
</el-button>
</template>
</drawer>
</div>
</template>
<script setup lang="ts">
@@ -176,9 +183,13 @@
Id,
Drawer
},
inheritAttrs: false,
computed: {
...mapState("auth", ["user"]),
filteredSecrets() {
return this.namespace === undefined
? this.secrets?.filter((secret: {key: string}) => !this.search || secret.key.toLowerCase().includes(this.search.toLowerCase()))
: this.secrets;
},
secretModalTitle() {
return this.secret?.update ? this.$t("secret.update", {name: this.secret.key}) : this.$t("secret.add");
},
@@ -219,13 +230,35 @@
}
},
emits: [
"update:addSecretModalVisible"
"update:addSecretModalVisible",
"update:isSecretReadOnly",
"hasData"
],
watch: {
addSecretModalVisible(newValue) {
if (!newValue) {
this.resetForm();
}
},
hasData(newValue, oldValue) {
if (oldValue === undefined) {
this.$emit("hasData", newValue);
}
},
search(newValue) {
if (newValue !== undefined) {
this.$router.push({query: {
q: newValue
}})
}
},
"$route.query.q"(newValue, oldValue) {
if (newValue !== undefined && newValue !== oldValue) {
if (this.namespace === undefined && this.search !== newValue) {
this.search = newValue;
}
this.reloadSecrets();
}
}
},
data() {
@@ -263,7 +296,9 @@
required: false,
},
]
}
},
hasData: undefined,
search: this.$route.query?.q ?? ""
};
},
methods: {
@@ -275,7 +310,14 @@
},
async fetchSecrets() {
if (this.secretsIterator === undefined) {
this.secretsIterator = this.namespace === undefined ? useAllSecrets(this.$store, 20) : useNamespaceSecrets(this.$store, this.namespace, 20);
this.secretsIterator = this.namespace === undefined ? useAllSecrets(this.$store, 20) : useNamespaceSecrets(this.$store, this.namespace, 20, {
sort: this.$route.query.sort || "key:asc",
...(this.$route.query.q === undefined ? {} : {filters: {
q: {
STARTS_WITH: this.$route.query.q[0]
}
}})
});
}
let emitReadOnly = false;
@@ -288,9 +330,11 @@
}
if (fetch.length === 0) {
this.hasData = false;
return undefined;
}
this.hasData = true;
this.secrets = [...(this.secrets || []), ...fetch];
return fetch;
@@ -338,26 +382,26 @@
removeSecretTag(index) {
this.secret.tags.splice(index, 1);
},
reloadSecrets() {
async reloadSecrets() {
this.secretsIterator = undefined;
const previousLength = this.secrets?.length ?? 0;
await this.$refs.selectTable.resetInfiniteScroll();
this.secrets = [];
this.$refs.selectTable.resetInfiniteScroll();
// If we are in the global Secrets view we let the infinite scroll handling the fetch
if (this.namespace !== undefined) {
if (this.namespace !== undefined || previousLength === 0) {
this.fetchSecrets();
}
},
removeSecret(key) {
removeSecret({key, namespace}) {
this.$toast().confirm(this.$t("delete confirm", {name: key}), () => {
return this.$store
.dispatch("namespace/deleteSecrets", {namespace: this.$route.params.id, key: key})
.dispatch("namespace/deleteSecrets", {namespace: namespace, key})
.then(() => {
this.$toast().deleted(key);
})
.then(() => {
this.reloadSecrets();
})
.then(() => this.reloadSecrets())
});
},
isSecretValueUpdated() {

View File

@@ -1,10 +1,12 @@
export type FetchResult<T> = { total: number, results: T[] };
export abstract class EntityIterator<T> {
private readonly fetchSize: number;
private total: number | undefined;
readonly fetchSize: number;
private _total: number | undefined;
private page = 0;
private alreadyFetched: T[] = [];
private buffered: T[] = [];
private readonly options: any;
readonly options: any;
protected constructor(fetchSize: number, options?: any) {
if (fetchSize <= 0) {
@@ -14,6 +16,10 @@ export abstract class EntityIterator<T> {
this.options = options ?? {};
}
get total(): number | undefined {
return this._total;
}
fetchOptions() {
return {
commit: false,
@@ -24,13 +30,17 @@ export abstract class EntityIterator<T> {
}
}
abstract fetchCall(options: any): Promise<{total: number, results: T[]}>;
abstract fetchCall(): Promise<FetchResult<T>>;
stopCondition() {
return this.total === this.alreadyFetched.length;
}
/**
* If no buffer is available, fetches the next entity and returns the first entity while buffering the rest
*/
async single(): Promise<T | undefined> {
if (this.total === this.alreadyFetched.length && this.buffered.length === 0) {
if (this.stopCondition() && this.buffered.length === 0) {
return Promise.resolve(undefined);
}
@@ -45,12 +55,12 @@ export abstract class EntityIterator<T> {
* Fetches the next batch of entities
*/
async next(): Promise<T[]> {
if (this.total === this.alreadyFetched.length) {
if (this.stopCondition()) {
return Promise.resolve([]);
}
const entityFetch = await this.fetchCall();
this.total = entityFetch.total;
this._total = entityFetch.total;
this.alreadyFetched = [...this.alreadyFetched, ...entityFetch.results];
return entityFetch.results;
}
@@ -64,9 +74,9 @@ export abstract class EntityIterator<T> {
}
await this.next();
const entitiesFetchPromises: Promise<T[]> = [];
const entitiesFetchPromises: Promise<T[]>[] = [];
for (let i = this.page; i < this.total / this.fetchSize; i++) {
for (let i = this.page; i < this.total! / this.fetchSize; i++) {
entitiesFetchPromises.push(this.next());
}

View File

@@ -1,5 +1,5 @@
import {Store} from "vuex";
import {EntityIterator} from "./entityIterator.ts";
import {EntityIterator, FetchResult} from "./entityIterator.ts";
import {NamespaceIterator} from "./useNamespaces.ts";
import {Me} from "../stores/auth";
import permissions from "../models/permission";
@@ -14,10 +14,12 @@ export interface NamespaceSecret {
export type SecretIterator = NamespaceSecretIterator | AllSecretIterator;
type NamespaceSecretFetchResult = FetchResult<NamespaceSecret> & { readOnly: boolean };
export class NamespaceSecretIterator extends EntityIterator<NamespaceSecret>{
private readonly store: Store<any>;
readonly namespace: string;
areNamespaceSecretsReadOnly: boolean | undefined = ref(undefined);
areNamespaceSecretsReadOnly = ref(undefined) as unknown as boolean | undefined;
constructor(store: Store<any>, namespace: string, fetchSize: number, options?: any) {
super(fetchSize, options);
@@ -34,12 +36,12 @@ export class NamespaceSecretIterator extends EntityIterator<NamespaceSecret>{
};
}
fetchCall(): Promise<{ total: number; results: NamespaceSecret[], readOnly: boolean }> {
fetchCall(): Promise<NamespaceSecretFetchResult> {
return this.doFetch();
}
private async doFetch(): Promise<{ total: number; results: NamespaceSecret[], readOnly: boolean }> {
const fetch = await this.store.dispatch("namespace/listSecrets", this.fetchOptions());
private async doFetch(): Promise<NamespaceSecretFetchResult> {
const fetch = (await this.store.dispatch("namespace/listSecrets", this.fetchOptions())) as NamespaceSecretFetchResult;
this.areNamespaceSecretsReadOnly = fetch.readOnly ?? true;
return {
@@ -52,9 +54,9 @@ export class NamespaceSecretIterator extends EntityIterator<NamespaceSecret>{
export class AllSecretIterator extends EntityIterator<NamespaceSecret>{
private readonly store: Store<any>;
private readonly user: Me;
private namespaceIterator: NamespaceIterator;
private namespaceSecretIterator: NamespaceSecretIterator;
private areNamespaceSecretsReadOnly: {[namespace: string]: boolean} = ref({});
private namespaceIterator: NamespaceIterator | undefined;
private namespaceSecretIterator: NamespaceSecretIterator | undefined;
private areNamespaceSecretsReadOnly = ref({}) as unknown as {[namespace: string]: boolean};
constructor(store: Store<any>, fetchSize: number, options?: any) {
super(fetchSize, options);
@@ -62,7 +64,11 @@ export class AllSecretIterator extends EntityIterator<NamespaceSecret>{
this.user = this.store.state?.["auth"]?.user;
}
async fetchCall(): Promise<{ total: number; results: NamespaceSecret[], readOnly: boolean }> {
stopCondition(): boolean {
return this.total === 0;
}
async fetchCall(): Promise<FetchResult<NamespaceSecret>> {
if (this.namespaceIterator === undefined) {
this.namespaceIterator = new NamespaceIterator(this.store, 20, {
commit: false,
@@ -83,16 +89,25 @@ export class AllSecretIterator extends EntityIterator<NamespaceSecret>{
this.namespaceSecretIterator = new NamespaceSecretIterator(this.store, namespace.id, this.fetchSize, this.options);
}
const fetch = await this.namespaceSecretIterator.fetchCall();
if (fetch.results.length > 0) {
this.areNamespaceSecretsReadOnly[this.namespaceSecretIterator.namespace] = fetch.readOnly;
return {
...fetch,
results: fetch.results.map(secret => ({...secret, namespace: this.namespaceSecretIterator.namespace}))
};
const fetch = {
results: await this.namespaceSecretIterator.next(),
namespace: this.namespaceSecretIterator.namespace,
areNamespaceSecretsReadOnly: this.namespaceSecretIterator.areNamespaceSecretsReadOnly,
total: this.namespaceSecretIterator.total
};
if (this.namespaceSecretIterator.stopCondition()) {
this.namespaceSecretIterator = undefined;
}
this.namespaceSecretIterator = undefined;
if (fetch.results.length > 0) {
this.areNamespaceSecretsReadOnly[fetch.namespace] = fetch.areNamespaceSecretsReadOnly!;
return {
total: fetch.total!,
results: fetch.results.map(secret => ({...secret, namespace: fetch.namespace}))
};
}
}
}
}
@@ -101,6 +116,6 @@ export function useNamespaceSecrets(store: Store<any>, namespace: string, secret
return new NamespaceSecretIterator(store, namespace, secretsFetchSize, options);
}
export function useAllSecrets(store: Store<any>, secretsFetchSize: number, options?: any): NamespaceSecretIterator {
export function useAllSecrets(store: Store<any>, secretsFetchSize: number, options?: any): AllSecretIterator {
return new AllSecretIterator(store, secretsFetchSize, options);
}

View File

@@ -14,6 +14,7 @@
:blueprint-id="selectedBlueprintId"
blueprint-type="community"
@back="selectedBlueprintId = undefined"
:combined-view
/>
<blueprints-browser
@loaded="$emit('loaded', $event)"
@@ -75,7 +76,11 @@
tab: {
type: String,
default: "community"
}
},
combinedView: {
type: Boolean,
default: false
},
},
data() {
return {

View File

@@ -54,7 +54,7 @@
<div class="left">
<div class="blueprint">
<div class="ps-0 title">
{{ blueprint.title }}
{{ blueprint.title ?? blueprint.id }}
</div>
<div v-if="!system" class="tags text-uppercase">
<div v-for="(tag, index) in blueprint.tags" :key="index" class="tag-box">

View File

@@ -138,6 +138,9 @@ export function useLeftMenu() {
icon: {
element: shallowRef(ShieldKeyOutline),
class: "menu-icon"
},
attributes: {
locked: true
}
},
{
@@ -233,7 +236,7 @@ export function useLeftMenu() {
{
href: {name: "admin/tenants/list"},
routes: routeStartWith("admin/tenants"),
title: t("tenants"),
title: t("tenant.names"),
icon: {
element: shallowRef(ShieldLockOutline),
class: "menu-icon"

View File

@@ -303,6 +303,14 @@
}
},
"secrets": {
"add_env": {
"first": "Kodieren Sie den <strong><a href=\"https://kestra.io/docs/how-to-guides/secrets#using-secrets-in-kestra\" target=\"_blank\">geheimen Wert in Base64</a></strong>",
"intro": "Um ein neues Secret zu erstellen:",
"second": "Fügen Sie eine Umgebungsvariable mit dem Namen '<strong>SECRET_{'{MY_SECRET_KEY}'}</strong>' mit dem obigen Wert hinzu",
"third": "Starte deine Kestra-Instanz neu"
},
"detected_env": "Hier sind Umgebungsvariablen vom Typ \"secret\", die beim Start der Instanz identifiziert wurden:",
"empty_env": "Sie haben noch keine Secrets in Ihrer Umgebung.",
"message": "Die Enterprise Edition (EE) ermöglicht es Ihnen, Geheimnisse direkt in der UI hinzuzufügen, zu bearbeiten oder zu löschen ohne Neustart der Instanz. Organisieren Sie Geheimnisse nach namespace mit detaillierten RBAC-Berechtigungen und erben Sie sie von übergeordneten zu untergeordneten namespaces. EE integriert sich mit Geheimnis-Managern wie HashiCorp Vault, AWS Secrets Manager, Azure Key Vault, Google Secret Manager und Elasticsearch. Richten Sie dedizierte Backends pro namespace, Mandant oder Instanz ein, um Geheimnisse zwischen Teams zu isolieren, oder aktivieren Sie schreibgeschützte Geheimnisse, die in externen Tresoren gespeichert sind. Geheimnisse bleiben sowohl im Ruhezustand als auch während der Übertragung verschlüsselt. Optionales Caching reduziert API-Aufrufe, und Prüfpfade protokollieren alle Zugriffe.",
"title": "Aktualisieren Sie Ihr Secrets Management"
},
@@ -547,6 +555,7 @@
},
"empty": "Sie haben noch keine Suche gespeichert.",
"label": "Gespeicherte Suchen",
"remove": "Entfernen",
"tooltip": "Sie können keine leeren Suchkriterien speichern."
},
"settings": {
@@ -1152,8 +1161,10 @@
"templates deleted": "<code>{count}</code> Vorlage(n) gelöscht",
"templates deprecated": "Vorlagen sind veraltet. Bitte verwenden Sie stattdessen Subflows. Siehe den <a href=\"https://kestra.io/docs/migration-guide/0.11.0/templates\" target=\"_blank\">Migrationsabschnitt</a>, der erklärt, wie Sie von Vorlagen zu Subflows migrieren können.",
"templates exported": "Vorlagen exportiert",
"tenant": "Tenant",
"tenants": "Tenants",
"tenant": {
"name": "Mandant",
"names": "Mandanten"
},
"theme": "Modus",
"this_task_has": "Diese Task hat",
"timezone": "Zeitzone",

View File

@@ -807,7 +807,15 @@
"get_a_demo_button": "Get a Demo",
"secrets": {
"title": "Upgrade your Secrets Management",
"message": "The Enterprise Edition (EE) lets you add, edit, or delete secrets directly in the UI—no instance restarts needed. Organize secrets by namespace with granular RBAC permissions, and inherit them from parent to child namespaces. EE integrates with secrets managers like HashiCorp Vault, AWS Secrets Manager, Azure Key Vault, Google Secret Manager, and Elasticsearch. Set up dedicated backends per namespace, tenant, or instance to isolate secrets across teams, or enable read-only secrets stored in external vaults. Secrets stay encrypted at rest and in transit. Optional caching reduces API calls, and audit trails log all access."
"message": "The Enterprise Edition (EE) lets you add, edit, or delete secrets directly in the UI—no instance restarts needed. Organize secrets by namespace with granular RBAC permissions, and inherit them from parent to child namespaces. EE integrates with secrets managers like HashiCorp Vault, AWS Secrets Manager, Azure Key Vault, Google Secret Manager, and Elasticsearch. Set up dedicated backends per namespace, tenant, or instance to isolate secrets across teams, or enable read-only secrets stored in external vaults. Secrets stay encrypted at rest and in transit. Optional caching reduces API calls, and audit trails log all access.",
"detected_env": "Here are secret-type environment variables identified at instance start-time:",
"empty_env": "You don't have any Secrets in your environment yet.",
"add_env": {
"intro": "To create a new Secret:",
"first": "Encode the <strong><a href=\"https://kestra.io/docs/how-to-guides/secrets#using-secrets-in-kestra\" target=\"_blank\">secret value in Base64</a></strong>",
"second": "Add an environment variable named '<strong>SECRET_{'{MY_SECRET_KEY}'}</strong>' with the above value",
"third": "Restart your Kestra instance"
}
},
"apps": {
"title": "Build custom Apps with Kestra",
@@ -905,8 +913,10 @@
"enable concurrency": "Enable concurrency",
"auditlogs": "Audit Logs",
"iam": "IAM",
"tenant": "Tenant",
"tenants": "Tenants",
"tenant": {
"name": "Tenant",
"names": "Tenants"
},
"ee-tooltip": {
"features-blocked": "This feature requires Enterprise Edition.",
"button": "Talk to us"
@@ -1116,6 +1126,7 @@
},
"save": {
"label": "Saved searches",
"remove": "Remove",
"empty": "You still haven't saved any search.",
"tooltip": "You can't save empty search criteria.",
"dialog": {
@@ -1248,7 +1259,7 @@
"task_runners": "Task Runners",
"empty": {
"announcements": {
"title": "You have no annoucements yet!",
"title": "You have no announcements yet!",
"content": "Announcements allow you to notify your users about any changes or inform them about planned maintenance downtime. Simply select the announcement type, the date range for which it should be displayed, and write the announcement message."
},
"apps": {

View File

@@ -303,6 +303,14 @@
}
},
"secrets": {
"add_env": {
"first": "Codifica el <strong><a href=\"https://kestra.io/docs/how-to-guides/secrets#using-secrets-in-kestra\" target=\"_blank\">valor secreto en Base64</a></strong>",
"intro": "Para crear un nuevo Secret:",
"second": "Agrega una variable de entorno llamada '<strong>SECRET_{'{MY_SECRET_KEY}'}</strong>' con el valor anterior",
"third": "Reinicia tu instancia de Kestra"
},
"detected_env": "Aquí están las variables de entorno de tipo secreto identificadas al momento de inicio de la instancia:",
"empty_env": "Todavía no tienes ningún Secret en tu entorno.",
"message": "La Enterprise Edition (EE) te permite agregar, editar o eliminar secretos directamente en la UI, sin necesidad de reiniciar la instancia. Organiza secretos por namespace con permisos RBAC granulares, y herédalos de namespaces padre a hijo. EE se integra con gestores de secretos como HashiCorp Vault, AWS Secrets Manager, Azure Key Vault, Google Secret Manager y Elasticsearch. Configura backends dedicados por namespace, tenant o instancia para aislar secretos entre equipos, o habilita secretos de solo lectura almacenados en vaults externos. Los secretos permanecen cifrados en reposo y en tránsito. El almacenamiento en caché opcional reduce las llamadas a la API, y las auditorías registran todo el acceso.",
"title": "Mejora tu Gestión de Secrets"
},
@@ -547,6 +555,7 @@
},
"empty": "Todavía no has guardado ninguna búsqueda.",
"label": "Búsquedas guardadas",
"remove": "Eliminar",
"tooltip": "No puedes guardar criterios de búsqueda vacíos."
},
"settings": {
@@ -1152,8 +1161,10 @@
"templates deleted": "<code>{count}</code> Plantilla(s) eliminadas",
"templates deprecated": "Las plantillas están obsoletas. Por favor usa subflows en su lugar. Consulta la <a href=\"https://kestra.io/docs/migration-guide/0.11.0/templates\" target=\"_blank\">sección de Migraciones</a> que explica cómo puedes migrar de plantillas a subflows.",
"templates exported": "Plantillas exportadas",
"tenant": "Tenant",
"tenants": "Tenants",
"tenant": {
"name": "Mandante",
"names": "Arrendatarios"
},
"theme": "Tema",
"this_task_has": "Esta tarea tiene",
"timezone": "Zona horaria",

View File

@@ -303,6 +303,14 @@
}
},
"secrets": {
"add_env": {
"first": "Encodez la <strong><a href=\"https://kestra.io/docs/how-to-guides/secrets#using-secrets-in-kestra\" target=\"_blank\">valeur secrète en Base64</a></strong>",
"intro": "Pour créer un nouveau Secret :",
"second": "Ajoutez une variable d'environnement nommée '<strong>SECRET_{'{MY_SECRET_KEY}'}</strong>' avec la valeur ci-dessus.",
"third": "Redémarrez votre instance Kestra"
},
"detected_env": "Voici les variables d'environnement de type secret identifiées au démarrage de l'instance :",
"empty_env": "Vous n'avez pas encore de Secrets dans votre environnement.",
"message": "L'Enterprise Edition (EE) vous permet d'ajouter, de modifier ou de supprimer des secrets directement dans l'UI, sans redémarrage de l'instance. Organisez les secrets par namespace avec des permissions RBAC granulaires, et héritez-les des namespaces parents aux namespaces enfants. EE s'intègre avec des gestionnaires de secrets comme HashiCorp Vault, AWS Secrets Manager, Azure Key Vault, Google Secret Manager et Elasticsearch. Configurez des backends dédiés par namespace, tenant ou instance pour isoler les secrets entre les équipes, ou activez des secrets en lecture seule stockés dans des coffres externes. Les secrets restent chiffrés au repos et en transit. Un cache optionnel réduit les appels API, et les pistes d'audit loguent tous les accès.",
"title": "Améliorez votre gestion des Secrets"
},
@@ -547,6 +555,7 @@
},
"empty": "Vous n'avez pas encore enregistré de recherche.",
"label": "Recherches enregistrées",
"remove": "Supprimer",
"tooltip": "Vous ne pouvez pas enregistrer des critères de recherche vides."
},
"settings": {
@@ -1152,8 +1161,10 @@
"templates deleted": "<code>{count}</code> Template(s) supprimé(s)",
"templates deprecated": "Les templates sont obsolètes. Veuillez utiliser des sous-flux au lieu des templates. Consultez <a href=\"https://kestra.io/docs/migration-guide/0.11.0/templates\" target=\"_blank\">section Migrations</a> expliquant comment vous pouvez migrer vers les sous-flux.",
"templates exported": "Templates exportés",
"tenant": "Tenant",
"tenants": "Tenants",
"tenant": {
"name": "Mandant",
"names": "Mandants"
},
"theme": "Thème",
"this_task_has": "Cette tâche a",
"timezone": "Fuseau horaire",

View File

@@ -303,6 +303,14 @@
}
},
"secrets": {
"add_env": {
"first": "<strong><a href=\"https://kestra.io/docs/how-to-guides/secrets#using-secrets-in-kestra\" target=\"_blank\">गुप्त value को Base64 में एन्कोड करें</a></strong>",
"intro": "नया Secret बनाने के लिए:",
"second": "'<strong>SECRET_{'{MY_SECRET_KEY}'}</strong>' नामक एक environment variable जोड़ें उपरोक्त value के साथ",
"third": "अपने Kestra instance को पुनः प्रारंभ करें"
},
"detected_env": "यहाँ instance start-time पर पहचाने गए secret-type environment variables हैं:",
"empty_env": "आपके वातावरण में अभी तक कोई Secrets नहीं हैं।",
"message": "एंटरप्राइज एडिशन (EE) आपको UI में सीधे secrets जोड़ने, संपादित करने या हटाने की अनुमति देता है—कोई instance पुनरारंभ की आवश्यकता नहीं है। secrets को namespace द्वारा संगठित करें, जिसमें सूक्ष्म RBAC अनुमतियाँ हों, और उन्हें parent से child namespaces में विरासत में लें। EE HashiCorp Vault, AWS Secrets Manager, Azure Key Vault, Google Secret Manager, और Elasticsearch जैसे secrets प्रबंधकों के साथ एकीकृत होता है। टीमों के बीच secrets को अलग करने के लिए namespace, tenant, या instance के अनुसार समर्पित बैकएंड सेट करें, या बाहरी vaults में संग्रहीत read-only secrets को सक्षम करें। secrets को आराम और ट्रांजिट में एन्क्रिप्टेड रखा जाता है। वैकल्पिक caching API कॉल्स को कम करता है, और audit trails सभी एक्सेस को log करते हैं।",
"title": "अपने Secrets Management को अपग्रेड करें"
},
@@ -547,6 +555,7 @@
},
"empty": "आपने अभी तक कोई खोज सहेजी नहीं है।",
"label": "सहेजे गए खोजें",
"remove": "हटाएं",
"tooltip": "आप खाली खोज मानदंड सहेज नहीं सकते।"
},
"settings": {
@@ -1152,8 +1161,10 @@
"templates deleted": "<code>{count}</code> template(s) हटाए गए",
"templates deprecated": "templates अप्रचलित हैं। कृपया इसके बजाय subflows का उपयोग करें। यह समझाने वाला <a href=\"https://kestra.io/docs/migration-guide/0.11.0/templates\" target=\"_blank\">माइग्रेशन अनुभाग</a> देखें कि आप टेम्पलेट्स से subflows में कैसे माइग्रेट कर सकते हैं।",
"templates exported": "templates निर्यात किए गए",
"tenant": "Tenant",
"tenants": "Tenants",
"tenant": {
"name": "मंडल",
"names": "मंडल"
},
"theme": "थीम",
"this_task_has": "यह task है",
"timezone": "समय क्षेत्र",

View File

@@ -303,6 +303,14 @@
}
},
"secrets": {
"add_env": {
"first": "Codifica il <strong><a href=\"https://kestra.io/docs/how-to-guides/secrets#using-secrets-in-kestra\" target=\"_blank\">valore segreto in Base64</a></strong>",
"intro": "Per creare un nuovo Secret:",
"second": "Aggiungi una variabile di ambiente chiamata '<strong>SECRET_{'{MY_SECRET_KEY}'}</strong>' con il valore sopra indicato",
"third": "Riavvia la tua istanza di Kestra"
},
"detected_env": "Ecco le variabili d'ambiente di tipo secret identificate al momento dell'avvio dell'istanza:",
"empty_env": "Non hai ancora alcun Secret nel tuo ambiente.",
"message": "L'Enterprise Edition (EE) ti consente di aggiungere, modificare o eliminare segreti direttamente nell'interfaccia utente, senza bisogno di riavviare l'istanza. Organizza i segreti per namespace con permessi RBAC granulari e ereditali dai namespace genitori a quelli figli. EE si integra con gestori di segreti come HashiCorp Vault, AWS Secrets Manager, Azure Key Vault, Google Secret Manager ed Elasticsearch. Configura backend dedicati per namespace, tenant o istanza per isolare i segreti tra i team, oppure abilita segreti di sola lettura memorizzati in vault esterni. I segreti rimangono crittografati a riposo e in transito. La cache opzionale riduce le chiamate API e le tracce di audit registrano tutti gli accessi.",
"title": "Aggiorna il tuo Secrets Management"
},
@@ -547,6 +555,7 @@
},
"empty": "Non hai ancora salvato alcuna ricerca.",
"label": "Ricerche salvate",
"remove": "Rimuovi",
"tooltip": "Non puoi salvare criteri di ricerca vuoti."
},
"settings": {
@@ -1152,8 +1161,10 @@
"templates deleted": "<code>{count}</code> Template/i eliminati",
"templates deprecated": "I templates sono deprecati. Si prega di utilizzare i subflows. Vedi la <a href=\"https://kestra.io/docs/migration-guide/0.11.0/templates\" target=\"_blank\">sezione Migrazioni</a> che spiega come migrare dai templates ai subflows.",
"templates exported": "Template esportati",
"tenant": "Tenant",
"tenants": "Tenants",
"tenant": {
"name": "Mandante",
"names": "Mandanti"
},
"theme": "Tema",
"this_task_has": "Questo task ha",
"timezone": "Fuso orario",

View File

@@ -303,6 +303,14 @@
}
},
"secrets": {
"add_env": {
"first": "<strong><a href=\"https://kestra.io/docs/how-to-guides/secrets#using-secrets-in-kestra\" target=\"_blank\">シークレットの値をBase64でエンコード</a></strong>",
"intro": "新しいSecretを作成するには",
"second": "`<strong>SECRET_{'{MY_SECRET_KEY}'}</strong>`という名前の環境変数を上記の値で追加してください。",
"third": "Kestraインスタンスを再起動する"
},
"detected_env": "インスタンス開始時に識別されたシークレットタイプの環境変数は次のとおりです:",
"empty_env": "まだ環境にSecretsがありません。",
"message": "エンタープライズエディション (EE) では、UIで直接シークレットを追加、編集、または削除できます。インスタンスの再起動は不要です。シークレットをnamespaceごとに整理し、詳細なRBAC権限を設定し、親namespaceから子namespaceに継承します。EEは、HashiCorp Vault、AWS Secrets Manager、Azure Key Vault、Google Secret Manager、Elasticsearchなどのシークレットマネージャーと統合します。namespace、テナント、またはインスタンスごとに専用のバックエンドを設定して、チーム間でシークレットを分離するか、外部のvaultに保存された読み取り専用のシークレットを有効にします。シークレットは、保存時および転送時に暗号化されたままです。オプションのキャッシュによりAPIコールが削減され、監査証跡がすべてのアクセスをログに記録します。",
"title": "シークレット管理をアップグレード"
},
@@ -547,6 +555,7 @@
},
"empty": "検索をまだ保存していません。",
"label": "保存済み検索",
"remove": "削除",
"tooltip": "空の検索条件を保存することはできません。"
},
"settings": {
@@ -1152,8 +1161,10 @@
"templates deleted": "<code>{count}</code>件のテンプレートが削除されました",
"templates deprecated": "テンプレートは非推奨です。代わりにsubflowsを使用してください。テンプレートからsubflowsへの移行方法については、<a href=\"https://kestra.io/docs/migration-guide/0.11.0/templates\" target=\"_blank\">移行セクション</a>を参照してください。",
"templates exported": "テンプレートがエクスポートされました",
"tenant": "Tenant",
"tenants": "Tenants",
"tenant": {
"name": "テナント",
"names": "テナント"
},
"theme": "テーマ",
"this_task_has": "このtaskは",
"timezone": "タイムゾーン",

View File

@@ -303,6 +303,14 @@
}
},
"secrets": {
"add_env": {
"first": "<strong><a href=\"https://kestra.io/docs/how-to-guides/secrets#using-secrets-in-kestra\" target=\"_blank\">비밀 값 Base64로 인코딩</a></strong>",
"intro": "새 Secret을 생성하려면:",
"second": "위의 값을 사용하여 '<strong>SECRET_{'{MY_SECRET_KEY}'}</strong>'라는 환경 변수를 추가하세요.",
"third": "Kestra 인스턴스를 재시작하십시오"
},
"detected_env": "다음은 인스턴스 시작 시 식별된 secret-type 환경 변수입니다:",
"empty_env": "아직 환경에 Secrets가 없습니다.",
"message": "Enterprise Edition (EE)는 UI에서 비밀을 직접 추가, 편집 또는 삭제할 수 있게 해줍니다. 인스턴스를 재시작할 필요가 없습니다. 비밀을 namespace별로 세분화된 RBAC 권한으로 구성하고, 상위 namespace에서 하위 namespace로 상속할 수 있습니다. EE는 HashiCorp Vault, AWS Secrets Manager, Azure Key Vault, Google Secret Manager, Elasticsearch와 같은 비밀 관리자와 통합됩니다. namespace, tenant, 인스턴스별로 전용 백엔드를 설정하여 팀 간 비밀을 격리하거나 외부 vault에 저장된 읽기 전용 비밀을 활성화할 수 있습니다. 비밀은 저장 중 및 전송 중에 암호화된 상태로 유지됩니다. 선택적 캐싱은 API 호출을 줄이고, 감사 추적은 모든 접근을 log합니다.",
"title": "비밀 관리 업그레이드"
},
@@ -547,6 +555,7 @@
},
"empty": "아직 검색을 저장하지 않았습니다.",
"label": "저장된 검색",
"remove": "제거",
"tooltip": "빈 검색 기준을 저장할 수 없습니다."
},
"settings": {
@@ -1152,8 +1161,10 @@
"templates deleted": "<code>{count}</code> 개의 템플릿이 삭제되었습니다",
"templates deprecated": "템플릿은 사용 중지되었습니다. 대신 subflows를 사용하세요. 템플릿에서 subflows로 마이그레이션하는 방법을 설명하는 <a href=\"https://kestra.io/docs/migration-guide/0.11.0/templates\" target=\"_blank\">마이그레이션 섹션</a>을 참조하세요.",
"templates exported": "템플릿이 내보내졌습니다",
"tenant": "Tenant",
"tenants": "Tenants",
"tenant": {
"name": "테넌트",
"names": "테넌트"
},
"theme": "테마",
"this_task_has": "이 task는",
"timezone": "시간대",

View File

@@ -303,6 +303,14 @@
}
},
"secrets": {
"add_env": {
"first": "Zakoduj <strong><a href=\"https://kestra.io/docs/how-to-guides/secrets#using-secrets-in-kestra\" target=\"_blank\">wartość secret w Base64</a></strong>",
"intro": "Aby utworzyć nowy Secret:",
"second": "Dodaj zmienną środowiskową o nazwie '<strong>SECRET_{'{MY_SECRET_KEY}'}</strong>' z powyższą wartością",
"third": "Uruchom ponownie instancję Kestra"
},
"detected_env": "Oto zmienne środowiskowe typu secret zidentyfikowane podczas uruchamiania instancji:",
"empty_env": "Nie masz jeszcze żadnych Secrets w swoim środowisku.",
"message": "Edycja Enterprise (EE) pozwala na dodawanie, edytowanie lub usuwanie sekretów bezpośrednio w UI — bez potrzeby ponownego uruchamiania instancji. Organizuj sekrety według namespace z precyzyjnymi uprawnieniami RBAC i dziedzicz je z namespace nadrzędnych do podrzędnych. EE integruje się z menedżerami sekretów, takimi jak HashiCorp Vault, AWS Secrets Manager, Azure Key Vault, Google Secret Manager i Elasticsearch. Skonfiguruj dedykowane backendy dla każdego namespace, mandanta lub instancji, aby izolować sekrety w zespołach, lub włącz sekrety tylko do odczytu przechowywane w zewnętrznych vaultach. Sekrety pozostają zaszyfrowane w spoczynku i podczas przesyłania. Opcjonalne buforowanie zmniejsza liczbę wywołań API, a ścieżki audytu logują cały dostęp.",
"title": "Ulepsz swoje zarządzanie Secrets Management"
},
@@ -547,6 +555,7 @@
},
"empty": "Nie zapisałeś jeszcze żadnego wyszukiwania.",
"label": "Zapisane wyszukiwania",
"remove": "Usuń",
"tooltip": "Nie można zapisać pustych kryteriów wyszukiwania."
},
"settings": {
@@ -1152,8 +1161,10 @@
"templates deleted": "<code>{count}</code> Szablon(y) usunięte",
"templates deprecated": "Szablony są przestarzałe. Proszę używać subflows. Zobacz <a href=\"https://kestra.io/docs/migration-guide/0.11.0/templates\" target=\"_blank\">sekcję migracji</a>, aby dowiedzieć się, jak migrować z szablonów do subflows.",
"templates exported": "Szablony wyeksportowane",
"tenant": "Tenant",
"tenants": "Tenants",
"tenant": {
"name": "Mandant",
"names": "Najemcy"
},
"theme": "Motyw",
"this_task_has": "To zadanie ma",
"timezone": "Strefa czasowa",

View File

@@ -303,6 +303,14 @@
}
},
"secrets": {
"add_env": {
"first": "Codifique o <strong><a href=\"https://kestra.io/docs/how-to-guides/secrets#using-secrets-in-kestra\" target=\"_blank\">valor secreto em Base64</a></strong>",
"intro": "Para criar um novo Secret:",
"second": "Adicione uma variável de ambiente chamada '<strong>SECRET_{'{MY_SECRET_KEY}'}</strong>' com o valor acima",
"third": "Reinicie sua instância do Kestra"
},
"detected_env": "Aqui estão as variáveis de ambiente do tipo secreto identificadas no momento de inicialização da instância:",
"empty_env": "Você ainda não tem nenhum Secret no seu ambiente.",
"message": "A Enterprise Edition (EE) permite adicionar, editar ou excluir segredos diretamente na UI—sem necessidade de reiniciar a instância. Organize segredos por namespace com permissões RBAC granulares e herde-os de namespaces pai para filho. A EE integra-se com gerenciadores de segredos como HashiCorp Vault, AWS Secrets Manager, Azure Key Vault, Google Secret Manager e Elasticsearch. Configure backends dedicados por namespace, tenant ou instância para isolar segredos entre equipes, ou habilite segredos somente leitura armazenados em cofres externos. Os segredos permanecem criptografados em repouso e em trânsito. O cache opcional reduz chamadas de API, e trilhas de auditoria registram todo o acesso.",
"title": "Atualize seu Gerenciamento de Secrets"
},
@@ -547,6 +555,7 @@
},
"empty": "Você ainda não salvou nenhuma pesquisa.",
"label": "Pesquisas salvas",
"remove": "Remover",
"tooltip": "Você não pode salvar critérios de pesquisa vazios."
},
"settings": {
@@ -1152,8 +1161,10 @@
"templates deleted": "<code>{count}</code> Template(s) deletado(s)",
"templates deprecated": "Templates estão obsoletos. Por favor, use subflows. Veja a <a href=\"https://kestra.io/docs/migration-guide/0.11.0/templates\" target=\"_blank\">Seção de Migrações</a> explicando como você pode migrar de templates para subflows.",
"templates exported": "Templates exportados",
"tenant": "Tenant",
"tenants": "Tenants",
"tenant": {
"name": "Mandante",
"names": "Mandantes"
},
"theme": "Tema",
"this_task_has": "Esta task tem",
"timezone": "Fuso horário",

View File

@@ -303,6 +303,14 @@
}
},
"secrets": {
"add_env": {
"first": "Закодируйте <strong><a href=\"https://kestra.io/docs/how-to-guides/secrets#using-secrets-in-kestra\" target=\"_blank\">секретное значение в Base64</a></strong>",
"intro": "Чтобы создать новый Secret:",
"second": "Добавьте переменную окружения с именем '<strong>SECRET_{'{MY_SECRET_KEY}'}</strong>' с указанным выше значением",
"third": "Перезапустите ваш экземпляр Kestra"
},
"detected_env": "Вот переменные окружения типа secret, определенные при запуске экземпляра:",
"empty_env": "У вас еще нет Secrets в вашей среде.",
"message": "Enterprise Edition (EE) позволяет добавлять, редактировать или удалять секреты непосредственно в UI — без необходимости перезапуска экземпляра. Организуйте секреты по namespace с детализированными RBAC-разрешениями и наследуйте их от родительских к дочерним namespace. EE интегрируется с менеджерами секретов, такими как HashiCorp Vault, AWS Secrets Manager, Azure Key Vault, Google Secret Manager и Elasticsearch. Настройте выделенные бэкенды для каждого namespace, арендатора или экземпляра, чтобы изолировать секреты между командами, или включите только для чтения секреты, хранящиеся во внешних хранилищах. Секреты остаются зашифрованными в состоянии покоя и при передаче. Опциональное кэширование уменьшает количество API-запросов, а аудиторские следы фиксируют весь доступ.",
"title": "Обновите управление Secrets"
},
@@ -547,6 +555,7 @@
},
"empty": "Вы еще не сохранили ни одного поиска.",
"label": "Сохранённые поиски",
"remove": "Удалить",
"tooltip": "Вы не можете сохранить пустые критерии поиска."
},
"settings": {
@@ -1152,8 +1161,10 @@
"templates deleted": "<code>{count}</code> Шаблон(ы) удалены",
"templates deprecated": "Шаблоны устарели. Пожалуйста, используйте subflows вместо них. См. <a href=\"https://kestra.io/docs/migration-guide/0.11.0/templates\" target=\"_blank\">раздел миграции</a>, объясняющий, как можно мигрировать с шаблонов на subflows.",
"templates exported": "Шаблоны экспортированы",
"tenant": "Tenant",
"tenants": "Tenants",
"tenant": {
"name": "Мандант",
"names": "Арендаторы"
},
"theme": "Тема",
"this_task_has": "Эта задача имеет",
"timezone": "Часовой пояс",

View File

@@ -303,6 +303,14 @@
}
},
"secrets": {
"add_env": {
"first": "将<strong><a href=\"https://kestra.io/docs/how-to-guides/secrets#using-secrets-in-kestra\" target=\"_blank\">secret value 编码为 Base64</a></strong>",
"intro": "创建新Secret",
"second": "添加一个名为 '<strong>SECRET_{'{MY_SECRET_KEY}'}</strong>' 的环境变量,并使用上述值",
"third": "重新启动您的Kestra实例"
},
"detected_env": "以下是在实例启动时识别的secret-type环境变量",
"empty_env": "您还没有在您的环境中添加任何Secrets。",
"message": "企业版 (EE) 允许您直接在UI中添加、编辑或删除秘密无需重启实例。通过细粒度的RBAC权限按namespace组织秘密并从父namespace继承到子namespace。EE与HashiCorp Vault、AWS Secrets Manager、Azure Key Vault、Google Secret Manager和Elasticsearch等秘密管理器集成。为每个namespace、tenant或实例设置专用后端以隔离团队之间的秘密或启用存储在外部vault中的只读秘密。秘密在静态和传输中保持加密。可选的缓存减少API调用并且审计跟踪记录所有访问。",
"title": "升级您的Secrets管理"
},
@@ -547,6 +555,7 @@
},
"empty": "您还没有保存任何搜索。",
"label": "已保存的搜索",
"remove": "移除",
"tooltip": "您不能保存空的搜索条件。"
},
"settings": {
@@ -1152,8 +1161,10 @@
"templates deleted": "<code>{count}</code> 个模板已删除",
"templates deprecated": "模板已弃用。请使用子流程代替。查看 <a href=\"https://kestra.io/docs/migration-guide/0.11.0/templates\" target=\"_blank\">迁移部分</a> 了解如何从模板迁移到子流程。",
"templates exported": "模板已导出",
"tenant": "租户",
"tenants": "租户",
"tenant": {
"name": "租户",
"names": "租户"
},
"theme": "主题",
"this_task_has": "此task已",
"timezone": "时区",

View File

@@ -309,6 +309,8 @@ public class ExecutionController {
private String runContextRender(Flow flow, Task task, Execution execution, TaskRun taskRun, String expression) throws IllegalVariableEvaluationException {
RunContext runContext = runContextFactory.of(flow, task, execution, taskRun, false);
String baseRender = runContext.render(expression);
if (expression.contains("{")) { // fast backoff from regex
Matcher matcher = SECRET_FUNCTION.matcher(expression);
String maskedExpression = expression;
@@ -318,7 +320,8 @@ public class ExecutionController {
}
return runContext.render(maskedExpression);
}
return runContext.render(expression);
return baseRender;
}
@SuperBuilder

View File

@@ -4,16 +4,19 @@ import io.kestra.core.models.namespaces.Namespace;
import io.kestra.core.models.topologies.FlowTopologyGraph;
import io.kestra.core.repositories.ArrayListTotal;
import io.kestra.core.repositories.FlowRepositoryInterface;
import io.kestra.core.utils.NamespaceUtils;
import io.kestra.core.tenant.TenantService;
import io.kestra.core.topologies.FlowTopologyService;
import io.kestra.core.utils.NamespaceUtils;
import io.kestra.webserver.models.namespaces.NamespaceWithDisabled;
import io.kestra.webserver.responses.PagedResults;
import io.kestra.webserver.utils.PageableUtils;
import io.micronaut.core.annotation.Nullable;
import io.micronaut.data.model.Pageable;
import io.micronaut.data.model.Sort;
import io.micronaut.http.annotation.*;
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Get;
import io.micronaut.http.annotation.PathVariable;
import io.micronaut.http.annotation.QueryValue;
import io.micronaut.http.exceptions.HttpStatusException;
import io.micronaut.scheduling.TaskExecutors;
import io.micronaut.scheduling.annotation.ExecuteOn;
@@ -23,7 +26,9 @@ import io.swagger.v3.oas.annotations.Parameter;
import jakarta.inject.Inject;
import jakarta.validation.constraints.Min;
import java.util.*;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.stream.Collectors;
@Validated
@@ -80,9 +85,13 @@ public class NamespaceController implements NamespaceControllerInterface<Namespa
.collect(Collectors.toList());
}
Pageable pageable = PageableUtils.from(page, size, sort);
var total = distinctNamespaces.size();
Pageable pageable = PageableUtils.from(page, size, sort);
if (total <= (pageable.getOffset() - pageable.getSize())) {
return PagedResults.of(new ArrayListTotal<>(0));
}
if (sort != null) {
Sort.Order.Direction direction = pageable.getSort().getOrderBy().getFirst().getDirection();

View File

@@ -1488,11 +1488,23 @@ class ExecutionControllerRunnerTest {
void shouldMaskSecretWhenEvalPebbleExpression(Execution execution) {
ExecutionController.EvalResult evalResult = client.toBlocking().retrieve(
HttpRequest
.POST("/api/v1/executions/" + execution.getId() + "/eval/" + execution.getTaskRunList().getFirst().getId(), "{{ secret('KEY') }}")
.POST("/api/v1/executions/" + execution.getId() + "/eval/" + execution.getTaskRunList().getFirst().getId(), "{{ secret('MY_SECRET') }}")
.contentType(MediaType.TEXT_PLAIN),
ExecutionController.EvalResult.class
);
assertThat(evalResult.getError(), nullValue());
assertThat(evalResult.getStackTrace(), nullValue());
assertThat(evalResult.getResult(), is("******"));
evalResult = client.toBlocking().retrieve(
HttpRequest
.POST("/api/v1/executions/" + execution.getId() + "/eval/" + execution.getTaskRunList().getFirst().getId(), "{{ secret('NON_EXISTING_KEY') }}")
.contentType(MediaType.TEXT_PLAIN),
ExecutionController.EvalResult.class
);
assertThat(evalResult.getError(), is("io.pebbletemplates.pebble.error.PebbleException: Cannot find secret for key 'NON_EXISTING_KEY'. ({{ secret('NON_EXISTING_KEY') }}:1)"));
assertThat(evalResult.getStackTrace(), startsWith("io.kestra.core.exceptions.IllegalVariableEvaluationException: io.pebbletemplates.pebble.error.PebbleException: Cannot find secret for key 'NON_EXISTING_KEY'. ({{ secret('NON_EXISTING_KEY') }}:1)"));
assertThat(evalResult.getResult(), is(nullValue()));
}
private ExecutionController.EvalResult eval(Execution execution, String expression, int index) {

View File

@@ -103,6 +103,13 @@ public class NamespaceControllerTest {
);
assertThat(list.getTotal(), is(3L));
assertThat(list.getResults().size(), is(3));
list = client.toBlocking().retrieve(
HttpRequest.GET("/api/v1/namespaces/search?page=4&size=2&sort=id:desc"),
Argument.of(PagedResults.class, NamespaceWithDisabled.class)
);
assertThat(list.getTotal(), is(0L));
assertThat(list.getResults(), empty());
}
@Test