Compare commits

...

31 Commits

Author SHA1 Message Date
Loïc Mathieu
09a16d16c2 WIP 2025-08-27 13:52:15 +02:00
Loïc Mathieu
f500276894 feat(execution): allow to listen the RUNNING state after being queued 2025-08-27 13:03:06 +02:00
Loïc Mathieu
868e88679f feat(system): execution state change queue
When inside the execution queue, we have process the execution, if the state of the execution change, send an ExecutionChangeMEssage.
Inside the Executor, process this new message end do all actions that was previously on the execution queue on terminated and state change.

The idea is to lower the work to be done synchronously when processing an execution message and process it later (async) in a new queue consumer.
2025-08-27 13:03:06 +02:00
brian-mulier-p
cf87145bb9 fix(docs): move proxy target from kestra to localhost and add UI README.md (#10916)
closes #10902
2025-08-27 11:50:19 +02:00
brian.mulier
0e2ddda6c7 fix(core): allow some left menu methods inheritance
part of kestra-io/kestra-ee#4728
2025-08-27 10:47:29 +02:00
brian-mulier-p
3b17b741f1 fix(doc): remove .env.development.local instructions as it's no longer required
closes #10902
2025-08-27 10:22:28 +02:00
Miloš Paunović
21c43e79e2 feat(core): implement improved graph for namespace dependencies view (#10909)
Closes https://github.com/kestra-io/kestra/issues/10634.
2025-08-27 08:34:24 +02:00
Piyush Bhaskar
810e80d989 fix(plugins): improve plugin documentation update logic for element selection (#10908) 2025-08-26 16:53:30 +05:30
Loïc Mathieu
2aafe15124 chore: add JacksonMapperTest.toMap() 2025-08-26 10:38:22 +02:00
Loïc Mathieu
cf866c059a fix: pause tasks didn't process erros or onFinally tasks
Fixes #9794

The Pause task was previously immediatly termindated without taken into account any errors or finally block.
To allow processing those blocks, we need to store the terminated state in the output, then use it to resolve the final state.
2025-08-26 10:38:22 +02:00
Loïc Mathieu
370fe210e5 fix: allow timeout on the Pause task 2025-08-26 10:38:22 +02:00
Abdur Rahman S
83e98be413 chore(executions): add parent execution link to execution overview page (#10810)
Closes https://github.com/kestra-io/kestra/issues/10745.

Co-authored-by: Miloš Paunović <paun992@hotmail.com>
2025-08-26 10:12:37 +02:00
Piyush Bhaskar
7d4d1631d2 fix(core): do not overflow the version selection on release notes (#10903) 2025-08-26 13:25:58 +05:30
github-actions[bot]
98534f16e2 chore(core): localize to languages other than english (#10904)
Extended localization support by adding translations for multiple languages using English as the base. This enhances accessibility and usability for non-English-speaking users while keeping English as the source reference.

Co-authored-by: GitHub Action <actions@github.com>
2025-08-26 09:48:43 +02:00
Barthélémy Ledoux
b308697449 refactor(flows): generalize no code editor (#10873) 2025-08-26 09:33:21 +02:00
Piyush Bhaskar
62e0550efd fix(ui): bring better small chart and tooltip. (#10839)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Miloš Paunović <paun992@hotmail.com>
2025-08-26 12:59:16 +05:30
YannC
1711e7fa05 fix: allow to enforce editor view when list is unreadable, also truncate too long column (#10885) 2025-08-26 09:10:39 +02:00
github-actions[bot]
04a3978fd2 chore(core): localize to languages other than english (#10901)
Extended localization support by adding translations for multiple languages using English as the base. This enhances accessibility and usability for non-English-speaking users while keeping English as the source reference.

Co-authored-by: GitHub Action <actions@github.com>
2025-08-26 08:47:39 +02:00
Biplab Bera
2d348786c3 chore(core): added closing button for horizontal panel in playground (#10777)
Closes https://github.com/kestra-io/kestra/issues/10660.

Co-authored-by: Miloš Paunović <paun992@hotmail.com>
2025-08-26 08:45:08 +02:00
Miloš Paunović
041a31e022 chore(core): make playground feature enabled by default (#10891)
Related to https://github.com/kestra-io/kestra-ee/issues/4555.
2025-08-26 08:30:44 +02:00
brian.mulier
11a6189bb8 fix(logs): emitAsync is now keeping messages order 2025-08-25 16:31:46 +02:00
brian.mulier
5c864eecc8 fix(logs): higher max message length to keep stacktraces in a single log 2025-08-25 16:31:46 +02:00
brian.mulier
af6d15dd13 chore(deps): bump Micronaut platform to 4.9.2
closes #10626
closes #10788
2025-08-25 16:31:46 +02:00
Piyush Bhaskar
0b555b3773 fix(core): return URI as string (#10892) 2025-08-25 18:55:54 +05:30
Piyush Bhaskar
6ed4c5af7e fix(core): show the logs for the task from topology graph. (#10890) 2025-08-25 18:39:30 +05:30
Barthélémy Ledoux
3752481756 chore(flows): load dependencies only once (#10782)
Co-authored-by: Miloš Paunović <paun992@hotmail.com>
2025-08-25 14:59:30 +02:00
Karthik D
94dc62aee1 chore(core): prevent running the invalid flow in playground (#10869)
Closes https://github.com/kestra-io/kestra/issues/10659.

Co-authored-by: Miloš Paunović <paun992@hotmail.com>
2025-08-25 14:51:04 +02:00
Piyush Bhaskar
09c79f76d7 fix(core): show the proper origin in webhook curl command (#10878)
Co-authored-by: Miloš Paunović <paun992@hotmail.com>
2025-08-25 14:20:04 +05:30
Piyush Bhaskar
086fd2a4cb fix(core): scope the styling to fix overflow of trigger render. (#10880) 2025-08-25 14:18:00 +05:30
YannC
3f9a2d9a57 feat: add action to merge release note between OSS and EE (#10882) 2025-08-25 10:41:53 +02:00
YannC
119bd51170 fix: do no trim . in file path when it starts with one when creating namespace file (#10876) 2025-08-25 10:18:41 +02:00
104 changed files with 2297 additions and 1704 deletions

View File

@@ -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.

View File

@@ -78,4 +78,11 @@ jobs:
"new_version": "${{ github.ref_name }}",
"github_repository": "${{ github.repository }}",
"github_actor": "${{ github.actor }}"
}
}
- name: Merge Release Notes
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
uses: ./actions/.github/actions/github-release-note-merge
env:
GITHUB_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }}
RELEASE_TAG: ${{ github.ref_name }}

View File

@@ -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 &amp; 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) {

View File

@@ -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();

View File

@@ -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);

View File

@@ -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();
}
}

View File

@@ -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 + "`");
}

View File

@@ -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));
}
}

View File

@@ -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);
}
}
}

View File

@@ -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()

View File

@@ -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()) {

View File

@@ -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) {

View File

@@ -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);
}
}

