mirror of
https://github.com/kestra-io/kestra.git
synced 2025-12-26 05:00:31 -05:00
Compare commits
27 Commits
feat/previ
...
feat/execu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09a16d16c2 | ||
|
|
f500276894 | ||
|
|
868e88679f | ||
|
|
cf87145bb9 | ||
|
|
0e2ddda6c7 | ||
|
|
3b17b741f1 | ||
|
|
21c43e79e2 | ||
|
|
810e80d989 | ||
|
|
2aafe15124 | ||
|
|
cf866c059a | ||
|
|
370fe210e5 | ||
|
|
83e98be413 | ||
|
|
7d4d1631d2 | ||
|
|
98534f16e2 | ||
|
|
b308697449 | ||
|
|
62e0550efd | ||
|
|
1711e7fa05 | ||
|
|
04a3978fd2 | ||
|
|
2d348786c3 | ||
|
|
041a31e022 | ||
|
|
11a6189bb8 | ||
|
|
5c864eecc8 | ||
|
|
af6d15dd13 | ||
|
|
0b555b3773 | ||
|
|
6ed4c5af7e | ||
|
|
3752481756 | ||
|
|
94dc62aee1 |
@@ -24,8 +24,10 @@ In the meantime, you can move onto the next step...
|
||||
---
|
||||
|
||||
### Development:
|
||||
|
||||
- Create a `.env.development.local` file in the `ui` folder and paste the following:
|
||||
- (Optional) By default, your dev server will target `localhost:8080`. If your backend is running elsewhere, you can create `.env.development.local` under `ui` folder with this content:
|
||||
```
|
||||
VITE_APP_API_URL={myApiUrl}
|
||||
```
|
||||
|
||||
- Navigate into the `ui` folder and run `npm install` to install the dependencies for the frontend project.
|
||||
|
||||
|
||||
@@ -6,8 +6,6 @@ import io.kestra.core.plugins.PluginCatalogService;
|
||||
import io.kestra.core.plugins.PluginRegistry;
|
||||
import io.kestra.core.storages.StorageInterface;
|
||||
import io.kestra.core.storages.StorageInterfaceFactory;
|
||||
import io.kestra.plugin.core.preview.PreviewRendererFactory;
|
||||
import io.kestra.plugin.core.preview.PreviewRendererRegistry;
|
||||
import io.micronaut.context.annotation.Bean;
|
||||
import io.micronaut.context.annotation.ConfigurationProperties;
|
||||
import io.micronaut.context.annotation.Factory;
|
||||
@@ -89,9 +87,4 @@ public class KestraBeansFactory {
|
||||
return (Map<String, Object>) storage.get(StringConvention.CAMEL_CASE.format(type));
|
||||
}
|
||||
}
|
||||
|
||||
@Singleton
|
||||
public PreviewRendererFactory previewRendererFactory(final PluginRegistry pluginRegistry) {
|
||||
return new PreviewRendererFactory(pluginRegistry);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@ import io.kestra.core.models.ServerType;
|
||||
import io.kestra.core.plugins.PluginRegistry;
|
||||
import io.kestra.core.storages.StorageInterface;
|
||||
import io.kestra.core.utils.VersionProvider;
|
||||
import io.kestra.plugin.core.preview.PreviewRenderer;
|
||||
import io.kestra.plugin.core.preview.PreviewRendererRegistry;
|
||||
import io.micronaut.context.ApplicationContext;
|
||||
import io.micronaut.context.annotation.Context;
|
||||
import io.micronaut.context.annotation.Requires;
|
||||
@@ -84,8 +82,6 @@ public abstract class KestraContext {
|
||||
*/
|
||||
public abstract PluginRegistry getPluginRegistry();
|
||||
|
||||
public abstract PreviewRenderer getPreviewRenderer();
|
||||
|
||||
public abstract StorageInterface getStorageInterface();
|
||||
|
||||
/**
|
||||
@@ -111,8 +107,8 @@ public abstract class KestraContext {
|
||||
/**
|
||||
* Creates a new {@link KestraContext} instance.
|
||||
*
|
||||
* @param applicationContext The {@link ApplicationContext}.
|
||||
* @param environment The {@link Environment}.
|
||||
* @param applicationContext The {@link ApplicationContext}.
|
||||
* @param environment The {@link Environment}.
|
||||
*/
|
||||
public Initializer(ApplicationContext applicationContext,
|
||||
Environment environment) {
|
||||
@@ -122,9 +118,7 @@ public abstract class KestraContext {
|
||||
KestraContext.setContext(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
**/
|
||||
/** {@inheritDoc} **/
|
||||
@Override
|
||||
public ServerType getServerType() {
|
||||
return Optional.ofNullable(environment)
|
||||
@@ -132,27 +126,20 @@ public abstract class KestraContext {
|
||||
.orElse(ServerType.STANDALONE);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
**/
|
||||
/** {@inheritDoc} **/
|
||||
@Override
|
||||
public Optional<Integer> getWorkerMaxNumThreads() {
|
||||
return Optional.ofNullable(environment)
|
||||
.flatMap(env -> env.getProperty(KESTRA_WORKER_MAX_NUM_THREADS, Integer.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
**/
|
||||
/** {@inheritDoc} **/
|
||||
@Override
|
||||
public Optional<String> getWorkerGroupKey() {
|
||||
return Optional.ofNullable(environment)
|
||||
.flatMap(env -> env.getProperty(KESTRA_WORKER_GROUP_KEY, String.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
**/
|
||||
/** {@inheritDoc} **/
|
||||
@Override
|
||||
public void injectWorkerConfigs(Integer maxNumThreads, String workerGroupKey) {
|
||||
final Map<String, Object> configs = new HashMap<>();
|
||||
@@ -167,9 +154,7 @@ public abstract class KestraContext {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
**/
|
||||
/** {@inheritDoc} **/
|
||||
@Override
|
||||
public void shutdown() {
|
||||
if (isShutdown.compareAndSet(false, true)) {
|
||||
@@ -179,17 +164,13 @@ public abstract class KestraContext {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
**/
|
||||
/** {@inheritDoc} **/
|
||||
@Override
|
||||
public String getVersion() {
|
||||
return version;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
**/
|
||||
/** {@inheritDoc} **/
|
||||
@Override
|
||||
public PluginRegistry getPluginRegistry() {
|
||||
// Lazy init of the PluginRegistry.
|
||||
@@ -201,11 +182,5 @@ public abstract class KestraContext {
|
||||
// Lazy init of the PluginRegistry.
|
||||
return this.applicationContext.getBean(StorageInterface.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PreviewRenderer getPreviewRenderer() {
|
||||
// Lazy init of the PreviewRenderer.
|
||||
return this.applicationContext.getBean(PreviewRenderer.class);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -441,6 +441,28 @@ public class Execution implements DeletedInterface, TenantInterface {
|
||||
@Nullable List<ResolvedTask> resolvedErrors,
|
||||
@Nullable List<ResolvedTask> resolvedFinally,
|
||||
TaskRun parentTaskRun
|
||||
) {
|
||||
return findTaskDependingFlowState(resolvedTasks, resolvedErrors, resolvedFinally, parentTaskRun, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the current execution is on error & normal tasks
|
||||
* <p>
|
||||
* if the current have errors, return tasks from errors if not, return the normal tasks
|
||||
*
|
||||
* @param resolvedTasks normal tasks
|
||||
* @param resolvedErrors errors tasks
|
||||
* @param resolvedFinally finally tasks
|
||||
* @param parentTaskRun the parent task
|
||||
* @param terminalState the parent task terminal state
|
||||
* @return the flow we need to follow
|
||||
*/
|
||||
public List<ResolvedTask> findTaskDependingFlowState(
|
||||
List<ResolvedTask> resolvedTasks,
|
||||
@Nullable List<ResolvedTask> resolvedErrors,
|
||||
@Nullable List<ResolvedTask> resolvedFinally,
|
||||
TaskRun parentTaskRun,
|
||||
@Nullable State.Type terminalState
|
||||
) {
|
||||
resolvedTasks = removeDisabled(resolvedTasks);
|
||||
resolvedErrors = removeDisabled(resolvedErrors);
|
||||
@@ -454,10 +476,15 @@ public class Execution implements DeletedInterface, TenantInterface {
|
||||
return resolvedFinally == null ? Collections.emptyList() : resolvedFinally;
|
||||
}
|
||||
|
||||
// Check if flow has failed task
|
||||
// check if the parent task should fail, and there is error tasks so we start them
|
||||
if (errorsFlow.isEmpty() && terminalState == State.Type.FAILED) {
|
||||
return resolvedErrors == null ? resolvedFinally == null ? Collections.emptyList() : resolvedFinally : resolvedErrors;
|
||||
}
|
||||
|
||||
// Check if flow has failed tasks
|
||||
if (!errorsFlow.isEmpty() || this.hasFailed(resolvedTasks, parentTaskRun)) {
|
||||
// Check if among the failed task, they will be retried
|
||||
if (!this.hasFailedNoRetry(resolvedTasks, parentTaskRun)) {
|
||||
if (!this.hasFailedNoRetry(resolvedTasks, parentTaskRun) && terminalState != State.Type.FAILED) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
@@ -666,6 +693,11 @@ public class Execution implements DeletedInterface, TenantInterface {
|
||||
|
||||
public State.Type guessFinalState(List<ResolvedTask> currentTasks, TaskRun parentTaskRun,
|
||||
boolean allowFailure, boolean allowWarning) {
|
||||
return guessFinalState(currentTasks, parentTaskRun, allowFailure, allowWarning, State.Type.SUCCESS);
|
||||
}
|
||||
|
||||
public State.Type guessFinalState(List<ResolvedTask> currentTasks, TaskRun parentTaskRun,
|
||||
boolean allowFailure, boolean allowWarning, State.Type terminalState) {
|
||||
List<TaskRun> taskRuns = this.findTaskRunByTasks(currentTasks, parentTaskRun);
|
||||
var state = this
|
||||
.findLastByState(taskRuns, State.Type.KILLED)
|
||||
@@ -682,7 +714,7 @@ public class Execution implements DeletedInterface, TenantInterface {
|
||||
.findLastByState(taskRuns, State.Type.PAUSED)
|
||||
.map(taskRun -> taskRun.getState().getCurrent())
|
||||
)
|
||||
.orElse(State.Type.SUCCESS);
|
||||
.orElse(terminalState);
|
||||
|
||||
if (state == State.Type.FAILED && allowFailure) {
|
||||
if (allowWarning) {
|
||||
|
||||
@@ -13,7 +13,6 @@ import io.kestra.core.models.tasks.runners.TaskRunner;
|
||||
import io.kestra.core.models.triggers.AbstractTrigger;
|
||||
import io.kestra.core.secret.SecretPluginInterface;
|
||||
import io.kestra.core.storages.StorageInterface;
|
||||
import io.kestra.plugin.core.preview.PreviewRenderer;
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.io.IOUtils;
|
||||
@@ -118,7 +117,6 @@ public class PluginScanner {
|
||||
List<Class<? extends AdditionalPlugin>> additionalPlugins = new ArrayList<>();
|
||||
List<String> guides = new ArrayList<>();
|
||||
Map<String, Class<?>> aliases = new HashMap<>();
|
||||
List<Class<? extends PreviewRenderer>> previewRenderers = new ArrayList<>();
|
||||
|
||||
if (manifest == null) {
|
||||
manifest = getManifest(classLoader);
|
||||
@@ -188,11 +186,6 @@ public class PluginScanner {
|
||||
log.debug("Loading additional plugin: '{}'", plugin.getClass());
|
||||
additionalPlugins.add(additionalPlugin.getClass());
|
||||
}
|
||||
case PreviewRenderer previewRenderer -> {
|
||||
log.info("Found PreviewRenderer: {}", plugin.getClass().getName());
|
||||
log.debug("Loading PreviewRenderer plugin: '{}'", plugin.getClass());
|
||||
previewRenderers.add(previewRenderer.getClass());
|
||||
}
|
||||
default -> {
|
||||
}
|
||||
}
|
||||
@@ -243,7 +236,6 @@ public class PluginScanner {
|
||||
e -> e.getKey().toLowerCase(),
|
||||
Function.identity()
|
||||
)))
|
||||
.previewRenderers(previewRenderers)
|
||||
.build();
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ import io.kestra.core.models.tasks.runners.TaskRunner;
|
||||
import io.kestra.core.models.triggers.AbstractTrigger;
|
||||
import io.kestra.core.secret.SecretPluginInterface;
|
||||
import io.kestra.core.storages.StorageInterface;
|
||||
import io.kestra.plugin.core.preview.PreviewRenderer;
|
||||
import lombok.*;
|
||||
import org.apache.commons.io.FilenameUtils;
|
||||
import org.apache.commons.io.IOUtils;
|
||||
@@ -47,8 +46,6 @@ public class RegisteredPlugin {
|
||||
public static final String DATA_FILTERS_KPI_GROUP_NAME = "data-filters-kpi";
|
||||
public static final String LOG_EXPORTERS_GROUP_NAME = "log-exporters";
|
||||
public static final String ADDITIONAL_PLUGINS_GROUP_NAME = "additional-plugins";
|
||||
public static final String PREVIEW_RENDERERS_GROUP_NAME = "preview-renderers";
|
||||
|
||||
|
||||
private final ExternalPlugin externalPlugin;
|
||||
private final Manifest manifest;
|
||||
@@ -66,7 +63,6 @@ public class RegisteredPlugin {
|
||||
private final List<Class<? extends DataFilterKPI<?, ?>>> dataFiltersKPI;
|
||||
private final List<Class<? extends LogExporter<?>>> logExporters;
|
||||
private final List<Class<? extends AdditionalPlugin>> additionalPlugins;
|
||||
private final List<Class<? extends PreviewRenderer>> previewRenderers;
|
||||
private final List<String> guides;
|
||||
// Map<lowercasealias, <Alias, Class>>
|
||||
private final Map<String, Map.Entry<String, Class<?>>> aliases;
|
||||
@@ -121,10 +117,6 @@ public class RegisteredPlugin {
|
||||
return StorageInterface.class;
|
||||
}
|
||||
|
||||
if (this.getPreviewRenderers().stream().anyMatch(r -> r.getName().equals(cls))) {
|
||||
return PreviewRenderer.class;
|
||||
}
|
||||
|
||||
if (this.getSecrets().stream().anyMatch(r -> r.getName().equals(cls))) {
|
||||
return SecretPluginInterface.class;
|
||||
}
|
||||
@@ -195,7 +187,6 @@ public class RegisteredPlugin {
|
||||
result.put(DATA_FILTERS_KPI_GROUP_NAME, Arrays.asList(this.getDataFiltersKPI().toArray(Class[]::new)));
|
||||
result.put(LOG_EXPORTERS_GROUP_NAME, Arrays.asList(this.getLogExporters().toArray(Class[]::new)));
|
||||
result.put(ADDITIONAL_PLUGINS_GROUP_NAME, Arrays.asList(this.getAdditionalPlugins().toArray(Class[]::new)));
|
||||
result.put(PREVIEW_RENDERERS_GROUP_NAME, Arrays.asList(this.getPreviewRenderers().toArray(Class[]::new)));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import io.kestra.core.runners.*;
|
||||
|
||||
public interface QueueFactoryInterface {
|
||||
String EXECUTION_NAMED = "executionQueue";
|
||||
String EXECUTION_STATE_CHANGE_NAMED = "executionStateChangeQueue";
|
||||
String EXECUTOR_NAMED = "executorQueue";
|
||||
String WORKERJOB_NAMED = "workerJobQueue";
|
||||
String WORKERTASKRESULT_NAMED = "workerTaskResultQueue";
|
||||
@@ -30,6 +31,8 @@ public interface QueueFactoryInterface {
|
||||
|
||||
QueueInterface<Execution> execution();
|
||||
|
||||
QueueInterface<ExecutionStateChange> executionStateChange();
|
||||
|
||||
QueueInterface<Executor> executor();
|
||||
|
||||
WorkerJobQueueInterface workerJob();
|
||||
|
||||
@@ -5,6 +5,7 @@ import io.kestra.core.models.Pauseable;
|
||||
import io.kestra.core.utils.Either;
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
public interface QueueInterface<T> extends Closeable, Pauseable {
|
||||
@@ -18,7 +19,15 @@ public interface QueueInterface<T> extends Closeable, Pauseable {
|
||||
emitAsync(null, message);
|
||||
}
|
||||
|
||||
void emitAsync(String consumerGroup, T message) throws QueueException;
|
||||
default void emitAsync(String consumerGroup, T message) throws QueueException {
|
||||
emitAsync(consumerGroup, List.of(message));
|
||||
}
|
||||
|
||||
default void emitAsync(List<T> messages) throws QueueException {
|
||||
emitAsync(null, messages);
|
||||
}
|
||||
|
||||
void emitAsync(String consumerGroup, List<T> messages) throws QueueException;
|
||||
|
||||
default void delete(T message) throws QueueException {
|
||||
delete(null, message);
|
||||
|
||||
@@ -16,8 +16,6 @@ import io.kestra.core.storages.StorageInterface;
|
||||
import io.kestra.core.storages.kv.KVStore;
|
||||
import io.kestra.core.utils.ListUtils;
|
||||
import io.kestra.core.utils.VersionProvider;
|
||||
import io.kestra.plugin.core.preview.PreviewRenderer;
|
||||
import io.kestra.plugin.core.preview.PreviewRendererRegistry;
|
||||
import io.micronaut.context.ApplicationContext;
|
||||
import io.micronaut.core.annotation.Introspected;
|
||||
import jakarta.validation.ConstraintViolation;
|
||||
@@ -604,8 +602,6 @@ public class DefaultRunContext extends RunContext {
|
||||
private List<String> secretInputs;
|
||||
private Task task;
|
||||
private AbstractTrigger trigger;
|
||||
private PreviewRenderer previewRenderer;
|
||||
private PreviewRendererRegistry previewRendererRegistry;
|
||||
|
||||
/**
|
||||
* Builds the new {@link DefaultRunContext} object.
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package io.kestra.core.runners;
|
||||
|
||||
import io.kestra.core.models.HasUID;
|
||||
import io.kestra.core.models.executions.Execution;
|
||||
import io.kestra.core.models.flows.State;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Value;
|
||||
|
||||
@Value
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class ExecutionStateChange implements HasUID {
|
||||
@NotNull
|
||||
Execution execution;
|
||||
|
||||
@NotNull
|
||||
State.Type oldState;
|
||||
|
||||
@NotNull
|
||||
State.Type newState;
|
||||
|
||||
@Override
|
||||
public String uid() {
|
||||
return execution.getId();
|
||||
}
|
||||
}
|
||||
@@ -486,7 +486,7 @@ public class FlowInputOutput {
|
||||
case URI -> {
|
||||
Matcher matcher = URI_PATTERN.matcher(current.toString());
|
||||
if (matcher.matches()) {
|
||||
yield current;
|
||||
yield current.toString();
|
||||
} else {
|
||||
throw new IllegalArgumentException("Expected `URI` but received `" + current + "`");
|
||||
}
|
||||
|
||||
@@ -49,6 +49,19 @@ public class FlowableUtils {
|
||||
return FlowableUtils.innerResolveSequentialNexts(execution, currentTasks, parentTaskRun);
|
||||
}
|
||||
|
||||
public static List<NextTaskRun> resolveSequentialNexts(
|
||||
Execution execution,
|
||||
List<ResolvedTask> tasks,
|
||||
List<ResolvedTask> errors,
|
||||
List<ResolvedTask> _finally,
|
||||
TaskRun parentTaskRun,
|
||||
State.Type terminalState
|
||||
) {
|
||||
List<ResolvedTask> currentTasks = execution.findTaskDependingFlowState(tasks, errors, _finally, parentTaskRun, terminalState);
|
||||
|
||||
return FlowableUtils.innerResolveSequentialNexts(execution, currentTasks, parentTaskRun);
|
||||
}
|
||||
|
||||
private static List<NextTaskRun> innerResolveSequentialNexts(
|
||||
Execution execution,
|
||||
List<ResolvedTask> currentTasks,
|
||||
@@ -149,7 +162,31 @@ public class FlowableUtils {
|
||||
boolean allowFailure,
|
||||
boolean allowWarning
|
||||
) {
|
||||
List<ResolvedTask> currentTasks = execution.findTaskDependingFlowState(tasks, errors, _finally, parentTaskRun);
|
||||
return resolveState(
|
||||
execution,
|
||||
tasks,
|
||||
errors,
|
||||
_finally,
|
||||
parentTaskRun,
|
||||
runContext,
|
||||
allowFailure,
|
||||
allowWarning,
|
||||
State.Type.SUCCESS
|
||||
);
|
||||
}
|
||||
|
||||
public static Optional<State.Type> resolveState(
|
||||
Execution execution,
|
||||
List<ResolvedTask> tasks,
|
||||
List<ResolvedTask> errors,
|
||||
List<ResolvedTask> _finally,
|
||||
TaskRun parentTaskRun,
|
||||
RunContext runContext,
|
||||
boolean allowFailure,
|
||||
boolean allowWarning,
|
||||
State.Type terminalState
|
||||
) {
|
||||
List<ResolvedTask> currentTasks = execution.findTaskDependingFlowState(tasks, errors, _finally, parentTaskRun, terminalState);
|
||||
|
||||
if (currentTasks == null) {
|
||||
runContext.logger().warn(
|
||||
@@ -161,17 +198,17 @@ public class FlowableUtils {
|
||||
|
||||
return Optional.of(allowFailure ? allowWarning ? State.Type.SUCCESS : State.Type.WARNING : State.Type.FAILED);
|
||||
} else if (currentTasks.stream().allMatch(t -> t.getTask().getDisabled()) && !currentTasks.isEmpty()) {
|
||||
// if all child tasks are disabled, we end in SUCCESS
|
||||
return Optional.of(State.Type.SUCCESS);
|
||||
// if all child tasks are disabled, we end in the terminal state
|
||||
return Optional.of(terminalState);
|
||||
} else if (!currentTasks.isEmpty()) {
|
||||
// handle nominal case, tasks or errors flow are ready to be analysed
|
||||
// handle nominal case, tasks or errors flow are ready to be analyzed
|
||||
if (execution.isTerminated(currentTasks, parentTaskRun)) {
|
||||
return Optional.of(execution.guessFinalState(tasks, parentTaskRun, allowFailure, allowWarning));
|
||||
return Optional.of(execution.guessFinalState(tasks, parentTaskRun, allowFailure, allowWarning, terminalState));
|
||||
}
|
||||
} else {
|
||||
// first call, the error flow is not ready, we need to notify the parent task that can be failed to init error flows
|
||||
if (execution.hasFailed(tasks, parentTaskRun)) {
|
||||
return Optional.of(execution.guessFinalState(tasks, parentTaskRun, allowFailure, allowWarning));
|
||||
if (execution.hasFailed(tasks, parentTaskRun) || terminalState == State.Type.FAILED) {
|
||||
return Optional.of(execution.guessFinalState(tasks, parentTaskRun, allowFailure, allowWarning, terminalState));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ import java.util.function.Supplier;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class RunContextLogger implements Supplier<org.slf4j.Logger> {
|
||||
private static final int MAX_MESSAGE_LENGTH = 1024 * 10;
|
||||
private static final int MAX_MESSAGE_LENGTH = 1024 * 15;
|
||||
public static final String ORIGINAL_TIMESTAMP_KEY = "originalTimestamp";
|
||||
|
||||
private final String loggerName;
|
||||
@@ -80,7 +80,6 @@ public class RunContextLogger implements Supplier<org.slf4j.Logger> {
|
||||
}
|
||||
|
||||
List<LogEntry> result = new ArrayList<>();
|
||||
long i = 0;
|
||||
for (String s : split) {
|
||||
result.add(LogEntry.builder()
|
||||
.namespace(logEntry.getNamespace())
|
||||
@@ -98,7 +97,6 @@ public class RunContextLogger implements Supplier<org.slf4j.Logger> {
|
||||
.thread(event.getThreadName())
|
||||
.build()
|
||||
);
|
||||
i++;
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -331,14 +329,11 @@ public class RunContextLogger implements Supplier<org.slf4j.Logger> {
|
||||
protected void append(ILoggingEvent e) {
|
||||
e = this.transform(e);
|
||||
|
||||
logEntries(e, logEntry)
|
||||
.forEach(l -> {
|
||||
try {
|
||||
logQueue.emitAsync(l);
|
||||
} catch (QueueException ex) {
|
||||
log.warn("Unable to emit logQueue", ex);
|
||||
}
|
||||
});
|
||||
try {
|
||||
logQueue.emitAsync(logEntries(e, logEntry));
|
||||
} catch (QueueException ex) {
|
||||
log.warn("Unable to emit logQueue", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -507,14 +507,11 @@ public class Worker implements Service, Runnable, AutoCloseable {
|
||||
Execution execution = workerTrigger.getTrigger().isFailOnTriggerError() ? TriggerService.generateExecution(workerTrigger.getTrigger(), workerTrigger.getConditionContext(), workerTrigger.getTriggerContext(), (Output) null)
|
||||
.withState(FAILED) : null;
|
||||
if (execution != null) {
|
||||
RunContextLogger.logEntries(Execution.loggingEventFromException(e), LogEntry.of(execution))
|
||||
.forEach(log -> {
|
||||
try {
|
||||
logQueue.emitAsync(log);
|
||||
} catch (QueueException ex) {
|
||||
// fail silently
|
||||
}
|
||||
});
|
||||
try {
|
||||
logQueue.emitAsync(RunContextLogger.logEntries(Execution.loggingEventFromException(e), LogEntry.of(execution)));
|
||||
} catch (QueueException ex) {
|
||||
// fail silently
|
||||
}
|
||||
}
|
||||
this.workerTriggerResultQueue.emit(
|
||||
WorkerTriggerResult.builder()
|
||||
|
||||
@@ -338,20 +338,29 @@ public class ExecutionService {
|
||||
boolean isFlowable = task.isFlowable();
|
||||
|
||||
if (!isFlowable || s.equals(taskRunId)) {
|
||||
TaskRun newTaskRun = originalTaskRun.withState(newState);
|
||||
TaskRun newTaskRun;
|
||||
|
||||
if (task instanceof Pause pauseTask) {
|
||||
Variables variables = variablesService.of(StorageContext.forTask(originalTaskRun), pauseTask.generateOutputs(onResumeInputs, resumed));
|
||||
newTaskRun = newTaskRun.withOutputs(variables);
|
||||
}
|
||||
State.Type terminalState = newState == State.Type.RUNNING ? State.Type.SUCCESS : newState;
|
||||
Pause.Resumed _resumed = resumed != null ? resumed : Pause.Resumed.now(terminalState);
|
||||
Variables variables = variablesService.of(StorageContext.forTask(originalTaskRun), pauseTask.generateOutputs(onResumeInputs, _resumed));
|
||||
newTaskRun = originalTaskRun.withOutputs(variables);
|
||||
|
||||
// if it's a Pause task with no subtask, we terminate the task
|
||||
if (task instanceof Pause pauseTask && ListUtils.isEmpty(pauseTask.getTasks())) {
|
||||
if (newState == State.Type.RUNNING) {
|
||||
newTaskRun = newTaskRun.withState(State.Type.SUCCESS);
|
||||
} else if (newState == State.Type.KILLING) {
|
||||
newTaskRun = newTaskRun.withState(State.Type.KILLED);
|
||||
// if it's a Pause task with no subtask, we terminate the task
|
||||
if (ListUtils.isEmpty(pauseTask.getTasks()) && ListUtils.isEmpty(pauseTask.getErrors()) && ListUtils.isEmpty(pauseTask.getFinally())) {
|
||||
if (newState == State.Type.RUNNING) {
|
||||
newTaskRun = newTaskRun.withState(State.Type.SUCCESS);
|
||||
} else if (newState == State.Type.KILLING) {
|
||||
newTaskRun = newTaskRun.withState(State.Type.KILLED);
|
||||
} else {
|
||||
newTaskRun = newTaskRun.withState(newState);
|
||||
}
|
||||
} else {
|
||||
// we should set the state to RUNNING so that subtasks are executed
|
||||
newTaskRun = newTaskRun.withState(State.Type.RUNNING);
|
||||
}
|
||||
} else {
|
||||
newTaskRun = originalTaskRun.withState(newState);
|
||||
}
|
||||
|
||||
if (originalTaskRun.getAttempts() != null && !originalTaskRun.getAttempts().isEmpty()) {
|
||||
|
||||
@@ -19,6 +19,7 @@ import io.kestra.core.repositories.FlowRepositoryInterface;
|
||||
import io.kestra.core.repositories.FlowTopologyRepositoryInterface;
|
||||
import io.kestra.core.serializers.JacksonMapper;
|
||||
import io.kestra.core.utils.ListUtils;
|
||||
import io.kestra.plugin.core.flow.Pause;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.inject.Singleton;
|
||||
import jakarta.validation.ConstraintViolationException;
|
||||
@@ -250,7 +251,7 @@ public class FlowService {
|
||||
// add warning for runnable properties (timeout, workerGroup, taskCache) when used not in a runnable
|
||||
flow.allTasksWithChilds().forEach(task -> {
|
||||
if (!(task instanceof RunnableTask<?>)) {
|
||||
if (task.getTimeout() != null) {
|
||||
if (task.getTimeout() != null && !(task instanceof Pause)) {
|
||||
warnings.add("The task '" + task.getId() + "' cannot use the 'timeout' property as it's only relevant for runnable tasks.");
|
||||
}
|
||||
if (task.getTaskCache() != null) {
|
||||
|
||||
@@ -173,18 +173,15 @@ public class PluginDefaultService {
|
||||
try {
|
||||
return this.injectAllDefaults(flow, false);
|
||||
} catch (Exception e) {
|
||||
RunContextLogger
|
||||
.logEntries(
|
||||
Execution.loggingEventFromException(e),
|
||||
LogEntry.of(execution)
|
||||
)
|
||||
.forEach(logEntry -> {
|
||||
try {
|
||||
logQueue.emitAsync(logEntry);
|
||||
} catch (QueueException e1) {
|
||||
// silently do nothing
|
||||
}
|
||||
});
|
||||
try {
|
||||
logQueue.emitAsync(RunContextLogger
|
||||
.logEntries(
|
||||
Execution.loggingEventFromException(e),
|
||||
LogEntry.of(execution)
|
||||
));
|
||||
} catch (QueueException e1) {
|
||||
// silently do nothing
|
||||
}
|
||||
return readWithoutDefaultsOrThrow(flow);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,7 +220,7 @@ public class Pause extends Task implements FlowableTask<Pause.Output> {
|
||||
|
||||
@Override
|
||||
public AbstractGraph tasksTree(Execution execution, TaskRun taskRun, List<String> parentValues) throws IllegalVariableEvaluationException {
|
||||
if (this.tasks == null || this.tasks.isEmpty()) {
|
||||
if (ListUtils.isEmpty(tasks) && ListUtils.isEmpty(errors) && ListUtils.isEmpty(_finally)) {
|
||||
return new GraphTask(this, taskRun, parentValues, RelationType.SEQUENTIAL);
|
||||
}
|
||||
|
||||
@@ -228,7 +228,7 @@ public class Pause extends Task implements FlowableTask<Pause.Output> {
|
||||
|
||||
GraphUtils.sequential(
|
||||
subGraph,
|
||||
this.getOnPause() != null ? ListUtils.concat(List.of(this.getOnPause()), this.tasks) : this.tasks,
|
||||
this.getOnPause() != null ? ListUtils.concat(List.of(this.getOnPause()), this.tasks) : ListUtils.emptyOnNull(this.tasks),
|
||||
this.errors,
|
||||
this._finally,
|
||||
taskRun,
|
||||
@@ -250,7 +250,7 @@ public class Pause extends Task implements FlowableTask<Pause.Output> {
|
||||
|
||||
@Override
|
||||
public List<ResolvedTask> childTasks(RunContext runContext, TaskRun parentTaskRun) throws IllegalVariableEvaluationException {
|
||||
List<Task> childTasks = new ArrayList<>(this.getTasks());
|
||||
List<Task> childTasks = new ArrayList<>(ListUtils.emptyOnNull(this.getTasks()));
|
||||
if (onPause != null) {
|
||||
childTasks.addFirst(onPause);
|
||||
}
|
||||
@@ -263,15 +263,24 @@ public class Pause extends Task implements FlowableTask<Pause.Output> {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
// get back the original state of the Pause task
|
||||
State.Type terminalState = findTerminalState(parentTaskRun);
|
||||
return FlowableUtils.resolveSequentialNexts(
|
||||
execution,
|
||||
this.childTasks(runContext, parentTaskRun),
|
||||
FlowableUtils.resolveTasks(this.errors, parentTaskRun),
|
||||
FlowableUtils.resolveTasks(this._finally, parentTaskRun),
|
||||
parentTaskRun
|
||||
parentTaskRun,
|
||||
terminalState
|
||||
);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static State.Type findTerminalState(TaskRun parentTaskRun) {
|
||||
Map<String, Object> resumed = (Map<String, Object>) parentTaskRun.getOutputs().get("resumed");
|
||||
return resumed.isEmpty() || !resumed.containsKey("to") ? State.Type.SUCCESS : State.Type.valueOf((String) resumed.get("to"));
|
||||
}
|
||||
|
||||
private boolean needPause(TaskRun parentTaskRun) {
|
||||
return parentTaskRun.getState().getCurrent() == State.Type.RUNNING &&
|
||||
parentTaskRun.getState().getHistories().stream().noneMatch(history -> history.getState() == State.Type.PAUSED);
|
||||
@@ -284,27 +293,19 @@ public class Pause extends Task implements FlowableTask<Pause.Output> {
|
||||
return Optional.of(State.Type.PAUSED);
|
||||
}
|
||||
|
||||
Behavior behavior = runContext.render(this.behavior).as(Behavior.class).orElse(Behavior.RESUME);
|
||||
return switch (behavior) {
|
||||
case Behavior.RESUME -> {
|
||||
// yield SUCCESS or the final flowable task state
|
||||
if (ListUtils.isEmpty(this.tasks)) {
|
||||
yield Optional.of(State.Type.SUCCESS);
|
||||
} else {
|
||||
yield FlowableTask.super.resolveState(runContext, execution, parentTaskRun);
|
||||
}
|
||||
}
|
||||
case Behavior.WARN -> {
|
||||
// yield WARNING or the final flowable task state, if the flowable ends in SUCCESS, yield WARNING
|
||||
if (ListUtils.isEmpty(this.tasks)) {
|
||||
yield Optional.of(State.Type.WARNING);
|
||||
} else {
|
||||
Optional<State.Type> finalState = FlowableTask.super.resolveState(runContext, execution, parentTaskRun);
|
||||
yield finalState.map(state -> state == State.Type.SUCCESS ? State.Type.WARNING : state);
|
||||
}
|
||||
}
|
||||
case Behavior.CANCEL ,Behavior.FAIL -> throw new IllegalArgumentException("The " + behavior + " cannot be handled at this stage, this is certainly a bug!");
|
||||
};
|
||||
// get back the original state of the Pause task
|
||||
State.Type terminalState = findTerminalState(parentTaskRun);
|
||||
return FlowableUtils.resolveState(
|
||||
execution,
|
||||
this.childTasks(runContext, parentTaskRun),
|
||||
FlowableUtils.resolveTasks(this.getErrors(), parentTaskRun),
|
||||
FlowableUtils.resolveTasks(this.getFinally(), parentTaskRun),
|
||||
parentTaskRun,
|
||||
runContext,
|
||||
isAllowFailure(),
|
||||
isAllowWarning(),
|
||||
terminalState
|
||||
);
|
||||
}
|
||||
|
||||
public Map<String, Object> generateOutputs(Map<String, Object> inputs, Resumed resumed) {
|
||||
@@ -325,13 +326,21 @@ public class Pause extends Task implements FlowableTask<Pause.Output> {
|
||||
private Resumed resumed;
|
||||
}
|
||||
|
||||
public record Resumed(@Nullable String by, LocalDateTime on) {
|
||||
public record Resumed(@Nullable String by, LocalDateTime on, State.Type to) {
|
||||
public static Resumed now() {
|
||||
return new Resumed(null, LocalDateTime.now());
|
||||
return new Resumed(null, LocalDateTime.now(), State.Type.SUCCESS);
|
||||
}
|
||||
|
||||
public static Resumed now(State.Type to) {
|
||||
return new Resumed(null, LocalDateTime.now(), to);
|
||||
}
|
||||
|
||||
public static Resumed now(String by) {
|
||||
return new Resumed(by, LocalDateTime.now());
|
||||
return new Resumed(by, LocalDateTime.now(), State.Type.SUCCESS);
|
||||
}
|
||||
|
||||
public static Resumed now(String by, State.Type to) {
|
||||
return new Resumed(by, LocalDateTime.now(), to);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
package io.kestra.plugin.core.preview;
|
||||
|
||||
import io.kestra.core.models.Plugin;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.Charset;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Interface for plugins to provide file preview rendering capabilities.
|
||||
* Plugins can implement this to support preview of specific file formats.
|
||||
*/
|
||||
public interface PreviewRenderer extends Plugin {
|
||||
|
||||
/**
|
||||
* File extensions this renderer supports (without dot, e.g., "parquet", "csv")
|
||||
*/
|
||||
List<String> supportedExtensions();
|
||||
|
||||
/**
|
||||
* Render preview for the given file
|
||||
*
|
||||
* @param extension file extension
|
||||
* @param fileStream input stream of the file
|
||||
* @param charset charset for text-based files (optional)
|
||||
* @param maxLines maximum number of lines/records to preview
|
||||
* @return PreviewResult object containing preview data
|
||||
* @throws IOException if file cannot be read or parsed
|
||||
*/
|
||||
PreviewResult render(String extension, InputStream fileStream, Optional<Charset> charset, Integer maxLines) throws IOException;
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
package io.kestra.plugin.core.preview;
|
||||
|
||||
import io.kestra.core.plugins.PluginRegistry;
|
||||
import io.kestra.core.plugins.RegisteredPlugin;
|
||||
import io.kestra.plugin.core.preview.PreviewRenderer;
|
||||
import jakarta.inject.Singleton;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
@Slf4j
|
||||
public class PreviewRendererFactory {
|
||||
|
||||
private final PluginRegistry pluginRegistry;
|
||||
private Map<String, Class<? extends PreviewRenderer>> rendererClasses;
|
||||
|
||||
public PreviewRendererFactory(PluginRegistry pluginRegistry) {
|
||||
this.pluginRegistry = pluginRegistry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get preview renderer for given file extension
|
||||
*/
|
||||
public Optional<PreviewRenderer> getRenderer(String extension) {
|
||||
log.info("Looking for preview renderer for extension: '{}'", extension);
|
||||
|
||||
if (rendererClasses == null) {
|
||||
log.info("Renderer classes not initialized, initializing now...");
|
||||
initializeRenderers();
|
||||
}
|
||||
|
||||
String normalizedExt = extension.toLowerCase();
|
||||
Class<? extends PreviewRenderer> rendererClass = rendererClasses.get(normalizedExt);
|
||||
|
||||
log.info("Available extensions: {}", rendererClasses.keySet());
|
||||
log.info("Looking for normalized extension: '{}', found class: {}", normalizedExt,
|
||||
rendererClass != null ? rendererClass.getName() : "null");
|
||||
|
||||
if (rendererClass == null) {
|
||||
log.warn("No preview renderer found for extension '{}'", extension);
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
try {
|
||||
PreviewRenderer renderer = rendererClass.getDeclaredConstructor().newInstance();
|
||||
log.info("Successfully created preview renderer instance: {}", rendererClass.getSimpleName());
|
||||
return Optional.of(renderer);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to instantiate preview renderer for extension '{}': {}", extension, e.getMessage(), e);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize renderers by discovering all PreviewRenderer plugins
|
||||
*/
|
||||
private void initializeRenderers() {
|
||||
rendererClasses = new HashMap<>();
|
||||
|
||||
log.info("Starting to initialize preview renderers...");
|
||||
|
||||
List<RegisteredPlugin> plugins = pluginRegistry.plugins().stream().toList();
|
||||
log.info("Found {} registered plugins", plugins.size());
|
||||
|
||||
plugins.forEach(plugin -> {
|
||||
List<Class<? extends PreviewRenderer>> renderers = plugin.getPreviewRenderers();
|
||||
log.info("Plugin '{}' has {} preview renderers", plugin.name(), renderers.size());
|
||||
renderers.forEach(rendererClass ->
|
||||
log.info(" - Preview renderer class: {}", rendererClass.getName())
|
||||
);
|
||||
});
|
||||
|
||||
pluginRegistry.plugins()
|
||||
.stream()
|
||||
.map(RegisteredPlugin::getPreviewRenderers)
|
||||
.flatMap(List::stream)
|
||||
.forEach(rendererClass -> {
|
||||
try {
|
||||
log.info("Trying to instantiate preview renderer: {}", rendererClass.getName());
|
||||
PreviewRenderer instance = rendererClass.getDeclaredConstructor().newInstance();
|
||||
List<String> extensions = instance.supportedExtensions();
|
||||
log.info("Preview renderer {} supports extensions: {}", rendererClass.getSimpleName(), extensions);
|
||||
|
||||
for (String extension : extensions) {
|
||||
String normalizedExt = extension.toLowerCase();
|
||||
rendererClasses.put(normalizedExt, rendererClass);
|
||||
log.info("Registered preview renderer for '{}': {}", normalizedExt, rendererClass.getSimpleName());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to register preview renderer {}: {}", rendererClass.getSimpleName(), e.getMessage(), e);
|
||||
}
|
||||
});
|
||||
|
||||
log.info("Initialization complete. Registered renderers for extensions: {}", rendererClasses.keySet());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all supported extensions
|
||||
*/
|
||||
public List<String> getSupportedExtensions() {
|
||||
if (rendererClasses == null) {
|
||||
initializeRenderers();
|
||||
}
|
||||
return List.copyOf(rendererClasses.keySet());
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
package io.kestra.plugin.core.preview;
|
||||
|
||||
import jakarta.inject.Singleton;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
@Singleton
|
||||
@Slf4j
|
||||
public class PreviewRendererRegistry {
|
||||
|
||||
private final Map<String, PreviewRenderer> renderers = new HashMap<>();
|
||||
|
||||
/**
|
||||
* Register a preview renderer for specific file extensions
|
||||
*/
|
||||
public void register(PreviewRenderer renderer) {
|
||||
for (String extension : renderer.supportedExtensions()) {
|
||||
String normalizedExt = extension.toLowerCase();
|
||||
if (renderers.containsKey(normalizedExt)) {
|
||||
log.warn("Preview renderer for extension '{}' is being overridden by {}",
|
||||
normalizedExt, renderer.getClass().getSimpleName());
|
||||
}
|
||||
renderers.put(normalizedExt, renderer);
|
||||
log.debug("Registered preview renderer for '{}': {}", normalizedExt, renderer.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get preview renderer for given file extension
|
||||
*/
|
||||
public Optional<PreviewRenderer> getRenderer(String extension) {
|
||||
return Optional.ofNullable(renderers.get(extension.toLowerCase()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if preview is available for given extension
|
||||
*/
|
||||
public boolean hasRenderer(String extension) {
|
||||
return renderers.containsKey(extension.toLowerCase());
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
package io.kestra.plugin.core.preview;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class PreviewResult {
|
||||
public String extension;
|
||||
public Type type;
|
||||
public Object content;
|
||||
public Integer maxLines;
|
||||
|
||||
@JsonInclude
|
||||
public boolean truncated = false;
|
||||
|
||||
public enum Type {
|
||||
TEXT, LIST, IMAGE, MARKDOWN, PDF
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,8 @@ public class OverrideRetryInterceptor implements MethodInterceptor<Object, Objec
|
||||
retry.get("delay", Duration.class).orElse(Duration.ofSeconds(1)),
|
||||
retry.get("maxDelay", Duration.class).orElse(null),
|
||||
new DefaultRetryPredicate(resolveIncludes(retry, "includes"), resolveIncludes(retry, "excludes")),
|
||||
Throwable.class
|
||||
Throwable.class,
|
||||
0
|
||||
);
|
||||
|
||||
MutableConvertibleValues<Object> attrs = context.getAttributes();
|
||||
|
||||
@@ -140,7 +140,7 @@ class RunContextTest {
|
||||
List<LogEntry> logs = new CopyOnWriteArrayList<>();
|
||||
Flux<LogEntry> receive = TestsUtils.receive(workerTaskLogQueue, either -> logs.add(either.getLeft()));
|
||||
|
||||
char[] chars = new char[1024 * 11];
|
||||
char[] chars = new char[1024 * 16];
|
||||
Arrays.fill(chars, 'a');
|
||||
|
||||
Map<String, Object> inputs = new HashMap<>(InputsTest.inputs);
|
||||
|
||||
@@ -13,6 +13,7 @@ import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TimeZone;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
@@ -66,6 +67,18 @@ class JacksonMapperTest {
|
||||
assertThat(integerList).containsExactlyInAnyOrder(1, 2, 3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void toMap() throws JsonProcessingException {
|
||||
assertThat(JacksonMapper.toMap("""
|
||||
{
|
||||
"some": "property",
|
||||
"another": "property"
|
||||
}""")).isEqualTo(Map.of(
|
||||
"some", "property",
|
||||
"another", "property"
|
||||
));
|
||||
}
|
||||
|
||||
void test(Pojo original, Pojo deserialize) {
|
||||
assertThat(deserialize.getString()).isEqualTo(original.getString());
|
||||
assertThat(deserialize.getInstant().toEpochMilli()).isEqualTo(original.getInstant().toEpochMilli());
|
||||
|
||||
@@ -139,6 +139,12 @@ public class PauseTest {
|
||||
suite.shouldExecuteOnPauseTask(execution);
|
||||
}
|
||||
|
||||
@Test
|
||||
@ExecuteFlow("flows/valids/pause-errors-finally-after-execution.yaml")
|
||||
void shouldExecuteErrorsFinallyAndAfterExecution(Execution execution) throws Exception {
|
||||
suite.shouldExecuteErrorsFinallyAndAfterExecution(execution);
|
||||
}
|
||||
|
||||
@Singleton
|
||||
public static class Suite {
|
||||
@Inject
|
||||
@@ -236,7 +242,7 @@ public class PauseTest {
|
||||
);
|
||||
|
||||
assertThat(execution.getTaskRunList().getFirst().getState().getHistories().stream().filter(history -> history.getState() == State.Type.PAUSED).count()).as("Task runs were: " + execution.getTaskRunList().toString()).isEqualTo(1L);
|
||||
assertThat(execution.getTaskRunList().getFirst().getState().getHistories().stream().filter(history -> history.getState() == State.Type.RUNNING).count()).isEqualTo(1L);
|
||||
assertThat(execution.getTaskRunList().getFirst().getState().getHistories().stream().filter(history -> history.getState() == State.Type.RUNNING).count()).isEqualTo(2L);
|
||||
assertThat(execution.getTaskRunList().getFirst().getState().getHistories().stream().filter(history -> history.getState() == State.Type.FAILED).count()).isEqualTo(1L);
|
||||
assertThat(execution.getTaskRunList()).hasSize(1);
|
||||
}
|
||||
@@ -255,9 +261,9 @@ public class PauseTest {
|
||||
);
|
||||
|
||||
assertThat(execution.getTaskRunList().getFirst().getState().getHistories().stream().filter(history -> history.getState() == State.Type.PAUSED).count()).as("Task runs were: " + execution.getTaskRunList().toString()).isEqualTo(1L);
|
||||
assertThat(execution.getTaskRunList().getFirst().getState().getHistories().stream().filter(history -> history.getState() == State.Type.RUNNING).count()).isEqualTo(1L);
|
||||
assertThat(execution.getTaskRunList().getFirst().getState().getHistories().stream().filter(history -> history.getState() == State.Type.RUNNING).count()).isEqualTo(2L);
|
||||
assertThat(execution.getTaskRunList().getFirst().getState().getHistories().stream().filter(history -> history.getState() == State.Type.WARNING).count()).isEqualTo(1L);
|
||||
assertThat(execution.getTaskRunList()).hasSize(2);
|
||||
assertThat(execution.getTaskRunList()).hasSize(3);
|
||||
}
|
||||
|
||||
public void runEmptyTasks(RunnerUtils runnerUtils) throws Exception {
|
||||
@@ -385,5 +391,18 @@ public class PauseTest {
|
||||
assertThat(execution.getTaskRunList().getLast().getTaskId()).isEqualTo("hello");
|
||||
assertThat(execution.getTaskRunList().getLast().getState().getCurrent()).isEqualTo(State.Type.SUCCESS);
|
||||
}
|
||||
|
||||
public void shouldExecuteErrorsFinallyAndAfterExecution(Execution execution) throws Exception {
|
||||
assertThat(execution.getState().getCurrent()).isEqualTo(State.Type.FAILED);
|
||||
assertThat(execution.getTaskRunList()).hasSize(4);
|
||||
assertThat(execution.findTaskRunsByTaskId("pause")).hasSize(1);
|
||||
assertThat(execution.findTaskRunsByTaskId("pause").getFirst().getState().getCurrent()).isEqualTo(State.Type.FAILED);
|
||||
assertThat(execution.findTaskRunsByTaskId("logError")).hasSize(1);
|
||||
assertThat(execution.findTaskRunsByTaskId("logError").getFirst().getState().getCurrent()).isEqualTo(State.Type.SUCCESS);
|
||||
assertThat(execution.findTaskRunsByTaskId("logFinally")).hasSize(1);
|
||||
assertThat(execution.findTaskRunsByTaskId("logFinally").getFirst().getState().getCurrent()).isEqualTo(State.Type.SUCCESS);
|
||||
assertThat(execution.findTaskRunsByTaskId("logAfter")).hasSize(1);
|
||||
assertThat(execution.findTaskRunsByTaskId("logAfter").getFirst().getState().getCurrent()).isEqualTo(State.Type.SUCCESS);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ namespace: io.kestra.tests
|
||||
inputs:
|
||||
- id: behavior
|
||||
type: STRING
|
||||
defaults: CONTINUE
|
||||
defaults: RESUME
|
||||
|
||||
tasks:
|
||||
- id: pause
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
id: pause-with-errors-finally-after-execution
|
||||
namespace: io.kestra.tests
|
||||
|
||||
tasks:
|
||||
- id: pause
|
||||
type: io.kestra.plugin.core.flow.Pause
|
||||
timeout: PT0.25S
|
||||
errors:
|
||||
- id: logError
|
||||
type: io.kestra.plugin.core.log.Log
|
||||
message: I'm in error
|
||||
finally:
|
||||
- id: logFinally
|
||||
type: io.kestra.plugin.core.log.Log
|
||||
message: I'm in finally
|
||||
- id: log
|
||||
type: io.kestra.plugin.core.log.Log
|
||||
message: I'm after the pause
|
||||
|
||||
afterExecution:
|
||||
- id: logAfter
|
||||
type: io.kestra.plugin.core.log.Log
|
||||
message: I'm after the execution
|
||||
@@ -33,6 +33,14 @@ public class H2QueueFactory implements QueueFactoryInterface {
|
||||
return new H2Queue<>(Execution.class, applicationContext);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Singleton
|
||||
@Named(QueueFactoryInterface.EXECUTION_STATE_CHANGE_NAMED)
|
||||
@Bean(preDestroy = "close")
|
||||
public QueueInterface<ExecutionStateChange> executionStateChange() {
|
||||
return new H2Queue<>(ExecutionStateChange.class, applicationContext);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Singleton
|
||||
@Named(QueueFactoryInterface.EXECUTOR_NAMED)
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
ALTER TABLE queues ALTER COLUMN "type" ENUM(
|
||||
'io.kestra.core.models.executions.Execution',
|
||||
'io.kestra.core.models.templates.Template',
|
||||
'io.kestra.core.models.executions.ExecutionKilled',
|
||||
'io.kestra.core.runners.WorkerJob',
|
||||
'io.kestra.core.runners.WorkerTaskResult',
|
||||
'io.kestra.core.runners.WorkerInstance',
|
||||
'io.kestra.core.runners.WorkerTaskRunning',
|
||||
'io.kestra.core.models.executions.LogEntry',
|
||||
'io.kestra.core.models.triggers.Trigger',
|
||||
'io.kestra.ee.models.audits.AuditLog',
|
||||
'io.kestra.core.models.executions.MetricEntry',
|
||||
'io.kestra.core.runners.WorkerTriggerResult',
|
||||
'io.kestra.core.runners.SubflowExecutionResult',
|
||||
'io.kestra.core.server.ClusterEvent',
|
||||
'io.kestra.core.runners.SubflowExecutionEnd',
|
||||
'io.kestra.core.models.flows.FlowInterface',
|
||||
'io.kestra.core.runners.ExecutionRunning',
|
||||
'io.kestra.core.runners.ExecutionStateChange'
|
||||
) NOT NULL;
|
||||
@@ -33,6 +33,14 @@ public class MysqlQueueFactory implements QueueFactoryInterface {
|
||||
return new MysqlQueue<>(Execution.class, applicationContext);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Singleton
|
||||
@Named(QueueFactoryInterface.EXECUTION_STATE_CHANGE_NAMED)
|
||||
@Bean(preDestroy = "close")
|
||||
public QueueInterface<ExecutionStateChange> executionStateChange() {
|
||||
return new MysqlQueue<>(ExecutionStateChange.class, applicationContext);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Singleton
|
||||
@Named(QueueFactoryInterface.EXECUTOR_NAMED)
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
ALTER TABLE queues MODIFY COLUMN `type` ENUM(
|
||||
'io.kestra.core.models.executions.Execution',
|
||||
'io.kestra.core.models.templates.Template',
|
||||
'io.kestra.core.models.executions.ExecutionKilled',
|
||||
'io.kestra.core.runners.WorkerJob',
|
||||
'io.kestra.core.runners.WorkerTaskResult',
|
||||
'io.kestra.core.runners.WorkerInstance',
|
||||
'io.kestra.core.runners.WorkerTaskRunning',
|
||||
'io.kestra.core.models.executions.LogEntry',
|
||||
'io.kestra.core.models.triggers.Trigger',
|
||||
'io.kestra.ee.models.audits.AuditLog',
|
||||
'io.kestra.core.models.executions.MetricEntry',
|
||||
'io.kestra.core.runners.WorkerTriggerResult',
|
||||
'io.kestra.core.runners.SubflowExecutionResult',
|
||||
'io.kestra.core.server.ClusterEvent',
|
||||
'io.kestra.core.runners.SubflowExecutionEnd',
|
||||
'io.kestra.core.models.flows.FlowInterface',
|
||||
'io.kestra.core.runners.ExecutionRunning',
|
||||
'io.kestra.core.runners.ExecutionStateChange'
|
||||
) NOT NULL;
|
||||
@@ -33,6 +33,14 @@ public class PostgresQueueFactory implements QueueFactoryInterface {
|
||||
return new PostgresQueue<>(Execution.class, applicationContext);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Singleton
|
||||
@Named(QueueFactoryInterface.EXECUTION_STATE_CHANGE_NAMED)
|
||||
@Bean(preDestroy = "close")
|
||||
public QueueInterface<ExecutionStateChange> executionStateChange() {
|
||||
return new PostgresQueue<>(ExecutionStateChange.class, applicationContext);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Singleton
|
||||
@Named(QueueFactoryInterface.EXECUTOR_NAMED)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TYPE queue_type ADD VALUE IF NOT EXISTS 'io.kestra.core.runners.ExecutionStateChange';
|
||||
@@ -82,6 +82,10 @@ public class JdbcExecutor implements ExecutorInterface, Service {
|
||||
@Named(QueueFactoryInterface.EXECUTION_NAMED)
|
||||
private QueueInterface<Execution> executionQueue;
|
||||
|
||||
@Inject
|
||||
@Named(QueueFactoryInterface.EXECUTION_STATE_CHANGE_NAMED)
|
||||
private QueueInterface<ExecutionStateChange> executionStateChangeQueue;
|
||||
|
||||
@Inject
|
||||
@Named(QueueFactoryInterface.WORKERJOB_NAMED)
|
||||
private QueueInterface<WorkerJob> workerJobQueue;
|
||||
@@ -308,6 +312,7 @@ public class JdbcExecutor implements ExecutorInterface, Service {
|
||||
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
|
||||
}
|
||||
));
|
||||
this.receiveCancellations.addFirst(this.executionStateChangeQueue.receive(Executor.class, this::executionStateChangeQueue));
|
||||
this.receiveCancellations.addFirst(this.killQueue.receive(Executor.class, this::killQueue));
|
||||
this.receiveCancellations.addFirst(this.subflowExecutionResultQueue.receive(Executor.class, this::subflowExecutionResultQueue));
|
||||
this.receiveCancellations.addFirst(this.subflowExecutionEndQueue.receive(Executor.class, this::subflowExecutionEndQueue));
|
||||
@@ -1078,11 +1083,12 @@ public class JdbcExecutor implements ExecutorInterface, Service {
|
||||
}
|
||||
boolean isTerminated = executor.getFlow() != null && executionService.isTerminated(executor.getFlow(), executor.getExecution());
|
||||
|
||||
// purge the executionQueue
|
||||
// purge the executionQueue and the executionStateChangeQueue
|
||||
// IMPORTANT: this must be done before emitting the last execution message so that all consumers are notified that the execution ends.
|
||||
// NOTE: we may also purge ExecutionKilled events, but as there may not be a lot of them, it may not be worth it.
|
||||
if (cleanExecutionQueue && isTerminated) {
|
||||
((JdbcQueue<Execution>) executionQueue).deleteByKey(executor.getExecution().getId());
|
||||
((JdbcQueue<ExecutionStateChange>) executionStateChangeQueue).deleteByKey(executor.getExecution().getId());
|
||||
}
|
||||
|
||||
// emit for other consumers than the executor if no failure
|
||||
@@ -1093,79 +1099,10 @@ public class JdbcExecutor implements ExecutorInterface, Service {
|
||||
}
|
||||
|
||||
Execution execution = executor.getExecution();
|
||||
// handle flow triggers on state change
|
||||
if (!execution.getState().getCurrent().equals(executor.getOriginalState())) {
|
||||
flowTriggerService.computeExecutionsFromFlowTriggers(execution, allFlows, Optional.of(multipleConditionStorage))
|
||||
.forEach(throwConsumer(executionFromFlowTrigger -> this.executionQueue.emit(executionFromFlowTrigger)));
|
||||
}
|
||||
|
||||
// handle actions on terminated state
|
||||
if (isTerminated) {
|
||||
// if there is a parent, we send a subflow execution result to it
|
||||
if (ExecutableUtils.isSubflow(execution)) {
|
||||
// locate the parent execution to find the parent task run
|
||||
String parentExecutionId = (String) execution.getTrigger().getVariables().get("executionId");
|
||||
String taskRunId = (String) execution.getTrigger().getVariables().get("taskRunId");
|
||||
String taskId = (String) execution.getTrigger().getVariables().get("taskId");
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> outputs = (Map<String, Object>) execution.getTrigger().getVariables().get("taskRunOutputs");
|
||||
Variables variables = variablesService.of(StorageContext.forExecution(executor.getExecution()), outputs);
|
||||
SubflowExecutionEnd subflowExecutionEnd = new SubflowExecutionEnd(executor.getExecution(), parentExecutionId, taskRunId, taskId, execution.getState().getCurrent(), variables);
|
||||
this.subflowExecutionEndQueue.emit(subflowExecutionEnd);
|
||||
}
|
||||
|
||||
// purge SLA monitors
|
||||
if (!ListUtils.isEmpty(executor.getFlow().getSla()) && executor.getFlow().getSla().stream().anyMatch(ExecutionMonitoringSLA.class::isInstance)) {
|
||||
slaMonitorStorage.purge(executor.getExecution().getId());
|
||||
}
|
||||
|
||||
// purge execution running
|
||||
if (executor.getFlow().getConcurrency() != null) {
|
||||
executionRunningStorage.remove(execution);
|
||||
}
|
||||
|
||||
// check if there exist a queued execution and submit it to the execution queue
|
||||
if (executor.getFlow().getConcurrency() != null && executor.getFlow().getConcurrency().getBehavior() == Concurrency.Behavior.QUEUE) {
|
||||
executionQueuedStorage.pop(executor.getFlow().getTenantId(),
|
||||
executor.getFlow().getNamespace(),
|
||||
executor.getFlow().getId(),
|
||||
throwConsumer(queued -> {
|
||||
var newExecution = queued.withState(State.Type.RUNNING);
|
||||
ExecutionRunning executionRunning = ExecutionRunning.builder()
|
||||
.tenantId(newExecution.getTenantId())
|
||||
.namespace(newExecution.getNamespace())
|
||||
.flowId(newExecution.getFlowId())
|
||||
.execution(newExecution)
|
||||
.concurrencyState(ExecutionRunning.ConcurrencyState.RUNNING)
|
||||
.build();
|
||||
executionRunningStorage.save(executionRunning);
|
||||
executionQueue.emit(newExecution);
|
||||
metricRegistry.counter(MetricRegistry.METRIC_EXECUTOR_EXECUTION_POPPED_COUNT, MetricRegistry.METRIC_EXECUTOR_EXECUTION_POPPED_COUNT_DESCRIPTION, metricRegistry.tags(newExecution)).increment();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// purge the trigger: reset scheduler trigger at end
|
||||
if (execution.getTrigger() != null) {
|
||||
FlowWithSource flow = executor.getFlow();
|
||||
triggerRepository
|
||||
.findByExecution(execution)
|
||||
.ifPresent(trigger -> {
|
||||
this.triggerState.update(executionService.resetExecution(flow, execution, trigger));
|
||||
});
|
||||
}
|
||||
|
||||
// Purge the workerTaskResultQueue and the workerJobQueue
|
||||
// IMPORTANT: this is safe as only the executor is listening to WorkerTaskResult,
|
||||
// and we are sure at this stage that all WorkerJob has been listened and processed by the Worker.
|
||||
// If any of these assumptions changed, this code would not be safe anymore.
|
||||
if (cleanWorkerJobQueue && !ListUtils.isEmpty(executor.getExecution().getTaskRunList())) {
|
||||
List<String> taskRunKeys = executor.getExecution().getTaskRunList().stream()
|
||||
.map(taskRun -> taskRun.getId())
|
||||
.toList();
|
||||
((JdbcQueue<WorkerTaskResult>) workerTaskResultQueue).deleteByKeys(taskRunKeys);
|
||||
((JdbcQueue<WorkerJob>) workerJobQueue).deleteByKeys(taskRunKeys);
|
||||
}
|
||||
// send a message to the executionStateChange queue for post-execution actions if the state change or the execution is terminated
|
||||
// we must always send terminated execution to the queue or afterExecution execution update would not be detected.
|
||||
if (!execution.getState().getCurrent().equals(executor.getOriginalState()) || isTerminated) {
|
||||
executionStateChangeQueue.emit(new ExecutionStateChange(execution, executor.getOriginalState(), execution.getState().getCurrent()));
|
||||
}
|
||||
} catch (QueueException e) {
|
||||
if (!ignoreFailure) {
|
||||
@@ -1223,7 +1160,7 @@ public class JdbcExecutor implements ExecutorInterface, Service {
|
||||
.increment();
|
||||
|
||||
try {
|
||||
// Handle paused tasks
|
||||
// Handle paused tasks and scheduledAt
|
||||
if (executionDelay.getDelayType().equals(ExecutionDelay.DelayType.RESUME_FLOW) && !pair.getLeft().getState().isTerminated()) {
|
||||
if (executionDelay.getTaskRunId() == null) {
|
||||
// if taskRunId is null, this means we restart a flow that was delayed at startup (scheduled on)
|
||||
@@ -1242,7 +1179,7 @@ public class JdbcExecutor implements ExecutorInterface, Service {
|
||||
executor = executor.withExecution(markAsExecution, "pausedRestart");
|
||||
}
|
||||
}
|
||||
// Handle failed tasks
|
||||
// Handle failed task retries
|
||||
else if (executionDelay.getDelayType().equals(ExecutionDelay.DelayType.RESTART_FAILED_TASK)) {
|
||||
Execution newAttempt = executionService.retryTask(
|
||||
pair.getKey(),
|
||||
@@ -1250,11 +1187,12 @@ public class JdbcExecutor implements ExecutorInterface, Service {
|
||||
);
|
||||
executor = executor.withExecution(newAttempt, "retryFailedTask");
|
||||
}
|
||||
// Handle failed flow
|
||||
// Handle failed flow retries
|
||||
else if (executionDelay.getDelayType().equals(ExecutionDelay.DelayType.RESTART_FAILED_FLOW)) {
|
||||
Execution newExecution = executionService.replay(executor.getExecution(), null, null);
|
||||
executor = executor.withExecution(newExecution, "retryFailedFlow");
|
||||
}
|
||||
// Handle WaitFor
|
||||
else if (executionDelay.getDelayType().equals(ExecutionDelay.DelayType.CONTINUE_FLOWABLE)) {
|
||||
Execution execution = executionService.retryWaitFor(executor.getExecution(), executionDelay.getTaskRunId());
|
||||
executor = executor.withExecution(execution, "continueLoop");
|
||||
@@ -1322,6 +1260,102 @@ public class JdbcExecutor implements ExecutorInterface, Service {
|
||||
});
|
||||
}
|
||||
|
||||
private void executionStateChangeQueue(Either<ExecutionStateChange, DeserializationException> either) {
|
||||
if (either.isRight()) {
|
||||
log.error("Unable to deserialize an execution state change: {}", either.getRight().getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
final ExecutionStateChange executionStateChange = either.getLeft();
|
||||
final Execution execution = executionStateChange.getExecution();
|
||||
|
||||
if (skipExecutionService.skipExecution(execution)) {
|
||||
log.warn("Skipping execution {}", execution.getId());
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
flowTriggerService.computeExecutionsFromFlowTriggers(execution, allFlows, Optional.of(multipleConditionStorage))
|
||||
.forEach(throwConsumer(executionFromFlowTrigger -> this.executionQueue.emit(executionFromFlowTrigger)));
|
||||
|
||||
FlowWithSource flow = findFlow(execution);
|
||||
boolean isTerminated = executionService.isTerminated(flow, execution);
|
||||
|
||||
// handle actions on terminated state
|
||||
if (isTerminated) {
|
||||
// if there is a parent, we send a subflow execution result to it
|
||||
if (ExecutableUtils.isSubflow(execution)) {
|
||||
// locate the parent execution to find the parent task run
|
||||
String parentExecutionId = (String) execution.getTrigger().getVariables().get("executionId");
|
||||
String taskRunId = (String) execution.getTrigger().getVariables().get("taskRunId");
|
||||
String taskId = (String) execution.getTrigger().getVariables().get("taskId");
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> outputs = (Map<String, Object>) execution.getTrigger().getVariables().get("taskRunOutputs");
|
||||
Variables variables = variablesService.of(StorageContext.forExecution(execution), outputs);
|
||||
SubflowExecutionEnd subflowExecutionEnd = new SubflowExecutionEnd(execution, parentExecutionId, taskRunId, taskId, execution.getState().getCurrent(), variables);
|
||||
this.subflowExecutionEndQueue.emit(subflowExecutionEnd);
|
||||
}
|
||||
|
||||
// purge SLA monitors
|
||||
if (!ListUtils.isEmpty(flow.getSla()) && flow.getSla().stream().anyMatch(ExecutionMonitoringSLA.class::isInstance)) {
|
||||
slaMonitorStorage.purge(execution.getId());
|
||||
}
|
||||
|
||||
// purge execution running
|
||||
if (flow.getConcurrency() != null) {
|
||||
executionRunningStorage.remove(execution);
|
||||
}
|
||||
|
||||
// check if there exist a queued execution and submit it to the execution queue
|
||||
if (flow.getConcurrency() != null && flow.getConcurrency().getBehavior() == Concurrency.Behavior.QUEUE) {
|
||||
executionQueuedStorage.pop(flow.getTenantId(),
|
||||
flow.getNamespace(),
|
||||
flow.getId(),
|
||||
throwConsumer(queued -> {
|
||||
var newExecution = queued.withState(State.Type.RUNNING);
|
||||
ExecutionRunning executionRunning = ExecutionRunning.builder()
|
||||
.tenantId(newExecution.getTenantId())
|
||||
.namespace(newExecution.getNamespace())
|
||||
.flowId(newExecution.getFlowId())
|
||||
.execution(newExecution)
|
||||
.concurrencyState(ExecutionRunning.ConcurrencyState.RUNNING)
|
||||
.build();
|
||||
executionRunningStorage.save(executionRunning);
|
||||
executionQueue.emit(newExecution);
|
||||
metricRegistry.counter(MetricRegistry.METRIC_EXECUTOR_EXECUTION_POPPED_COUNT, MetricRegistry.METRIC_EXECUTOR_EXECUTION_POPPED_COUNT_DESCRIPTION, metricRegistry.tags(newExecution)).increment();
|
||||
|
||||
// send an execution state change event so we can use a flow trigger on the queued state
|
||||
executionStateChangeQueue.emit(new ExecutionStateChange(newExecution, State.Type.QUEUED, State.Type.RUNNING));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// purge the trigger: reset scheduler trigger at end
|
||||
if (execution.getTrigger() != null) {
|
||||
triggerRepository
|
||||
.findByExecution(execution)
|
||||
.ifPresent(trigger -> {
|
||||
this.triggerState.update(executionService.resetExecution(flow, execution, trigger));
|
||||
});
|
||||
}
|
||||
|
||||
// Purge the workerTaskResultQueue and the workerJobQueue
|
||||
// IMPORTANT: this is safe as only the executor is listening to WorkerTaskResult,
|
||||
// and we are sure at this stage that all WorkerJob has been listened and processed by the Worker.
|
||||
// If any of these assumptions changed, this code would not be safe anymore.
|
||||
if (cleanWorkerJobQueue && !ListUtils.isEmpty(execution.getTaskRunList())) {
|
||||
List<String> taskRunKeys = execution.getTaskRunList().stream()
|
||||
.map(taskRun -> taskRun.getId())
|
||||
.toList();
|
||||
((JdbcQueue<WorkerTaskResult>) workerTaskResultQueue).deleteByKeys(taskRunKeys);
|
||||
((JdbcQueue<WorkerJob>) workerJobQueue).deleteByKeys(taskRunKeys);
|
||||
}
|
||||
}
|
||||
} catch (QueueException e) {
|
||||
//FIXME
|
||||
}
|
||||
}
|
||||
|
||||
private boolean deduplicateNexts(Execution execution, ExecutorState executorState, List<TaskRun> taskRuns) {
|
||||
return taskRuns
|
||||
.stream()
|
||||
@@ -1380,13 +1414,11 @@ public class JdbcExecutor implements ExecutorInterface, Service {
|
||||
private Executor handleFailedExecutionFromExecutor(Executor executor, Exception e) {
|
||||
Execution.FailedExecutionWithLog failedExecutionWithLog = executor.getExecution().failedExecutionFromExecutor(e);
|
||||
|
||||
failedExecutionWithLog.getLogs().forEach(log -> {
|
||||
try {
|
||||
logQueue.emitAsync(log);
|
||||
} catch (QueueException ex) {
|
||||
// fail silently
|
||||
}
|
||||
});
|
||||
try {
|
||||
logQueue.emitAsync(failedExecutionWithLog.getLogs());
|
||||
} catch (QueueException ex) {
|
||||
// fail silently
|
||||
}
|
||||
|
||||
return executor.withExecution(failedExecutionWithLog.getExecution(), "exception");
|
||||
}
|
||||
@@ -1441,4 +1473,4 @@ public class JdbcExecutor implements ExecutorInterface, Service {
|
||||
public ServiceState getState() {
|
||||
return state.get();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ import java.util.function.BiConsumer;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import static io.kestra.core.utils.Rethrow.throwConsumer;
|
||||
import static io.kestra.core.utils.Rethrow.throwRunnable;
|
||||
|
||||
@Slf4j
|
||||
@@ -173,8 +174,8 @@ public abstract class JdbcQueue<T> implements QueueInterface<T> {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void emitAsync(String consumerGroup, T message) throws QueueException {
|
||||
this.asyncPoolExecutor.submit(throwRunnable(() -> this.emit(consumerGroup, message)));
|
||||
public void emitAsync(String consumerGroup, List<T> messages) throws QueueException {
|
||||
this.asyncPoolExecutor.submit(throwRunnable(() -> messages.forEach(throwConsumer(message -> this.emit(consumerGroup, message)))));
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
6
package-lock.json
generated
6
package-lock.json
generated
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "kestra",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ dependencies {
|
||||
// as Jackson is in the Micronaut BOM, to force its version we need to use enforcedPlatform but it didn't really work, see later :(
|
||||
api enforcedPlatform("com.fasterxml.jackson:jackson-bom:$jacksonVersion")
|
||||
api enforcedPlatform("org.slf4j:slf4j-api:$slf4jVersion")
|
||||
api platform("io.micronaut.platform:micronaut-platform:4.8.2")
|
||||
api platform("io.micronaut.platform:micronaut-platform:4.9.2")
|
||||
api platform("io.qameta.allure:allure-bom:2.29.1")
|
||||
// we define cloud bom here for GCP, Azure and AWS so they are aligned for all plugins that use them (secret, storage, oss and ee plugins)
|
||||
api platform('com.google.cloud:libraries-bom:26.66.0')
|
||||
@@ -148,4 +148,4 @@ dependencies {
|
||||
api "io.kestra:runner-memory:$version"
|
||||
api "io.kestra:storage-local:$version"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
86
ui/README.md
Normal file
86
ui/README.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# Kestra UI
|
||||
|
||||
Kestra UI is running using [Vite](https://vite.dev/).
|
||||
|
||||
---
|
||||
|
||||
## INSTRUCTIONS
|
||||
|
||||
### Development:
|
||||
- (Optional) By default, your dev server will target `localhost:8080`. If your backend is running elsewhere, you can create `.env.development.local` under `ui` folder with this content:
|
||||
```
|
||||
VITE_APP_API_URL={myApiUrl}
|
||||
```
|
||||
|
||||
- Navigate into the `ui` folder and run `npm install` to install the dependencies for the frontend project.
|
||||
|
||||
- Now go to the `cli/src/main/resources` folder and create a `application-override.yml` file.
|
||||
|
||||
Now you have two choices:
|
||||
|
||||
`Local mode`:
|
||||
|
||||
Runs the Kestra server in local mode which uses a H2 database, so this is the only config you'd need:
|
||||
|
||||
```yaml
|
||||
micronaut:
|
||||
server:
|
||||
cors:
|
||||
enabled: true
|
||||
configurations:
|
||||
all:
|
||||
allowedOrigins:
|
||||
- http://localhost:5173
|
||||
```
|
||||
|
||||
You can then open a new terminal and run the following command to start the backend server: `./gradlew runLocal`
|
||||
|
||||
`Standalone mode`:
|
||||
|
||||
Runs in standalone mode which uses Postgres. Make sure to have a local Postgres instance already running on localhost:
|
||||
|
||||
```yaml
|
||||
kestra:
|
||||
repository:
|
||||
type: postgres
|
||||
storage:
|
||||
type: local
|
||||
local:
|
||||
base-path: "/app/storage"
|
||||
queue:
|
||||
type: postgres
|
||||
tasks:
|
||||
tmp-dir:
|
||||
path: /tmp/kestra-wd/tmp
|
||||
anonymous-usage-report:
|
||||
enabled: false
|
||||
|
||||
datasources:
|
||||
postgres:
|
||||
# It is important to note that you must use the "host.docker.internal" host when connecting to a docker container outside of your devcontainer as attempting to use localhost will only point back to this devcontainer.
|
||||
url: jdbc:postgresql://host.docker.internal:5432/kestra
|
||||
driverClassName: org.postgresql.Driver
|
||||
username: kestra
|
||||
password: k3str4
|
||||
|
||||
flyway:
|
||||
datasources:
|
||||
postgres:
|
||||
enabled: true
|
||||
locations:
|
||||
- classpath:migrations/postgres
|
||||
# We must ignore missing migrations as we may delete the wrong ones or delete those that are not used anymore.
|
||||
ignore-migration-patterns: "*:missing,*:future"
|
||||
out-of-order: true
|
||||
|
||||
micronaut:
|
||||
server:
|
||||
cors:
|
||||
enabled: true
|
||||
configurations:
|
||||
all:
|
||||
allowedOrigins:
|
||||
- http://localhost:5173
|
||||
```
|
||||
|
||||
If you're doing frontend development, you can run `npm run dev` from the `ui` folder after having the above running (which will provide a backend) to access your application from `localhost:5173`. This has the benefit to watch your changes and hot-reload upon doing frontend changes.
|
||||
@@ -3,34 +3,37 @@
|
||||
<el-table-column v-for="(column, index) in generateTableColumns" :key="index" :prop="column" :label="column">
|
||||
<template #default="scope">
|
||||
<template v-if="isComplex(scope.row[column])">
|
||||
<editor
|
||||
:full-height="false"
|
||||
:input="true"
|
||||
:navbar="false"
|
||||
:model-value="JSON.stringify(scope.row[column])"
|
||||
lang="json"
|
||||
read-only
|
||||
<el-input
|
||||
type="textarea"
|
||||
:model-value="truncate(JSON.stringify(scope.row[column], null, 2))"
|
||||
readonly
|
||||
:rows="3"
|
||||
autosize
|
||||
class="ks-editor"
|
||||
resize="none"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ scope.row[column] }}
|
||||
{{ truncate(scope.row[column]) }}
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
<script>
|
||||
import Editor from "./inputs/Editor.vue";
|
||||
|
||||
export default {
|
||||
name: "ListPreview",
|
||||
components: {Editor},
|
||||
props: {
|
||||
value: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
maxColumnLength: 100
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
generateTableColumns() {
|
||||
const allKeys = new Set();
|
||||
@@ -43,6 +46,12 @@
|
||||
methods: {
|
||||
isComplex(data) {
|
||||
return data instanceof Array || data instanceof Object;
|
||||
},
|
||||
truncate(text) {
|
||||
if (typeof text !== "string") return text;
|
||||
return text.length > this.maxColumnLength
|
||||
? text.slice(0, this.maxColumnLength) + "..."
|
||||
: text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,76 +1,196 @@
|
||||
<template>
|
||||
<div class="no-code">
|
||||
<Editor
|
||||
@update-task="(yaml) => emit('updateTask', yaml)"
|
||||
@reorder="(yaml) => emit('reorder', yaml)"
|
||||
/>
|
||||
<div class="p-4">
|
||||
<Task
|
||||
v-if="creatingTask || editingTask"
|
||||
/>
|
||||
|
||||
<el-form v-else label-position="top">
|
||||
<TaskWrapper :key="v.fieldKey" v-for="(v) in fieldsFromSchemaTop" :merge="shouldMerge(v.schema)" :transparent="v.fieldKey === 'inputs'">
|
||||
<template #tasks>
|
||||
<TaskObjectField
|
||||
v-bind="v"
|
||||
@update:model-value="(val) => onTaskUpdateField(v.fieldKey, val)"
|
||||
/>
|
||||
</template>
|
||||
</TaskWrapper>
|
||||
|
||||
<hr class="my-4">
|
||||
|
||||
<TaskWrapper :key="v.fieldKey" v-for="(v) in fieldsFromSchemaRest" :merge="shouldMerge(v.schema)" :transparent="SECTIONS_IDS.includes(v.fieldKey)">
|
||||
<template #tasks>
|
||||
<TaskObjectField
|
||||
v-bind="v"
|
||||
@update:model-value="(val) => onTaskUpdateField(v.fieldKey, val)"
|
||||
/>
|
||||
</template>
|
||||
</TaskWrapper>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, provide, ref} from "vue";
|
||||
import {computed, onActivated, provide, ref, watch} from "vue";
|
||||
|
||||
import * as YAML_UTILS from "@kestra-io/ui-libs/flow-yaml-utils";
|
||||
import {removeNullAndUndefined} from "./utils/cleanUp";
|
||||
|
||||
import Task from "./segments/Task.vue";
|
||||
import TaskWrapper from "../flows/tasks/TaskWrapper.vue";
|
||||
import TaskObjectField from "../flows/tasks/TaskObjectField.vue";
|
||||
import {
|
||||
BLOCK_SCHEMA_PATH_INJECTION_KEY,
|
||||
CLOSE_TASK_FUNCTION_INJECTION_KEY,
|
||||
CREATE_TASK_FUNCTION_INJECTION_KEY,
|
||||
CREATING_TASK_INJECTION_KEY,
|
||||
PANEL_INJECTION_KEY, POSITION_INJECTION_KEY,
|
||||
REF_PATH_INJECTION_KEY, PARENT_PATH_INJECTION_KEY,
|
||||
FLOW_INJECTION_KEY, FIELDNAME_INJECTION_KEY,
|
||||
EDITING_TASK_INJECTION_KEY, BLOCK_SCHEMA_PATH_INJECTION_KEY
|
||||
EDIT_TASK_FUNCTION_INJECTION_KEY,
|
||||
EDITING_TASK_INJECTION_KEY,
|
||||
FIELDNAME_INJECTION_KEY,
|
||||
FULL_SCHEMA_INJECTION_KEY,
|
||||
FULL_SOURCE_INJECTION_KEY,
|
||||
PANEL_INJECTION_KEY,
|
||||
PARENT_PATH_INJECTION_KEY,
|
||||
POSITION_INJECTION_KEY,
|
||||
REF_PATH_INJECTION_KEY,
|
||||
ROOT_SCHEMA_INJECTION_KEY,
|
||||
SCHEMA_DEFINITIONS_INJECTION_KEY,
|
||||
UPDATE_TASK_FUNCTION_INJECTION_KEY,
|
||||
} from "./injectionKeys";
|
||||
import Editor from "./segments/Editor.vue";
|
||||
import {useFlowFields, SECTIONS_IDS} from "./utils/useFlowFields";
|
||||
import {debounce} from "lodash";
|
||||
import {useEditorStore} from "../../stores/editor";
|
||||
import {useFlowStore} from "../../stores/flow";
|
||||
import {usePluginsStore} from "../../stores/plugins";
|
||||
import {useKeyboardSave} from "./utils/useKeyboardSave";
|
||||
import {NoCodeProps} from "../flows/noCodeTypes";
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "updateTask", yaml: string): void
|
||||
(e: "reorder", yaml: string): void
|
||||
(e: "createTask", parentPath: string, refPath: number | undefined, position?: "before" | "after"): boolean | void
|
||||
(e: "editTask", parentPath: string, refPath?: number): boolean | void
|
||||
(e: "closeTask"): boolean | void
|
||||
}>()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
flow: string;
|
||||
/**
|
||||
* The path of the parent block
|
||||
*/
|
||||
parentPath?: string;
|
||||
/**
|
||||
* Initial block index when opening
|
||||
* a no-code panel from topology
|
||||
*/
|
||||
refPath?: number;
|
||||
creatingTask?: boolean;
|
||||
editingTask?: boolean;
|
||||
position?: "before" | "after";
|
||||
blockSchemaPath?: string;
|
||||
fieldName?: string | undefined;
|
||||
}>(), {
|
||||
creatingTask: false,
|
||||
editingTask: false,
|
||||
position: "after",
|
||||
refPath: undefined,
|
||||
parentPath: undefined,
|
||||
blockSchemaPath: "",
|
||||
fieldName: undefined,
|
||||
const props = defineProps<NoCodeProps>();
|
||||
|
||||
function shouldMerge(schema: any): boolean {
|
||||
const complexObject = ["object", "array"].includes(schema?.type) || schema?.$ref || schema?.oneOf || schema?.anyOf || schema?.allOf;
|
||||
return !complexObject
|
||||
}
|
||||
|
||||
function onTaskUpdateField(key: string, val: any) {
|
||||
const realValue = val === null || val === undefined ? undefined :
|
||||
// allow array to be created with null values (specifically for metadata)
|
||||
// metadata do not use a buffer value, so each change needs to be reflected in the code,
|
||||
// for TaskKvPair.vue (object) we added the buffer value in the input component
|
||||
typeof val === "object" && !Array.isArray(val)
|
||||
? removeNullAndUndefined(val)
|
||||
: val; // Handle null values
|
||||
|
||||
|
||||
const currentFlow = parsedFlow.value;
|
||||
|
||||
currentFlow[key] = realValue;
|
||||
|
||||
editorUpdate(YAML_UTILS.stringify(currentFlow));
|
||||
}
|
||||
|
||||
const lastValidFlowYaml = computed<string>(
|
||||
(oldValue) => {
|
||||
try {
|
||||
YAML_UTILS.parse(flowYaml.value);
|
||||
return flowYaml.value;
|
||||
} catch {
|
||||
return oldValue ?? "";
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const {
|
||||
fieldsFromSchemaTop,
|
||||
fieldsFromSchemaRest,
|
||||
parsedFlow,
|
||||
} = useFlowFields(lastValidFlowYaml)
|
||||
|
||||
useKeyboardSave(lastValidFlowYaml)
|
||||
|
||||
const flowStore = useFlowStore();
|
||||
const flowYaml = computed<string>(() => flowStore.flowYaml ?? "");
|
||||
|
||||
const validateFlow = debounce(() => {
|
||||
flowStore.validateFlow({flow: flowYaml.value});
|
||||
}, 500);
|
||||
|
||||
const timeout = ref();
|
||||
const editorStore = useEditorStore();
|
||||
|
||||
const editorUpdate = (source: string) => {
|
||||
flowStore.flowYaml = source;
|
||||
flowStore.haveChange = true;
|
||||
validateFlow();
|
||||
editorStore.setTabDirty({
|
||||
name: "Flow",
|
||||
dirty: true
|
||||
});
|
||||
|
||||
// throttle the trigger of the flow update
|
||||
clearTimeout(timeout.value);
|
||||
timeout.value = setTimeout(() => {
|
||||
flowStore.onEdit({
|
||||
source,
|
||||
currentIsFlow: true,
|
||||
topologyVisible: true,
|
||||
});
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
onActivated(() => {
|
||||
pluginsStore.updateDocumentation();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => flowStore.flowYaml,
|
||||
(newVal, oldVal) => {
|
||||
if (newVal !== oldVal) {
|
||||
editorUpdate(newVal);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const panel = ref()
|
||||
const pluginsStore = usePluginsStore();
|
||||
|
||||
provide(FLOW_INJECTION_KEY, computed(() => props.flow));
|
||||
provide(FULL_SOURCE_INJECTION_KEY, computed(() => lastValidFlowYaml.value));
|
||||
provide(PARENT_PATH_INJECTION_KEY, props.parentPath ?? "");
|
||||
provide(REF_PATH_INJECTION_KEY, props.refPath);
|
||||
provide(PANEL_INJECTION_KEY, panel)
|
||||
provide(POSITION_INJECTION_KEY, props.position);
|
||||
provide(POSITION_INJECTION_KEY, props.position ?? "after");
|
||||
provide(CREATING_TASK_INJECTION_KEY, props.creatingTask);
|
||||
provide(EDITING_TASK_INJECTION_KEY, props.editingTask);
|
||||
provide(BLOCK_SCHEMA_PATH_INJECTION_KEY, props.blockSchemaPath);
|
||||
provide(FIELDNAME_INJECTION_KEY, props.fieldName);
|
||||
provide(BLOCK_SCHEMA_PATH_INJECTION_KEY, computed(() => props.blockSchemaPath ?? pluginsStore.flowSchema?.$ref ?? ""));
|
||||
provide(FULL_SCHEMA_INJECTION_KEY, computed(() => pluginsStore.flowSchema ?? {}));
|
||||
provide(ROOT_SCHEMA_INJECTION_KEY, computed(() => pluginsStore.flowRootSchema ?? {}));
|
||||
provide(SCHEMA_DEFINITIONS_INJECTION_KEY, computed(() => pluginsStore.flowDefinitions ?? {}));
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "createTask", parentPath: string, blockSchemaPath: string, refPath: number | undefined, position: "after" | "before"): boolean | void;
|
||||
(e: "editTask", parentPath: string, blockSchemaPath: string, refPath: number | undefined): boolean | void;
|
||||
(e: "closeTask"): boolean | void;
|
||||
}>();
|
||||
|
||||
provide(CLOSE_TASK_FUNCTION_INJECTION_KEY, () => {
|
||||
emit("closeTask")
|
||||
})
|
||||
|
||||
provide(UPDATE_TASK_FUNCTION_INJECTION_KEY, (yaml) => {
|
||||
editorUpdate(yaml)
|
||||
})
|
||||
|
||||
provide(CREATE_TASK_FUNCTION_INJECTION_KEY, (parentPath, blockSchemaPath, refPath) => {
|
||||
emit("createTask", parentPath, blockSchemaPath, refPath, "after")
|
||||
});
|
||||
|
||||
provide(EDIT_TASK_FUNCTION_INJECTION_KEY, ( parentPath, blockSchemaPath, refPath) => {
|
||||
emit("editTask", parentPath, blockSchemaPath, refPath)
|
||||
});
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
<template>
|
||||
<NoCode
|
||||
:flow="lastValidFlowYaml"
|
||||
:parent-path="parentPath"
|
||||
:ref-path="refPath"
|
||||
:creating-task="creatingTask"
|
||||
:editing-task="editingTask"
|
||||
:field-name="fieldName"
|
||||
:position
|
||||
:block-schema-path="blockSchemaPath"
|
||||
@update-task="(e) => editorUpdate(e)"
|
||||
@reorder="(yaml) => flowStore.flowYaml = yaml"
|
||||
@close-task="() => emit('closeTask')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, provide, ref, watch} from "vue";
|
||||
import debounce from "lodash/debounce";
|
||||
import * as YAML_UTILS from "@kestra-io/ui-libs/flow-yaml-utils";
|
||||
import NoCode from "./NoCode.vue";
|
||||
import {CREATE_TASK_FUNCTION_INJECTION_KEY, EDIT_TASK_FUNCTION_INJECTION_KEY} from "./injectionKeys";
|
||||
import {useEditorStore} from "../../stores/editor";
|
||||
import {useFlowStore} from "../../stores/flow";
|
||||
|
||||
export interface NoCodeProps {
|
||||
creatingTask?: boolean;
|
||||
editingTask?: boolean;
|
||||
parentPath?: string;
|
||||
refPath?: number;
|
||||
position?: "before" | "after";
|
||||
blockSchemaPath?: string;
|
||||
fieldName?: string | undefined;
|
||||
}
|
||||
|
||||
defineProps<NoCodeProps>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "createTask", parentPath: string, blockSchemaPath: string, refPath: number | undefined, position: "after" | "before"): boolean | void;
|
||||
(e: "editTask", parentPath: string, blockSchemaPath: string, refPath?: number): boolean | void;
|
||||
(e: "closeTask"): boolean | void;
|
||||
}>();
|
||||
|
||||
provide(CREATE_TASK_FUNCTION_INJECTION_KEY, (parentPath, blockSchemaPath, refPath) => {
|
||||
emit("createTask", parentPath, blockSchemaPath, refPath, "after")
|
||||
});
|
||||
|
||||
provide(EDIT_TASK_FUNCTION_INJECTION_KEY, ( parentPath, blockSchemaPath, refPath) => {
|
||||
emit("editTask", parentPath, blockSchemaPath, refPath)
|
||||
});
|
||||
|
||||
const flowStore = useFlowStore();
|
||||
const flowYaml = computed<string>(() => flowStore.flowYaml ?? "");
|
||||
|
||||
const lastValidFlowYaml = computed<string>(
|
||||
(oldValue) => {
|
||||
try {
|
||||
YAML_UTILS.parse(flowYaml.value);
|
||||
return flowYaml.value;
|
||||
} catch {
|
||||
return oldValue ?? "";
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const validateFlow = debounce(() => {
|
||||
flowStore.validateFlow({flow: flowYaml.value});
|
||||
}, 500);
|
||||
|
||||
const timeout = ref();
|
||||
const editorStore = useEditorStore();
|
||||
|
||||
const editorUpdate = (source: string) => {
|
||||
flowStore.flowYaml = source;
|
||||
flowStore.haveChange = true;
|
||||
validateFlow();
|
||||
editorStore.setTabDirty({
|
||||
name: "Flow",
|
||||
dirty: true
|
||||
});
|
||||
|
||||
// throttle the trigger of the flow update
|
||||
clearTimeout(timeout.value);
|
||||
timeout.value = setTimeout(() => {
|
||||
flowStore.onEdit({
|
||||
source,
|
||||
currentIsFlow: true,
|
||||
topologyVisible: true,
|
||||
});
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
watch(
|
||||
() => flowStore.flowYaml,
|
||||
(newVal, oldVal) => {
|
||||
if (newVal !== oldVal) {
|
||||
editorUpdate(newVal);
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
@@ -21,6 +21,7 @@
|
||||
:element-index="elementIndex"
|
||||
:moved="elementIndex == movedIndex"
|
||||
:block-schema-path
|
||||
:type-field-schema
|
||||
@remove-element="removeElement(elementIndex)"
|
||||
@move-element="
|
||||
(direction: 'up' | 'down') =>
|
||||
@@ -46,14 +47,15 @@
|
||||
import Creation from "./buttons/Creation.vue";
|
||||
import Element from "./Element.vue";
|
||||
import {
|
||||
CREATING_TASK_INJECTION_KEY, FLOW_INJECTION_KEY,
|
||||
PARENT_PATH_INJECTION_KEY, REF_PATH_INJECTION_KEY
|
||||
CREATING_TASK_INJECTION_KEY, FULL_SCHEMA_INJECTION_KEY, FULL_SOURCE_INJECTION_KEY,
|
||||
PARENT_PATH_INJECTION_KEY, REF_PATH_INJECTION_KEY,
|
||||
} from "../../injectionKeys";
|
||||
import {SECTIONS_MAP} from "../../../../utils/constants";
|
||||
import {getValueAtJsonPath} from "../../../../utils/utils";
|
||||
|
||||
const emits = defineEmits(["remove", "reorder"]);
|
||||
|
||||
const flow = inject(FLOW_INJECTION_KEY, ref(""));
|
||||
const flow = inject(FULL_SOURCE_INJECTION_KEY, ref(""));
|
||||
|
||||
const props = defineProps<CollapseItem>();
|
||||
const filteredElements = computed(() => props.elements?.filter(Boolean) ?? []);
|
||||
@@ -121,6 +123,14 @@
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const fullSchema = inject(FULL_SCHEMA_INJECTION_KEY, ref<Record<string, any>>({}));
|
||||
|
||||
// resolve parentPathComplete field schema from pluginsStore
|
||||
const typeFieldSchema = computed(() => {
|
||||
const blockSchema = getValueAtJsonPath(fullSchema.value, props.blockSchemaPath)?.properties;
|
||||
return blockSchema?.type ? "type" : blockSchema?.on ? "on" : "type";
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div @click="handleClick" class="d-flex my-2 p-2 rounded element" :class="{'moved': moved}">
|
||||
<div v-if="props.parentPathComplete !== 'inputs'" class="me-2 icon">
|
||||
<TaskIcon :cls="element.type" :icons only-icon />
|
||||
<div v-if="!['inputs', 'layout'].includes(props.parentPathComplete)" class="me-2 icon">
|
||||
<TaskIcon :cls="element.type" :icons="pluginsStore.icons" only-icon />
|
||||
</div>
|
||||
|
||||
<div class="flex-grow-1 label">
|
||||
@@ -35,7 +35,7 @@
|
||||
|
||||
import {DeleteOutline, ChevronUp, ChevronDown} from "../../utils/icons";
|
||||
import {
|
||||
EDIT_TASK_FUNCTION_INJECTION_KEY
|
||||
EDIT_TASK_FUNCTION_INJECTION_KEY,
|
||||
} from "../../injectionKeys";
|
||||
|
||||
import TaskIcon from "@kestra-io/ui-libs/src/components/misc/TaskIcon.vue";
|
||||
@@ -48,29 +48,26 @@
|
||||
section: string;
|
||||
parentPathComplete: string;
|
||||
element: {
|
||||
id: string;
|
||||
type: string;
|
||||
id?: string;
|
||||
type?: string;
|
||||
on?: string;
|
||||
};
|
||||
blockSchemaPath: string;
|
||||
elementIndex?: number;
|
||||
typeFieldSchema: "on" | "type";
|
||||
moved?: boolean;
|
||||
}>();
|
||||
|
||||
const pluginsStore = usePluginsStore();
|
||||
const playgroundStore = usePlaygroundStore();
|
||||
|
||||
const isTask = computed(() => ["tasks", "task"].includes(props.parentPathComplete?.split(".").pop() ?? "not-found"));
|
||||
const isTask = computed(() => ["tasks", "task"].includes(props.parentPathComplete.split(".").pop() ?? "not-found"));
|
||||
|
||||
const icons = computed(() => pluginsStore.icons);
|
||||
|
||||
const editTask = inject(
|
||||
EDIT_TASK_FUNCTION_INJECTION_KEY,
|
||||
() => {},
|
||||
);
|
||||
const editTask = inject(EDIT_TASK_FUNCTION_INJECTION_KEY, () => {});
|
||||
|
||||
const identifier = computed(() => {
|
||||
return props.element.id
|
||||
?? props.element.type
|
||||
?? props.element[props.typeFieldSchema]
|
||||
?? `<${t("no_code.unnamed")} ${props.elementIndex}>`;
|
||||
});
|
||||
|
||||
@@ -78,7 +75,7 @@
|
||||
editTask(
|
||||
props.parentPathComplete,
|
||||
props.blockSchemaPath,
|
||||
props.elementIndex,
|
||||
props.elementIndex
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -2,12 +2,11 @@ import type {ComputedRef, InjectionKey, Ref} from "vue"
|
||||
import {TopologyClickParams} from "./utils/types"
|
||||
import {Panel} from "../MultiPanelTabs.vue"
|
||||
|
||||
export const BLOCK_SCHEMA_PATH_INJECTION_KEY = Symbol("block-schema-path-injection-key") as InjectionKey<string>
|
||||
export const SCHEMA_PATH_INJECTION_KEY = Symbol("schema-path-injection-key") as InjectionKey<ComputedRef<string>>
|
||||
export const BLOCK_SCHEMA_PATH_INJECTION_KEY = Symbol("block-schema-path-injection-key") as InjectionKey<ComputedRef<string>>
|
||||
/**
|
||||
* Complete flow YAML string for the no-code
|
||||
*/
|
||||
export const FLOW_INJECTION_KEY = Symbol("flow-injection-key") as InjectionKey<ComputedRef<string>>
|
||||
export const FULL_SOURCE_INJECTION_KEY = Symbol("flow-injection-key") as InjectionKey<ComputedRef<string>>
|
||||
/**
|
||||
* When creating a subtask, this is the parent task path
|
||||
*/
|
||||
@@ -46,11 +45,15 @@ export const CREATE_TASK_FUNCTION_INJECTION_KEY = Symbol("creating-function-inje
|
||||
* Call this when starting to edit a task, when the user clicks on the task line
|
||||
* to start the edition process
|
||||
*/
|
||||
export const EDIT_TASK_FUNCTION_INJECTION_KEY = Symbol("edit-function-injection-key") as InjectionKey<(parentPath: string, blockSchemaPath: string, refPath?: number) => void>
|
||||
export const EDIT_TASK_FUNCTION_INJECTION_KEY = Symbol("edit-function-injection-key") as InjectionKey<(parentPath: string, blockSchemaPath: string, refPath: number | undefined) => void>
|
||||
/**
|
||||
* Call this when closing a task, when the user clicks on the close button
|
||||
*/
|
||||
export const CLOSE_TASK_FUNCTION_INJECTION_KEY = Symbol("close-function-injection-key") as InjectionKey<() => void>
|
||||
/**
|
||||
* We call this function when a task is changed, as soon as the first click or type is done
|
||||
*/
|
||||
export const UPDATE_TASK_FUNCTION_INJECTION_KEY = Symbol("update-function-injection-key") as InjectionKey<(yaml: string) => void>
|
||||
/**
|
||||
* Set this to override the contents of the no-code editor with a component of your choice
|
||||
* This is used to display the metadata edition inputs
|
||||
@@ -76,4 +79,13 @@ export const EDITOR_HIGHLIGHT_INJECTION_KEY = Symbol("editor-highlight-injection
|
||||
/**
|
||||
* Indicates if the Monaco editor is being used within EditorWrapper context for flow editing
|
||||
*/
|
||||
export const EDITOR_WRAPPER_INJECTION_KEY = Symbol("editor-wrapper-injection-key") as InjectionKey<boolean>
|
||||
export const EDITOR_WRAPPER_INJECTION_KEY = Symbol("editor-wrapper-injection-key") as InjectionKey<boolean>
|
||||
|
||||
export const ROOT_SCHEMA_INJECTION_KEY = Symbol("root-schema-injection-key") as InjectionKey<Ref<Record<string, any>>>
|
||||
|
||||
export const FULL_SCHEMA_INJECTION_KEY = Symbol("full-schema-injection-key") as InjectionKey<Ref<{
|
||||
definitions: Record<string, any>,
|
||||
$ref: string,
|
||||
}>>
|
||||
|
||||
export const SCHEMA_DEFINITIONS_INJECTION_KEY = Symbol("schema-definitions-injection-key") as InjectionKey<ComputedRef<Record<string, any>>>
|
||||
@@ -1,181 +0,0 @@
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<Task
|
||||
v-if="creatingTask || editingTask"
|
||||
@update-task="onTaskUpdate"
|
||||
/>
|
||||
|
||||
<el-form label-position="top" v-else>
|
||||
<TaskWrapper :key="v.fieldKey" v-for="(v) in fieldsFromSchemaTop" :merge="shouldMerge(v.schema, v.fieldKey)" :transparent="v.fieldKey === 'inputs'">
|
||||
<template #tasks>
|
||||
<TaskObjectField
|
||||
v-bind="v"
|
||||
@update:model-value="(val) => onTaskUpdateField(v.fieldKey, val)"
|
||||
/>
|
||||
</template>
|
||||
</TaskWrapper>
|
||||
|
||||
<hr class="my-4">
|
||||
|
||||
<TaskWrapper :key="v.fieldKey" v-for="(v) in fieldsFromSchemaRest" :merge="shouldMerge(v.schema, v.fieldKey)" :transparent="SECTIONS_IDS.includes(v.fieldKey)">
|
||||
<template #tasks>
|
||||
<TaskObjectField
|
||||
v-bind="v"
|
||||
@update:model-value="(val) => onTaskUpdateField(v.fieldKey, val)"
|
||||
/>
|
||||
</template>
|
||||
</TaskWrapper>
|
||||
</el-form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {onMounted, computed, inject, ref, provide, onActivated} from "vue";
|
||||
import {useI18n} from "vue-i18n";
|
||||
import {usePluginsStore} from "../../../stores/plugins";
|
||||
import {useFlowStore} from "../../../stores/flow";
|
||||
|
||||
import * as YAML_UTILS from "@kestra-io/ui-libs/flow-yaml-utils";
|
||||
|
||||
import {
|
||||
CREATING_TASK_INJECTION_KEY, EDITING_TASK_INJECTION_KEY,
|
||||
FLOW_INJECTION_KEY,
|
||||
SCHEMA_PATH_INJECTION_KEY,
|
||||
} from "../injectionKeys";
|
||||
|
||||
import Task from "./Task.vue";
|
||||
import TaskWrapper from "../../flows/tasks/TaskWrapper.vue";
|
||||
import TaskObjectField from "../../flows/tasks/TaskObjectField.vue";
|
||||
import {removeNullAndUndefined} from "../utils/cleanUp";
|
||||
const editingTask = inject(EDITING_TASK_INJECTION_KEY, false);
|
||||
|
||||
provide(SCHEMA_PATH_INJECTION_KEY, computed(() => pluginsStore.schemaType?.flow.$ref ?? ""));
|
||||
|
||||
const {t} = useI18n();
|
||||
|
||||
const emits = defineEmits([
|
||||
"save",
|
||||
"updateTask",
|
||||
"reorder",
|
||||
]);
|
||||
|
||||
const saveEvent = (e: KeyboardEvent) => {
|
||||
if (e.type === "keydown" && e.key === "s" && e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
emits("save");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
function shouldMerge(schema: any, _key: string): boolean {
|
||||
const complexObject = ["object", "array"].includes(schema?.type) || schema?.$ref || schema?.oneOf || schema?.anyOf || schema?.allOf;
|
||||
return !complexObject
|
||||
}
|
||||
|
||||
onActivated(() => {
|
||||
pluginsStore.updateDocumentation();
|
||||
});
|
||||
|
||||
function onTaskUpdateField(key: string, val: any) {
|
||||
const realValue = val === null || val === undefined ? undefined :
|
||||
// allow array to be created with null values (specifically for metadata)
|
||||
// metadata do not use a buffer value, so each change needs to be reflected in the code,
|
||||
// for TaskKvPair.vue (object) we added the buffer value in the input component
|
||||
typeof val === "object" && !Array.isArray(val) ? removeNullAndUndefined(val) :
|
||||
val; // Handle null values
|
||||
|
||||
|
||||
const currentFlow = parsedFlow.value;
|
||||
|
||||
currentFlow[key] = realValue;
|
||||
|
||||
emits("updateTask", YAML_UTILS.stringify(currentFlow));
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", saveEvent);
|
||||
|
||||
const flowStore = useFlowStore();
|
||||
const creatingFlow = computed(() => {
|
||||
return flowStore.isCreating;
|
||||
});
|
||||
|
||||
const creatingTask = inject(CREATING_TASK_INJECTION_KEY);
|
||||
const flow = inject(FLOW_INJECTION_KEY, ref(""));
|
||||
|
||||
const parsedFlow = computed(() => {
|
||||
try {
|
||||
return YAML_UTILS.parse(flow.value);
|
||||
} catch (e) {
|
||||
console.error("Error parsing flow YAML", e);
|
||||
return {};
|
||||
}
|
||||
});
|
||||
|
||||
function onTaskUpdate(yaml: string) {
|
||||
emits("updateTask", yaml)
|
||||
}
|
||||
|
||||
const pluginsStore = usePluginsStore();
|
||||
|
||||
onMounted(async () => {
|
||||
if(pluginsStore.schemaType?.flow) {
|
||||
return; // Schema already loaded
|
||||
}
|
||||
|
||||
await pluginsStore.loadSchemaType()
|
||||
});
|
||||
|
||||
// fields displayed on top of the form
|
||||
const MAIN_KEYS = [
|
||||
"id",
|
||||
"namespace",
|
||||
"description",
|
||||
"inputs"
|
||||
]
|
||||
|
||||
// ---
|
||||
|
||||
// fields displayed just after the horizontal bar
|
||||
const SECTIONS_IDS = [
|
||||
"tasks",
|
||||
"triggers",
|
||||
"errors",
|
||||
"finally",
|
||||
"afterExecution",
|
||||
"pluginDefaults",
|
||||
]
|
||||
|
||||
// once all those fields are displayed, the rest of the fields are displayed
|
||||
// in alphabetical order, except the ones in HIDDEN_FIELDS
|
||||
const HIDDEN_FIELDS = [
|
||||
"deleted",
|
||||
"tenantId",
|
||||
"revision"
|
||||
];
|
||||
|
||||
const getFieldFromKey = (key:string, translateGroup: string) => ({
|
||||
modelValue: parsedFlow.value[key],
|
||||
required: pluginsStore.flowRootSchema?.required ?? [],
|
||||
disabled: !creatingFlow.value && (key === "id" || key === "namespace"),
|
||||
schema: pluginsStore.flowRootProperties?.[key] ?? {},
|
||||
definitions: pluginsStore.flowDefinitions,
|
||||
label: SECTIONS_IDS.includes(key) ? key : t(`no_code.fields.${translateGroup}.${key}`),
|
||||
fieldKey: key,
|
||||
task: parsedFlow.value,
|
||||
})
|
||||
|
||||
const fieldsFromSchemaTop = computed(() => MAIN_KEYS.map(key => getFieldFromKey(key, "main")))
|
||||
|
||||
const fieldsFromSchemaRest = computed(() => {
|
||||
return Object.keys(pluginsStore.flowRootProperties ?? {})
|
||||
.filter((key) => !MAIN_KEYS.includes(key) && !HIDDEN_FIELDS.includes(key))
|
||||
.map((key) => getFieldFromKey(key, "general")).sort((a, b) => {
|
||||
const indexA = SECTIONS_IDS.indexOf(a.fieldKey as typeof SECTIONS_IDS[number]);
|
||||
const indexB = SECTIONS_IDS.indexOf(b.fieldKey as typeof SECTIONS_IDS[number]);
|
||||
if(indexA === -1 || indexB === -1) {
|
||||
return indexB - indexA;
|
||||
}
|
||||
return indexA - indexB;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -16,7 +16,8 @@
|
||||
import {PLUGIN_DEFAULTS_SECTION, SECTIONS_MAP} from "../../../utils/constants";
|
||||
import {
|
||||
CLOSE_TASK_FUNCTION_INJECTION_KEY,
|
||||
FLOW_INJECTION_KEY, CREATING_TASK_INJECTION_KEY,
|
||||
UPDATE_TASK_FUNCTION_INJECTION_KEY,
|
||||
FULL_SOURCE_INJECTION_KEY, CREATING_TASK_INJECTION_KEY,
|
||||
PARENT_PATH_INJECTION_KEY, POSITION_INJECTION_KEY,
|
||||
REF_PATH_INJECTION_KEY, EDIT_TASK_FUNCTION_INJECTION_KEY,
|
||||
FIELDNAME_INJECTION_KEY, BLOCK_SCHEMA_PATH_INJECTION_KEY,
|
||||
@@ -25,8 +26,7 @@
|
||||
import ValidationError from "../../../components/flows/ValidationError.vue";
|
||||
import {useFlowStore} from "../../../stores/flow";
|
||||
|
||||
const emits = defineEmits(["updateTask", "exitTask", "updateDocumentation"]);
|
||||
const flow = inject(FLOW_INJECTION_KEY, ref(""));
|
||||
const flow = inject(FULL_SOURCE_INJECTION_KEY, ref(""));
|
||||
const parentPath = inject(PARENT_PATH_INJECTION_KEY, "");
|
||||
const refPath = inject(REF_PATH_INJECTION_KEY, undefined);
|
||||
const position = inject(POSITION_INJECTION_KEY, "after");
|
||||
@@ -36,7 +36,8 @@
|
||||
);
|
||||
|
||||
const fieldName = inject(FIELDNAME_INJECTION_KEY, undefined);
|
||||
const blockSchemaPath = inject(BLOCK_SCHEMA_PATH_INJECTION_KEY, "");
|
||||
const blockSchemaPath = inject(BLOCK_SCHEMA_PATH_INJECTION_KEY, ref(""));
|
||||
const updateTask = inject(UPDATE_TASK_FUNCTION_INJECTION_KEY, () => {})
|
||||
|
||||
const closeTaskAddition = inject(
|
||||
CLOSE_TASK_FUNCTION_INJECTION_KEY,
|
||||
@@ -140,8 +141,8 @@
|
||||
const currentRefPath = (refPath !== undefined && refPath !== null) ? refPath + (position === "after" ? 1 : 0) : 0;
|
||||
editTask(
|
||||
fieldName ? `${parentPath}[${currentRefPath}].${fieldName}` : parentPath,
|
||||
blockSchemaPath,
|
||||
fieldName ? undefined : currentRefPath,
|
||||
blockSchemaPath.value,
|
||||
fieldName ? undefined : currentRefPath
|
||||
);
|
||||
hasMovedToEdit.value = true;
|
||||
nextTick(() => {
|
||||
@@ -149,7 +150,7 @@
|
||||
});
|
||||
}
|
||||
|
||||
emits("updateTask", result);
|
||||
updateTask(result);
|
||||
};
|
||||
|
||||
const hasMovedToEdit = ref(false);
|
||||
|
||||
90
ui/src/components/code/utils/useFlowFields.ts
Normal file
90
ui/src/components/code/utils/useFlowFields.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import {computed, ComputedRef, onMounted} from "vue";
|
||||
import {useI18n} from "vue-i18n";
|
||||
import {useFlowStore} from "../../../stores/flow";
|
||||
import {usePluginsStore} from "../../../stores/plugins";
|
||||
import * as YAML_UTILS from "@kestra-io/ui-libs/flow-yaml-utils";
|
||||
|
||||
|
||||
// fields displayed on top of the form
|
||||
const MAIN_KEYS = [
|
||||
"id",
|
||||
"namespace",
|
||||
"description",
|
||||
"inputs"
|
||||
]
|
||||
|
||||
// ---
|
||||
|
||||
// fields displayed just after the horizontal bar
|
||||
export const SECTIONS_IDS = [
|
||||
"tasks",
|
||||
"triggers",
|
||||
"errors",
|
||||
"finally",
|
||||
"afterExecution",
|
||||
"pluginDefaults",
|
||||
]
|
||||
|
||||
// once all those fields are displayed, the rest of the fields are displayed
|
||||
// in alphabetical order, except the ones in HIDDEN_FIELDS
|
||||
const HIDDEN_FIELDS = [
|
||||
"deleted",
|
||||
"tenantId",
|
||||
"revision"
|
||||
];
|
||||
|
||||
export function useFlowFields(flowSource: ComputedRef<string>){
|
||||
const flowStore = useFlowStore();
|
||||
const pluginsStore = usePluginsStore();
|
||||
|
||||
const {t} = useI18n();
|
||||
|
||||
onMounted(async () => {
|
||||
if(pluginsStore.schemaType?.flow) {
|
||||
return; // Schema already loaded
|
||||
}
|
||||
|
||||
await pluginsStore.loadSchemaType()
|
||||
});
|
||||
|
||||
const parsedFlow = computed(() => {
|
||||
try {
|
||||
return YAML_UTILS.parse(flowSource.value) ?? {};
|
||||
} catch (e) {
|
||||
console.error("Error parsing flow YAML", e);
|
||||
return {};
|
||||
}
|
||||
});
|
||||
|
||||
const getFieldFromKey = (key:string, translateGroup: string) => ({
|
||||
modelValue: parsedFlow.value[key],
|
||||
required: pluginsStore.flowRootSchema?.required ?? [],
|
||||
disabled: !flowStore.isCreating && (key === "id" || key === "namespace"),
|
||||
schema: pluginsStore.flowRootProperties?.[key] ?? {},
|
||||
definitions: pluginsStore.flowDefinitions,
|
||||
label: SECTIONS_IDS.includes(key) ? key : t(`no_code.fields.${translateGroup}.${key}`),
|
||||
fieldKey: key,
|
||||
task: parsedFlow.value,
|
||||
})
|
||||
|
||||
const fieldsFromSchemaTop = computed(() => MAIN_KEYS.map(key => getFieldFromKey(key, "main")))
|
||||
|
||||
const fieldsFromSchemaRest = computed(() => {
|
||||
return Object.keys(pluginsStore.flowRootProperties ?? {})
|
||||
.filter((key) => !MAIN_KEYS.includes(key) && !HIDDEN_FIELDS.includes(key))
|
||||
.map((key) => getFieldFromKey(key, "general")).sort((a, b) => {
|
||||
const indexA = SECTIONS_IDS.indexOf(a.fieldKey as typeof SECTIONS_IDS[number]);
|
||||
const indexB = SECTIONS_IDS.indexOf(b.fieldKey as typeof SECTIONS_IDS[number]);
|
||||
if(indexA === -1 || indexB === -1) {
|
||||
return indexB - indexA;
|
||||
}
|
||||
return indexA - indexB;
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
fieldsFromSchemaTop,
|
||||
fieldsFromSchemaRest,
|
||||
parsedFlow,
|
||||
}
|
||||
}
|
||||
23
ui/src/components/code/utils/useKeyboardSave.ts
Normal file
23
ui/src/components/code/utils/useKeyboardSave.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import {ComputedRef, onActivated, onDeactivated} from "vue";
|
||||
import {useFlowStore} from "../../../stores/flow";
|
||||
|
||||
export function useKeyboardSave(flowSource: ComputedRef<string>) {
|
||||
const flowStore = useFlowStore();
|
||||
const handleKeyboardSave = (e: KeyboardEvent) => {
|
||||
if (e.type === "keydown" && e.key === "s" && e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
flowStore.save({
|
||||
content: flowSource.value
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
onActivated(() => {
|
||||
document.addEventListener("keydown", handleKeyboardSave);
|
||||
});
|
||||
|
||||
|
||||
onDeactivated(() => {
|
||||
document.removeEventListener("keydown", handleKeyboardSave);
|
||||
});
|
||||
}
|
||||
@@ -48,7 +48,7 @@
|
||||
const DEFAULTS = {
|
||||
display: true,
|
||||
stacked: true,
|
||||
ticks: {maxTicksLimit: 8 , stepSize: 1},
|
||||
ticks: {maxTicksLimit: 8},
|
||||
grid: {display: false},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,35 +1,39 @@
|
||||
<template>
|
||||
<div :id="containerID" />
|
||||
<Bar
|
||||
<el-tooltip
|
||||
v-if="generated !== undefined"
|
||||
:data="parsedData"
|
||||
:options
|
||||
:plugins="chartOptions?.legend?.enabled ? [customBarLegend] : []"
|
||||
class="chart"
|
||||
:class="chartOptions?.legend?.enabled ? 'with-legend' : ''"
|
||||
/>
|
||||
<NoData v-else />
|
||||
effect="light"
|
||||
placement="top"
|
||||
:persistent="false"
|
||||
:hide-after="0"
|
||||
:popper-class="tooltipContent === '' ? 'd-none' : 'tooltip-stats'"
|
||||
:content="tooltipContent"
|
||||
raw-content
|
||||
>
|
||||
<div>
|
||||
<Bar
|
||||
:data="parsedData"
|
||||
:options
|
||||
:plugins="chartOptions?.legend?.enabled ? [customBarLegend] : []"
|
||||
:class="props.short ? 'short-chart' : 'chart'"
|
||||
class="chart"
|
||||
/>
|
||||
</div>
|
||||
</el-tooltip>
|
||||
<NoData v-else-if="!props.short" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {PropType, computed, watch} from "vue";
|
||||
|
||||
import NoData from "../../layout/NoData.vue";
|
||||
|
||||
import {Bar} from "vue-chartjs";
|
||||
|
||||
import {Chart, getDashboard} from "../composables/useDashboards";
|
||||
import {useChartGenerator} from "../composables/useDashboards";
|
||||
|
||||
|
||||
import {customBarLegend} from "../composables/useLegend";
|
||||
import {defaultConfig, getConsistentHEXColor, chartClick} from "../composables/charts.js";
|
||||
|
||||
import moment from "moment";
|
||||
|
||||
import {computed, ref, watch, PropType} from "vue";
|
||||
import {useRoute, useRouter} from "vue-router";
|
||||
import moment from "moment";
|
||||
import {Bar} from "vue-chartjs";
|
||||
import NoData from "../../layout/NoData.vue";
|
||||
import {Chart, getDashboard, useChartGenerator} from "../composables/useDashboards";
|
||||
import {customBarLegend} from "../composables/useLegend";
|
||||
import {defaultConfig, getConsistentHEXColor, chartClick, tooltip} from "../composables/charts.js";
|
||||
import {cssVariable, Utils} from "@kestra-io/ui-libs";
|
||||
import KestraUtils, {useTheme} from "../../../utils/utils"
|
||||
import KestraUtils, {useTheme} from "../../../utils/utils";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -39,30 +43,42 @@
|
||||
chart: {type: Object as PropType<Chart>, required: true},
|
||||
filters: {type: Array as PropType<string[]>, default: () => []},
|
||||
showDefault: {type: Boolean, default: false},
|
||||
short: {type: Boolean, default: false},
|
||||
});
|
||||
|
||||
|
||||
const containerID = `${props.chart.id}__${Math.random()}`;
|
||||
const tooltipContent = ref("");
|
||||
|
||||
const {data, chartOptions} = props.chart;
|
||||
|
||||
const aggregator = Object.entries(data.columns)
|
||||
.filter(([_, v]) => v.agg)
|
||||
.sort((a, b) => a[1].graphStyle.localeCompare(b[1].graphStyle));
|
||||
const yBShown = aggregator.length === 2;
|
||||
const aggregator = computed(() => {
|
||||
return Object.entries(data.columns)
|
||||
.filter(([_, v]) => v.agg)
|
||||
.sort((a, b) => {
|
||||
const aStyle = a[1].graphStyle || "";
|
||||
const bStyle = b[1].graphStyle || "";
|
||||
return aStyle.localeCompare(bStyle);
|
||||
});
|
||||
});
|
||||
|
||||
const yBShown = computed(() => aggregator.value.length === 2);
|
||||
|
||||
const theme = useTheme();
|
||||
|
||||
const DEFAULTS = {
|
||||
display: true,
|
||||
stacked: true,
|
||||
ticks: {maxTicksLimit: 8, stepSize:1},
|
||||
ticks: {maxTicksLimit: 8},
|
||||
grid: {display: false},
|
||||
};
|
||||
const options = computed(() => {
|
||||
return defaultConfig({
|
||||
skipNull: true,
|
||||
barThickness: 12,
|
||||
barThickness: props.short ? 8 : 12,
|
||||
maxBarThickness: props.short ? 8 : 12,
|
||||
categoryPercentage: props.short ? 1.0 : 0.8,
|
||||
barPercentage: props.short ? 1.0 : 0.9,
|
||||
borderSkipped: false,
|
||||
borderColor: "transparent",
|
||||
borderWidth: 2,
|
||||
@@ -76,7 +92,7 @@
|
||||
}
|
||||
: {}),
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
enabled: props.short ? false : true,
|
||||
filter: (value) => value.raw,
|
||||
callbacks: {
|
||||
label: (value) => {
|
||||
@@ -84,41 +100,46 @@
|
||||
return `${value.dataset.tooltip}`;
|
||||
},
|
||||
},
|
||||
external: (props.short) ? function (context) {
|
||||
tooltipContent.value = tooltip(context.tooltip);
|
||||
} : undefined,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
title: {
|
||||
display: true,
|
||||
display: props.short ? false : true,
|
||||
text: data.columns[chartOptions.column].displayName ?? chartOptions.column,
|
||||
},
|
||||
position: "bottom",
|
||||
...DEFAULTS
|
||||
...DEFAULTS,
|
||||
display: props.short ? false : true,
|
||||
},
|
||||
y: {
|
||||
title: {
|
||||
display: true,
|
||||
text: aggregator[0][1].displayName ?? aggregator[0][0],
|
||||
display: props.short ? false : true,
|
||||
text: aggregator.value[0]?.[1]?.displayName ?? aggregator.value[0]?.[0],
|
||||
},
|
||||
position: "left",
|
||||
...DEFAULTS,
|
||||
display: props.short ? false : true,
|
||||
ticks: {
|
||||
...DEFAULTS.ticks,
|
||||
callback: value => isDuration(aggregator[0][1].field) ? Utils.humanDuration(value) : value
|
||||
callback: (value: any) => isDuration(aggregator.value[0]?.[1]?.field) ? Utils.humanDuration(value) : value
|
||||
}
|
||||
},
|
||||
...(yBShown && {
|
||||
...(yBShown.value && {
|
||||
yB: {
|
||||
title: {
|
||||
display: true,
|
||||
text: aggregator[1][1].displayName ?? aggregator[1][0],
|
||||
display: props.short ? false : true,
|
||||
text: aggregator.value[1]?.[1]?.displayName ?? aggregator.value[1]?.[0],
|
||||
},
|
||||
position: "right",
|
||||
...DEFAULTS,
|
||||
display: true,
|
||||
display: props.short ? false : true,
|
||||
ticks: {
|
||||
...DEFAULTS.ticks,
|
||||
callback: value => isDuration(aggregator[1][1].field) ? Utils.humanDuration(value) : value
|
||||
callback: (value: any) => isDuration(aggregator.value[1]?.[1]?.field) ? Utils.humanDuration(value) : value
|
||||
}
|
||||
},
|
||||
}),
|
||||
@@ -151,7 +172,7 @@
|
||||
return Array.from(new Set(values)).sort();
|
||||
})();
|
||||
|
||||
const aggregatorKeys = aggregator.map(([key]) => key);
|
||||
const aggregatorKeys = aggregator.value.map(([key]) => key);
|
||||
|
||||
const reducer = (array, field, yAxisID) => {
|
||||
if (!array.length) return;
|
||||
@@ -164,8 +185,8 @@
|
||||
.filter(key => !aggregatorKeys.includes(key))
|
||||
.filter(key => key !== column);
|
||||
|
||||
return array.reduce((acc, {...params}) => {
|
||||
const stack = `(${fields.map(field => params[field]).join(", ")}): ${aggregator.map(agg => agg[0] + " = " + (isDuration(agg[1].field) ? Utils.humanDuration(params[agg[0]]) : params[agg[0]])).join(", ")}`;
|
||||
return array.reduce((acc: any, {...params}) => {
|
||||
const stack = `(${fields.map(field => params[field]).join(", ")}): ${aggregator.value.map(agg => agg[0] + " = " + (isDuration(agg[1].field) ? Utils.humanDuration(params[agg[0]]) : params[agg[0]])).join(", ")}`;
|
||||
|
||||
if (!acc[stack]) {
|
||||
acc[stack] = {
|
||||
@@ -213,13 +234,13 @@
|
||||
});
|
||||
};
|
||||
|
||||
const yDataset = reducer(rawData, aggregator[0][0], "y");
|
||||
const yDataset = reducer(rawData, aggregator.value[0][0], "y");
|
||||
|
||||
// Sorts the dataset array by the descending sum of 'data' values.
|
||||
// If two datasets have the same sum, it sorts them alphabetically by 'label'.
|
||||
const yDatasetData = Object.values(getData(aggregator[0][0], yDataset)).sort((a, b) => {
|
||||
const sumA = a.data.reduce((sum, val) => sum + val, 0);
|
||||
const sumB = b.data.reduce((sum, val) => sum + val, 0);
|
||||
const yDatasetData = Object.values(getData(aggregator.value[0][0], yDataset)).sort((a: any, b: any) => {
|
||||
const sumA = a.data.reduce((sum: number, val: number) => sum + val, 0);
|
||||
const sumB = b.data.reduce((sum: number, val: number) => sum + val, 0);
|
||||
|
||||
if (sumB !== sumA) {
|
||||
return sumB - sumA; // Descending by sum
|
||||
@@ -228,10 +249,10 @@
|
||||
return a.label.localeCompare(b.label); // Ascending alphabetically by label
|
||||
});
|
||||
|
||||
const label = aggregator?.[1]?.[1]?.displayName ?? aggregator?.[1]?.[1]?.field;
|
||||
const label = aggregator.value?.[1]?.[1]?.displayName ?? aggregator.value?.[1]?.[1]?.field;
|
||||
|
||||
let duration: number[] = [];
|
||||
if(yBShown){
|
||||
if(yBShown.value){
|
||||
const helper = Array.from(new Set(rawData.map((v) => parseValue(v.date)))).sort();
|
||||
|
||||
// Step 1: Group durations by formatted date
|
||||
@@ -247,7 +268,7 @@
|
||||
|
||||
return {
|
||||
labels: xAxis,
|
||||
datasets: yBShown
|
||||
datasets: yBShown.value
|
||||
? [
|
||||
{
|
||||
yAxisID: "yB",
|
||||
@@ -257,7 +278,7 @@
|
||||
pointRadius: 0,
|
||||
borderWidth: 0.75,
|
||||
label: label,
|
||||
borderColor: cssVariable("--ks-border-running")
|
||||
borderColor: props.short ? cssVariable("--ks-background-running") : cssVariable("--ks-border-running")
|
||||
},
|
||||
...yDatasetData,
|
||||
]
|
||||
@@ -290,4 +311,13 @@
|
||||
min-height: var(--chart-height);
|
||||
max-height: var(--chart-height);
|
||||
}
|
||||
|
||||
.short-chart {
|
||||
&:not(.with-legend) {
|
||||
#{--chart-height}: 40px;
|
||||
}
|
||||
|
||||
min-height: var(--chart-height);
|
||||
max-height: var(--chart-height);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,27 +1,47 @@
|
||||
<template>
|
||||
<Empty v-if="!loading && !getElements().length" type="dependencies" />
|
||||
<Empty v-if="!loading && !getElements().length" :type="`dependencies.${SUBTYPE}`" />
|
||||
<el-splitter v-else class="dependencies">
|
||||
<el-splitter-panel id="graph" v-bind="PANEL">
|
||||
<div v-loading="loading" ref="container" />
|
||||
|
||||
<div class="controls">
|
||||
<el-button size="small" :title="t('dependency.controls.zoom_in')" @click="handlers.zoomIn">
|
||||
<el-button
|
||||
size="small"
|
||||
:title="t('dependency.controls.zoom_in')"
|
||||
@click="handlers.zoomIn"
|
||||
>
|
||||
<Plus />
|
||||
</el-button>
|
||||
<el-button size="small" :title="t('dependency.controls.zoom_out')" @click="handlers.zoomOut">
|
||||
<el-button
|
||||
size="small"
|
||||
:title="t('dependency.controls.zoom_out')"
|
||||
@click="handlers.zoomOut"
|
||||
>
|
||||
<Minus />
|
||||
</el-button>
|
||||
<el-button size="small" :title="t('dependency.controls.clear_selection')" @click="handlers.clearSelection">
|
||||
<el-button
|
||||
size="small"
|
||||
:title="t('dependency.controls.clear_selection')"
|
||||
@click="handlers.clearSelection"
|
||||
>
|
||||
<SelectionRemove />
|
||||
</el-button>
|
||||
<el-button size="small" :title="t('dependency.controls.fit_view')" @click="handlers.fit">
|
||||
<el-button
|
||||
size="small"
|
||||
:title="t('dependency.controls.fit_view')"
|
||||
@click="handlers.fit"
|
||||
>
|
||||
<FitToScreenOutline />
|
||||
</el-button>
|
||||
</div>
|
||||
</el-splitter-panel>
|
||||
|
||||
<el-splitter-panel id="table">
|
||||
<Table :elements="getElements()" @select="selectNode" :selected="selectedNodeID" />
|
||||
<Table
|
||||
:elements="getElements()"
|
||||
@select="selectNode"
|
||||
:selected="selectedNodeID"
|
||||
/>
|
||||
</el-splitter-panel>
|
||||
</el-splitter>
|
||||
</template>
|
||||
@@ -33,7 +53,7 @@
|
||||
import Empty from "../layout/empty/Empty.vue";
|
||||
|
||||
import {useDependencies} from "./composables/useDependencies";
|
||||
import {FLOW, EXECUTION} from "./utils/types";
|
||||
import {FLOW, EXECUTION, NAMESPACE} from "./utils/types";
|
||||
|
||||
const PANEL = {size: "70%", min: "30%", max: "80%"};
|
||||
|
||||
@@ -48,11 +68,10 @@
|
||||
import SelectionRemove from "vue-material-design-icons/SelectionRemove.vue";
|
||||
import FitToScreenOutline from "vue-material-design-icons/FitToScreenOutline.vue";
|
||||
|
||||
const SUBTYPE = route.name === "flows/update" ? FLOW : EXECUTION;
|
||||
const SUBTYPE = route.name === "flows/update" ? FLOW : route.name === "namespaces/update" ? NAMESPACE : EXECUTION;
|
||||
|
||||
const container = ref(null);
|
||||
const initialNodeID: string = SUBTYPE === FLOW ? String(route.params.id) : String(route.params.flowId);
|
||||
|
||||
const initialNodeID: string = SUBTYPE === FLOW || SUBTYPE === NAMESPACE ? String(route.params.id) : String(route.params.flowId);
|
||||
const TESTING = false; // When true, bypasses API data fetching and uses mock/test data.
|
||||
|
||||
const {getElements, loading, selectedNodeID, selectNode, handlers} = useDependencies(container, SUBTYPE, initialNodeID, route.params, TESTING);
|
||||
|
||||
@@ -8,10 +8,13 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed} from "vue";
|
||||
|
||||
import {FLOW, EXECUTION, type Node} from "../utils/types";
|
||||
|
||||
const props = defineProps<{ node: Node, subtype: typeof FLOW | typeof EXECUTION}>();
|
||||
import {FLOW, EXECUTION, NAMESPACE, type Node} from "../utils/types";
|
||||
|
||||
const props = defineProps<{
|
||||
node: Node;
|
||||
subtype: typeof FLOW | typeof EXECUTION | typeof NAMESPACE;
|
||||
}>();
|
||||
|
||||
const to = computed(() => {
|
||||
const base = {namespace: props.node.namespace};
|
||||
|
||||
@@ -20,7 +20,10 @@
|
||||
<section id="row">
|
||||
<section id="left">
|
||||
<div id="link">
|
||||
<Link :node="row.data" :subtype="row.data.metadata.subtype" />
|
||||
<Link
|
||||
:node="row.data"
|
||||
:subtype="row.data.metadata.subtype"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p class="description">
|
||||
@@ -60,21 +63,24 @@
|
||||
selected: Node["id"] | undefined;
|
||||
}>();
|
||||
|
||||
const focusSelectedRow = ()=>{
|
||||
const focusSelectedRow = () => {
|
||||
const row = document.querySelector<HTMLElement>(".el-table__row.selected");
|
||||
|
||||
if (!row) return;
|
||||
|
||||
row.scrollIntoView({behavior: "smooth", block: "center"});
|
||||
}
|
||||
};
|
||||
|
||||
watch(() => props.selected, async (ID) => {
|
||||
if (!ID) return;
|
||||
watch(
|
||||
() => props.selected,
|
||||
async (ID) => {
|
||||
if (!ID) return;
|
||||
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
focusSelectedRow();
|
||||
});
|
||||
focusSelectedRow();
|
||||
},
|
||||
);
|
||||
|
||||
const search = ref("");
|
||||
const results = computed(() => {
|
||||
@@ -87,7 +93,10 @@
|
||||
return NODES.filter(({data}) => {
|
||||
const {flow, namespace} = data;
|
||||
|
||||
return (flow?.toLowerCase().includes(f) || namespace?.toLowerCase().includes(f));
|
||||
return (
|
||||
flow?.toLowerCase().includes(f) ||
|
||||
namespace?.toLowerCase().includes(f)
|
||||
);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -96,7 +105,7 @@
|
||||
section#input {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10; // keeps it above table rows
|
||||
z-index: 10; // Keeps it above table rows
|
||||
padding: 0.5rem;
|
||||
background-color: var(--ks-background-input);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import {onMounted, onBeforeUnmount, nextTick, watch, ref} from "vue";
|
||||
import {useCoreStore} from "../../../stores/core";
|
||||
import {useFlowStore} from "../../../stores/flow";
|
||||
import {useExecutionsStore} from "../../../stores/executions";
|
||||
import {useNamespacesStore} from "override/stores/namespaces";
|
||||
|
||||
import {useI18n} from "vue-i18n";
|
||||
|
||||
@@ -16,11 +17,11 @@ import cytoscape from "cytoscape";
|
||||
|
||||
import {State, cssVariable} from "@kestra-io/ui-libs";
|
||||
|
||||
import {NODE, EDGE, FLOW, EXECUTION, type Node, type Edge, type Element} from "../utils/types";
|
||||
import {NODE, EDGE, FLOW, EXECUTION, NAMESPACE, type Node, type Edge, type Element} from "../utils/types";
|
||||
import {getRandomNumber, getDependencies} from "../../../../tests/fixtures/dependencies/getDependencies";
|
||||
|
||||
import {edgeColors, style} from "../utils/style";
|
||||
const SELECTED = "selected", FADED = "faded", HOVERED = "hovered", EXECUTIONS = "executions";
|
||||
const SELECTED = "selected", FADED = "faded", HOVERED = "hovered", EXECUTIONS = "executions";
|
||||
|
||||
const options: Omit<cytoscape.CytoscapeOptions, "container" | "elements"> & {elements?: Element[]} = {
|
||||
minZoom: 0.1,
|
||||
@@ -113,7 +114,7 @@ function setExecutionNodeColors(cy: cytoscape.Core, nodes?: cytoscape.NodeSingul
|
||||
(nodes ?? cy.nodes()).forEach((node) => {
|
||||
node.style({
|
||||
"background-color": getStateColor(node),
|
||||
"border-color": getStateColor(node)
|
||||
"border-color": getStateColor(node),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -129,27 +130,30 @@ function setExecutionNodeColors(cy: cytoscape.Core, nodes?: cytoscape.NodeSingul
|
||||
*/
|
||||
function setExecutionEdgeColors(edges: cytoscape.EdgeCollection, color: string): void {
|
||||
edges.forEach((edge) => {
|
||||
edge.removeClass(FADED).addClass(EXECUTIONS).style({"line-color": color, "target-arrow-color": color});
|
||||
edge.removeClass(FADED).addClass(EXECUTIONS).style({
|
||||
"line-color": color,
|
||||
"target-arrow-color": color
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the specified CSS classes from all elements (nodes and edges) in the cytoscape instance.
|
||||
*
|
||||
* If the subtype is "EXECUTION", it also reapplies the default edge styling.
|
||||
*
|
||||
* If the subtype is `EXECUTION`, it also reapplies the default edge styling.
|
||||
*
|
||||
* This function is typically used to clear selection, hover, and execution-related classes
|
||||
* before applying new styles or resetting the graph state.
|
||||
*
|
||||
* @param cy - The cytoscape core instance containing the graph elements.
|
||||
* @param subtype - The dependency subtype, either "FLOW" or "EXECUTION".
|
||||
* Edge styles are only reset when subtype is "EXECUTION".
|
||||
* @param subtype - The dependency subtype, either `FLOW`, `EXECUTION` or `NAMESPACE`.
|
||||
* Edge styles are only reset when subtype is `EXECUTION`.
|
||||
* @param classes - An array of class names to remove from all elements.
|
||||
* Defaults to ["selected", "faded", "hovered", "executions"].
|
||||
* Defaults to [`selected`, `faded`, `hovered`, `executions`].
|
||||
*/
|
||||
export function clearClasses(cy: cytoscape.Core, subtype: typeof FLOW | typeof EXECUTION, classes: string[] = [SELECTED, FADED, HOVERED, EXECUTIONS]): void {
|
||||
cy.elements().removeClass(classes.join(" "));
|
||||
if (subtype === EXECUTION) cy.edges().style(edgeColors());
|
||||
export function clearClasses(cy: cytoscape.Core, subtype: typeof FLOW | typeof EXECUTION | typeof NAMESPACE, classes: string[] = [SELECTED, FADED, HOVERED, EXECUTIONS]): void {
|
||||
cy.elements().removeClass(classes.join(" "));
|
||||
if (subtype === EXECUTION) cy.edges().style(edgeColors());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -165,29 +169,30 @@ export function fit(cy: cytoscape.Core, padding: number = 50): void {
|
||||
/**
|
||||
* Handles selecting a node in the cytoscape graph.
|
||||
*
|
||||
* - Removes all existing "selected", "faded", "hovered" and "executions" states from nodes and edges.
|
||||
* - Removes all existing `selected`, `faded`, `hovered` and `executions` states from nodes and edges.
|
||||
* - Marks the chosen node as selected.
|
||||
* - Applies a faded style to connected elements based on the subtype:
|
||||
* - FLOW: Fades both connected edges and neighbor nodes.
|
||||
* - EXECUTION: Highlights connected edges with execution color, fades neighbor nodes.
|
||||
* - NAMESPACE: Fades both connected edges and neighbor nodes.
|
||||
* - Updates the provided Vue ref with the selected node’s ID.
|
||||
* - Smoothly centers and zooms the viewport on the selected node.
|
||||
*
|
||||
* @param cy - The cytoscape core instance managing the graph.
|
||||
* @param node - The node element to select.
|
||||
* @param selected - Vue ref storing the currently selected node ID.
|
||||
* @param subtype - Determines how connected elements are highlighted ("FLOW" or "EXECUTION").
|
||||
* @param subtype - Determines how connected elements are highlighted (`FLOW`, `EXECUTION` or `NAMESPACE`).
|
||||
* @param id - Optional explicit ID to assign to the ref (defaults to the node’s own ID).
|
||||
*/
|
||||
function selectHandler(cy: cytoscape.Core, node: cytoscape.NodeSingular, selected: Ref<Node["id"] | undefined>, subtype: typeof FLOW | typeof EXECUTION, id?: Node["id"]): void {
|
||||
function selectHandler(cy: cytoscape.Core, node: cytoscape.NodeSingular, selected: Ref<Node["id"] | undefined>, subtype: typeof FLOW | typeof EXECUTION | typeof NAMESPACE, id?: Node["id"]): void {
|
||||
// Remove all "selected", "faded", "hovered" and "executions" classes from every element
|
||||
clearClasses(cy, subtype);
|
||||
|
||||
// Mark the chosen node as selected
|
||||
node.addClass(SELECTED);
|
||||
|
||||
if (subtype === FLOW) {
|
||||
// FLOW: Fade both connected edges and neighbor nodes
|
||||
if (subtype === FLOW || subtype === NAMESPACE) {
|
||||
// FLOW or NAMESPACE: Fade both connected edges and neighbor nodes
|
||||
node.connectedEdges().union(node.connectedEdges().connectedNodes()).addClass(FADED);
|
||||
} else {
|
||||
// EXECUTION: Highlight connected edges with execution color
|
||||
@@ -217,17 +222,18 @@ function hoverHandler(cy: cytoscape.Core): void {
|
||||
* Initializes and manages a cytoscape instance within a Vue component.
|
||||
*
|
||||
* @param container - Vue ref pointing to the DOM element that hosts the cytoscape graph.
|
||||
* @param subtype - Dependency subtype, either `"FLOW"` or `"EXECUTION"`. Defaults to `"FLOW"`.
|
||||
* @param subtype - Dependency subtype, either `FLOW`, `EXECUTION` or `NAMESPACE`. Defaults to `FLOW`.
|
||||
* @param initialNodeID - Optional ID of the node to preselect after layout completes.
|
||||
* @param params - Vue Router params, expected to include `id` and `namespace`.
|
||||
* @param isTesting - When true, bypasses API data fetching and uses mock/test data.
|
||||
* @returns An object with element getters, loading state, selected node ID,
|
||||
* selection helpers, and control handlers.
|
||||
*/
|
||||
export function useDependencies(container: Ref<HTMLElement | null>, subtype: typeof FLOW | typeof EXECUTION = FLOW, initialNodeID: string, params: RouteParams, isTesting = false) {
|
||||
export function useDependencies(container: Ref<HTMLElement | null>, subtype: typeof FLOW | typeof EXECUTION | typeof NAMESPACE = FLOW, initialNodeID: string, params: RouteParams, isTesting = false) {
|
||||
const coreStore = useCoreStore();
|
||||
const flowStore = useFlowStore();
|
||||
const executionsStore = useExecutionsStore();
|
||||
const namespacesStore = useNamespacesStore();
|
||||
|
||||
const {t} = useI18n({useScope: "global"});
|
||||
|
||||
@@ -252,16 +258,25 @@ export function useDependencies(container: Ref<HTMLElement | null>, subtype: typ
|
||||
}
|
||||
};
|
||||
|
||||
let elements: { data: cytoscape.ElementDefinition[]; count: number } = {data: [], count: 0};
|
||||
const elements = ref<{ data: cytoscape.ElementDefinition[]; count: number; }>({data: [], count: 0});
|
||||
onMounted(async () => {
|
||||
if (!container.value) return;
|
||||
|
||||
if(isTesting) elements = {data: getDependencies({subtype}), count: getRandomNumber(1, 100)};
|
||||
else elements = await flowStore.loadDependencies({id: (subtype === FLOW ? params.id : params.flowId) as string, namespace: params.namespace as string, subtype});
|
||||
if (isTesting) elements.value = {data: getDependencies({subtype}), count: getRandomNumber(1, 100)};
|
||||
else {
|
||||
if (subtype === NAMESPACE) {
|
||||
const {data} = await namespacesStore.loadDependencies({namespace: params.id as string});
|
||||
const nodes = data.nodes ?? [];
|
||||
elements.value = {data: transformResponse(data, NAMESPACE), count: new Set(nodes.map((r: { uid: string }) => r.uid)).size};
|
||||
} else {
|
||||
const result = await flowStore.loadDependencies({id: (subtype === FLOW ? params.id : params.flowId) as string, namespace: params.namespace as string, subtype});
|
||||
elements.value = {data: result.data ?? [], count: result.count};
|
||||
}
|
||||
}
|
||||
|
||||
if(subtype === EXECUTION) nextTick(() => openSSE());
|
||||
if (subtype === EXECUTION) nextTick(() => openSSE());
|
||||
|
||||
cy = cytoscape({container: container.value, layout, ...options, style, elements: elements.data});
|
||||
cy = cytoscape({container: container.value, layout, ...options, style, elements: elements.value.data});
|
||||
|
||||
// Hide nodes immediately after initialization to avoid visual flickering or rearrangement during layout setup
|
||||
cy.ready(() => cy.nodes().style("display", "none"));
|
||||
@@ -270,7 +285,7 @@ export function useDependencies(container: Ref<HTMLElement | null>, subtype: typ
|
||||
setNodeSizes(cy);
|
||||
|
||||
// Apply execution state colors to each node
|
||||
if(subtype === EXECUTION) setExecutionNodeColors(cy);
|
||||
if (subtype === EXECUTION) setExecutionNodeColors(cy);
|
||||
|
||||
// Setup hover handlers for nodes and edges
|
||||
hoverHandler(cy);
|
||||
@@ -297,36 +312,38 @@ export function useDependencies(container: Ref<HTMLElement | null>, subtype: typ
|
||||
// Reveal nodes after layout rendering completes
|
||||
cy.nodes().style("display", "element");
|
||||
|
||||
// Preselect the proper node after layout rendering completes
|
||||
const node = isTesting ? cy.nodes()[0] : cy.nodes().filter((n) => n.data("flow") === initialNodeID);
|
||||
if (node) selectHandler(cy, node, selectedNodeID, subtype);
|
||||
if (subtype === NAMESPACE) fit(cy); // If the subtype is NAMESPACE, fit the entire graph in the viewport
|
||||
else if (node) selectHandler(cy, node, selectedNodeID, subtype); // Else, preselect the proper node after layout rendering completes
|
||||
});
|
||||
});
|
||||
|
||||
const sse = ref();
|
||||
const messages = ref<Record<string, any>[]>([]);
|
||||
|
||||
watch(messages, (newMessages) => {
|
||||
if (newMessages.length <= 0) return;
|
||||
watch(
|
||||
messages,
|
||||
(newMessages) => {
|
||||
if (newMessages.length <= 0) return;
|
||||
|
||||
newMessages.forEach((message: Record<string, any>) => {
|
||||
const matched = cy.nodes().filter((element) => element.data("id") === `${message.tenantId}_${message.namespace}_${message.flowId}`);
|
||||
newMessages.forEach((message: Record<string, any>) => {
|
||||
const matched = cy.nodes().filter((element) => element.data("id") === `${message.tenantId}_${message.namespace}_${message.flowId}`);
|
||||
|
||||
if (matched.nonempty()) {
|
||||
matched.forEach((node: cytoscape.NodeSingular) => {
|
||||
const state = message.state.current;
|
||||
if (matched.nonempty()) {
|
||||
matched.forEach((node: cytoscape.NodeSingular) => {
|
||||
const state = message.state.current;
|
||||
|
||||
node.data({...node.data(), metadata: {...node.data("metadata"), state}});
|
||||
node.data({...node.data(), metadata: {...node.data("metadata"), state}});
|
||||
|
||||
nextTick(() => {}) // Needed to ensure that table nodes are updated after the DOM is ready
|
||||
nextTick(() => {}); // Needed to ensure that table nodes are updated after the DOM is ready
|
||||
|
||||
setExecutionNodeColors(cy, node.toArray());
|
||||
setExecutionEdgeColors(node.connectedEdges(), getStateColor(undefined, state));
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
{deep: true},
|
||||
setExecutionNodeColors(cy, node.toArray());
|
||||
setExecutionEdgeColors(node.connectedEdges(), getStateColor(undefined, state));
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
{deep: true},
|
||||
);
|
||||
|
||||
const openSSE = () => {
|
||||
@@ -364,7 +381,7 @@ export function useDependencies(container: Ref<HTMLElement | null>, subtype: typ
|
||||
});
|
||||
|
||||
return {
|
||||
getElements: () => elements.data,
|
||||
getElements: () => elements.value.data,
|
||||
loading,
|
||||
selectedNodeID,
|
||||
selectNode,
|
||||
@@ -376,8 +393,8 @@ export function useDependencies(container: Ref<HTMLElement | null>, subtype: typ
|
||||
selectedNodeID.value = undefined;
|
||||
fit(cy);
|
||||
},
|
||||
fit: () => fit(cy)
|
||||
}
|
||||
fit: () => fit(cy),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -386,11 +403,12 @@ export function useDependencies(container: Ref<HTMLElement | null>, subtype: typ
|
||||
* Cytoscape-compatible elements with the given subtype.
|
||||
*
|
||||
* @param response - The API response object containing `nodes` and `edges` arrays.
|
||||
* @param subtype - The node subtype, either `"FLOW"` or `"EXECUTION"`.
|
||||
* @param subtype - The node subtype, either `FLOW`, `EXECUTION`, or `NAMESPACE`.
|
||||
* @returns An array of cytoscape elements with correctly typed nodes and edges.
|
||||
*/
|
||||
export function transformResponse(response: { nodes: { uid: string; namespace: string; id: string; }[]; edges: { source: string; target: string }[] }, subtype: typeof FLOW | typeof EXECUTION): Element[] {
|
||||
const nodes: Node[] = response.nodes.map((node) => ({id: node.uid, type: NODE, flow: node.id, namespace: node.namespace, metadata: subtype === FLOW ? {subtype: FLOW} : {subtype: EXECUTION}}));
|
||||
const edges: Edge[] = response.edges.map((edge) => ({id: uuid(), type: EDGE, source: edge.source, target: edge.target}));
|
||||
return [...nodes.map((node) => ({data: node} as Element)), ...edges.map((edge) => ({data: edge} as Element))];
|
||||
}
|
||||
export function transformResponse(response: {nodes: { uid: string; namespace: string; id: string }[]; edges: { source: string; target: string }[]; }, subtype: typeof FLOW | typeof EXECUTION | typeof NAMESPACE): Element[] {
|
||||
const nodes: Node[] = response.nodes.map((node) => ({id: node.uid, type: NODE, flow: node.id, namespace: node.namespace, metadata: {subtype}}));
|
||||
const edges: Edge[] = response.edges.map((edge) => ({id: uuid(), type: EDGE, source: edge.source, target: edge.target}));
|
||||
|
||||
return [...nodes.map((node) => ({data: node}) as Element), ...edges.map((edge) => ({data: edge}) as Element)];
|
||||
}
|
||||
|
||||
@@ -29,10 +29,10 @@ const VARIABLES = {
|
||||
};
|
||||
|
||||
const nodeBase: cytoscape.Css.Node = {
|
||||
"label": "data(flow)",
|
||||
label: "data(flow)",
|
||||
"border-width": 2,
|
||||
"border-style": "solid",
|
||||
"color": cssVariable("--ks-content-primary"),
|
||||
color: cssVariable("--ks-content-primary"),
|
||||
"font-size": 10,
|
||||
"text-valign": "bottom",
|
||||
"text-margin-y": 10,
|
||||
@@ -41,13 +41,13 @@ const nodeBase: cytoscape.Css.Node = {
|
||||
const edgeBase: cytoscape.Css.Edge = {
|
||||
"target-arrow-shape": "triangle",
|
||||
"curve-style": "bezier",
|
||||
"width": 2,
|
||||
width: 2,
|
||||
"line-style": "solid",
|
||||
};
|
||||
|
||||
const edgeAnimated: cytoscape.Css.Edge = {
|
||||
"line-style": "dashed",
|
||||
"line-dash-pattern": [3, 5]
|
||||
"line-dash-pattern": [3, 5],
|
||||
};
|
||||
|
||||
function nodeColors(type: keyof typeof VARIABLES.node = "default"): Partial<cytoscape.Css.Node> {
|
||||
@@ -71,7 +71,12 @@ export const style: cytoscape.StylesheetJson = [
|
||||
},
|
||||
{
|
||||
selector: "node.faded",
|
||||
style: {...nodeBase, ...nodeColors("faded"), "background-opacity": 0.75, "border-opacity": 0.75},
|
||||
style: {
|
||||
...nodeBase,
|
||||
...nodeColors("faded"),
|
||||
"background-opacity": 0.75,
|
||||
"border-opacity": 0.75,
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: "node.selected",
|
||||
|
||||
@@ -3,6 +3,7 @@ export const EDGE = "EDGE" as const;
|
||||
|
||||
export const FLOW = "FLOW" as const;
|
||||
export const EXECUTION = "EXECUTION" as const;
|
||||
export const NAMESPACE = "NAMESPACE" as const;
|
||||
|
||||
type Flow = {
|
||||
subtype: typeof FLOW;
|
||||
@@ -13,12 +14,16 @@ type Execution = {
|
||||
state?: string;
|
||||
};
|
||||
|
||||
type Namespace = {
|
||||
subtype: typeof NAMESPACE;
|
||||
};
|
||||
|
||||
export type Node = {
|
||||
id: string;
|
||||
type: "NODE";
|
||||
flow: string;
|
||||
namespace: string;
|
||||
metadata: Flow | Execution;
|
||||
metadata: Flow | Execution | Namespace;
|
||||
};
|
||||
|
||||
export type Edge = {
|
||||
|
||||
@@ -6,52 +6,19 @@
|
||||
@click="getFilePreview"
|
||||
:disabled="isZipFile"
|
||||
>
|
||||
{{ $t("preview") }}
|
||||
{{ $t("preview.label") }}
|
||||
</el-button>
|
||||
<drawer
|
||||
v-if="selectedPreview === value && preview"
|
||||
v-model="isPreviewOpen"
|
||||
>
|
||||
<template #header>
|
||||
{{ $t("preview") }}
|
||||
{{ $t("preview.label") }}
|
||||
</template>
|
||||
<template #default>
|
||||
<el-alert v-if="preview.truncated" show-icon type="warning" :closable="false" class="mb-2">
|
||||
{{ $t('file preview truncated') }}
|
||||
</el-alert>
|
||||
<list-preview v-if="preview.type === 'LIST'" :value="preview.content" />
|
||||
<img v-else-if="preview.type === 'IMAGE'" :src="imageContent" alt="Image output preview">
|
||||
<pdf-preview v-else-if="preview.type === 'PDF'" :source="preview.content" />
|
||||
<markdown v-else-if="preview.type === 'MARKDOWN'" :source="preview.content" />
|
||||
<editor
|
||||
v-else
|
||||
:model-value="preview.content"
|
||||
:lang="extensionToMonacoLang"
|
||||
read-only
|
||||
input
|
||||
:word-wrap="wordWrap"
|
||||
:full-height="false"
|
||||
:navbar="false"
|
||||
class="position-relative"
|
||||
>
|
||||
<template #absolute>
|
||||
<CopyToClipboard :text="preview.content">
|
||||
<template #right>
|
||||
<el-tooltip
|
||||
:content="$t('toggle_word_wrap')"
|
||||
placement="bottom"
|
||||
:auto-close="2000"
|
||||
>
|
||||
<el-button
|
||||
:icon="Wrap"
|
||||
type="default"
|
||||
@click="wordWrap = !wordWrap"
|
||||
/>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</CopyToClipboard>
|
||||
</template>
|
||||
</editor>
|
||||
<el-form class="ks-horizontal max-size mt-3">
|
||||
<el-form-item :label="$t('row count')">
|
||||
<el-select
|
||||
@@ -87,7 +54,48 @@
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="($t('preview.view'))">
|
||||
<el-switch
|
||||
v-model="forceEditor"
|
||||
class="ml-3"
|
||||
:active-text="$t('preview.force-editor')"
|
||||
:inactive-text="$t('preview.auto-view')"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<list-preview v-if="!forceEditor && preview.type === 'LIST'" :value="preview.content" />
|
||||
<img v-else-if="!forceEditor && preview.type === 'IMAGE'" :src="imageContent" alt="Image output preview">
|
||||
<pdf-preview v-else-if="!forceEditor && preview.type === 'PDF'" :source="preview.content" />
|
||||
<markdown v-else-if="!forceEditor && preview.type === 'MARKDOWN'" :source="preview.content" />
|
||||
<editor
|
||||
v-else
|
||||
:model-value="!forceEditor ? preview.content : JSON.stringify(preview.content, null, 2)"
|
||||
:lang="!forceEditor ? extensionToMonacoLang : 'json'"
|
||||
read-only
|
||||
input
|
||||
:word-wrap="wordWrap"
|
||||
:full-height="false"
|
||||
:navbar="false"
|
||||
class="position-relative"
|
||||
>
|
||||
<template #absolute>
|
||||
<CopyToClipboard :text="!forceEditor ? preview.content : JSON.stringify(preview.content, null, 2)">
|
||||
<template #right>
|
||||
<el-tooltip
|
||||
:content="$t('toggle_word_wrap')"
|
||||
placement="bottom"
|
||||
:auto-close="2000"
|
||||
>
|
||||
<el-button
|
||||
:icon="Wrap"
|
||||
type="default"
|
||||
@click="wordWrap = !wordWrap"
|
||||
/>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</CopyToClipboard>
|
||||
</template>
|
||||
</editor>
|
||||
</template>
|
||||
</drawer>
|
||||
</template>
|
||||
@@ -137,7 +145,8 @@
|
||||
{value: "Cp500", label: "EBCDIC IBM-500"},
|
||||
],
|
||||
preview: undefined,
|
||||
wordWrap: false
|
||||
wordWrap: false,
|
||||
forceEditor: false
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
@@ -169,7 +178,7 @@
|
||||
return "data:image/" + this.extension + ";base64," + this.preview.content;
|
||||
},
|
||||
maxPreviewOptions() {
|
||||
return [10, 25, 100, 500, 1000, 5000, 10000, 25000, 50000].filter(value => value <= this.configPreviewMaxRows())
|
||||
return [10, 25, 50, 100, 500, 1000, 5000, 10000, 25000, 50000].filter(value => value <= this.configPreviewMaxRows())
|
||||
},
|
||||
isZipFile() {
|
||||
// Checks if the file extension is .zip (case-insensitive)
|
||||
@@ -179,7 +188,7 @@
|
||||
emits: ["preview"],
|
||||
methods: {
|
||||
configPreviewInitialRows() {
|
||||
return this.miscStore.configs?.preview.initial || 100
|
||||
return this.miscStore.configs?.preview.initial || 50
|
||||
},
|
||||
configPreviewMaxRows() {
|
||||
return this.miscStore.configs?.preview.max || 5000
|
||||
|
||||
@@ -86,9 +86,12 @@
|
||||
<template #default="scope">
|
||||
<router-link
|
||||
v-if="scope.row.link"
|
||||
:to="{name: 'executions/update', params: scope.row.link}"
|
||||
:to="{
|
||||
name: 'executions/update',
|
||||
params: scope.row.link
|
||||
}"
|
||||
>
|
||||
{{ scope.row.value }}
|
||||
<code class="parent-execution">{{ scope.row.value }}</code>
|
||||
</router-link>
|
||||
<span v-else-if="scope.row.date">
|
||||
<date-ago :date="scope.row.value" />
|
||||
@@ -105,8 +108,18 @@
|
||||
<span v-else>
|
||||
<span v-if="scope.row.key === $t('revision')">
|
||||
<router-link
|
||||
:to="{name: 'flows/update', params: {id: $route.params.flowId, namespace: $route.params.namespace, tab: 'revisions'}, query: {revisionRight: scope.row.value}}"
|
||||
>{{ scope.row.value }}</router-link>
|
||||
:to="{
|
||||
name: 'flows/update',
|
||||
params: {
|
||||
id: $route.params.flowId,
|
||||
namespace: $route.params.namespace,
|
||||
tab: 'revisions'
|
||||
},
|
||||
query: {revisionRight: scope.row.value}
|
||||
}"
|
||||
>
|
||||
{{ scope.row.value }}
|
||||
</router-link>
|
||||
</span>
|
||||
<span v-else>{{ scope.row.value }}</span>
|
||||
</span>
|
||||
@@ -440,14 +453,14 @@
|
||||
{key: this.$t("scheduleDate"), value: this.execution?.scheduleDate, date: true},
|
||||
];
|
||||
|
||||
if (this.execution.parentId) {
|
||||
if (this.execution?.trigger?.type === "io.kestra.plugin.core.flow.Subflow" && this.execution?.trigger?.variables?.executionId) {
|
||||
ret.push({
|
||||
key: this.$t("parent execution"),
|
||||
value: this.execution.parentId,
|
||||
value: this.execution.trigger.variables.executionId,
|
||||
link: {
|
||||
flowId: this.execution.flowId,
|
||||
id: this.execution.parentId,
|
||||
namespace: this.execution.namespace
|
||||
flowId: this.execution.trigger.variables.flowId,
|
||||
id: this.execution.trigger.variables.executionId,
|
||||
namespace: this.execution.trigger.variables.namespace
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -487,7 +500,6 @@
|
||||
|
||||
<style lang="scss">
|
||||
.execution-overview {
|
||||
|
||||
.wrapper {
|
||||
background: var(--ks-background-card);
|
||||
}
|
||||
@@ -627,4 +639,8 @@
|
||||
border-top: 1px solid var(--ks-log-background-error);
|
||||
}
|
||||
}
|
||||
|
||||
code.parent-execution {
|
||||
color: var(--ks-content-link);
|
||||
}
|
||||
</style>
|
||||
@@ -1,9 +1,37 @@
|
||||
<template>
|
||||
<section class="playground">
|
||||
<h2>
|
||||
<ChartTimelineIcon class="tab-icon" />
|
||||
{{ t("playground.title") }}
|
||||
</h2>
|
||||
<div class="playground-header">
|
||||
<div class="title-section">
|
||||
<ChartTimelineIcon class="tab-icon" />
|
||||
{{ t("playground.title") }}
|
||||
</div>
|
||||
<div class="extra-options">
|
||||
<Kill
|
||||
v-if="executionsStore.execution"
|
||||
:execution="executionsStore.execution"
|
||||
/>
|
||||
<el-dropdown trigger="click" placement="bottom-end">
|
||||
<el-button :icon="DotsVertical" link class="tab-icon" />
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu class="m-2">
|
||||
<el-dropdown-item :icon="Backspace" @click="playgroundStore.clearExecutions()">
|
||||
<span class="small-text">{{ t('playground.clear_history') }}</span>
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item :icon="CloseIcon" @click="playgroundStore.enabled = false">
|
||||
<span class="small-text">{{ t('close') }} {{ t('playground.toggle').toLowerCase() }}</span>
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
<el-button
|
||||
:icon="CloseIcon"
|
||||
link
|
||||
class="tab-icon"
|
||||
@click="playgroundStore.enabled = false"
|
||||
:title="t('close')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="current-run">
|
||||
<div class="current-run-header">
|
||||
@@ -18,12 +46,6 @@
|
||||
{{ tab.title }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="extra-options">
|
||||
<Kill
|
||||
v-if="executionsStore.execution"
|
||||
:execution="executionsStore.execution"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="activeTab?.component && playgroundStore.latestExecution" class="tab-content">
|
||||
<component
|
||||
@@ -54,7 +76,9 @@
|
||||
import {useI18n} from "vue-i18n";
|
||||
import ChartTimelineIcon from "vue-material-design-icons/ChartTimeline.vue";
|
||||
import HistoryIcon from "vue-material-design-icons/History.vue";
|
||||
import Backspace from "vue-material-design-icons/Backspace.vue";
|
||||
import CloseIcon from "vue-material-design-icons/Close.vue";
|
||||
import DotsVertical from "vue-material-design-icons/DotsVertical.vue";
|
||||
import Gantt from "../executions/Gantt.vue";
|
||||
import Logs from "../executions/Logs.vue";
|
||||
import ExecutionOutput from "../executions/outputs/Wrapper.vue";
|
||||
@@ -120,6 +144,10 @@
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.small-text {
|
||||
font-size: .8rem;
|
||||
}
|
||||
|
||||
.playground {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
@@ -128,16 +156,29 @@
|
||||
color: var(--ks-color-text-secondary);
|
||||
background-color: var(--ks-background-panel);
|
||||
overflow-y: auto;
|
||||
h2{
|
||||
border-bottom: 1px solid var(--ks-border-primary);
|
||||
font-size: .8rem;
|
||||
font-weight: normal;
|
||||
line-height: 1.2rem;
|
||||
padding: .25rem .5rem;
|
||||
position: sticky;
|
||||
background-color: var(--ks-background-panel);
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.playground-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid var(--ks-border-primary);
|
||||
padding: 8px;
|
||||
position: sticky;
|
||||
background-color: var(--ks-background-panel);
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.title-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: .8rem;
|
||||
font-weight: normal;
|
||||
line-height: 1.2rem;
|
||||
.tab-icon {
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,15 +192,18 @@
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.extra-options{
|
||||
.extra-options{
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-right: 4rem;
|
||||
align-items: center;
|
||||
.tab-icon{
|
||||
color: var(--ks-content-inactive);
|
||||
}
|
||||
}
|
||||
|
||||
.toggle-history{
|
||||
position: absolute;
|
||||
top: 36px;
|
||||
top: 56px;
|
||||
right: 12px;
|
||||
background-color: var(--ks-background-card);
|
||||
border: none;
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
// https://github.com/kestra-io/kestra/issues/10484
|
||||
setTimeout(() => {
|
||||
this.flowStore
|
||||
.loadDependencies({namespace: flow.namespace, id: flow.id})
|
||||
.loadDependencies({namespace: flow.namespace, id: flow.id}, true)
|
||||
.then(({count}) => this.dependenciesCount = count);
|
||||
}, 1000);
|
||||
}
|
||||
@@ -113,15 +113,8 @@
|
||||
this.flowStore.loadGraph({
|
||||
flow: this.flowStore.flow,
|
||||
});
|
||||
|
||||
return this.flowStore.loadDependencies({
|
||||
namespace: this.$route.params.namespace,
|
||||
id: this.$route.params.id
|
||||
});
|
||||
}
|
||||
}).then(({count}) => {
|
||||
this.dependenciesCount = count;
|
||||
});
|
||||
})
|
||||
}
|
||||
},
|
||||
flowKey() {
|
||||
|
||||
@@ -218,13 +218,22 @@
|
||||
class="d-flex justify-content-between align-items-center"
|
||||
>
|
||||
<Status :status="getLastExecution(scope.row)?.status" size="small" />
|
||||
<div class="height: 100px;">
|
||||
<Bar :chart="mappedChart(scope.row.id, scope.row.namespace)" show-default short />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column
|
||||
prop="state"
|
||||
v-if="displayColumn('state') &&
|
||||
user.hasAny(permission.EXECUTION)"
|
||||
:label="$t('execution statistics')"
|
||||
class-name="row-graph"
|
||||
>
|
||||
<template #default="scope">
|
||||
<TimeSeries :chart="mappedChart(scope.row.id, scope.row.namespace)" show-default short />
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column
|
||||
v-if="displayColumn('triggers')"
|
||||
:label="$t('triggers')"
|
||||
@@ -280,8 +289,7 @@
|
||||
import Upload from "vue-material-design-icons/Upload.vue";
|
||||
import KestraFilter from "../filter/KestraFilter.vue";
|
||||
import FlowFilterLanguage from "../../composables/monaco/languages/filters/impl/flowFilterLanguage.ts";
|
||||
|
||||
import Bar from "../dashboard/sections/Bar.vue";
|
||||
import TimeSeries from "../dashboard/sections/TimeSeries.vue";
|
||||
|
||||
const file = ref(null);
|
||||
</script>
|
||||
@@ -313,12 +321,14 @@
|
||||
import {useFlowStore} from "../../stores/flow.ts";
|
||||
|
||||
const CHART_DEFINITION = {
|
||||
id: "executions_per_namespace_bars",
|
||||
type: "io.kestra.plugin.core.dashboard.chart.Bar",
|
||||
id: "total_executions_timeseries",
|
||||
type: "io.kestra.plugin.core.dashboard.chart.TimeSeries",
|
||||
chartOptions: {
|
||||
displayName: "Executions (per namespace)",
|
||||
displayName: "Total Executions",
|
||||
description: "Executions duration and count per date",
|
||||
legend: {enabled: false},
|
||||
column: "total",
|
||||
column: "date",
|
||||
colorByColumn: "state",
|
||||
width: 12,
|
||||
},
|
||||
data: {
|
||||
@@ -327,6 +337,7 @@
|
||||
date: {field: "START_DATE", displayName: "Date"},
|
||||
state: {field: "STATE"},
|
||||
total: {displayName: "Executions", agg: "COUNT"},
|
||||
duration: {field: "DURATION", displayName: "Duration", agg: "SUM"},
|
||||
},
|
||||
where: [
|
||||
{
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, onMounted, onUnmounted, Ref, watch} from "vue";
|
||||
import {computed, markRaw, onMounted, onUnmounted, ref, watch} from "vue";
|
||||
import {useStorage} from "@vueuse/core";
|
||||
import {useI18n} from "vue-i18n";
|
||||
import {useCoreStore} from "../../stores/core";
|
||||
@@ -30,12 +30,13 @@
|
||||
import FlowPlayground from "./FlowPlayground.vue";
|
||||
import EditorButtonsWrapper from "../inputs/EditorButtonsWrapper.vue";
|
||||
import KeyShortcuts from "../inputs/KeyShortcuts.vue";
|
||||
import NoCode from "../code/NoCode.vue";
|
||||
import {DEFAULT_ACTIVE_TABS, EDITOR_ELEMENTS} from "override/components/flows/panelDefinition";
|
||||
import {useCodePanels, useInitialCodeTabs} from "./useCodePanels";
|
||||
import {useTopologyPanels} from "./useTopologyPanels";
|
||||
import {useKeyShortcuts} from "../../utils/useKeyShortcuts";
|
||||
|
||||
import {getCreateTabKey, getEditTabKey, setupInitialNoCodeTab, setupInitialNoCodeTabIfExists, useNoCodePanels} from "./useNoCodePanels";
|
||||
import {setupInitialNoCodeTab, setupInitialNoCodeTabIfExists, useNoCodeHandlers, useNoCodePanels} from "./useNoCodePanels";
|
||||
import {useFlowStore} from "../../stores/flow";
|
||||
import {trackTabOpen} from "../../utils/tabTracking";
|
||||
|
||||
@@ -45,6 +46,8 @@
|
||||
|| element.value.startsWith("nocode-")
|
||||
}
|
||||
|
||||
const RawNoCode = markRaw(NoCode)
|
||||
|
||||
const coreStore = useCoreStore()
|
||||
const flowStore = useFlowStore()
|
||||
const {showKeyShortcuts} = useKeyShortcuts()
|
||||
@@ -73,6 +76,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
const openTabs = ref<string[]>([])
|
||||
|
||||
function setTabValue(tabValue: string){
|
||||
// Show dialog instead of creating panel
|
||||
if(tabValue === "keyshortcuts"){
|
||||
@@ -95,57 +100,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
const noCodeHandlers: Parameters<typeof setupInitialNoCodeTab>[2] = {
|
||||
onCreateTask(opener, parentPath, blockSchemaPath, refPath, position){
|
||||
const createTabId = getCreateTabKey({
|
||||
parentPath,
|
||||
refPath,
|
||||
position,
|
||||
}, 0).slice(12)
|
||||
const {t} = useI18n()
|
||||
|
||||
const tAdd = openTabs.value.find(t => t.endsWith(createTabId))
|
||||
|
||||
// if the tab is already open and has no data, to avoid conflicting data
|
||||
// focus it and don't open a new one
|
||||
if(tAdd && tAdd.startsWith("nocode-")){
|
||||
focusTab(tAdd)
|
||||
return false
|
||||
}
|
||||
|
||||
openAddTaskTab(opener, parentPath, blockSchemaPath, refPath, position, isFlowDirty.value)
|
||||
return false
|
||||
},
|
||||
onEditTask(...args){
|
||||
// if the tab is already open, focus it
|
||||
// and don't open a new one)
|
||||
const [
|
||||
,
|
||||
parentPath,
|
||||
_blockSchemaPath,
|
||||
refPath,
|
||||
] = args
|
||||
const editKey = getEditTabKey({
|
||||
parentPath,
|
||||
refPath
|
||||
}, 0).slice(12)
|
||||
|
||||
const tEdit = openTabs.value.find(t => t.endsWith(editKey))
|
||||
if(tEdit && tEdit.startsWith("nocode-")){
|
||||
focusTab(tEdit)
|
||||
return false
|
||||
}
|
||||
openEditTaskTab(...args, isFlowDirty.value)
|
||||
return false
|
||||
},
|
||||
onCloseTask(...args){
|
||||
closeTaskTab(...args)
|
||||
return false
|
||||
},
|
||||
function getPanelFromValue(value: string, dirtyFlow = false): {prepend: boolean, panel: Panel}{
|
||||
const tab = setupInitialNoCodeTab(RawNoCode, value, t, noCodeHandlers, flowStore.flowYaml ?? "")
|
||||
return staticGetPanelFromValue(value, tab, dirtyFlow)
|
||||
}
|
||||
|
||||
const {t} = useI18n()
|
||||
function getPanelFromValue(value: string, dirtyFlow = false): {prepend: boolean, panel: Panel}{
|
||||
const tab = setupInitialNoCodeTab(value, t, noCodeHandlers, flowStore.flowYaml ?? "")
|
||||
function staticGetPanelFromValue(value: string, tab?: Tab, dirtyFlow = false): {prepend: boolean, panel: Panel}{
|
||||
const element: Tab = tab ?? EDITOR_ELEMENTS.find(e => e.value === value)!
|
||||
|
||||
if(isTabFlowRelated(element)){
|
||||
@@ -173,51 +136,73 @@
|
||||
return /^nocode-\d{4}/.test(key) ? key.slice(0, 6) + key.slice(11) : key
|
||||
}
|
||||
|
||||
const panels: Ref<Panel[]> = useStorage<any>(
|
||||
function serializePanel(v:Panel[]){
|
||||
return v.map(p => ({
|
||||
tabs: p.tabs.map(t => t.value),
|
||||
activeTab: cleanupNoCodeTabKey(p.activeTab?.value),
|
||||
size: p.size,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* these actions are placeholders
|
||||
* that will be replaced later on
|
||||
*/
|
||||
const tempActions = {
|
||||
openAddTaskTab(){},
|
||||
openEditTaskTab(){},
|
||||
closeTaskTab(){}
|
||||
} as ReturnType<typeof useNoCodePanels>
|
||||
|
||||
const noCodeHandlers = useNoCodeHandlers(openTabs, focusTab, tempActions)
|
||||
|
||||
const panels = useStorage<Panel[]>(
|
||||
`flow-${flowStore.flow?.namespace}-${flowStore.flow?.id}`,
|
||||
DEFAULT_ACTIVE_TABS
|
||||
.map((t):Panel => getPanelFromValue(t).panel),
|
||||
.map((t) => staticGetPanelFromValue(t).panel),
|
||||
undefined,
|
||||
{
|
||||
serializer: {
|
||||
write(v: Panel[]){
|
||||
return JSON.stringify(v.map(p => ({
|
||||
tabs: p.tabs.map(t => t.value),
|
||||
activeTab: cleanupNoCodeTabKey(p.activeTab?.value),
|
||||
size: p.size,
|
||||
})))
|
||||
return JSON.stringify(serializePanel(v))
|
||||
},
|
||||
read(v?: string) {
|
||||
if(v){
|
||||
const panels: {tabs: string[], activeTab: string, size: number}[] = isTourRunning.value ? DEFAULT_TOUR_TABS : JSON.parse(v)
|
||||
if (v) {
|
||||
const panels: { tabs: string[], activeTab: string, size: number }[] = isTourRunning.value ? DEFAULT_TOUR_TABS : JSON.parse(v);
|
||||
return panels
|
||||
.filter((p) => p.tabs.length)
|
||||
.map((p):Panel => {
|
||||
const tabs = p.tabs.map((tab) =>
|
||||
.map((p): Panel => {
|
||||
const tabs: Tab[] = p.tabs.map((tab) =>
|
||||
setupInitialCodeTab(tab)
|
||||
?? setupInitialNoCodeTabIfExists(flowStore.flowYaml ?? "", tab, t, noCodeHandlers)
|
||||
?? setupInitialNoCodeTabIfExists(RawNoCode, tab, t, noCodeHandlers, flowStore.flowYaml ?? "")
|
||||
?? EDITOR_ELEMENTS.find(e => e.value === tab)!
|
||||
)
|
||||
// filter out any tab that may have disappeared
|
||||
.filter(Boolean)
|
||||
const activeTab = tabs.find(t => cleanupNoCodeTabKey(t.value) === p.activeTab) ?? tabs[0]
|
||||
.filter(t => t !== undefined);
|
||||
const activeTab = tabs.find(t => cleanupNoCodeTabKey(t.value) === p.activeTab) ?? tabs[0];
|
||||
return {
|
||||
activeTab,
|
||||
tabs,
|
||||
size: p.size
|
||||
}
|
||||
})
|
||||
}else{
|
||||
return null
|
||||
};
|
||||
});
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const {openAddTaskTab, openEditTaskTab, closeTaskTab} = useNoCodePanels(panels, noCodeHandlers)
|
||||
|
||||
const openTabs = computed(() => panels.value.flatMap(p => p.tabs.map(t => t.value)))
|
||||
// we maintain openTabs using watcher to avoid circular references
|
||||
// The obvious choice would have been to have a computed,
|
||||
// but this would have required panels to be defined before openTabs.
|
||||
// We need openTabs for noCodeHandlers and the latter for panels
|
||||
// deserialization/initialization
|
||||
// openTabs -> noCodeHandlers -> panels -> openTabs
|
||||
watch(panels, (ps) => {
|
||||
openTabs.value = ps.flatMap(p => p.tabs.map(t => t.value))
|
||||
}, {deep: true, immediate: true})
|
||||
|
||||
// Track initial tabs opened while editing or creating flow.
|
||||
let hasTrackedInitialTabs = false;
|
||||
@@ -231,11 +216,17 @@
|
||||
|
||||
const {onRemoveTab: onRemoveCodeTab, isFlowDirty} = useCodePanels(panels)
|
||||
|
||||
const actions = useNoCodePanels(RawNoCode, panels, openTabs, focusTab)
|
||||
|
||||
tempActions.openAddTaskTab = actions.openAddTaskTab
|
||||
tempActions.openEditTaskTab = actions.openEditTaskTab
|
||||
tempActions.closeTaskTab = actions.closeTaskTab
|
||||
|
||||
function onRemoveTab(tab: string){
|
||||
onRemoveCodeTab(tab)
|
||||
}
|
||||
|
||||
useTopologyPanels(panels, openAddTaskTab, openEditTaskTab)
|
||||
useTopologyPanels(panels, actions.openAddTaskTab, actions.openEditTaskTab)
|
||||
|
||||
watch(isFlowDirty, (dirty) => {
|
||||
for(const panel of panels.value){
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div v-if="playgroundStore.enabled && isTask && taskObject?.id" class="flow-playground">
|
||||
<PlaygroundRunTaskButton :task-id="taskObject?.id" />
|
||||
</div>
|
||||
<el-form label-position="top">
|
||||
<el-form v-if="isTaskDefinitionBasedOnType" label-position="top">
|
||||
<el-form-item>
|
||||
<template #label>
|
||||
<div class="type-div">
|
||||
@@ -12,21 +12,21 @@
|
||||
</template>
|
||||
<PluginSelect
|
||||
v-model="selectedTaskType"
|
||||
:block-schema-path
|
||||
@update:model-value="onTaskTypeSelect"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div @click="isPlugin && pluginsStore.updateDocumentation(taskObject as Parameters<typeof pluginsStore.updateDocumentation>[0])">
|
||||
<TaskObject
|
||||
v-loading="isLoading"
|
||||
v-if="selectedTaskType && schema"
|
||||
v-if="(selectedTaskType || !isTaskDefinitionBasedOnType) && schemaProp"
|
||||
name="root"
|
||||
:model-value="taskObject"
|
||||
@update:model-value="onTaskInput"
|
||||
:schema="schemaProp"
|
||||
:properties="properties"
|
||||
:definitions="schema.definitions"
|
||||
:definitions="fullSchema.definitions"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -35,18 +35,20 @@
|
||||
import {computed, inject, onActivated, provide, ref, toRaw, watch} from "vue";
|
||||
import {useI18n} from "vue-i18n";
|
||||
import * as YAML_UTILS from "@kestra-io/ui-libs/flow-yaml-utils";
|
||||
// @ts-expect-error TaskObject can't be typed for now because of time constraints
|
||||
import TaskObject from "./tasks/TaskObject.vue";
|
||||
import PluginSelect from "../../components/plugins/PluginSelect.vue";
|
||||
import {NoCodeElement, Schemas} from "../code/utils/types";
|
||||
import {
|
||||
SCHEMA_PATH_INJECTION_KEY,
|
||||
FIELDNAME_INJECTION_KEY, PARENT_PATH_INJECTION_KEY,
|
||||
BLOCK_SCHEMA_PATH_INJECTION_KEY,
|
||||
FULL_SCHEMA_INJECTION_KEY,
|
||||
SCHEMA_DEFINITIONS_INJECTION_KEY,
|
||||
} from "../code/injectionKeys";
|
||||
import {removeNullAndUndefined} from "../code/utils/cleanUp";
|
||||
import {removeRefPrefix, usePluginsStore} from "../../stores/plugins";
|
||||
import {usePlaygroundStore} from "../../stores/playground";
|
||||
import {getValueAtJsonPath} from "../../utils/utils";
|
||||
import {getValueAtJsonPath, resolve$ref} from "../../utils/utils";
|
||||
import PlaygroundRunTaskButton from "../inputs/PlaygroundRunTaskButton.vue";
|
||||
|
||||
const {t} = useI18n();
|
||||
@@ -71,9 +73,8 @@
|
||||
const parentPath = inject(PARENT_PATH_INJECTION_KEY, "");
|
||||
const fieldName = inject(FIELDNAME_INJECTION_KEY, undefined);
|
||||
|
||||
provide(SCHEMA_PATH_INJECTION_KEY, computed(() => `#/definitions/${selectedTaskType.value}`))
|
||||
|
||||
const blockSchemaPath = inject(BLOCK_SCHEMA_PATH_INJECTION_KEY, "");
|
||||
const blockSchemaPath = inject(BLOCK_SCHEMA_PATH_INJECTION_KEY, ref(""));
|
||||
|
||||
const isTask = computed(() => ["task", "tasks"].includes(parentPath.split(".").pop() ?? ""));
|
||||
|
||||
@@ -85,6 +86,27 @@
|
||||
return parentPath !== "inputs"
|
||||
});
|
||||
|
||||
const schemaAtBlockPath = computed(() => getValueAtJsonPath(fullSchema.value, blockSchemaPath.value))
|
||||
const isTaskDefinitionBasedOnType = computed(() => {
|
||||
if(isPluginDefaults.value){
|
||||
return true
|
||||
}
|
||||
const firstAnyOf = Array.isArray(schemaAtBlockPath.value?.anyOf) ? schemaAtBlockPath.value?.anyOf[0] : undefined;
|
||||
if (!firstAnyOf) return false;
|
||||
if(firstAnyOf.properties){
|
||||
return firstAnyOf?.properties?.type !== undefined;
|
||||
}
|
||||
if(Array.isArray(firstAnyOf.allOf)){
|
||||
return firstAnyOf.allOf.some((item: any) => {
|
||||
return resolve$ref(fullSchema.value, item)
|
||||
.properties?.type !== undefined;
|
||||
});
|
||||
}
|
||||
return true
|
||||
});
|
||||
|
||||
provide(BLOCK_SCHEMA_PATH_INJECTION_KEY, computed(() => selectedTaskType.value ? `#/definitions/${resolvedType.value}` : blockSchemaPath.value));
|
||||
|
||||
watch(modelValue, (v) => {
|
||||
if (!v) {
|
||||
taskObject.value = {};
|
||||
@@ -94,9 +116,15 @@
|
||||
}
|
||||
}, {immediate: true});
|
||||
|
||||
const schema = computed(() => {
|
||||
return plugin.value?.schema;
|
||||
});
|
||||
const fullSchema = inject(FULL_SCHEMA_INJECTION_KEY, ref<{
|
||||
definitions: Record<string, any>,
|
||||
$ref: string,
|
||||
}>({
|
||||
definitions: {},
|
||||
$ref: "",
|
||||
}));
|
||||
|
||||
const schema = computed(() => plugin.value?.schema);
|
||||
|
||||
const properties = computed(() => {
|
||||
const updatedProperties = schemaProp.value?.properties;
|
||||
@@ -118,11 +146,15 @@
|
||||
$required: true
|
||||
};
|
||||
}
|
||||
|
||||
return updatedProperties
|
||||
});
|
||||
|
||||
const schemaProp = computed(() => {
|
||||
const prop = schema.value?.properties;
|
||||
const prop = isTaskDefinitionBasedOnType.value
|
||||
? schema.value?.properties
|
||||
: schemaAtBlockPath.value
|
||||
|
||||
if(!prop){
|
||||
return undefined;
|
||||
}
|
||||
@@ -152,14 +184,14 @@
|
||||
}
|
||||
});
|
||||
|
||||
const fieldDefinition = computed(() => getValueAtJsonPath(fullSchema.value, blockSchemaPath.value));
|
||||
|
||||
// useful to map inputs to their real schema
|
||||
const typeMap = computed<Record<string, string>>(() => {
|
||||
const field = getValueAtJsonPath(pluginsStore.flowSchema, blockSchemaPath)
|
||||
|
||||
if (field?.anyOf) {
|
||||
const f = field.anyOf.reduce((acc: Record<string, string>, item: any) => {
|
||||
if (fieldDefinition.value?.anyOf) {
|
||||
const f = fieldDefinition.value.anyOf.reduce((acc: Record<string, string>, item: any) => {
|
||||
if (item.$ref) {
|
||||
const i = getValueAtJsonPath(pluginsStore.flowSchema, item.$ref);
|
||||
const i = getValueAtJsonPath(fullSchema.value, item.$ref);
|
||||
if(i) item = i;
|
||||
}
|
||||
if (item.allOf) {
|
||||
@@ -185,7 +217,24 @@
|
||||
return {}
|
||||
});
|
||||
|
||||
watch([selectedTaskType, () => pluginsStore.flowSchema], ([task]) => {
|
||||
const definitions = inject(SCHEMA_DEFINITIONS_INJECTION_KEY, ref<Record<string, any>>({}));
|
||||
const resolvedType = computed(() => typeMap.value[selectedTaskType.value ?? ""] ?? selectedTaskType.value ?? "");
|
||||
|
||||
function load() {
|
||||
// try to resolve the type from local schema
|
||||
if (definitions.value?.[resolvedType.value]) {
|
||||
const defs = definitions.value ?? {}
|
||||
plugin.value = {
|
||||
schema: {
|
||||
properties: defs[resolvedType.value],
|
||||
definitions: defs,
|
||||
}
|
||||
};
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
watch([selectedTaskType, fullSchema], ([task]) => {
|
||||
if (task) {
|
||||
load();
|
||||
if(isPlugin.value){
|
||||
@@ -194,20 +243,7 @@
|
||||
}
|
||||
}, {immediate: true});
|
||||
|
||||
function load() {
|
||||
const resolvedType = typeMap.value[selectedTaskType.value ?? ""] ?? selectedTaskType.value ?? "";
|
||||
// try to resolve the type from local schema
|
||||
if (pluginsStore.flowDefinitions?.[resolvedType]) {
|
||||
const defs = pluginsStore.flowDefinitions ?? {}
|
||||
plugin.value = {
|
||||
schema: {
|
||||
properties: defs[resolvedType],
|
||||
definitions: defs,
|
||||
}
|
||||
};
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function onTaskInput(val: PartialCodeElement | undefined) {
|
||||
taskObject.value = val;
|
||||
|
||||
9
ui/src/components/flows/noCodeTypes.ts
Normal file
9
ui/src/components/flows/noCodeTypes.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export interface NoCodeProps {
|
||||
creatingTask?: boolean;
|
||||
editingTask?: boolean;
|
||||
parentPath?: string;
|
||||
refPath?: number;
|
||||
position?: "before" | "after";
|
||||
blockSchemaPath?: string;
|
||||
fieldName?: string | undefined;
|
||||
}
|
||||
@@ -47,14 +47,14 @@
|
||||
import Add from "../../code/components/Add.vue";
|
||||
import getTaskComponent from "./getTaskComponent";
|
||||
import TaskWrapper from "./TaskWrapper.vue";
|
||||
import {SCHEMA_PATH_INJECTION_KEY} from "../../code/injectionKeys";
|
||||
import {BLOCK_SCHEMA_PATH_INJECTION_KEY} from "../../code/injectionKeys";
|
||||
|
||||
defineOptions({inheritAttrs: false});
|
||||
|
||||
const schemaPath = inject(SCHEMA_PATH_INJECTION_KEY, ref())
|
||||
const blockSchemaPath = inject(BLOCK_SCHEMA_PATH_INJECTION_KEY, ref())
|
||||
|
||||
provide(SCHEMA_PATH_INJECTION_KEY, computed(() => {
|
||||
return [schemaPath.value, "properties", props.root, "items"].join("/");
|
||||
provide(BLOCK_SCHEMA_PATH_INJECTION_KEY, computed(() => {
|
||||
return [blockSchemaPath.value, "properties", props.root, "items"].join("/");
|
||||
}));
|
||||
|
||||
const emits = defineEmits(["update:modelValue"]);
|
||||
@@ -95,7 +95,9 @@
|
||||
);
|
||||
|
||||
const handleInput = (value: string, index: number) => {
|
||||
emits("update:modelValue", [...items.value].splice(index, 1, value));
|
||||
const newVal = [...items.value]
|
||||
newVal.splice(index, 1, value);
|
||||
emits("update:modelValue", newVal);
|
||||
};
|
||||
|
||||
const newEmptyValue = computed(() => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<pre>{{ model }}</pre>
|
||||
<pre class="ks-constant">{{ model }}</pre>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -26,5 +26,14 @@
|
||||
watch(constValue, (val) => {
|
||||
model.value = val
|
||||
}, {immediate: true});
|
||||
</script>
|
||||
|
||||
</script>
|
||||
<style scoped>
|
||||
.ks-constant {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0 9px;
|
||||
border: 1px solid var(--ks-border-primary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -4,7 +4,7 @@
|
||||
:title="root"
|
||||
:elements="items"
|
||||
:section
|
||||
:block-schema-path="[schemaPath, 'properties', root, 'items'].join('/')"
|
||||
:block-schema-path="[blockSchemaPath, 'properties', root, 'items'].join('/')"
|
||||
@remove="(yaml) => flowStore.flowYaml = yaml"
|
||||
@reorder="(yaml) => flowStore.flowYaml = yaml"
|
||||
/>
|
||||
@@ -14,10 +14,10 @@
|
||||
<script setup lang="ts">
|
||||
import {computed, inject, ref} from "vue";
|
||||
import Collapse from "../../code/components/collapse/Collapse.vue";
|
||||
import {SCHEMA_PATH_INJECTION_KEY} from "../../code/injectionKeys";
|
||||
import {BLOCK_SCHEMA_PATH_INJECTION_KEY} from "../../code/injectionKeys";
|
||||
import {useFlowStore} from "../../../stores/flow";
|
||||
|
||||
const schemaPath = inject(SCHEMA_PATH_INJECTION_KEY, ref())
|
||||
const blockSchemaPath = inject(BLOCK_SCHEMA_PATH_INJECTION_KEY, ref(""))
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<el-form label-position="top" class="w-100">
|
||||
<template v-if="sortedProperties">
|
||||
<template v-for="[fieldKey, fieldSchema] in requiredProperties" :key="fieldKey">
|
||||
<template v-for="[fieldKey, fieldSchema] in protectedRequiredProperties" :key="fieldKey">
|
||||
<TaskWrapper :merge>
|
||||
<template #tasks>
|
||||
<TaskObjectField v-bind="fieldProps(fieldKey, fieldSchema)" />
|
||||
@@ -9,7 +9,7 @@
|
||||
</TaskWrapper>
|
||||
</template>
|
||||
|
||||
<el-collapse v-model="activeNames" v-if="optionalProperties?.length || deprecatedProperties?.length || connectionProperties?.length" class="collapse">
|
||||
<el-collapse v-model="activeNames" v-if="requiredProperties.length && (optionalProperties?.length || deprecatedProperties?.length || connectionProperties?.length)" class="collapse">
|
||||
<el-collapse-item name="connection" v-if="connectionProperties?.length" :title="$t('no_code.sections.connection')">
|
||||
<template v-for="[fieldKey, fieldSchema] in connectionProperties" :key="fieldKey">
|
||||
<TaskWrapper>
|
||||
@@ -68,14 +68,16 @@
|
||||
<script>
|
||||
import Task from "./Task";
|
||||
|
||||
const FIRST_FIELDS = ["id", "forced", "on", "type"];
|
||||
|
||||
function sortProperties(properties, required) {
|
||||
if(!properties.length) {
|
||||
return [];
|
||||
}
|
||||
return properties.sort((a, b) => {
|
||||
if (a[0] === "id" || a[0] === "forced") {
|
||||
if (FIRST_FIELDS.includes(a[0])) {
|
||||
return -1;
|
||||
} else if (b[0] === "id" || b[0] === "forced") {
|
||||
} else if (FIRST_FIELDS.includes(b[0])) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -124,8 +126,8 @@
|
||||
},
|
||||
computed: {
|
||||
filteredProperties() {
|
||||
return this.properties ? Object.entries(this.properties).filter(([key]) => {
|
||||
return !(key === "type");
|
||||
return this.properties ? Object.entries(this.properties).filter(([key, value]) => {
|
||||
return !(key === "type") && !Array.isArray(value);
|
||||
}) : [];
|
||||
},
|
||||
sortedProperties() {
|
||||
@@ -134,6 +136,9 @@
|
||||
requiredProperties() {
|
||||
return this.merge ? this.sortedProperties : this.sortedProperties.filter(([p,v]) => v && this.isRequired(p));
|
||||
},
|
||||
protectedRequiredProperties(){
|
||||
return this.requiredProperties.length ? this.requiredProperties : this.sortedProperties;
|
||||
},
|
||||
optionalProperties() {
|
||||
return this.merge ? [] : this.sortedProperties.filter(([p,v]) => v && !this.isRequired(p) && !v.$deprecated && v.$group !== "connection");
|
||||
},
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
<Element
|
||||
:section="root"
|
||||
:parent-path-complete="parentPathComplete"
|
||||
:block-schema-path="[schemaPath, 'properties', root.split('.').pop()].join('/')"
|
||||
:block-schema-path="[blockSchemaPath, 'properties', root.split('.').pop()].join('/')"
|
||||
:element="{
|
||||
id: model?.id ?? 'Set a task',
|
||||
type: model?.type,
|
||||
}"
|
||||
type-field-schema="type"
|
||||
@remove-element="removeElement()"
|
||||
/>
|
||||
</div>
|
||||
@@ -19,7 +20,7 @@
|
||||
PARENT_PATH_INJECTION_KEY,
|
||||
REF_PATH_INJECTION_KEY,
|
||||
CREATING_TASK_INJECTION_KEY,
|
||||
SCHEMA_PATH_INJECTION_KEY
|
||||
BLOCK_SCHEMA_PATH_INJECTION_KEY
|
||||
} from "../../code/injectionKeys";
|
||||
import Element from "../../code/components/collapse/Element.vue";
|
||||
|
||||
@@ -38,7 +39,7 @@
|
||||
const parentPath = inject(PARENT_PATH_INJECTION_KEY, "");
|
||||
const refPath = inject(REF_PATH_INJECTION_KEY, undefined);
|
||||
const creatingTask = inject(CREATING_TASK_INJECTION_KEY, false);
|
||||
const schemaPath = inject(SCHEMA_PATH_INJECTION_KEY, ref())
|
||||
const blockSchemaPath = inject(BLOCK_SCHEMA_PATH_INJECTION_KEY, ref())
|
||||
|
||||
const parentPathComplete = computed(() => {
|
||||
return `${[
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
title="tasks"
|
||||
:elements="items"
|
||||
:section
|
||||
:block-schema-path="[schemaPath, 'properties', root, 'items'].join('/')"
|
||||
:block-schema-path="[blockSchemaPath, 'properties', root, 'items'].join('/')"
|
||||
@remove="(yaml) => flowStore.flowYaml = yaml"
|
||||
@reorder="(yaml) => flowStore.flowYaml = yaml"
|
||||
/>
|
||||
@@ -14,10 +14,10 @@
|
||||
<script setup lang="ts">
|
||||
import {computed, inject, ref} from "vue";
|
||||
import Collapse from "../../code/components/collapse/Collapse.vue";
|
||||
import {SCHEMA_PATH_INJECTION_KEY} from "../../code/injectionKeys";
|
||||
import {BLOCK_SCHEMA_PATH_INJECTION_KEY} from "../../code/injectionKeys";
|
||||
import {useFlowStore} from "../../../stores/flow";
|
||||
|
||||
const schemaPath = inject(SCHEMA_PATH_INJECTION_KEY, ref())
|
||||
const blockSchemaPath = inject(BLOCK_SCHEMA_PATH_INJECTION_KEY, ref())
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
|
||||
@@ -74,7 +74,7 @@ function getType(property: any, key?: string, schema?: any): string {
|
||||
}
|
||||
|
||||
if (property.type === "array") {
|
||||
if (property.items?.anyOf?.length === 0 || property.items?.anyOf?.length > 10 || key === "pluginDefaults") {
|
||||
if (property.items?.anyOf?.length === 0 || property.items?.anyOf?.length > 10 || key === "pluginDefaults" || key === "layout") {
|
||||
return "list";
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import {h, markRaw, Ref, Suspense} from "vue"
|
||||
import {computed, h, markRaw, Ref, Suspense} from "vue"
|
||||
import {useI18n} from "vue-i18n";
|
||||
import MouseRightClickIcon from "vue-material-design-icons/MouseRightClick.vue";
|
||||
import * as YAML_UTILS from "@kestra-io/ui-libs/flow-yaml-utils";
|
||||
import type {Panel, Tab} from "../MultiPanelTabs.vue";
|
||||
import NoCodeWrapper from "../code/NoCodeWrapper.vue"
|
||||
|
||||
import type {NoCodeProps} from "../code/NoCodeWrapper.vue";
|
||||
import {useFlowStore} from "../../stores/flow";
|
||||
import {useEditorStore} from "../../stores/editor";
|
||||
import {NoCodeProps} from "./noCodeTypes";
|
||||
|
||||
|
||||
import {trackTabOpen, trackTabClose} from "../../utils/tabTracking";
|
||||
|
||||
const NOCODE_PREFIX = "nocode"
|
||||
@@ -38,22 +40,25 @@ export function getEditTabKey(tab: NoCodeProps, index: number) {
|
||||
// remove irrelevant properties from the tab object
|
||||
const {
|
||||
creatingTask: _,
|
||||
editingTask: ___,
|
||||
position: __,
|
||||
editingTask: ___,
|
||||
...relevantTabProps
|
||||
} = tab
|
||||
return `${NOCODE_PREFIX}-${indexWithLeftPadding}-${JSON.stringify({
|
||||
action: "edit",
|
||||
...relevantTabProps
|
||||
})}`
|
||||
|
||||
const keyParts = {
|
||||
action: "edit",
|
||||
...relevantTabProps
|
||||
}
|
||||
return `${NOCODE_PREFIX}-${indexWithLeftPadding}-${JSON.stringify(keyParts, Object.keys(keyParts).sort())}`
|
||||
}
|
||||
|
||||
export function getCreateTabKey(tab: NoCodeProps, index: number) {
|
||||
const indexWithLeftPadding = String(index).padStart(4, "0")
|
||||
return `${NOCODE_PREFIX}-${indexWithLeftPadding}-${JSON.stringify({
|
||||
const keyParts = {
|
||||
action: "create",
|
||||
...tab,
|
||||
})}`
|
||||
}
|
||||
return `${NOCODE_PREFIX}-${indexWithLeftPadding}-${JSON.stringify(keyParts, Object.keys(keyParts).sort())}`
|
||||
}
|
||||
|
||||
interface NoCodeTabWithAction extends NoCodeProps {
|
||||
@@ -62,7 +67,7 @@ interface NoCodeTabWithAction extends NoCodeProps {
|
||||
|
||||
let keepAliveCacheBuster = 0
|
||||
|
||||
export function getTabFromNoCodeTab(tab: NoCodeTabWithAction, t: (key: string) => string, handlers: Handlers, flow: string, dirty: boolean = false): Tab {
|
||||
function getTabFromNoCodeTab(Comp: any, tab: NoCodeTabWithAction, t: (key: string) => string, handlers: Handlers, flow: string, dirty: boolean = false): Tab {
|
||||
function getTabValues(tab: NoCodeTabWithAction) {
|
||||
// FIXME optimize by avoiding to stringify then parse again the yaml object.
|
||||
// maybe we could have a function in the YAML_UTILS that returns the parsed value.
|
||||
@@ -120,12 +125,12 @@ export function getTabFromNoCodeTab(tab: NoCodeTabWithAction, t: (key: string) =
|
||||
name: "NoCodeTab",
|
||||
props: ["panelIndex", "tabIndex"],
|
||||
setup: (props: Opener) => () => h(Suspense, {},
|
||||
[h(NoCodeWrapper, {
|
||||
[h(Comp, {
|
||||
...restOfTab,
|
||||
creatingTask: tab.action === "create",
|
||||
editingTask: tab.action === "edit",
|
||||
onCreateTask: onCreateTask?.bind({}, props) as any,
|
||||
onEditTask: onEditTask?.bind({}, props) as any,
|
||||
onCreateTask: onCreateTask?.bind({}, props),
|
||||
onEditTask: onEditTask?.bind({}, props),
|
||||
onCloseTask: onCloseTask?.bind({}, props),
|
||||
})]
|
||||
)
|
||||
@@ -133,17 +138,21 @@ export function getTabFromNoCodeTab(tab: NoCodeTabWithAction, t: (key: string) =
|
||||
}
|
||||
}
|
||||
|
||||
export function setupInitialNoCodeTabIfExists(flow: string, tab: string, t: (key: string) => string, handlers: Handlers) {
|
||||
export function setupInitialNoCodeTabIfExists(Comp: any, tab: string, t: (key: string) => string, handlers: Handlers, flowYaml: string) {
|
||||
if (tab === NOCODE_PREFIX) {
|
||||
return getTabFromNoCodeTab(Comp, parseTabId(tab), t, handlers, flowYaml)
|
||||
}
|
||||
|
||||
if (tab.startsWith(`${NOCODE_PREFIX}-`)){
|
||||
const {parentPath, refPath, action} = parseTabId(tab)
|
||||
const path = (refPath === undefined ? parentPath : `${parentPath}[${refPath}]`) ?? ""
|
||||
if(action === "edit" && !YAML_UTILS.extractBlockWithPath({source: flow, path})) {
|
||||
if(action === "edit" && !YAML_UTILS.extractBlockWithPath({source: flowYaml, path})) {
|
||||
// if the task is not found, we don't create the tab
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
return setupInitialNoCodeTab(tab, t, handlers, flow)
|
||||
return setupInitialNoCodeTab(Comp, tab, t, handlers, flowYaml)
|
||||
}
|
||||
|
||||
function parseTabId(tabId: string) {
|
||||
@@ -159,15 +168,72 @@ function parseTabId(tabId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export function setupInitialNoCodeTab(tab: string, t: (key: string) => string, handlers: Handlers, flowYaml: string) {
|
||||
export function setupInitialNoCodeTab(Comp: any, tab: string, t: (key: string) => string, handlers: Handlers, flowYaml: string) {
|
||||
if (!tab.startsWith(NOCODE_PREFIX)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return getTabFromNoCodeTab(parseTabId(tab), t, handlers, flowYaml)
|
||||
return getTabFromNoCodeTab(Comp, parseTabId(tab), t, handlers, flowYaml)
|
||||
}
|
||||
|
||||
export function useNoCodePanels(panels: Ref<Panel[]>, handlers: Handlers) {
|
||||
export function useNoCodeHandlers(openTabs: Ref<string[]>, focusTab: (tab: string) => void, actions: ReturnType<typeof useNoCodePanels>) {
|
||||
const editorStore = useEditorStore()
|
||||
const isFlowDirty = computed(() => editorStore.tabs.some((t:any) => t.flow && t.dirty))
|
||||
const noCodeHandlers: Handlers = {
|
||||
onCreateTask(opener, parentPath, blockSchemaPath, refPath, position){
|
||||
const createTabId = getCreateTabKey({
|
||||
parentPath,
|
||||
refPath,
|
||||
blockSchemaPath,
|
||||
position,
|
||||
}, 0).slice(12)
|
||||
|
||||
const tAdd = openTabs.value.find(t => t.endsWith(createTabId))
|
||||
|
||||
// if the tab is already open and has no data, to avoid conflicting data
|
||||
// focus it and don't open a new one
|
||||
if(tAdd && tAdd.startsWith(`${NOCODE_PREFIX}-`)){
|
||||
focusTab(tAdd)
|
||||
return false
|
||||
}
|
||||
|
||||
actions.openAddTaskTab(opener, parentPath, blockSchemaPath, refPath, position, isFlowDirty.value, undefined)
|
||||
return false
|
||||
},
|
||||
onEditTask(...args){
|
||||
// if the tab is already open, focus it
|
||||
// and don't open a new one)
|
||||
const [
|
||||
,
|
||||
parentPath,
|
||||
blockSchemaPath,
|
||||
refPath
|
||||
] = args
|
||||
const editKey = getEditTabKey({
|
||||
parentPath,
|
||||
blockSchemaPath,
|
||||
refPath,
|
||||
}, 0).slice(12)
|
||||
|
||||
const tEdit = openTabs.value.find(t => t.endsWith(editKey))
|
||||
|
||||
if(tEdit && tEdit.startsWith(`${NOCODE_PREFIX}-`)){
|
||||
focusTab(tEdit)
|
||||
return false
|
||||
}
|
||||
actions.openEditTaskTab(...args, isFlowDirty.value)
|
||||
return false
|
||||
},
|
||||
onCloseTask(...args){
|
||||
actions.closeTaskTab(...args)
|
||||
return false
|
||||
},
|
||||
}
|
||||
|
||||
return noCodeHandlers
|
||||
}
|
||||
|
||||
export function useNoCodePanels(component: any, panels: Ref<Panel[]>, openTabs: Ref<string[]>, focusTab: (tab: string) => void) {
|
||||
const {t} = useI18n()
|
||||
const flowStore = useFlowStore()
|
||||
|
||||
@@ -181,10 +247,10 @@ export function useNoCodePanels(panels: Ref<Panel[]>, handlers: Handlers) {
|
||||
refPath?: number,
|
||||
position: "before" | "after" = "after",
|
||||
dirty: boolean = false,
|
||||
fieldName?: string | undefined
|
||||
fieldName?: string | undefined,
|
||||
) {
|
||||
// create a new tab with the next createIndex
|
||||
const tab = getTabFromNoCodeTab({
|
||||
const tab = getTabFromNoCodeTab(component, {
|
||||
action: "create",
|
||||
parentPath,
|
||||
blockSchemaPath,
|
||||
@@ -195,24 +261,23 @@ export function useNoCodePanels(panels: Ref<Panel[]>, handlers: Handlers) {
|
||||
|
||||
trackTabOpen(tab);
|
||||
|
||||
panels.value[opener.panelIndex]?.tabs.splice(opener.tabIndex + 1, 0, tab)
|
||||
|
||||
const openerPanel = panels.value[opener.panelIndex]
|
||||
if (!openerPanel) {
|
||||
return
|
||||
}
|
||||
|
||||
openerPanel.tabs.splice(opener.tabIndex + 1, 0, tab)
|
||||
openerPanel.activeTab = tab
|
||||
}
|
||||
|
||||
function openEditTaskTab(
|
||||
function openEditTaskTab(
|
||||
opener: { panelIndex: number, tabIndex: number },
|
||||
parentPath: string,
|
||||
blockSchemaPath: string,
|
||||
refPath?: number,
|
||||
dirty: boolean = false
|
||||
) {
|
||||
const tab = getTabFromNoCodeTab({
|
||||
const tab = getTabFromNoCodeTab(component, {
|
||||
action: "edit",
|
||||
parentPath,
|
||||
blockSchemaPath,
|
||||
@@ -244,9 +309,13 @@ export function useNoCodePanels(panels: Ref<Panel[]>, handlers: Handlers) {
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
const actions = {
|
||||
openAddTaskTab,
|
||||
openEditTaskTab,
|
||||
closeTaskTab,
|
||||
}
|
||||
|
||||
const handlers = useNoCodeHandlers(openTabs, focusTab, actions)
|
||||
|
||||
return actions
|
||||
}
|
||||
@@ -68,7 +68,9 @@
|
||||
|
||||
const {translateError, translateErrorWithKey} = useFlowOutdatedErrors();
|
||||
|
||||
const isSettingsPlaygroundEnabled = computed(() => localStorage.getItem("editorPlayground") === "true");
|
||||
// If playground is not defined, enable it by default
|
||||
const isSettingsPlaygroundEnabled = computed(() => localStorage.getItem("editorPlayground") === "false" ? false : true);
|
||||
|
||||
const isCreating = computed(() => flowStore.isCreating === true)
|
||||
const isReadOnly = computed(() => flowStore.isReadOnly)
|
||||
const isAllowedEdit = computed(() => flowStore.isAllowedEdit)
|
||||
|
||||
@@ -184,9 +184,31 @@
|
||||
|
||||
|
||||
function updatePluginDocumentation(event: any) {
|
||||
const elementWrapper = YAML_UTILS.localizeElementAtIndex(event.model.getValue(), event.model.getOffsetAt(event.position));
|
||||
let element = (elementWrapper?.value?.type !== undefined ? elementWrapper.value : elementWrapper?.parents?.findLast(p => p.type !== undefined)) as Parameters<typeof pluginsStore.updateDocumentation>[0];
|
||||
pluginsStore.updateDocumentation(element);
|
||||
const source = event.model.getValue();
|
||||
const cursorOffset = event.model.getOffsetAt(event.position);
|
||||
|
||||
const isPlugin = (type: string) => pluginsStore.allTypes.includes(type);
|
||||
const isInRange = (range: [number, number, number]) =>
|
||||
cursorOffset >= range[0] && cursorOffset <= range[2];
|
||||
const getRangeSize = (range: [number, number, number]) => range[2] - range[0];
|
||||
|
||||
const getElementFromRange = (typeElement: any) => {
|
||||
const wrapper = YAML_UTILS.localizeElementAtIndex(source, typeElement.range[0]);
|
||||
return wrapper?.value?.type && isPlugin(wrapper.value.type)
|
||||
? wrapper.value
|
||||
: {type: typeElement.type};
|
||||
};
|
||||
|
||||
const selectedElement = YAML_UTILS.extractFieldFromMaps(source, "type", () => true, isPlugin)
|
||||
.filter(el => el.range && isInRange(el.range))
|
||||
.reduce((closest, current) =>
|
||||
!closest || getRangeSize(current.range) < getRangeSize(closest.range)
|
||||
? current
|
||||
: closest
|
||||
, null as any);
|
||||
|
||||
const result = selectedElement ? getElementFromRange(selectedElement) : undefined;
|
||||
pluginsStore.updateDocumentation(result as Parameters<typeof pluginsStore.updateDocumentation>[0]);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<template>
|
||||
<el-switch v-model="playgroundStore.enabled" :active-text="t('playground.toggle')" class="toggle" :class="{'is-active': playgroundStore.enabled}" />
|
||||
<el-tooltip placement="bottom" :content="t('playground.tooltip_persistence')">
|
||||
<el-switch v-model="playgroundStore.enabled" :active-text="t('playground.toggle')" class="toggle" :class="{'is-active': playgroundStore.enabled}" />
|
||||
</el-tooltip>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
@@ -31,36 +31,97 @@
|
||||
@expand-subflow="expandSubflow"
|
||||
@run-task="playgroundStore.runUntilTask($event.task.id)"
|
||||
/>
|
||||
|
||||
<Drawer v-if="isDrawerOpen && selectedTask" v-model="isDrawerOpen">
|
||||
<template #header>
|
||||
<code>{{ selectedTask.id }}</code>
|
||||
</template>
|
||||
<div v-if="isShowLogsOpen">
|
||||
<Collapse>
|
||||
<el-form-item>
|
||||
<search-field
|
||||
:router="false"
|
||||
@search="onSearch"
|
||||
class="me-2"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<log-level-selector
|
||||
:value="logLevel"
|
||||
@update:model-value="onLevelChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
</Collapse>
|
||||
<TaskRunDetails
|
||||
v-for="taskRun in selectedTask.taskRuns"
|
||||
:key="taskRun.id"
|
||||
:target-execution-id="selectedTask.execution?.id"
|
||||
:task-run-id="taskRun.id"
|
||||
:filter="logFilter"
|
||||
:exclude-metas="[
|
||||
'namespace',
|
||||
'flowId',
|
||||
'taskId',
|
||||
'executionId',
|
||||
]"
|
||||
:level="logLevel"
|
||||
@follow="emit('follow', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="isShowDescriptionOpen">
|
||||
<Markdown
|
||||
:source="selectedTask.description"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="isShowConditionOpen">
|
||||
<Editor
|
||||
:read-only="true"
|
||||
:input="true"
|
||||
:full-height="false"
|
||||
:navbar="false"
|
||||
:model-value="selectedTask.runIf"
|
||||
lang="yaml"
|
||||
class="mt-3"
|
||||
/>
|
||||
</div>
|
||||
</Drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
// Core
|
||||
import {getCurrentInstance, nextTick, onMounted, ref, inject, watch} from "vue";
|
||||
import type {Ref} from "vue";
|
||||
|
||||
import {useI18n} from "vue-i18n";
|
||||
import {useStorage} from "@vueuse/core";
|
||||
import {useRouter} from "vue-router";
|
||||
import {useVueFlow} from "@vue-flow/core";
|
||||
|
||||
// Topology
|
||||
import {Topology} from "@kestra-io/ui-libs";
|
||||
import SearchField from "../layout/SearchField.vue";
|
||||
import LogLevelSelector from "../logs/LogLevelSelector.vue";
|
||||
import TaskRunDetails from "../logs/TaskRunDetails.vue";
|
||||
import Collapse from "../layout/Collapse.vue";
|
||||
import Drawer from "../Drawer.vue";
|
||||
import Markdown from "../layout/Markdown.vue";
|
||||
import Editor from "./Editor.vue";
|
||||
|
||||
// Utils
|
||||
import {Topology} from "@kestra-io/ui-libs";
|
||||
import {SECTIONS} from "@kestra-io/ui-libs";
|
||||
import * as YAML_UTILS from "@kestra-io/ui-libs/flow-yaml-utils";
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const vueflowId = ref(Math.random().toString());
|
||||
const {fitView} = useVueFlow(vueflowId.value);
|
||||
|
||||
import {TOPOLOGY_CLICK_INJECTION_KEY} from "../code/injectionKeys";
|
||||
import {useCoreStore} from "../../stores/core";
|
||||
import {usePluginsStore} from "../../stores/plugins";
|
||||
import {useExecutionsStore} from "../../stores/executions";
|
||||
import {usePlaygroundStore} from "../../stores/playground";
|
||||
const topologyClick = inject(TOPOLOGY_CLICK_INJECTION_KEY, ref());
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const vueflowId = ref(Math.random().toString());
|
||||
const {fitView} = useVueFlow(vueflowId.value);
|
||||
|
||||
const topologyClick = inject(TOPOLOGY_CLICK_INJECTION_KEY, ref(null)) as Ref<any>;
|
||||
|
||||
const executionsStore = useExecutionsStore();
|
||||
const playgroundStore = usePlaygroundStore();
|
||||
@@ -114,6 +175,8 @@
|
||||
const taskEditData = ref();
|
||||
const taskEditDomElement = ref();
|
||||
const isShowLogsOpen = ref(false);
|
||||
const logFilter = ref("");
|
||||
const logLevel = ref(localStorage.getItem("defaultLogLevel") || "INFO");
|
||||
const isDrawerOpen = ref(false);
|
||||
const isShowDescriptionOpen = ref(false);
|
||||
const isShowConditionOpen = ref(false);
|
||||
@@ -279,6 +342,14 @@
|
||||
isDrawerOpen.value = true;
|
||||
};
|
||||
|
||||
const onSearch = (search: string) => {
|
||||
logFilter.value = search;
|
||||
};
|
||||
|
||||
const onLevelChange = (level: string) => {
|
||||
logLevel.value = level;
|
||||
};
|
||||
|
||||
const showDescription = (event: string) => {
|
||||
selectedTask.value = event;
|
||||
isShowDescriptionOpen.value = true;
|
||||
|
||||
@@ -14,7 +14,9 @@ export const images: Record<string, string> = {
|
||||
testSuites,
|
||||
concurrency_executions,
|
||||
concurrency_limit,
|
||||
dependencies,
|
||||
"dependencies.FLOW": dependencies,
|
||||
"dependencies.EXECUTION": dependencies,
|
||||
"dependencies.NAMESPACE": dependencies,
|
||||
plugins,
|
||||
triggers,
|
||||
versionPlugin,
|
||||
|
||||
@@ -1,268 +0,0 @@
|
||||
<template>
|
||||
<el-card v-loading="isLoading">
|
||||
<el-alert v-if="!hasNodes" type="info" :closable="false">
|
||||
{{ $t("no result") }}
|
||||
</el-alert>
|
||||
|
||||
<VueFlow
|
||||
v-else
|
||||
:default-marker-color="cssVariable('--bs-cyan')"
|
||||
:fit-view-on-init="true"
|
||||
:nodes-connectable="false"
|
||||
:nodes-draggable="false"
|
||||
:elevate-nodes-on-select="false"
|
||||
>
|
||||
<Background />
|
||||
|
||||
<template #node-flow="nodeProps">
|
||||
<DependenciesNode
|
||||
v-bind="nodeProps"
|
||||
@expand-dependencies="expand"
|
||||
@mouseover="onMouseOver"
|
||||
@mouseleave="onMouseLeave"
|
||||
@open-link="openFlow($route.params.tenant, $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<Controls :show-interactive="false">
|
||||
<ControlButton>
|
||||
<el-tooltip
|
||||
:content="$t('expand dependencies')"
|
||||
:persistent="false"
|
||||
transition=""
|
||||
:hide-after="0"
|
||||
effect="light"
|
||||
>
|
||||
<el-button
|
||||
:icon="ArrowExpandAll"
|
||||
size="small"
|
||||
@click="expandAll"
|
||||
/>
|
||||
</el-tooltip>
|
||||
</ControlButton>
|
||||
</Controls>
|
||||
</VueFlow>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {ref, onMounted, inject, nextTick} from "vue";
|
||||
import {useStore} from "vuex";
|
||||
import {useRouter} from "vue-router";
|
||||
import {VueFlow, useVueFlow, Position, MarkerType} from "@vue-flow/core";
|
||||
import {Controls, ControlButton} from "@vue-flow/controls";
|
||||
import {Background} from "@vue-flow/background";
|
||||
import dagre from "dagre";
|
||||
import ArrowExpandAll from "vue-material-design-icons/ArrowExpandAll.vue";
|
||||
|
||||
import {cssVariable, DependenciesNode} from "@kestra-io/ui-libs";
|
||||
|
||||
import {linkedElements} from "../../../../utils/vueFlow";
|
||||
import {apiUrl} from "override/utils/route";
|
||||
|
||||
const {
|
||||
id: vueFlowId,
|
||||
addNodes,
|
||||
addEdges,
|
||||
getNodes,
|
||||
removeNodes,
|
||||
getEdges,
|
||||
removeEdges,
|
||||
fitView,
|
||||
addSelectedElements,
|
||||
removeSelectedNodes,
|
||||
removeSelectedEdges,
|
||||
} = useVueFlow();
|
||||
|
||||
const axios = inject("axios");
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
|
||||
const loaded = ref([]);
|
||||
const dependencies = ref({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
});
|
||||
|
||||
const isLoading = ref(false);
|
||||
const hasNodes = ref(false);
|
||||
|
||||
const props = defineProps({
|
||||
namespace: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const load = () => {
|
||||
isLoading.value = true;
|
||||
return axios
|
||||
.get(`${apiUrl(store)}/namespaces/${props.namespace}/dependencies`)
|
||||
.then((response) => {
|
||||
loaded.value.push(`${props.namespace}`);
|
||||
|
||||
if (Object.keys(response.data).length > 0) {
|
||||
dependencies.value.nodes.push(...response.data.nodes);
|
||||
if (response.data.edges) {
|
||||
dependencies.value.edges.push(...response.data.edges);
|
||||
}
|
||||
}
|
||||
|
||||
if (dependencies?.value?.nodes?.length > 0) {
|
||||
hasNodes.value = true;
|
||||
}
|
||||
|
||||
removeEdges(getEdges.value);
|
||||
removeNodes(getNodes.value);
|
||||
|
||||
nextTick(() => {
|
||||
generateGraph();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const loadOutsideDependencies = (options) => {
|
||||
isLoading.value = true;
|
||||
return axios
|
||||
.get(
|
||||
`${apiUrl(store)}/flows/${options.namespace}/${options.id}/dependencies`,
|
||||
)
|
||||
.then((response) => {
|
||||
loaded.value.push(`${options.namespace}_${options.id}`);
|
||||
|
||||
if (Object.keys(response.data).length > 0) {
|
||||
dependencies.value.nodes.push(...response.data.nodes);
|
||||
dependencies.value.edges.push(...response.data.edges);
|
||||
}
|
||||
|
||||
removeEdges(getEdges.value);
|
||||
removeNodes(getNodes.value);
|
||||
|
||||
nextTick(() => {
|
||||
generateGraph();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const expandAll = () => {
|
||||
for (const node of dependencies.value.nodes) {
|
||||
if (loaded.value.indexOf(node.uid) < 0) {
|
||||
loadOutsideDependencies({namespace: node.namespace, id: node.id});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const expand = (data) => {
|
||||
loadOutsideDependencies({namespace: data.namespace, id: data.flowId});
|
||||
};
|
||||
|
||||
const generateDagreGraph = () => {
|
||||
const dagreGraph = new dagre.graphlib.Graph();
|
||||
dagreGraph.setDefaultEdgeLabel(() => ({}));
|
||||
dagreGraph.setGraph({rankdir: "LR"});
|
||||
|
||||
for (const node of dependencies.value.nodes) {
|
||||
dagreGraph.setNode(node.uid, {
|
||||
width: 184,
|
||||
height: 44,
|
||||
});
|
||||
}
|
||||
|
||||
for (const edge of dependencies.value.edges) {
|
||||
dagreGraph.setEdge(edge.source, edge.target);
|
||||
}
|
||||
|
||||
dagre.layout(dagreGraph);
|
||||
|
||||
return dagreGraph;
|
||||
};
|
||||
|
||||
const getNodePosition = (n) => {
|
||||
return {x: n.x - n.width / 2, y: n.y - n.height / 2};
|
||||
};
|
||||
|
||||
const generateGraph = () => {
|
||||
const dagreGraph = generateDagreGraph();
|
||||
|
||||
for (const node of dependencies.value.nodes) {
|
||||
const dagreNode = dagreGraph.node(node.uid);
|
||||
|
||||
addNodes([
|
||||
{
|
||||
id: node.uid,
|
||||
type: "flow",
|
||||
position: getNodePosition(dagreNode),
|
||||
style: {
|
||||
width: "184px",
|
||||
height: "44px",
|
||||
},
|
||||
sourcePosition: Position.Right,
|
||||
targetPosition: Position.Left,
|
||||
data: {
|
||||
node: node,
|
||||
loaded: loaded.value.indexOf(node.uid) >= 0,
|
||||
namespace: node.namespace,
|
||||
flowId: node.id,
|
||||
current: node.id === props.namespace,
|
||||
color: "pink",
|
||||
link: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
for (const edge of dependencies.value.edges) {
|
||||
addEdges([
|
||||
{
|
||||
id: edge.source + "|" + edge.target,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
markerEnd: {
|
||||
id: "marker-custom",
|
||||
type: MarkerType.ArrowClosed,
|
||||
},
|
||||
type: "smoothstep",
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
fitView();
|
||||
isLoading.value = false;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (props.namespace !== undefined) {
|
||||
load();
|
||||
}
|
||||
});
|
||||
|
||||
const onMouseOver = (node) => {
|
||||
addSelectedElements(linkedElements(vueFlowId, node.uid));
|
||||
};
|
||||
|
||||
const onMouseLeave = () => {
|
||||
removeSelectedNodes(getNodes.value);
|
||||
removeSelectedEdges(getEdges.value);
|
||||
};
|
||||
|
||||
const openFlow = (tenant, data) => {
|
||||
router.push({
|
||||
name: "flows/update",
|
||||
params: {
|
||||
namespace: data.namespace,
|
||||
id: data.flowId,
|
||||
tab: "dependencies",
|
||||
tenant: tenant,
|
||||
},
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.el-card {
|
||||
height: calc(100vh - 174px);
|
||||
:deep(.el-card__body) {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -6,7 +6,7 @@ import BlueprintsBrowser from "../../../override/components/flows/blueprints/Blu
|
||||
import Dashboard from "../../../components/dashboard/Dashboard.vue";
|
||||
import Flows from "../../../components/flows/Flows.vue";
|
||||
import Executions from "../../../components/executions/Executions.vue";
|
||||
import Dependencies from "../../../components/namespaces/components/content/Dependencies.vue";
|
||||
import Dependencies from "../../../components/dependencies/Dependencies.vue";
|
||||
import EditorView from "../../../components/inputs/EditorView.vue";
|
||||
|
||||
export interface Tab {
|
||||
@@ -104,7 +104,7 @@ export function useHelpers() {
|
||||
name: "dependencies",
|
||||
title: t("dependencies"),
|
||||
component: Dependencies,
|
||||
props: {namespace: namespace.value, type: "dependencies"},
|
||||
maximized: true,
|
||||
},
|
||||
{
|
||||
maximized: true,
|
||||
|
||||
@@ -9,45 +9,48 @@
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="plugin-doc">
|
||||
<div class="versions" v-if="pluginsStore.versions?.length > 0">
|
||||
<el-select
|
||||
v-model="version"
|
||||
placeholder="Version"
|
||||
size="small"
|
||||
:disabled="pluginsStore.versions?.length === 1"
|
||||
@change="selectVersion(version)"
|
||||
>
|
||||
<template #label="{value}">
|
||||
<span>Version: </span>
|
||||
<span style="font-weight: bold">{{ value }}</span>
|
||||
</template>
|
||||
<el-option
|
||||
v-for="item in pluginsStore.versions"
|
||||
:key="item"
|
||||
:label="item"
|
||||
:value="item"
|
||||
<div class="d-flex align-items-center justify-content-between gap-3">
|
||||
<div class="d-flex gap-3 mb-3 align-items-center">
|
||||
<task-icon
|
||||
class="plugin-icon"
|
||||
:cls="pluginType"
|
||||
only-icon
|
||||
:icons="pluginsStore.icons"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="d-flex gap-3 mb-3 align-items-center">
|
||||
<task-icon
|
||||
class="plugin-icon"
|
||||
:cls="pluginType"
|
||||
only-icon
|
||||
:icons="pluginsStore.icons"
|
||||
/>
|
||||
<h4 class="mb-0">
|
||||
{{ pluginName }}
|
||||
</h4>
|
||||
<el-button
|
||||
v-if="releaseNotesUrl"
|
||||
size="small"
|
||||
class="release-notes-btn"
|
||||
:icon="GitHub"
|
||||
@click="openReleaseNotes"
|
||||
>
|
||||
{{ $t('plugins.release') }}
|
||||
</el-button>
|
||||
<h4 class="mb-0">
|
||||
{{ pluginName }}
|
||||
</h4>
|
||||
<el-button
|
||||
v-if="releaseNotesUrl"
|
||||
size="small"
|
||||
class="release-notes-btn"
|
||||
:icon="GitHub"
|
||||
@click="openReleaseNotes"
|
||||
>
|
||||
{{ $t('plugins.release') }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 versions" v-if="pluginsStore.versions?.length > 0">
|
||||
<el-select
|
||||
v-model="version"
|
||||
placeholder="Version"
|
||||
size="small"
|
||||
:disabled="pluginsStore.versions?.length === 1"
|
||||
@change="selectVersion(version)"
|
||||
>
|
||||
<template #label="{value}">
|
||||
<span>Version: </span>
|
||||
<span style="font-weight: bold">{{ value }}</span>
|
||||
</template>
|
||||
<el-option
|
||||
v-for="item in pluginsStore.versions"
|
||||
:key="item"
|
||||
:label="item"
|
||||
:value="item"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
<Suspense v-loading="isLoading">
|
||||
<schema-to-html
|
||||
@@ -194,8 +197,6 @@
|
||||
|
||||
.versions {
|
||||
min-width: 200px;
|
||||
display: inline-grid;
|
||||
float: right;
|
||||
}
|
||||
|
||||
:deep(.main-container) {
|
||||
|
||||
@@ -25,28 +25,30 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, inject, onBeforeMount} from "vue";
|
||||
import {computed, inject, onBeforeMount, ref} from "vue";
|
||||
import {useI18n} from "vue-i18n";
|
||||
import {TaskIcon} from "@kestra-io/ui-libs";
|
||||
import {removeRefPrefix, usePluginsStore} from "../../stores/plugins";
|
||||
import {
|
||||
BLOCK_SCHEMA_PATH_INJECTION_KEY,
|
||||
PARENT_PATH_INJECTION_KEY
|
||||
FULL_SCHEMA_INJECTION_KEY,
|
||||
PARENT_PATH_INJECTION_KEY,
|
||||
SCHEMA_DEFINITIONS_INJECTION_KEY,
|
||||
} from "../code/injectionKeys";
|
||||
import {getValueAtJsonPath} from "../../utils/utils";
|
||||
|
||||
const pluginsStore = usePluginsStore();
|
||||
|
||||
const blockSchemaPath = inject(BLOCK_SCHEMA_PATH_INJECTION_KEY, "");
|
||||
const parentPath = inject(PARENT_PATH_INJECTION_KEY, "");
|
||||
const fullSchema = inject(FULL_SCHEMA_INJECTION_KEY, ref<Record<string, any>>({}));
|
||||
const rootDefinitions = inject(SCHEMA_DEFINITIONS_INJECTION_KEY, ref<Record<string, any>>({}));
|
||||
|
||||
const blockType = parentPath.split(".").pop() ?? "";
|
||||
|
||||
const fieldDefinition = computed(() => {
|
||||
if (blockSchemaPath.length === 0) {
|
||||
if (props.blockSchemaPath.length === 0) {
|
||||
console.error("Definition key is required for PluginSelect component");
|
||||
}
|
||||
return getValueAtJsonPath(pluginsStore.flowSchema, blockSchemaPath);
|
||||
return getValueAtJsonPath(fullSchema.value, props.blockSchemaPath);
|
||||
})
|
||||
|
||||
onBeforeMount(() => {
|
||||
@@ -55,6 +57,17 @@
|
||||
}
|
||||
})
|
||||
|
||||
const allRefs = computed(() => fieldDefinition.value?.anyOf?.map((item: any) => {
|
||||
if (item.allOf) {
|
||||
// if the item is an allOf, we need to find the first item that has a $ref
|
||||
const refItem = item.allOf.find((d: any) => d.$ref);
|
||||
if (refItem?.$ref) {
|
||||
return removeRefPrefix(refItem.$ref);
|
||||
}
|
||||
}
|
||||
return removeRefPrefix(item.$ref);
|
||||
}) || []);
|
||||
|
||||
const taskModels = computed(() => {
|
||||
if (blockType === "pluginDefaults") {
|
||||
const models = new Set<any>();
|
||||
@@ -73,20 +86,10 @@
|
||||
|
||||
return Array.from(models);
|
||||
}
|
||||
const allRefs = fieldDefinition.value?.anyOf?.map((item: any) => {
|
||||
if (item.allOf) {
|
||||
// if the item is an allOf, we need to find the first item that has a $ref
|
||||
const refItem = item.allOf.find((d: any) => d.$ref);
|
||||
if (refItem?.$ref) {
|
||||
return removeRefPrefix(refItem.$ref);
|
||||
}
|
||||
}
|
||||
return removeRefPrefix(item.$ref);
|
||||
}) || [];
|
||||
|
||||
return allRefs.reduce((acc: string[], item: string) => {
|
||||
const def = pluginsStore.flowDefinitions?.[item]
|
||||
|
||||
return allRefs.value.reduce((acc: string[], item: string) => {
|
||||
const def = rootDefinitions.value?.[item]
|
||||
|
||||
if (!def || def.$deprecated) {
|
||||
return acc;
|
||||
}
|
||||
@@ -112,6 +115,10 @@
|
||||
type: String,
|
||||
default: "",
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
blockSchemaPath: string,
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -358,7 +358,7 @@
|
||||
this.pendingSettings.executeFlowBehaviour = localStorage.getItem("executeFlowBehaviour") || "same tab";
|
||||
this.pendingSettings.executeDefaultTab = localStorage.getItem("executeDefaultTab") || "gantt";
|
||||
this.pendingSettings.flowDefaultTab = localStorage.getItem("flowDefaultTab") || "overview";
|
||||
this.pendingSettings.editorPlayground = localStorage.getItem("editorPlayground") === "true";
|
||||
this.pendingSettings.editorPlayground = localStorage.getItem("editorPlayground") === "false" ? false : true;
|
||||
this.pendingSettings.envName = this.layoutStore.envName || this.miscStore.configs?.environment?.name;
|
||||
this.pendingSettings.envColor = this.layoutStore.envColor || this.miscStore.configs?.environment?.color;
|
||||
this.pendingSettings.logsFontSize = parseInt(localStorage.getItem("logsFontSize")) || 12;
|
||||
|
||||
@@ -54,6 +54,10 @@ export function useBaseNamespacesStore() {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function loadDependencies(this: any, options: {namespace: string}) {
|
||||
return await this.$http.get(`${apiUrl(this.vuexStore)}/namespaces/${options.namespace}/dependencies`);
|
||||
}
|
||||
|
||||
async function kvsList(this: any, item: {id: string}) {
|
||||
const response = await this.$http.get(`${apiUrl(this.vuexStore)}/namespaces/${item.id}/kv`, VALIDATE);
|
||||
kvs.value = response.data;
|
||||
@@ -227,6 +231,7 @@ export function useBaseNamespacesStore() {
|
||||
search,
|
||||
total,
|
||||
load,
|
||||
loadDependencies,
|
||||
existing,
|
||||
namespace,
|
||||
namespaces,
|
||||
|
||||
@@ -6,19 +6,19 @@ export default function useRouteContext(routeInfoTitle: Ref<string>, embed: bool
|
||||
|
||||
function handleTitle(){
|
||||
if(!embed) {
|
||||
let baseTitle;
|
||||
let baseTitle;
|
||||
|
||||
if (document.title.lastIndexOf("|") > 0) {
|
||||
baseTitle = document.title.substring(document.title.lastIndexOf("|") + 1);
|
||||
} else {
|
||||
baseTitle = document.title;
|
||||
}
|
||||
|
||||
document.title = routeInfoTitle.value + " | " + baseTitle;
|
||||
if (document.title.lastIndexOf("|") > 0) {
|
||||
baseTitle = document.title.substring(document.title.lastIndexOf("|") + 1);
|
||||
} else {
|
||||
baseTitle = document.title;
|
||||
}
|
||||
|
||||
document.title = routeInfoTitle.value + " | " + baseTitle;
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => route, () => {
|
||||
handleTitle()
|
||||
})
|
||||
}, {immediate: true})
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import BallotOutlineIcon from "vue-material-design-icons/BallotOutline.vue";
|
||||
|
||||
import EditorSidebarWrapper from "../../../components/inputs/EditorSidebarWrapper.vue";
|
||||
import EditorWrapper from "../../../components/inputs/EditorWrapper.vue";
|
||||
import NoCodeWrapper from "../../../components/code/NoCodeWrapper.vue";
|
||||
import NoCode from "../../../components/code/NoCode.vue";
|
||||
import LowCodeEditorWrapper from "../../../components/inputs/LowCodeEditorWrapper.vue";
|
||||
import PluginDocumentationWrapper from "../../../components/plugins/PluginDocumentationWrapper.vue";
|
||||
import BlueprintsWrapper from "../../../components/flows/blueprints/BlueprintsWrapper.vue";
|
||||
@@ -31,7 +31,7 @@ export const EDITOR_ELEMENTS = [
|
||||
label: "No-code"
|
||||
},
|
||||
value: "nocode",
|
||||
component: markRaw(NoCodeWrapper),
|
||||
component: markRaw(NoCode),
|
||||
},
|
||||
{
|
||||
button: {
|
||||
|
||||
@@ -259,5 +259,8 @@ export function useLeftMenu() {
|
||||
];
|
||||
};
|
||||
|
||||
return {generateMenu};
|
||||
}
|
||||
return {
|
||||
routeStartWith,
|
||||
generateMenu
|
||||
};
|
||||
}
|
||||
|
||||
@@ -489,10 +489,10 @@ export const useFlowStore = defineStore("flow", () => {
|
||||
})
|
||||
}
|
||||
|
||||
function loadDependencies(options: { namespace: string, id: string, subtype: "FLOW" | "EXECUTION" }) {
|
||||
function loadDependencies(options: { namespace: string, id: string, subtype: "FLOW" | "EXECUTION" }, onlyCount = false) {
|
||||
return store.$http.get(`${apiUrl(store)}/flows/${options.namespace}/${options.id}/dependencies?expandAll=true`).then(response => {
|
||||
return {
|
||||
data: transformResponse(response.data, options.subtype),
|
||||
...(!onlyCount ? {data: transformResponse(response.data, options.subtype)} : {}),
|
||||
count: response.data.nodes ? [...new Set(response.data.nodes.map((r:{uid:string}) => r.uid))].length : 0
|
||||
};
|
||||
})
|
||||
|
||||
@@ -143,8 +143,10 @@ export const usePlaygroundStore = defineStore("playground", () => {
|
||||
return latestExecution.value?.state.current;
|
||||
})
|
||||
|
||||
const readyToStartPure = computed(() => {
|
||||
return !latestExecution.value || !nonFinalStates.includes(executionState.value)
|
||||
const readyToStartPure = computed(()=>{
|
||||
const executionReady = !latestExecution.value || !nonFinalStates.includes(executionState.value);
|
||||
const flowValid = !(flowStore.haveChange && flowStore.flowErrors);
|
||||
return executionReady && flowValid;
|
||||
})
|
||||
|
||||
const readyToStart = ref(readyToStartPure.value);
|
||||
@@ -185,7 +187,9 @@ export const usePlaygroundStore = defineStore("playground", () => {
|
||||
console.warn("Playground is not ready to start, latest execution is still in progress");
|
||||
return
|
||||
}
|
||||
|
||||
if (flowStore.haveChange && flowStore.flowErrors) {
|
||||
return;
|
||||
}
|
||||
readyToStart.value = false;
|
||||
|
||||
if(flowStore.isCreating){
|
||||
|
||||
@@ -489,7 +489,7 @@ form.ks-horizontal {
|
||||
}
|
||||
|
||||
td.row-graph {
|
||||
padding: 0;
|
||||
padding: 0.75rem 0 0;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
@@ -1246,4 +1246,4 @@ form.ks-horizontal {
|
||||
|
||||
.el-scrollbar__thumb {
|
||||
background-color: var(--ks-border-primary) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -447,8 +447,18 @@
|
||||
"title": "Für diesen Flow sind keine Grenzen festgelegt."
|
||||
},
|
||||
"dependencies": {
|
||||
"content": "Lesen Sie mehr über <a href=\"https://kestra.io/docs/ui/flows#dependencies\" target=\"_blank\">Flow-Abhängigkeiten</a> in unserer Dokumentation.",
|
||||
"title": "Für diesen Flow sind keine Abhängigkeiten festgelegt."
|
||||
"EXECUTION": {
|
||||
"content": "Erfahren Sie mehr über <a href=\"https://kestra.io/docs/ui/executions#dependencies\" target=\"_blank\">Ausführungsabhängigkeiten</a> in unserer Dokumentation.",
|
||||
"title": "Derzeit gibt es keine Abhängigkeiten."
|
||||
},
|
||||
"FLOW": {
|
||||
"content": "Erfahren Sie mehr über <a href=\"https://kestra.io/docs/ui/flows#dependencies\" target=\"_blank\">Flow-Abhängigkeiten</a> in unserer Dokumentation.",
|
||||
"title": "Derzeit gibt es keine Abhängigkeiten."
|
||||
},
|
||||
"NAMESPACE": {
|
||||
"content": "Lesen Sie mehr über <a href=\"https://kestra.io/docs/ui/namespaces#dependencies\" target=\"_blank\">Namespace Dependencies</a> in unserer Dokumentation.",
|
||||
"title": "Derzeit gibt es keine Abhängigkeiten."
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"content": "Lesen Sie mehr über <strong><a href=\"https://kestra.io/docs/workflow-components/plugin-defaults\" target=\"_blank\">Nebenläufigkeitsgrenzen</a></strong> in unserer Dokumentation.",
|
||||
@@ -1035,6 +1045,7 @@
|
||||
"pause done": "Die Ausführung ist PAUSED.",
|
||||
"pause title": "Ausführung <code>{id}</code> pausieren.<br/>Bitte beachten Sie, dass derzeit laufende Tasks weiterhin verarbeitet werden und die Ausführung manuell fortgesetzt werden muss.",
|
||||
"playground": {
|
||||
"clear_history": "Verlauf löschen",
|
||||
"confirm_create": "Sie können den Playground nicht ausführen, während Sie einen flow erstellen. Das Starten eines Playground-Laufs wird den flow erstellen.",
|
||||
"history": "Letzte 10 Ausführungen",
|
||||
"play_icon_info": "Sie können auch das Play-Symbol in den No-Code- oder Topologie-Ansichten anklicken.",
|
||||
@@ -1044,7 +1055,8 @@
|
||||
"run_task_info": "Bewegen Sie den Mauszeiger über eine beliebige Task im Flow-Code-Editor und klicken Sie auf die Schaltfläche \"Run task\", um Ihre Task zu testen.",
|
||||
"run_this_task": "Führe diese Task aus",
|
||||
"title": "Spielwiese",
|
||||
"toggle": "Spielplatz"
|
||||
"toggle": "Spielplatz",
|
||||
"tooltip_persistence": "Wenn Sie den Playground ausschalten und wieder einschalten, bleiben die Informationen erhalten, solange Sie auf der Seite bleiben."
|
||||
},
|
||||
"pluginDefaults": "Plugin-Standardeinstellungen",
|
||||
"pluginPage": {
|
||||
@@ -1062,7 +1074,12 @@
|
||||
"port": "Port",
|
||||
"prefill inputs": "Vorausfüllen",
|
||||
"prev_execution": "Vorherige Ausführung",
|
||||
"preview": "Vorschau",
|
||||
"preview": {
|
||||
"auto-view": "Automatische Ansicht",
|
||||
"force-editor": "Editoransicht erzwingen",
|
||||
"label": "Vorschau",
|
||||
"view": "Anzeigen"
|
||||
},
|
||||
"properties": {
|
||||
"hidden": "In Tabelle versteckt",
|
||||
"hint": "Sichtbare Spalten auswählen",
|
||||
|
||||
@@ -579,7 +579,6 @@
|
||||
"warning detected": "Warning(s) detected",
|
||||
"informative notice": "Note(s)",
|
||||
"cannot swap tasks": "Can't swap the tasks",
|
||||
"preview": "Preview",
|
||||
"open": "Open",
|
||||
"dependency task": "Task {taskId} is a dependency of another task",
|
||||
"administration": "Administration",
|
||||
@@ -1319,8 +1318,18 @@
|
||||
"content": "Read more about <strong><a href=\"https://kestra.io/docs/workflow-components/concurrency\" target=\"_blank\">Concurrency Limits</a></strong> in our documentation."
|
||||
},
|
||||
"dependencies": {
|
||||
"title": "No dependencies are set for this Flow.",
|
||||
"content": "Read more about <a href=\"https://kestra.io/docs/ui/flows#dependencies\" target=\"_blank\">Flow Dependencies</a> in our documentation."
|
||||
"FLOW": {
|
||||
"title": "There are currently no dependencies.",
|
||||
"content": "Read more about <a href=\"https://kestra.io/docs/ui/flows#dependencies\" target=\"_blank\">Flow Dependencies</a> in our documentation."
|
||||
},
|
||||
"EXECUTION": {
|
||||
"title": "There are currently no dependencies.",
|
||||
"content": "Read more about <a href=\"https://kestra.io/docs/ui/executions#dependencies\" target=\"_blank\">Execution Dependencies</a> in our documentation."
|
||||
},
|
||||
"NAMESPACE": {
|
||||
"title": "There are currently no dependencies.",
|
||||
"content": "Read more about <a href=\"https://kestra.io/docs/ui/namespaces#dependencies\" target=\"_blank\">Namespace Dependencies</a> in our documentation."
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"title": "No plugin defaults are set for this for this Namespace.",
|
||||
@@ -1472,7 +1481,9 @@
|
||||
"run_task_and_downstream": "Run task & downstream",
|
||||
"run_all_tasks": "Run All Tasks",
|
||||
"history": "Last 10 runs",
|
||||
"confirm_create": "You cannot run the playground while creating a flow. Launching a playground run will create the flow."
|
||||
"clear_history": "Clear history",
|
||||
"confirm_create": "You cannot run the playground while creating a flow. Launching a playground run will create the flow.",
|
||||
"tooltip_persistence": "If you turn Playground off and back on, the information remains as long as you stay on the page."
|
||||
},
|
||||
"submit": "Submit",
|
||||
"to toggle": "to toggle",
|
||||
@@ -1498,6 +1509,12 @@
|
||||
"placeholder": "Search by flow or namespace...",
|
||||
"no_results": "No results found for {term}"
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
"label": "Preview",
|
||||
"force-editor": "Enforce editor view",
|
||||
"auto-view": "Auto view",
|
||||
"view": "View"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -447,8 +447,18 @@
|
||||
"title": "No se han establecido límites para este Flow."
|
||||
},
|
||||
"dependencies": {
|
||||
"content": "Lea más sobre <a href=\"https://kestra.io/docs/ui/flows#dependencies\" target=\"_blank\">Flow Dependencies</a> en nuestra documentación.",
|
||||
"title": "No hay dependencias establecidas para este Flow."
|
||||
"EXECUTION": {
|
||||
"content": "Lea más sobre <a href=\"https://kestra.io/docs/ui/executions#dependencies\" target=\"_blank\">Dependencias de Ejecución</a> en nuestra documentación.",
|
||||
"title": "Actualmente no hay dependencias."
|
||||
},
|
||||
"FLOW": {
|
||||
"content": "Lea más sobre <a href=\"https://kestra.io/docs/ui/flows#dependencies\" target=\"_blank\">Flow Dependencies</a> en nuestra documentación.",
|
||||
"title": "Actualmente no hay dependencias."
|
||||
},
|
||||
"NAMESPACE": {
|
||||
"content": "Lea más sobre <a href=\"https://kestra.io/docs/ui/namespaces#dependencies\" target=\"_blank\">Namespace Dependencies</a> en nuestra documentación.",
|
||||
"title": "Actualmente no hay dependencias."
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"content": "Lee más sobre <strong><a href=\"https://kestra.io/docs/workflow-components/plugin-defaults\" target=\"_blank\">Concurrency Limits</a></strong> en nuestra documentación.",
|
||||
@@ -1035,6 +1045,7 @@
|
||||
"pause done": "La ejecución está PAUSED",
|
||||
"pause title": "Pausar la ejecución <code>{id}</code>.<br/>Tenga en cuenta que las tasks que se están ejecutando actualmente seguirán siendo procesadas, y la ejecución tendrá que reanudarse manualmente.",
|
||||
"playground": {
|
||||
"clear_history": "Borrar historial",
|
||||
"confirm_create": "No puedes ejecutar el playground mientras creas un flow. Iniciar una ejecución de playground creará el flow.",
|
||||
"history": "Últimas 10 ejecuciones",
|
||||
"play_icon_info": "También puedes presionar el ícono de Play en las vistas No-Code o Topology.",
|
||||
@@ -1044,7 +1055,8 @@
|
||||
"run_task_info": "Pasa el cursor sobre cualquier task en el editor de Flow Code y haz clic en el botón \"Run task\" para probar tu task.",
|
||||
"run_this_task": "Ejecutar esta task",
|
||||
"title": "Área de Pruebas",
|
||||
"toggle": "Área de pruebas"
|
||||
"toggle": "Área de pruebas",
|
||||
"tooltip_persistence": "Si desactivas y vuelves a activar el Playground, la información permanece mientras te mantengas en la página."
|
||||
},
|
||||
"pluginDefaults": "Valores predeterminados del plugin",
|
||||
"pluginPage": {
|
||||
@@ -1062,7 +1074,12 @@
|
||||
"port": "Puerto",
|
||||
"prefill inputs": "Prefill",
|
||||
"prev_execution": "Ejecución anterior",
|
||||
"preview": "Vista previa",
|
||||
"preview": {
|
||||
"auto-view": "Vista automática",
|
||||
"force-editor": "Forzar vista del editor",
|
||||
"label": "Vista previa",
|
||||
"view": "Ver"
|
||||
},
|
||||
"properties": {
|
||||
"hidden": "Oculto en la Tabla",
|
||||
"hint": "Elegir Columnas Visibles",
|
||||
|
||||
@@ -447,8 +447,18 @@
|
||||
"title": "Aucune limite n'est définie pour ce flow."
|
||||
},
|
||||
"dependencies": {
|
||||
"content": "En savoir plus sur les <a href=\"https://kestra.io/docs/ui/flows#dependencies\" target=\"_blank\">dépendances de flow</a> dans notre documentation.",
|
||||
"title": "Aucune dépendance n'est définie pour ce flow."
|
||||
"EXECUTION": {
|
||||
"content": "En savoir plus sur les <a href=\"https://kestra.io/docs/ui/executions#dependencies\" target=\"_blank\">dépendances d'exécution</a> dans notre documentation.",
|
||||
"title": "Il n'y a actuellement aucune dépendance."
|
||||
},
|
||||
"FLOW": {
|
||||
"content": "En savoir plus sur les <a href=\"https://kestra.io/docs/ui/flows#dependencies\" target=\"_blank\">Flow Dependencies</a> dans notre documentation.",
|
||||
"title": "Il n'y a actuellement aucune dépendance."
|
||||
},
|
||||
"NAMESPACE": {
|
||||
"content": "En savoir plus sur les <a href=\"https://kestra.io/docs/ui/namespaces#dependencies\" target=\"_blank\">dépendances de namespace</a> dans notre documentation.",
|
||||
"title": "Il n'y a actuellement aucune dépendance."
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"content": "En savoir plus sur les <strong><a href=\"https://kestra.io/docs/workflow-components/plugin-defaults\" target=\"_blank\">limites de Concurrency</a></strong> dans notre documentation.",
|
||||
@@ -1035,6 +1045,7 @@
|
||||
"pause done": "L'exécution est en PAUSE",
|
||||
"pause title": "Mettre en pause l'exécution <code>{id}</code>.<br/>Notez que les tasks actuellement en cours d'exécution seront toujours traitées, et l'exécution devra être reprise manuellement.",
|
||||
"playground": {
|
||||
"clear_history": "Effacer l'historique",
|
||||
"confirm_create": "Vous ne pouvez pas exécuter le playground lors de la création d'un flow. Lancer une exécution de playground créera le flow.",
|
||||
"history": "Dernières 10 exécutions",
|
||||
"play_icon_info": "Vous pouvez également cliquer sur l'icône Play dans les vues No-Code ou Topology.",
|
||||
@@ -1044,7 +1055,8 @@
|
||||
"run_task_info": "Survolez n'importe quelle task dans l'éditeur de Flow Code et cliquez sur le bouton \"Run task\" pour tester votre task.",
|
||||
"run_this_task": "Exécuter cette task",
|
||||
"title": "Aire de jeu",
|
||||
"toggle": "Terrain de jeu"
|
||||
"toggle": "Terrain de jeu",
|
||||
"tooltip_persistence": "Si vous désactivez et réactivez le Playground, les informations restent tant que vous restez sur la page."
|
||||
},
|
||||
"pluginDefaults": "Par défaut du plugin",
|
||||
"pluginPage": {
|
||||
@@ -1062,7 +1074,12 @@
|
||||
"port": "Port",
|
||||
"prefill inputs": "Pré-remplir",
|
||||
"prev_execution": "Exécution précédente",
|
||||
"preview": "Aperçu",
|
||||
"preview": {
|
||||
"auto-view": "Vue automatique",
|
||||
"force-editor": "Imposer la vue de l'éditeur",
|
||||
"label": "Aperçu",
|
||||
"view": "Voir"
|
||||
},
|
||||
"properties": {
|
||||
"hidden": "Caché dans le tableau",
|
||||
"hint": "Choisir les colonnes visibles",
|
||||
|
||||
@@ -447,8 +447,18 @@
|
||||
"title": "इस Flow के लिए कोई सीमाएँ निर्धारित नहीं हैं।"
|
||||
},
|
||||
"dependencies": {
|
||||
"content": "हमारे दस्तावेज़ में <a href=\"https://kestra.io/docs/ui/flows#dependencies\" target=\"_blank\">Flow Dependencies</a> के बारे में अधिक पढ़ें।",
|
||||
"title": "इस Flow के लिए कोई dependencies सेट नहीं हैं।"
|
||||
"EXECUTION": {
|
||||
"content": "हमारे दस्तावेज़ में <a href=\"https://kestra.io/docs/ui/executions#dependencies\" target=\"_blank\">Execution Dependencies</a> के बारे में अधिक पढ़ें।",
|
||||
"title": "वर्तमान में कोई निर्भरता नहीं है।"
|
||||
},
|
||||
"FLOW": {
|
||||
"content": "हमारे दस्तावेज़ में <a href=\"https://kestra.io/docs/ui/flows#dependencies\" target=\"_blank\">Flow Dependencies</a> के बारे में अधिक पढ़ें।",
|
||||
"title": "वर्तमान में कोई dependencies नहीं हैं।"
|
||||
},
|
||||
"NAMESPACE": {
|
||||
"content": "हमारे दस्तावेज़ में <a href=\"https://kestra.io/docs/ui/namespaces#dependencies\" target=\"_blank\">Namespace Dependencies</a> के बारे में अधिक पढ़ें।",
|
||||
"title": "वर्तमान में कोई निर्भरता नहीं है।"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"content": "हमारे दस्तावेज़ में <strong><a href=\"https://kestra.io/docs/workflow-components/plugin-defaults\" target=\"_blank\">Concurrency Limits</a></strong> के बारे में अधिक पढ़ें।",
|
||||
@@ -1035,6 +1045,7 @@
|
||||
"pause done": "निष्पादन PAUSED है",
|
||||
"pause title": "प्रक्रिया को PAUSED करें <code>{id}</code>।<br/>ध्यान दें कि वर्तमान में चल रहे tasks अभी भी प्रक्रिया में होंगे, और प्रक्रिया को मैन्युअल रूप से पुनः शुरू करना होगा।",
|
||||
"playground": {
|
||||
"clear_history": "इतिहास साफ़ करें",
|
||||
"confirm_create": "आप एक flow बनाते समय playground नहीं चला सकते। एक playground रन शुरू करने से flow बन जाएगा।",
|
||||
"history": "पिछले 10 रन",
|
||||
"play_icon_info": "आप No-Code या Topology दृश्य में Play आइकन पर भी क्लिक कर सकते हैं।",
|
||||
@@ -1044,7 +1055,8 @@
|
||||
"run_task_info": "Flow Code संपादक में किसी भी task पर होवर करें और अपने task का परीक्षण करने के लिए \"Run task\" बटन पर क्लिक करें।",
|
||||
"run_this_task": "इस task को चलाएं",
|
||||
"title": "प्लेग्राउंड",
|
||||
"toggle": "प्लेग्राउंड"
|
||||
"toggle": "प्लेग्राउंड",
|
||||
"tooltip_persistence": "यदि आप Playground को बंद करके फिर से चालू करते हैं, तो जानकारी तब तक बनी रहती है जब तक आप पृष्ठ पर रहते हैं।"
|
||||
},
|
||||
"pluginDefaults": "प्लगइन डिफॉल्ट्स",
|
||||
"pluginPage": {
|
||||
@@ -1062,7 +1074,12 @@
|
||||
"port": "पोर्ट",
|
||||
"prefill inputs": "पूर्व-भरण",
|
||||
"prev_execution": "पिछला निष्पादन",
|
||||
"preview": "पूर्वावलोकन",
|
||||
"preview": {
|
||||
"auto-view": "स्वचालित दृश्य",
|
||||
"force-editor": "संपादक दृश्य लागू करें",
|
||||
"label": "पूर्वावलोकन",
|
||||
"view": "देखें"
|
||||
},
|
||||
"properties": {
|
||||
"hidden": "टेबल में छिपा हुआ",
|
||||
"hint": "दृश्य स्तंभ चुनें",
|
||||
|
||||
@@ -447,8 +447,18 @@
|
||||
"title": "Nessun limite è impostato per questo Flow."
|
||||
},
|
||||
"dependencies": {
|
||||
"content": "Per saperne di più sulle <a href=\"https://kestra.io/docs/ui/flows#dependencies\" target=\"_blank\">Dipendenze dei Flow</a> nella nostra documentazione.",
|
||||
"title": "Nessuna dipendenza è impostata per questo Flow."
|
||||
"EXECUTION": {
|
||||
"content": "Per saperne di più sulle <a href=\"https://kestra.io/docs/ui/executions#dependencies\" target=\"_blank\">Dipendenze di Esecuzione</a> nella nostra documentazione.",
|
||||
"title": "Attualmente non ci sono dipendenze."
|
||||
},
|
||||
"FLOW": {
|
||||
"content": "Leggi di più su <a href=\"https://kestra.io/docs/ui/flows#dependencies\" target=\"_blank\">Flow Dependencies</a> nella nostra documentazione.",
|
||||
"title": "Attualmente non ci sono dipendenze."
|
||||
},
|
||||
"NAMESPACE": {
|
||||
"content": "Per saperne di più sulle <a href=\"https://kestra.io/docs/ui/namespaces#dependencies\" target=\"_blank\">Namespace Dependencies</a>, consulta la nostra documentazione.",
|
||||
"title": "Attualmente non ci sono dipendenze."
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"content": "Leggi di più sui <strong><a href=\"https://kestra.io/docs/workflow-components/plugin-defaults\" target=\"_blank\">limiti di Concurrency</a></strong> nella nostra documentazione.",
|
||||
@@ -1035,6 +1045,7 @@
|
||||
"pause done": "L'esecuzione è PAUSED",
|
||||
"pause title": "Metti in pausa l'esecuzione <code>{id}</code>.<br/>Nota che i task attualmente in esecuzione verranno comunque elaborati e l'esecuzione dovrà essere ripresa manualmente.",
|
||||
"playground": {
|
||||
"clear_history": "Cancella cronologia",
|
||||
"confirm_create": "Non puoi eseguire il playground mentre crei un flow. Avviare un'esecuzione del playground creerà il flow.",
|
||||
"history": "Ultime 10 esecuzioni",
|
||||
"play_icon_info": "Puoi anche fare clic sull'icona Play nelle viste No-Code o Topology.",
|
||||
@@ -1044,7 +1055,8 @@
|
||||
"run_task_info": "Passa il mouse su qualsiasi task nell'editor del Flow Code e fai clic sul pulsante \"Run task\" per testare il tuo task.",
|
||||
"run_this_task": "Esegui questo task",
|
||||
"title": "Playground",
|
||||
"toggle": "Playground"
|
||||
"toggle": "Playground",
|
||||
"tooltip_persistence": "Se disattivi e riattivi il Playground, le informazioni rimangono finché resti sulla pagina."
|
||||
},
|
||||
"pluginDefaults": "Plugin predefiniti",
|
||||
"pluginPage": {
|
||||
@@ -1062,7 +1074,12 @@
|
||||
"port": "Porta",
|
||||
"prefill inputs": "Precompila",
|
||||
"prev_execution": "Esecuzione precedente",
|
||||
"preview": "Anteprima",
|
||||
"preview": {
|
||||
"auto-view": "Visualizzazione automatica",
|
||||
"force-editor": "Forza vista editor",
|
||||
"label": "Anteprima",
|
||||
"view": "Visualizza"
|
||||
},
|
||||
"properties": {
|
||||
"hidden": "Nascosto nella Tabella",
|
||||
"hint": "Scegli colonne visibili",
|
||||
|
||||
@@ -447,8 +447,18 @@
|
||||
"title": "このFlowには制限が設定されていません。"
|
||||
},
|
||||
"dependencies": {
|
||||
"content": "<a href=\"https://kestra.io/docs/ui/flows#dependencies\" target=\"_blank\">Flow Dependencies</a>についての詳細は、ドキュメントをご覧ください。",
|
||||
"title": "このFlowには依存関係が設定されていません。"
|
||||
"EXECUTION": {
|
||||
"content": "<a href=\"https://kestra.io/docs/ui/executions#dependencies\" target=\"_blank\">実行の依存関係</a>についての詳細は、ドキュメントをご覧ください。",
|
||||
"title": "現在、依存関係はありません。"
|
||||
},
|
||||
"FLOW": {
|
||||
"content": "<a href=\"https://kestra.io/docs/ui/flows#dependencies\" target=\"_blank\">Flow Dependencies</a> についての詳細は、ドキュメントをご覧ください。",
|
||||
"title": "現在、依存関係はありません。"
|
||||
},
|
||||
"NAMESPACE": {
|
||||
"content": "<a href=\"https://kestra.io/docs/ui/namespaces#dependencies\" target=\"_blank\">Namespace Dependencies</a> についての詳細は、ドキュメントをご覧ください。",
|
||||
"title": "現在、依存関係はありません。"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"content": "詳細については、<strong><a href=\"https://kestra.io/docs/workflow-components/plugin-defaults\" target=\"_blank\">Concurrency Limits</a></strong>に関するドキュメントをご覧ください。",
|
||||
@@ -1035,6 +1045,7 @@
|
||||
"pause done": "実行がPAUSEDされています",
|
||||
"pause title": "実行を一時停止 <code>{id}</code>。<br/>現在実行中のタスクは引き続き処理されることに注意してください。実行は手動で再開する必要があります。",
|
||||
"playground": {
|
||||
"clear_history": "履歴をクリア",
|
||||
"confirm_create": "フローを作成中はプレイグラウンドを実行できません。プレイグラウンドの実行を開始すると、flowが作成されます。",
|
||||
"history": "直近10回の実行",
|
||||
"play_icon_info": "ノーコードまたはトポロジービューで再生アイコンをクリックすることもできます。",
|
||||
@@ -1044,7 +1055,8 @@
|
||||
"run_task_info": "Flow Codeエディタ内の任意のtaskにカーソルを合わせ、「Run task」ボタンをクリックしてtaskをテストします。",
|
||||
"run_this_task": "このtaskを実行",
|
||||
"title": "プレイグラウンド",
|
||||
"toggle": "プレイグラウンド"
|
||||
"toggle": "プレイグラウンド",
|
||||
"tooltip_persistence": "Playgroundをオフにしてから再度オンにすると、ページに留まっている限り情報は保持されます。"
|
||||
},
|
||||
"pluginDefaults": "プラグインのデフォルト",
|
||||
"pluginPage": {
|
||||
@@ -1062,7 +1074,12 @@
|
||||
"port": "ポート",
|
||||
"prefill inputs": "自動入力",
|
||||
"prev_execution": "前回の実行",
|
||||
"preview": "プレビュー",
|
||||
"preview": {
|
||||
"auto-view": "自動ビュー",
|
||||
"force-editor": "エディタービューを強制する",
|
||||
"label": "プレビュー",
|
||||
"view": "表示"
|
||||
},
|
||||
"properties": {
|
||||
"hidden": "テーブルに隠す",
|
||||
"hint": "表示する列を選択",
|
||||
|
||||
@@ -447,8 +447,18 @@
|
||||
"title": "이 Flow에 대한 제한이 설정되지 않았습니다."
|
||||
},
|
||||
"dependencies": {
|
||||
"content": "<a href=\"https://kestra.io/docs/ui/flows#dependencies\" target=\"_blank\">Flow Dependencies</a>에 대한 자세한 내용은 문서를 참조하세요.",
|
||||
"title": "이 Flow에 대한 종속성이 설정되지 않았습니다."
|
||||
"EXECUTION": {
|
||||
"content": "<a href=\"https://kestra.io/docs/ui/executions#dependencies\" target=\"_blank\">실행 종속성</a>에 대한 자세한 내용은 문서를 참조하세요.",
|
||||
"title": "현재 종속성이 없습니다."
|
||||
},
|
||||
"FLOW": {
|
||||
"content": "<a href=\"https://kestra.io/docs/ui/flows#dependencies\" target=\"_blank\">Flow Dependencies</a>에 대한 자세한 내용은 문서를 참조하세요.",
|
||||
"title": "현재 종속성이 없습니다."
|
||||
},
|
||||
"NAMESPACE": {
|
||||
"content": "<a href=\"https://kestra.io/docs/ui/namespaces#dependencies\" target=\"_blank\">Namespace Dependencies</a>에 대한 자세한 내용은 문서를 참조하세요.",
|
||||
"title": "현재 종속성이 없습니다."
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"content": "우리 문서에서 <strong><a href=\"https://kestra.io/docs/workflow-components/plugin-defaults\" target=\"_blank\">Concurrency Limits</a></strong>에 대해 더 읽어보세요.",
|
||||
@@ -1035,6 +1045,7 @@
|
||||
"pause done": "실행이 PAUSED 상태입니다.",
|
||||
"pause title": "실행 <code>{id}</code>을(를) 일시 중지합니다.<br/>현재 실행 중인 task는 계속 처리되며, 실행은 수동으로 다시 시작해야 합니다.",
|
||||
"playground": {
|
||||
"clear_history": "기록 지우기",
|
||||
"confirm_create": "flow를 생성하는 동안에는 playground를 실행할 수 없습니다. playground 실행을 시작하면 flow가 생성됩니다.",
|
||||
"history": "최근 10회 실행",
|
||||
"play_icon_info": "또한 No-Code 또는 Topology 보기에서 Play 아이콘을 클릭할 수 있습니다.",
|
||||
@@ -1044,7 +1055,8 @@
|
||||
"run_task_info": "Flow Code 편집기에서 어떤 task 위에 마우스를 올리고 \"Run task\" 버튼을 클릭하여 task를 테스트하세요.",
|
||||
"run_this_task": "이 task 실행",
|
||||
"title": "플레이그라운드",
|
||||
"toggle": "플레이그라운드"
|
||||
"toggle": "플레이그라운드",
|
||||
"tooltip_persistence": "Playground를 끄고 다시 켜면, 페이지에 머무르는 동안 정보가 유지됩니다."
|
||||
},
|
||||
"pluginDefaults": "플러그인 기본값",
|
||||
"pluginPage": {
|
||||
@@ -1062,7 +1074,12 @@
|
||||
"port": "포트",
|
||||
"prefill inputs": "미리 채우기",
|
||||
"prev_execution": "이전 실행",
|
||||
"preview": "미리보기",
|
||||
"preview": {
|
||||
"auto-view": "자동 보기",
|
||||
"force-editor": "편집기 보기 강제 적용",
|
||||
"label": "미리보기",
|
||||
"view": "보기"
|
||||
},
|
||||
"properties": {
|
||||
"hidden": "테이블에 숨김",
|
||||
"hint": "열 표시 선택",
|
||||
|
||||
@@ -447,8 +447,18 @@
|
||||
"title": "Dla tego flow nie ustawiono żadnych limitów."
|
||||
},
|
||||
"dependencies": {
|
||||
"content": "Przeczytaj więcej o <a href=\"https://kestra.io/docs/ui/flows#dependencies\" target=\"_blank\">Flow Dependencies</a> w naszej dokumentacji.",
|
||||
"title": "Dla tego flow nie ustawiono żadnych zależności."
|
||||
"EXECUTION": {
|
||||
"content": "Przeczytaj więcej o <a href=\"https://kestra.io/docs/ui/executions#dependencies\" target=\"_blank\">zależnościach wykonania</a> w naszej dokumentacji.",
|
||||
"title": "Obecnie brak zależności."
|
||||
},
|
||||
"FLOW": {
|
||||
"content": "Dowiedz się więcej o <a href=\"https://kestra.io/docs/ui/flows#dependencies\" target=\"_blank\">Flow Dependencies</a> w naszej dokumentacji.",
|
||||
"title": "Obecnie brak zależności."
|
||||
},
|
||||
"NAMESPACE": {
|
||||
"content": "Dowiedz się więcej o <a href=\"https://kestra.io/docs/ui/namespaces#dependencies\" target=\"_blank\">zależnościach Namespace</a> w naszej dokumentacji.",
|
||||
"title": "Obecnie nie ma zależności."
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"content": "Przeczytaj więcej o <strong><a href=\"https://kestra.io/docs/workflow-components/plugin-defaults\" target=\"_blank\">Limitach Concurrency</a></strong> w naszej dokumentacji.",
|
||||
@@ -1035,6 +1045,7 @@
|
||||
"pause done": "Wykonanie jest PAUSED",
|
||||
"pause title": "Wstrzymaj wykonanie <code>{id}</code>.<br/>Zauważ, że aktualnie uruchomione taski nadal będą przetwarzane, a wykonanie będzie musiało zostać wznowione ręcznie.",
|
||||
"playground": {
|
||||
"clear_history": "Wyczyść historię",
|
||||
"confirm_create": "Nie można uruchomić playground podczas tworzenia flow. Uruchomienie playground run utworzy flow.",
|
||||
"history": "Ostatnie 10 uruchomień",
|
||||
"play_icon_info": "Możesz również kliknąć ikonę Play w widokach No-Code lub Topology.",
|
||||
@@ -1044,7 +1055,8 @@
|
||||
"run_task_info": "Najedź kursorem na dowolne zadanie w edytorze Flow Code i kliknij przycisk \"Run task\", aby przetestować swoje zadanie.",
|
||||
"run_this_task": "Uruchom ten task",
|
||||
"title": "Plac zabaw",
|
||||
"toggle": "Plac zabaw"
|
||||
"toggle": "Plac zabaw",
|
||||
"tooltip_persistence": "Jeśli wyłączysz i ponownie włączysz Playground, informacje pozostaną, dopóki pozostaniesz na stronie."
|
||||
},
|
||||
"pluginDefaults": "Domyślne ustawienia pluginu",
|
||||
"pluginPage": {
|
||||
@@ -1062,7 +1074,12 @@
|
||||
"port": "Port",
|
||||
"prefill inputs": "Wypełnij",
|
||||
"prev_execution": "Poprzednia wykonanie",
|
||||
"preview": "Podgląd",
|
||||
"preview": {
|
||||
"auto-view": "Widok automatyczny",
|
||||
"force-editor": "Wymuś widok edytora",
|
||||
"label": "Podgląd",
|
||||
"view": "Zobacz"
|
||||
},
|
||||
"properties": {
|
||||
"hidden": "Ukryte w tabeli",
|
||||
"hint": "Wybierz widoczne kolumny",
|
||||
|
||||
@@ -447,8 +447,18 @@
|
||||
"title": "Nenhum limite está definido para este Flow."
|
||||
},
|
||||
"dependencies": {
|
||||
"content": "Leia mais sobre <a href=\"https://kestra.io/docs/ui/flows#dependencies\" target=\"_blank\">Dependências de Flow</a> em nossa documentação.",
|
||||
"title": "Nenhuma dependência está definida para este Flow."
|
||||
"EXECUTION": {
|
||||
"content": "Leia mais sobre <a href=\"https://kestra.io/docs/ui/executions#dependencies\" target=\"_blank\">Dependências de Execução</a> na nossa documentação.",
|
||||
"title": "Atualmente, não há dependências."
|
||||
},
|
||||
"FLOW": {
|
||||
"content": "Leia mais sobre <a href=\"https://kestra.io/docs/ui/flows#dependencies\" target=\"_blank\">Dependências de Flow</a> na nossa documentação.",
|
||||
"title": "Atualmente, não há dependências."
|
||||
},
|
||||
"NAMESPACE": {
|
||||
"content": "Leia mais sobre <a href=\"https://kestra.io/docs/ui/namespaces#dependencies\" target=\"_blank\">Dependências de Namespace</a> em nossa documentação.",
|
||||
"title": "Atualmente, não há dependências."
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"content": "Leia mais sobre <strong><a href=\"https://kestra.io/docs/workflow-components/plugin-defaults\" target=\"_blank\">Limites de Concurrency</a></strong> em nossa documentação.",
|
||||
@@ -1035,6 +1045,7 @@
|
||||
"pause done": "A execução está PAUSED",
|
||||
"pause title": "Pausar execução <code>{id}</code>.<br/>Note que as tasks atualmente em execução ainda serão processadas, e a execução terá que ser retomada manualmente.",
|
||||
"playground": {
|
||||
"clear_history": "Limpar histórico",
|
||||
"confirm_create": "Você não pode executar o playground enquanto cria um flow. Iniciar uma execução do playground criará o flow.",
|
||||
"history": "Últimas 10 execuções",
|
||||
"play_icon_info": "Você também pode clicar no ícone de Play nas visualizações No-Code ou Topology.",
|
||||
@@ -1044,7 +1055,8 @@
|
||||
"run_task_info": "Passe o cursor sobre qualquer task no editor de Flow Code e clique no botão \"Run task\" para testar sua task.",
|
||||
"run_this_task": "Execute esta task",
|
||||
"title": "Playground",
|
||||
"toggle": "Playground"
|
||||
"toggle": "Playground",
|
||||
"tooltip_persistence": "Se você desligar e ligar o Playground novamente, as informações permanecem enquanto você estiver na página."
|
||||
},
|
||||
"pluginDefaults": "Padrões do plugin",
|
||||
"pluginPage": {
|
||||
@@ -1062,7 +1074,12 @@
|
||||
"port": "Port",
|
||||
"prefill inputs": "Preencher automaticamente",
|
||||
"prev_execution": "Execução anterior",
|
||||
"preview": "Pré-visualização",
|
||||
"preview": {
|
||||
"auto-view": "Visualização automática",
|
||||
"force-editor": "Forçar visualização do editor",
|
||||
"label": "Visualizar",
|
||||
"view": "Visualizar"
|
||||
},
|
||||
"properties": {
|
||||
"hidden": "Oculto na Tabela",
|
||||
"hint": "Escolher Colunas Visíveis",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user