View File

@@ -2,7 +2,6 @@ package io.kestra.core.storages;
import io.kestra.core.utils.WindowsUtils;
import jakarta.annotation.Nullable;
import org.apache.commons.io.FilenameUtils;
import java.net.URI;
import java.nio.file.Path;
@@ -103,7 +102,7 @@ public record NamespaceFile(
filePath = filePath.getRoot().relativize(filePath);
}
// Need to remove starting trailing slash for Windows
String pathWithoutTrailingSlash = path.toString().replaceFirst("^[.]*[\\\\|/]*", "");
String pathWithoutTrailingSlash = path.toString().replaceFirst("^[.]*[\\\\|/]+", "");
return new NamespaceFile(
pathWithoutTrailingSlash,

View File

@@ -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);
}
}

View File

@@ -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();

View File

@@ -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);

View File

@@ -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());

View File

@@ -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);
}
}
}

View File

@@ -4,7 +4,7 @@ namespace: io.kestra.tests
inputs:
- id: behavior
type: STRING
defaults: CONTINUE
defaults: RESUME
tasks:
- id: pause

View File

@@ -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

View File

@@ -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)

View File

@@ -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;

View File

@@ -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)

View File

@@ -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;

View File

@@ -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)

View File

@@ -0,0 +1 @@
ALTER TYPE queue_type ADD VALUE IF NOT EXISTS 'io.kestra.core.runners.ExecutionStateChange';

View File

@@ -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();
}
}
}

View File

@@ -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
View File

@@ -1,6 +0,0 @@
{
"name": "kestra",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

View File

@@ -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
View 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.

View File

@@ -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;
}
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>>>

View File

@@ -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>

View File

@@ -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);

View 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,
}
}

View 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);
});
}

View File

@@ -48,7 +48,7 @@
const DEFAULTS = {
display: true,
stacked: true,
ticks: {maxTicksLimit: 8 , stepSize: 1},
ticks: {maxTicksLimit: 8},
grid: {display: false},
};

View File

@@ -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>

View File

@@ -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);

View File

@@ -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};

View File

@@ -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);

View File

@@ -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 nodes 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 nodes 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)];
}

View File

@@ -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",

View File

@@ -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 = {

View File

@@ -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

View File

@@ -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>

View File

@@ -324,24 +324,24 @@
overflow-x: auto;
}
.el-cascader-panel {
:deep(.el-cascader-panel) {
min-height: 197px;
border: 1px solid var(--ks-border-primary);
border-radius: 0;
overflow-x: auto !important;
overflow-y: hidden !important;
:deep(.el-scrollbar.el-cascader-menu:nth-of-type(-n + 2) ul li:first-child) {
.el-scrollbar.el-cascader-menu:nth-of-type(-n + 2) ul li:first-child {
pointer-events: auto !important;
margin: 0 !important;
}
:deep(.el-cascader-node) {
.el-cascader-node {
pointer-events: auto !important;
cursor: pointer !important;
}
:deep(.el-cascader-panel__wrap) {
.el-cascader-panel__wrap {
overflow-x: auto !important;
display: flex !important;
min-width: max-content !important;
@@ -360,7 +360,7 @@
height: 100%;
}
& .el-cascader-node {
.el-cascader-node {
height: 36px;
line-height: 36px;
font-size: var(--el-font-size-small);

View File

@@ -91,7 +91,6 @@
onDebugExpression(
editorValue.length > 0 ? editorValue : computedDebugValue,
)
"
class="mt-3 el-button--wrap"
>
@@ -153,24 +152,29 @@
<script setup lang="ts">
import {ref, computed, shallowRef, onMounted} from "vue";
import {ElTree} from "element-plus";
import {useStore} from "vuex";
const store = useStore();
import {useExecutionsStore} from "../../../stores/executions";
import {usePluginsStore} from "../../../stores/plugins";
import {useI18n} from "vue-i18n";
const {t} = useI18n({useScope: "global"});
import {apiUrl} from "override/utils/route";
import {TaskIcon} from "@kestra-io/ui-libs";
import CopyToClipboard from "../../layout/CopyToClipboard.vue";
import Editor from "../../inputs/Editor.vue";
const editorValue = ref("");
const debugCollapse = ref("");
import VarValue from "../VarValue.vue";
import SubFlowLink from "../../flows/SubFlowLink.vue";
import TimelineTextOutline from "vue-material-design-icons/TimelineTextOutline.vue";
import TextBoxSearchOutline from "vue-material-design-icons/TextBoxSearchOutline.vue";
const store = useStore();
const {t} = useI18n({useScope: "global"});
const editorValue = ref<string>("");
const debugCollapse = ref<string>("");
const debugEditor = ref<InstanceType<typeof Editor>>();
const debugExpression = ref("");
const debugExpression = ref<string>("");
const computedDebugValue = computed(() => {
const formatTask = (task) => {
if (!task) return "";
@@ -236,15 +240,6 @@
});
};
import VarValue from "../VarValue.vue";
import SubFlowLink from "../../flows/SubFlowLink.vue";
import {TaskIcon} from "@kestra-io/ui-libs";
import TimelineTextOutline from "vue-material-design-icons/TimelineTextOutline.vue";
import TextBoxSearchOutline from "vue-material-design-icons/TextBoxSearchOutline.vue";
import {usePluginsStore} from "../../../stores/plugins";
const cascader = ref<InstanceType<typeof ElTree> | null>(null);
const scrollRight = () =>
setTimeout(
@@ -431,133 +426,131 @@
const leftWidth = ref("70%");
</script>
<style lang="scss">
<style lang="scss" scoped>
.outputs {
display: flex;
width: 100%;
height: 100vh;
overflow: hidden;
}
.el-splitter-bar {
width: 3px !important;
background-color: var(--ks-border-primary);
:deep(.el-splitter-bar) {
width: 3px !important;
background-color: var(--ks-border-primary);
&:hover {
background-color: var(--ks-border-active);
}
&:hover {
background-color: var(--ks-border-active);
}
}
:deep(.el-scrollbar.el-cascader-menu:nth-of-type(-n + 2) ul li:first-child),
.values {
pointer-events: none;
margin: 0.75rem 0 1.25rem 0;
}
:deep(.el-cascader-menu__list) {
min-height: 100vh;
}
:deep(.el-cascader-panel) {
height: 100%;
}
.debug {
background: var(--ks-background-body);
}
.bordered {
border: 1px solid var(--ks-border-primary);
}
.bordered > :deep(.el-collapse-item) {
margin-bottom: 0px !important;
}
.wrapper {
background: var(--ks-background-card);
}
:deep(.el-cascader-menu) {
min-width: 300px;
max-width: 300px;
&:last-child {
border-right: 1px solid var(--ks-border-primary);
}
.el-scrollbar.el-cascader-menu:nth-of-type(-n + 2) ul li:first-child,
.values {
pointer-events: none;
margin: 0.75rem 0 1.25rem 0;
}
.el-cascader-menu__list {
min-height: 100vh;
}
.el-cascader-panel {
.el-cascader-menu__wrap {
height: 100%;
}
.debug {
background: var(--ks-background-body);
}
& .el-cascader-node {
height: 36px;
line-height: 36px;
font-size: var(--el-font-size-small);
color: var(--ks-content-primary);
.bordered {
border: 1px solid var(--ks-border-primary);
}
.bordered > .el-collapse-item {
margin-bottom: 0px !important;
}
.wrapper {
background: var(--ks-background-card);
}
.el-cascader-menu {
min-width: 300px;
max-width: 300px;
&:last-child {
border-right: 1px solid var(--ks-border-primary);
&[aria-haspopup="false"] {
padding-right: 0.5rem !important;
}
.el-cascader-menu__wrap {
height: 100%;
&:hover {
background-color: var(--ks-border-primary);
}
& .el-cascader-node {
height: 36px;
line-height: 36px;
font-size: var(--el-font-size-small);
&.in-active-path,
&.is-active {
background-color: var(--ks-border-primary);
font-weight: normal;
}
.el-cascader-node__prefix {
display: none;
}
.task .wrapper {
align-self: center;
height: var(--el-font-size-small);
width: var(--el-font-size-small);
}
code span.regular {
color: var(--ks-content-primary);
&[aria-haspopup="false"] {
padding-right: 0.5rem !important;
}
&:hover {
background-color: var(--ks-border-primary);
}
&.in-active-path,
&.is-active {
background-color: var(--ks-border-primary);
font-weight: normal;
}
.el-cascader-node__prefix {
display: none;
}
.task .wrapper {
align-self: center;
height: var(--el-font-size-small);
width: var(--el-font-size-small);
}
code span.regular {
color: var(--ks-content-primary);
}
}
}
}
</style>
<style lang="scss" scoped>
.content-container {
height: calc(100vh - 0px);
.content-container {
height: calc(100vh - 0px);
overflow-y: auto !important;
overflow-x: hidden;
word-wrap: break-word;
word-break: break-word;
}
:deep(.el-collapse) {
.el-collapse-item__wrap {
overflow-y: auto !important;
overflow-x: hidden;
word-wrap: break-word;
word-break: break-word;
max-height: none !important;
}
:deep(.el-collapse) {
.el-collapse-item__wrap {
overflow-y: auto !important;
max-height: none !important;
}
.el-collapse-item__content {
overflow-y: auto !important;
word-wrap: break-word;
word-break: break-word;
}
}
:deep(.var-value) {
.el-collapse-item__content {
overflow-y: auto !important;
word-wrap: break-word;
word-break: break-word;
}
}
:deep(pre) {
white-space: pre-wrap !important;
word-wrap: break-word !important;
word-break: break-word !important;
overflow-wrap: break-word !important;
}
:deep(.var-value) {
overflow-y: auto !important;
word-wrap: break-word;
word-break: break-word;
}
:deep(pre) {
white-space: pre-wrap !important;
word-wrap: break-word !important;
word-break: break-word !important;
overflow-wrap: break-word !important;
}
</style>

View File

@@ -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;

View File

@@ -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() {

View File

@@ -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: [
{

View File

@@ -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){

View File

@@ -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;

View File

@@ -35,7 +35,7 @@
import {useI18n} from "vue-i18n";
import CopyToClipboard from "../layout/CopyToClipboard.vue";
import Editor from "../inputs/Editor.vue";
import {apiUrlWithoutTenants} from "../../override/utils/route";
import {baseUrl, basePathWithoutTenant, apiUrlWithoutTenants} from "../../override/utils/route";
import {useFlowStore} from "../../stores/flow";
interface Flow {
@@ -73,7 +73,8 @@
});
const generateWebhookUrl = (trigger: Trigger): string => {
return `${apiUrlWithoutTenants()}/executions/webhook/${props.flow.namespace}/${props.flow.id}/${trigger.key}`;
const origin = baseUrl ? apiUrlWithoutTenants() : `${location.origin}${basePathWithoutTenant()}`;
return `${origin}/executions/webhook/${props.flow.namespace}/${props.flow.id}/${trigger.key}`;
};
const generateWebhookCurlCommand = (trigger: Trigger): string => {

View File

@@ -0,0 +1,9 @@
export interface NoCodeProps {
creatingTask?: boolean;
editingTask?: boolean;
parentPath?: string;
refPath?: number;
position?: "before" | "after";
blockSchemaPath?: string;
fieldName?: string | undefined;
}

View File

@@ -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(() => {

View File

@@ -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>

View File

@@ -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

View File

@@ -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");
},

View File

@@ -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 `${[

View File

@@ -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

View File

@@ -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";
}

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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 () => {

View File

@@ -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">

View File

@@ -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;

View File

@@ -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,

View File

@@ -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>

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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>

View File

@@ -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;

View File

@@ -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,

View File

@@ -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})
}

View File

@@ -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: {

View File

@@ -259,5 +259,8 @@ export function useLeftMenu() {
];
};
return {generateMenu};
}
return {
routeStartWith,
generateMenu
};
}

View File

@@ -14,10 +14,11 @@ const createBaseUrl = (): string => {
export const baseUrl = createBaseUrl().replace(/\/$/, "")
export const basePath = () => "/api/v1/main"
export const basePathWithoutTenant = () => "/api/v1"
export const apiUrl = (_: Store<any>): string => {
return `${baseUrl}${basePath()}`;
}
export const apiUrlWithTenant = (store: Store<any>, _: RouteLocationNormalizedLoaded): string => apiUrl(store);
export const apiUrlWithoutTenants = (): string => `${baseUrl}/api/v1`
export const apiUrlWithoutTenants = (): string => `${baseUrl}${basePathWithoutTenant()}`;

View File

@@ -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
};
})

View File

@@ -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){

View File

@@ -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;
}
}

View File

@@ -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",

View File

@@ -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"
}
}
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "दृश्य स्तंभ चुनें",

View File

@@ -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",

View File

@@ -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": "表示する列を選択",

View File

@@ -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": "열 표시 선택",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "Выполнение приостановлено",
"pause title": "Приостановить выполнение <code>{id}</code>.<br/>Обратите внимание, что текущие RUNNING задачи все равно будут обрабатываться, и выполнение придется возобновить вручную.",
"playground": {
"clear_history": "Очистить историю",
"confirm_create": "Вы не можете запустить playground во время создания flow. Запуск playground создаст flow.",
"history": "Последние 10 запусков",
"play_icon_info": "Вы также можете нажать на значок Play в представлениях No-Code или Topology.",
@@ -1044,7 +1055,8 @@
"run_task_info": "Наведите курсор на любую task в редакторе Flow Code и нажмите кнопку \"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": "Выберите видимые столбцы",

View File

@@ -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": "您还可以在无代码或拓扑视图中点击播放图标。",
@@ -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": "选择可见列",

View File

@@ -229,9 +229,9 @@ export default class Utils {
removeClasses();
htmlClass.add(theme);
}
miscStore.theme = theme;
localStorage.setItem("theme", theme);
}
@@ -316,27 +316,27 @@ export const useTheme = () => {
return computed<"light" | "dark">(() => miscStore.theme as "light" | "dark");
}
function resolve$ref(obj: Record<string, any>, fullObject: Record<string, any>) {
export function resolve$ref(fullSchema: Record<string, any>, obj: Record<string, any>, ) {
if (obj === undefined || obj === null) {
return;
return obj;
}
if(obj.$ref){
return getValueAtJsonPath(fullObject, obj.$ref);
return getValueAtJsonPath(fullSchema, obj.$ref);
}
return obj
return obj;
}
export function getValueAtJsonPath(obj: Record<string, any>, path: string): any {
if (!obj || !path || typeof path !== "string") {
export function getValueAtJsonPath(fullSchema: Record<string, any>, path: string): any {
if (!fullSchema || !path || typeof path !== "string") {
return undefined;
}
const keys = path.replace(/^#\//, "").split("/");
let current = obj;
let current = fullSchema;
for (const key of keys) {
if (current && key in current) {
current = resolve$ref(current[key], obj);
current = resolve$ref(fullSchema, current[key]);
} else {
return undefined;
}

Some files were not shown because too many files have changed in this diff Show More