Compare commits

...

49 Commits

Author SHA1 Message Date
github-actions[bot]
fbbc0824ff chore(version): update to version '1.1.3' 2025-11-13 13:42:46 +00:00
Loïc Mathieu
842b8d604b fix(flow): don't URLEncode the fileName inside the Download task
Also provide a `fileName` property that when set would override any filename from the content disposition in case it causes issues.
2025-11-13 11:12:43 +01:00
Loïc Mathieu
bd5ac06c5b fix(system): consume the trigger queue so it is properly cleaned
Fixes https://github.com/kestra-io/kestra/issues/11671
2025-11-13 11:12:34 +01:00
Barthélémy Ledoux
335fe1e88c fix(executions): simplify LabelInput usage in execution labels dialog (#12921)
Co-authored-by: Piyush Bhaskar <impiyush0012@gmail.com>
2025-11-13 14:38:19 +05:30
Piyush Bhaskar
5c52ab300a fix(flow): enhance error handling and validation for flow save operations (#12926) 2025-11-13 14:09:44 +05:30
Miloš Paunović
756069f1a6 fix(core): amend paths for consuming custom blueprints (#12925)
Closes https://github.com/kestra-io/kestra-ee/issues/5814.
2025-11-13 09:34:40 +01:00
Piyush Bhaskar
faba958f08 fix(core): adjust overflow behavior (#12879) 2025-11-13 13:59:01 +05:30
Piyush Bhaskar
a772a61d62 fix(core): update toast to use util (#12924) 2025-11-13 13:57:07 +05:30
Loïc Mathieu
f2cb79cb98 fix(system): access log configuration
Due to a change in the configuration file, access log configuration was in the wrong sub-document.

Fixes https://github.com/kestra-io/kestra-ee/issues/5670
2025-11-12 15:09:26 +01:00
Piyush Bhaskar
9ea0b1cebb fix(filters): conditionally include namespace/ flowId key based on route (#12840) 2025-11-12 13:58:02 +05:30
Piyush Bhaskar
867dc20d47 fix(core): handle potential null values for children (#12842) 2025-11-12 12:48:38 +05:30
Piyush Bhaskar
c669759afb fix(secrets): NS update for a secret should be disabled properly with correct prop (#12834) 2025-11-12 12:28:20 +05:30
Barthélémy Ledoux
7e3cd8a2cb fix: run validation when editing a dashboard (#12827) 2025-11-10 18:35:45 +01:00
YannC
f203c5f43a fix: where prop can be null (#12828) 2025-11-10 18:35:45 +01:00
github-actions[bot]
f4e90cc540 chore(version): update to version '1.1.2' 2025-11-10 14:36:53 +00:00
YannC
ce0fd58c94 fix: make sure datafilter is validated (#12822) 2025-11-10 13:29:59 +01:00
Loïc Mathieu
f1b950941c fix(executions): allow reading from subflow even if we have a parent
This fixes an issue where you cannot read from a Subflow file if the execution has iteself be triggered by another Subflow task.
It was caused by the trigger check beeing too aggressive, if it didn't pass the check it fail instead of return false so the other check would not be processed.

Fixes #12629
2025-11-10 13:26:44 +01:00
Piyush Bhaskar
559f3f2634 fix(core): bring the usage of restore url (#12762)
Co-authored-by: Bart Ledoux <bledoux@kestra.io>
2025-11-10 16:06:06 +05:30
YannC
9bc65b84f1 fix: when removing a queued execution, directly delete instead of fetching then delete to reduce deadlock (#12789) 2025-11-10 10:32:48 +01:00
Piyush Bhaskar
223b137381 fix(core): add defaults for component (#12814) 2025-11-10 15:02:20 +05:30
Piyush Bhaskar
80d1df6eeb fix(core): bulk deletion of executions (#12813) 2025-11-10 14:05:16 +05:30
Piyush Bhaskar
a87e7f3b8d fix(core): filter the minichart by duration from api which is 30D (#12740) 2025-11-10 13:58:33 +05:30
Loïc Mathieu
710862ef33 fix(executions): don't urlencode files as they would already be inside the storage 2025-11-10 09:28:04 +01:00
Miloš Paunović
d74f535ea1 chore(flows): amend flow export filename to include namespace and id parameters (#12800)
Closes https://github.com/kestra-io/kestra/issues/12790.
2025-11-07 13:58:16 +01:00
Piyush Bhaskar
1673f24356 fix(core): bring dashboard selector in navbar and also keep the selected dashboard route specific (#12703)
Co-authored-by: Bart Ledoux <bledoux@kestra.io>
2025-11-07 16:27:53 +05:30
brian-mulier-p
2ad90625b8 fix(tests): bump amount of threads on tests (#12777) 2025-11-07 09:44:19 +01:00
Piyush Bhaskar
e77b80a1a8 refactor(core): properly do trigger filter (#12780) 2025-11-07 11:35:59 +05:30
Ludovic DEHON
6223b1f672 feat(cli): add --flow-path on executor to preload some flows
close kestra-io/kestra-ee#5721
2025-11-06 19:26:17 +01:00
github-actions[bot]
23329f4d48 chore(version): update to version '1.1.1' 2025-11-06 17:18:11 +00:00
Loïc Mathieu
ed60cb6670 fix(core): relax assertion on ConcurrencyLimitServiceTest.findById() 2025-11-06 18:16:32 +01:00
brian-mulier-p
f6306883b4 fix(kv): all types properly handled and avoid trimming string KV values (#12765)
closes https://github.com/kestra-io/kestra-ee/issues/5718
2025-11-06 15:47:44 +01:00
Loïc Mathieu
89433dc04c fix(system): killing a paused flow should kill the Pause task attempt
Fixes #12421
2025-11-06 15:33:56 +01:00
Loïc Mathieu
4837408c59 chore(test): try to un-flaky ConcurrencyLimitServiceTest.findById().
By making sure the unqueueExecution() test wait for the unqueued execution to ends to avoid any potential races.
2025-11-06 15:33:56 +01:00
Miloš Paunović
5a8c36caa5 fix(variables): properly send kv value when the type is json (#12759)
Closes https://github.com/kestra-io/kestra/issues/12739.
2025-11-06 15:33:56 +01:00
Piyush Bhaskar
a2335abc0c fix(core): make the interval in triggers work (#12764) 2025-11-06 19:39:10 +05:30
Piyush Bhaskar
310a7bbbe9 Revert "fix(core): apply timeRange filter in triggers (#12721)" 2025-11-06 18:56:37 +05:30
Jay Balwani
162feaf38c Fix(UI)/kv type boolean (#12643)
Co-authored-by: Miloš Paunović <paun992@hotmail.com>
Co-authored-by: Piyush Bhaskar <impiyush0012@gmail.com>
2025-11-06 16:37:01 +05:30
Piyush Bhaskar
94050be49c fix(core): apply timeRange filter in triggers (#12721) 2025-11-06 16:29:47 +05:30
brian-mulier-p
848a5ac9d7 fix(cli): metadata commands weren't working with external storages (#12743)
closes #12713
2025-11-06 11:47:59 +01:00
Barthélémy Ledoux
9ac7a9ce9a fix: responsive dashboard grid (#12608) 2025-11-06 11:03:26 +01:00
Piyush Bhaskar
c42838f3e1 feat(ui): persist scroll across No‑code, editor tabs, and docs via Pinia view-state and scroll-memory (#12358) 2025-11-06 11:53:07 +05:30
Irfan
c499d62b63 fix(core): going back from plugin doc will take to plugins home (#12621)
Co-authored-by: iitzIrFan <irfanlhawk@gmail.com>
Co-authored-by: Piyush Bhaskar <impiyush0012@gmail.com>
2025-11-06 11:50:34 +05:30
Piyush Bhaskar
8fbc62e12c fix(core): proper deletion of single and multi ns files (#12618) 2025-11-06 11:49:51 +05:30
Vipin Chandra Sao
ae143f29f4 fix(ui): prevent "Invalid date" display in Gantt view for executions … (#12605)
* fix(ui): prevent "Invalid date" display in Gantt view for executions that never started

- Added defensive checks wherever histories arrays might be empty
- Now renders blank or safe values instead of "Invalid date"
- Improved comments for maintainability and future debugging
- Addresses issue #12583

* revert the changes

* fix: remove the card when invalid date

---------

Co-authored-by: Piyush Bhaskar <impiyush0012@gmail.com>
Co-authored-by: Piyush Bhaskar <102078527+Piyush-r-bhaskar@users.noreply.github.com>
2025-11-06 11:49:02 +05:30
Piyush Bhaskar
e4a11fc9ce fix(core): remove double info icon (#12623) 2025-11-06 11:48:36 +05:30
Piyush Bhaskar
ebacfc70b9 fix(core): use proper option after P30D in misc (#12624) 2025-11-06 11:47:57 +05:30
Loïc Mathieu
5bf67180a3 fix(system): trigger an execution once per condition on flow triggers
Fixes #12560
2025-11-05 15:31:41 +01:00
Roman Acevedo
1e670b5e7e test(kv): plain text header is sent now 2025-11-04 15:17:02 +01:00
brian.mulier
0dacad5ee1 chore(version): upgrade to v1.1.0 2025-11-04 13:58:32 +01:00
97 changed files with 1541 additions and 996 deletions

View File

@@ -2,6 +2,7 @@ package io.kestra.cli.commands.migrations.metadata;
import io.kestra.cli.AbstractCommand;
import jakarta.inject.Inject;
import jakarta.inject.Provider;
import lombok.extern.slf4j.Slf4j;
import picocli.CommandLine;
@@ -12,13 +13,13 @@ import picocli.CommandLine;
@Slf4j
public class KvMetadataMigrationCommand extends AbstractCommand {
@Inject
private MetadataMigrationService metadataMigrationService;
private Provider<MetadataMigrationService> metadataMigrationServiceProvider;
@Override
public Integer call() throws Exception {
super.call();
try {
metadataMigrationService.kvMigration();
metadataMigrationServiceProvider.get().kvMigration();
} catch (Exception e) {
System.err.println("❌ KV Metadata migration failed: " + e.getMessage());
e.printStackTrace();

View File

@@ -2,6 +2,7 @@ package io.kestra.cli.commands.migrations.metadata;
import io.kestra.cli.AbstractCommand;
import jakarta.inject.Inject;
import jakarta.inject.Provider;
import lombok.extern.slf4j.Slf4j;
import picocli.CommandLine;
@@ -12,13 +13,13 @@ import picocli.CommandLine;
@Slf4j
public class SecretsMetadataMigrationCommand extends AbstractCommand {
@Inject
private MetadataMigrationService metadataMigrationService;
private Provider<MetadataMigrationService> metadataMigrationServiceProvider;
@Override
public Integer call() throws Exception {
super.call();
try {
metadataMigrationService.secretMigration();
metadataMigrationServiceProvider.get().secretMigration();
} catch (Exception e) {
System.err.println("❌ Secrets Metadata migration failed: " + e.getMessage());
e.printStackTrace();

View File

@@ -1,7 +1,9 @@
package io.kestra.cli.commands.servers;
import com.google.common.collect.ImmutableMap;
import io.kestra.cli.services.TenantIdSelectorService;
import io.kestra.core.models.ServerType;
import io.kestra.core.repositories.LocalFlowRepositoryLoader;
import io.kestra.core.runners.ExecutorInterface;
import io.kestra.core.services.SkipExecutionService;
import io.kestra.core.services.StartExecutorService;
@@ -10,6 +12,8 @@ import io.micronaut.context.ApplicationContext;
import jakarta.inject.Inject;
import picocli.CommandLine;
import java.io.File;
import java.io.IOException;
import java.util.Collections;
import java.util.List;
import java.util.Map;
@@ -19,6 +23,9 @@ import java.util.Map;
description = "Start the Kestra executor"
)
public class ExecutorCommand extends AbstractServerCommand {
@CommandLine.Spec
CommandLine.Model.CommandSpec spec;
@Inject
private ApplicationContext applicationContext;
@@ -28,22 +35,28 @@ public class ExecutorCommand extends AbstractServerCommand {
@Inject
private StartExecutorService startExecutorService;
@CommandLine.Option(names = {"--skip-executions"}, split=",", description = "The list of execution identifiers to skip, separated by a coma; for troubleshooting purpose only")
@CommandLine.Option(names = {"-f", "--flow-path"}, description = "Tenant identifier required to load flows from the specified path")
private File flowPath;
@CommandLine.Option(names = "--tenant", description = "Tenant identifier, Required to load flows from path")
private String tenantId;
@CommandLine.Option(names = {"--skip-executions"}, split=",", description = "List of execution IDs to skip, separated by commas; for troubleshooting only")
private List<String> skipExecutions = Collections.emptyList();
@CommandLine.Option(names = {"--skip-flows"}, split=",", description = "The list of flow identifiers (tenant|namespace|flowId) to skip, separated by a coma; for troubleshooting purpose only")
@CommandLine.Option(names = {"--skip-flows"}, split=",", description = "List of flow identifiers (tenant|namespace|flowId) to skip, separated by a coma; for troubleshooting only")
private List<String> skipFlows = Collections.emptyList();
@CommandLine.Option(names = {"--skip-namespaces"}, split=",", description = "The list of namespace identifiers (tenant|namespace) to skip, separated by a coma; for troubleshooting purpose only")
@CommandLine.Option(names = {"--skip-namespaces"}, split=",", description = "List of namespace identifiers (tenant|namespace) to skip, separated by a coma; for troubleshooting only")
private List<String> skipNamespaces = Collections.emptyList();
@CommandLine.Option(names = {"--skip-tenants"}, split=",", description = "The list of tenants to skip, separated by a coma; for troubleshooting purpose only")
@CommandLine.Option(names = {"--skip-tenants"}, split=",", description = "List of tenants to skip, separated by a coma; for troubleshooting only")
private List<String> skipTenants = Collections.emptyList();
@CommandLine.Option(names = {"--start-executors"}, split=",", description = "The list of Kafka Stream executors to start, separated by a command. Use it only with the Kafka queue, for debugging purpose.")
@CommandLine.Option(names = {"--start-executors"}, split=",", description = "List of Kafka Stream executors to start, separated by a command. Use it only with the Kafka queue; for debugging only")
private List<String> startExecutors = Collections.emptyList();
@CommandLine.Option(names = {"--not-start-executors"}, split=",", description = "The list of Kafka Stream executors to not start, separated by a command. Use it only with the Kafka queue, for debugging purpose.")
@CommandLine.Option(names = {"--not-start-executors"}, split=",", description = "Lst of Kafka Stream executors to not start, separated by a command. Use it only with the Kafka queue; for debugging only")
private List<String> notStartExecutors = Collections.emptyList();
@SuppressWarnings("unused")
@@ -64,6 +77,16 @@ public class ExecutorCommand extends AbstractServerCommand {
super.call();
if (flowPath != null) {
try {
LocalFlowRepositoryLoader localFlowRepositoryLoader = applicationContext.getBean(LocalFlowRepositoryLoader.class);
TenantIdSelectorService tenantIdSelectorService = applicationContext.getBean(TenantIdSelectorService.class);
localFlowRepositoryLoader.load(tenantIdSelectorService.getTenantId(this.tenantId), this.flowPath);
} catch (IOException e) {
throw new CommandLine.ParameterException(this.spec.commandLine(), "Invalid flow path", e);
}
}
ExecutorInterface executorService = applicationContext.getBean(ExecutorInterface.class);
executorService.run();

View File

@@ -23,7 +23,7 @@ public class IndexerCommand extends AbstractServerCommand {
@Inject
private SkipExecutionService skipExecutionService;
@CommandLine.Option(names = {"--skip-indexer-records"}, split=",", description = "a list of indexer record keys, separated by a coma; for troubleshooting purpose only")
@CommandLine.Option(names = {"--skip-indexer-records"}, split=",", description = "a list of indexer record keys, separated by a coma; for troubleshooting only")
private List<String> skipIndexerRecords = Collections.emptyList();
@SuppressWarnings("unused")

View File

@@ -42,7 +42,7 @@ public class StandAloneCommand extends AbstractServerCommand {
@Nullable
private FileChangedEventListener fileWatcher;
@CommandLine.Option(names = {"-f", "--flow-path"}, description = "the flow path containing flow to inject at startup (when running with a memory flow repository)")
@CommandLine.Option(names = {"-f", "--flow-path"}, description = "Tenant identifier required to load flows from the specified path")
private File flowPath;
@CommandLine.Option(names = "--tenant", description = "Tenant identifier, Required to load flows from path with the enterprise edition")
@@ -51,19 +51,19 @@ public class StandAloneCommand extends AbstractServerCommand {
@CommandLine.Option(names = {"--worker-thread"}, description = "the number of worker threads, defaults to eight times the number of available processors. Set it to 0 to avoid starting a worker.")
private int workerThread = defaultWorkerThread();
@CommandLine.Option(names = {"--skip-executions"}, split=",", description = "a list of execution identifiers to skip, separated by a coma; for troubleshooting purpose only")
@CommandLine.Option(names = {"--skip-executions"}, split=",", description = "a list of execution identifiers to skip, separated by a coma; for troubleshooting only")
private List<String> skipExecutions = Collections.emptyList();
@CommandLine.Option(names = {"--skip-flows"}, split=",", description = "a list of flow identifiers (namespace.flowId) to skip, separated by a coma; for troubleshooting purpose only")
@CommandLine.Option(names = {"--skip-flows"}, split=",", description = "a list of flow identifiers (namespace.flowId) to skip, separated by a coma; for troubleshooting only")
private List<String> skipFlows = Collections.emptyList();
@CommandLine.Option(names = {"--skip-namespaces"}, split=",", description = "a list of namespace identifiers (tenant|namespace) to skip, separated by a coma; for troubleshooting purpose only")
@CommandLine.Option(names = {"--skip-namespaces"}, split=",", description = "a list of namespace identifiers (tenant|namespace) to skip, separated by a coma; for troubleshooting only")
private List<String> skipNamespaces = Collections.emptyList();
@CommandLine.Option(names = {"--skip-tenants"}, split=",", description = "a list of tenants to skip, separated by a coma; for troubleshooting purpose only")
@CommandLine.Option(names = {"--skip-tenants"}, split=",", description = "a list of tenants to skip, separated by a coma; for troubleshooting only")
private List<String> skipTenants = Collections.emptyList();
@CommandLine.Option(names = {"--skip-indexer-records"}, split=",", description = "a list of indexer record keys, separated by a coma; for troubleshooting purpose only")
@CommandLine.Option(names = {"--skip-indexer-records"}, split=",", description = "a list of indexer record keys, separated by a coma; for troubleshooting only")
private List<String> skipIndexerRecords = Collections.emptyList();
@CommandLine.Option(names = {"--no-tutorials"}, description = "Flag to disable auto-loading of tutorial flows.")

View File

@@ -40,7 +40,7 @@ public class WebServerCommand extends AbstractServerCommand {
@Option(names = {"--no-indexer"}, description = "Flag to disable starting an embedded indexer.")
private boolean indexerDisabled = false;
@CommandLine.Option(names = {"--skip-indexer-records"}, split=",", description = "a list of indexer record keys, separated by a coma; for troubleshooting purpose only")
@CommandLine.Option(names = {"--skip-indexer-records"}, split=",", description = "a list of indexer record keys, separated by a coma; for troubleshooting only")
private List<String> skipIndexerRecords = Collections.emptyList();
@Override

View File

@@ -30,15 +30,15 @@ micronaut:
read-idle-timeout: 60m
write-idle-timeout: 60m
idle-timeout: 60m
netty:
max-zstd-encode-size: 67108864 # increased to 64MB from the default of 32MB
max-chunk-size: 10MB
max-header-size: 32768 # increased from the default of 8k
responses:
file:
cache-seconds: 86400
cache-control:
public: true
netty:
max-zstd-encode-size: 67108864 # increased to 64MB from the default of 32MB
max-chunk-size: 10MB
max-header-size: 32768 # increased from the default of 8k
# Access log configuration, see https://docs.micronaut.io/latest/guide/index.html#accessLogger
access-logger:

View File

@@ -68,7 +68,8 @@ class NoConfigCommandTest {
assertThat(exitCode).isNotZero();
assertThat(out.toString()).isEmpty();
// check that the only log is an access log: this has the advantage to also check that access log is working!
assertThat(out.toString()).contains("POST /api/v1/main/flows HTTP/1.1 | status: 500");
assertThat(err.toString()).contains("No bean of type [io.kestra.core.repositories.FlowRepositoryInterface] exists");
}
}

View File

@@ -5,6 +5,8 @@ import io.kestra.core.models.annotations.Plugin;
import io.kestra.core.models.dashboards.filters.AbstractFilter;
import io.kestra.core.repositories.QueryBuilderInterface;
import io.kestra.plugin.core.dashboard.data.IData;
import jakarta.annotation.Nullable;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
@@ -33,9 +35,12 @@ public abstract class DataFilter<F extends Enum<F>, C extends ColumnDescriptor<F
@Pattern(regexp = JAVA_IDENTIFIER_REGEX)
private String type;
@Valid
private Map<String, C> columns;
@Setter
@Valid
@Nullable
private List<AbstractFilter<F>> where;
private List<OrderBy> orderBy;

View File

@@ -5,6 +5,7 @@ import io.kestra.core.models.annotations.Plugin;
import io.kestra.core.models.dashboards.ChartOption;
import io.kestra.core.models.dashboards.DataFilter;
import io.kestra.core.validations.DataChartValidation;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import lombok.EqualsAndHashCode;
import lombok.Getter;
@@ -20,6 +21,7 @@ import lombok.experimental.SuperBuilder;
@DataChartValidation
public abstract class DataChart<P extends ChartOption, D extends DataFilter<?, ?>> extends Chart<P> implements io.kestra.core.models.Plugin {
@NotNull
@Valid
private D data;
public Integer minNumberOfAggregations() {

View File

@@ -1,8 +1,11 @@
package io.kestra.core.models.dashboards.filters;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import io.micronaut.core.annotation.Introspected;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
@@ -32,6 +35,9 @@ import lombok.experimental.SuperBuilder;
@SuperBuilder
@Introspected
public abstract class AbstractFilter<F extends Enum<F>> {
@NotNull
@JsonProperty(value = "field", required = true)
@Valid
private F field;
private String labelKey;

View File

@@ -82,8 +82,7 @@ public abstract class FilesService {
}
private static String resolveUniqueNameForFile(final Path path) {
String filename = path.getFileName().toString();
String encodedFilename = java.net.URLEncoder.encode(filename, java.nio.charset.StandardCharsets.UTF_8);
return IdUtils.from(path.toString()) + "-" + encodedFilename;
String filename = path.getFileName().toString().replace(' ', '+');
return IdUtils.from(path.toString()) + "-" + filename;
}
}

View File

@@ -151,10 +151,7 @@ abstract class AbstractFileFunction implements Function {
// if there is a trigger of type execution, we also allow accessing a file from the parent execution
Map<String, String> trigger = (Map<String, String>) context.getVariable(TRIGGER);
if (!isFileUriValid(trigger.get(NAMESPACE), trigger.get("flowId"), trigger.get("executionId"), path)) {
throw new IllegalArgumentException("Unable to read the file '" + path + "' as it didn't belong to the parent execution");
}
return true;
return isFileUriValid(trigger.get(NAMESPACE), trigger.get("flowId"), trigger.get("executionId"), path);
}
return false;
}

View File

@@ -383,6 +383,7 @@ public class ExecutionService {
if (!isFlowable || s.equals(taskRunId)) {
TaskRun newTaskRun;
State.Type targetState = newState;
if (task instanceof Pause pauseTask) {
State.Type terminalState = newState == State.Type.RUNNING ? State.Type.SUCCESS : newState;
Pause.Resumed _resumed = resumed != null ? resumed : Pause.Resumed.now(terminalState);
@@ -392,23 +393,23 @@ public class ExecutionService {
// 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);
targetState = State.Type.SUCCESS;
} else if (newState == State.Type.KILLING) {
newTaskRun = newTaskRun.withState(State.Type.KILLED);
} else {
newTaskRun = newTaskRun.withState(newState);
targetState = State.Type.KILLED;
}
} else {
// we should set the state to RUNNING so that subtasks are executed
newTaskRun = newTaskRun.withState(State.Type.RUNNING);
targetState = State.Type.RUNNING;
}
newTaskRun = newTaskRun.withState(targetState);
} else {
newTaskRun = originalTaskRun.withState(newState);
newTaskRun = originalTaskRun.withState(targetState);
}
if (originalTaskRun.getAttempts() != null && !originalTaskRun.getAttempts().isEmpty()) {
ArrayList<TaskRunAttempt> attempts = new ArrayList<>(originalTaskRun.getAttempts());
attempts.set(attempts.size() - 1, attempts.getLast().withState(newState));
attempts.set(attempts.size() - 1, attempts.getLast().withState(targetState));
newTaskRun = newTaskRun.withAttempts(attempts);
}

View File

@@ -33,11 +33,13 @@ public class ExecutionsDataFilterValidator implements ConstraintValidator<Execut
}
});
executionsDataFilter.getWhere().forEach(filter -> {
if (filter.getField() == Executions.Fields.LABELS && filter.getLabelKey() == null) {
violations.add("Label filters must have a `labelKey`.");
}
});
if (executionsDataFilter.getWhere() != null) {
executionsDataFilter.getWhere().forEach(filter -> {
if (filter.getField() == Executions.Fields.LABELS && filter.getLabelKey() == null) {
violations.add("Label filters must have a `labelKey`.");
}
});
}
if (!violations.isEmpty()) {
context.disableDefaultConstraintViolation();

View File

@@ -20,8 +20,6 @@ import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
@@ -60,7 +58,15 @@ import static io.kestra.core.utils.Rethrow.throwConsumer;
public class Download extends AbstractHttp implements RunnableTask<Download.Output> {
@Schema(title = "Should the task fail when downloading an empty file.")
@Builder.Default
private final Property<Boolean> failOnEmptyResponse = Property.ofValue(true);
private Property<Boolean> failOnEmptyResponse = Property.ofValue(true);
@Schema(
title = "Name of the file inside the output.",
description = """
If not provided, the filename will be extracted from the `Content-Disposition` header.
If no `Content-Disposition` header, a name would be generated."""
)
private Property<String> saveAs;
public Output run(RunContext runContext) throws Exception {
Logger logger = runContext.logger();
@@ -111,20 +117,22 @@ public class Download extends AbstractHttp implements RunnableTask<Download.Outp
}
}
String filename = null;
if (response.getHeaders().firstValue("Content-Disposition").isPresent()) {
String contentDisposition = response.getHeaders().firstValue("Content-Disposition").orElseThrow();
filename = filenameFromHeader(runContext, contentDisposition);
}
if (filename != null) {
filename = URLEncoder.encode(filename, StandardCharsets.UTF_8);
String rFilename = runContext.render(this.saveAs).as(String.class).orElse(null);
if (rFilename == null) {
if (response.getHeaders().firstValue("Content-Disposition").isPresent()) {
String contentDisposition = response.getHeaders().firstValue("Content-Disposition").orElseThrow();
rFilename = filenameFromHeader(runContext, contentDisposition);
if (rFilename != null) {
rFilename = rFilename.replace(' ', '+');
}
}
}
logger.debug("File '{}' downloaded with size '{}'", from, size);
return Output.builder()
.code(response.getStatus().getCode())
.uri(runContext.storage().putFile(tempFile, filename))
.uri(runContext.storage().putFile(tempFile, rFilename))
.headers(response.getHeaders().map())
.length(size.get())
.build();

View File

@@ -267,6 +267,12 @@ public abstract class AbstractRunnerTest {
multipleConditionTriggerCaseTest.flowTriggerMultiplePreconditions();
}
@Test
@LoadFlows({"flows/valids/flow-trigger-multiple-conditions-flow-a.yaml", "flows/valids/flow-trigger-multiple-conditions-flow-listen.yaml"})
void flowTriggerMultipleConditions() throws Exception {
multipleConditionTriggerCaseTest.flowTriggerMultipleConditions();
}
@Test
@LoadFlows({"flows/valids/each-null.yaml"})
void eachWithNull() throws Exception {

View File

@@ -445,6 +445,7 @@ class ExecutionServiceTest {
assertThat(killed.getState().getCurrent()).isEqualTo(State.Type.CANCELLED);
assertThat(killed.findTaskRunsByTaskId("pause").getFirst().getState().getCurrent()).isEqualTo(State.Type.KILLED);
assertThat(killed.findTaskRunsByTaskId("pause").getFirst().getAttempts().getFirst().getState().getCurrent()).isEqualTo(State.Type.KILLED);
assertThat(killed.getState().getHistories()).hasSize(5);
}

View File

@@ -106,28 +106,28 @@ class FilesServiceTest {
var runContext = runContextFactory.of();
Path fileWithSpace = tempDir.resolve("with space.txt");
Path fileWithUnicode = tempDir.resolve("สวัสดี.txt");
Path fileWithUnicode = tempDir.resolve("สวัสดี&.txt");
Files.writeString(fileWithSpace, "content");
Files.writeString(fileWithUnicode, "content");
Path targetFileWithSpace = runContext.workingDir().path().resolve("with space.txt");
Path targetFileWithUnicode = runContext.workingDir().path().resolve("สวัสดี.txt");
Path targetFileWithUnicode = runContext.workingDir().path().resolve("สวัสดี&.txt");
Files.copy(fileWithSpace, targetFileWithSpace);
Files.copy(fileWithUnicode, targetFileWithUnicode);
Map<String, URI> outputFiles = FilesService.outputFiles(
runContext,
List.of("with space.txt", "สวัสดี.txt")
List.of("with space.txt", "สวัสดี&.txt")
);
assertThat(outputFiles).hasSize(2);
assertThat(outputFiles).containsKey("with space.txt");
assertThat(outputFiles).containsKey("สวัสดี.txt");
assertThat(outputFiles).containsKey("สวัสดี&.txt");
assertThat(runContext.storage().getFile(outputFiles.get("with space.txt"))).isNotNull();
assertThat(runContext.storage().getFile(outputFiles.get("สวัสดี.txt"))).isNotNull();
assertThat(runContext.storage().getFile(outputFiles.get("สวัสดี&.txt"))).isNotNull();
}
private URI createFile() throws IOException {

View File

@@ -212,4 +212,24 @@ public class MultipleConditionTriggerCaseTest {
e -> e.getState().getCurrent().equals(Type.SUCCESS),
MAIN_TENANT, "io.kestra.tests.trigger.multiple.preconditions", "flow-trigger-multiple-preconditions-flow-listen", Duration.ofSeconds(1)));
}
public void flowTriggerMultipleConditions() throws TimeoutException, QueueException {
Execution execution = runnerUtils.runOne(MAIN_TENANT, "io.kestra.tests.trigger.multiple.conditions",
"flow-trigger-multiple-conditions-flow-a");
assertThat(execution.getTaskRunList().size()).isEqualTo(1);
assertThat(execution.getState().getCurrent()).isEqualTo(State.Type.SUCCESS);
// trigger is done
Execution triggerExecution = runnerUtils.awaitFlowExecution(
e -> e.getState().getCurrent().equals(Type.SUCCESS),
MAIN_TENANT, "io.kestra.tests.trigger.multiple.conditions", "flow-trigger-multiple-conditions-flow-listen");
executionRepository.delete(triggerExecution);
assertThat(triggerExecution.getTaskRunList().size()).isEqualTo(1);
assertThat(triggerExecution.getState().getCurrent()).isEqualTo(State.Type.SUCCESS);
// we assert that we didn't have any other flow triggered
assertThrows(RuntimeException.class, () -> runnerUtils.awaitFlowExecution(
e -> e.getState().getCurrent().equals(Type.SUCCESS),
MAIN_TENANT, "io.kestra.tests.trigger.multiple.conditions", "flow-trigger-multiple-conditions-flow-listen", Duration.ofSeconds(1)));
}
}

View File

@@ -112,33 +112,6 @@ public class FileSizeFunctionTest {
assertThat(size).isEqualTo(FILE_SIZE);
}
@Test
void shouldThrowIllegalArgumentException_givenTrigger_andParentExecution_andMissingNamespace() throws IOException {
String executionId = IdUtils.create();
URI internalStorageURI = getInternalStorageURI(executionId);
URI internalStorageFile = getInternalStorageFile(internalStorageURI);
Map<String, Object> variables = Map.of(
"flow", Map.of(
"id", "subflow",
"namespace", NAMESPACE,
"tenantId", MAIN_TENANT),
"execution", Map.of("id", IdUtils.create()),
"trigger", Map.of(
"flowId", FLOW,
"executionId", executionId,
"tenantId", MAIN_TENANT
)
);
Exception ex = assertThrows(
IllegalArgumentException.class,
() -> variableRenderer.render("{{ fileSize('" + internalStorageFile + "') }}", variables)
);
assertTrue(ex.getMessage().startsWith("Unable to read the file"), "Exception message doesn't match expected one");
}
@Test
void returnsCorrectSize_givenUri_andCurrentExecution() throws IOException, IllegalVariableEvaluationException {
String executionId = IdUtils.create();

View File

@@ -259,6 +259,27 @@ class ReadFileFunctionTest {
assertThat(variableRenderer.render("{{ read(nsfile) }}", variables)).isEqualTo("Hello World");
}
@Test
void shouldReadChildFileEvenIfTrigger() throws IOException, IllegalVariableEvaluationException {
String namespace = "my.namespace";
String flowId = "flow";
String executionId = IdUtils.create();
URI internalStorageURI = URI.create("/" + namespace.replace(".", "/") + "/" + flowId + "/executions/" + executionId + "/tasks/task/" + IdUtils.create() + "/123456.ion");
URI internalStorageFile = storageInterface.put(MAIN_TENANT, namespace, internalStorageURI, new ByteArrayInputStream("Hello from a task output".getBytes()));
Map<String, Object> variables = Map.of(
"flow", Map.of(
"id", "flow",
"namespace", "notme",
"tenantId", MAIN_TENANT),
"execution", Map.of("id", "notme"),
"trigger", Map.of("namespace", "notme", "flowId", "parent", "executionId", "parent")
);
String render = variableRenderer.render("{{ read('" + internalStorageFile + "') }}", variables);
assertThat(render).isEqualTo("Hello from a task output");
}
private URI createFile() throws IOException {
File tempFile = File.createTempFile("file", ".txt");
Files.write(tempFile.toPath(), "Hello World".getBytes());

View File

@@ -12,20 +12,24 @@ import io.kestra.core.queues.QueueInterface;
import io.kestra.core.repositories.FlowRepositoryInterface;
import io.kestra.core.runners.ConcurrencyLimit;
import io.kestra.core.runners.RunnerUtils;
import io.kestra.core.utils.TestsUtils;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import reactor.core.publisher.Flux;
import java.time.Duration;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import static io.kestra.core.utils.Rethrow.throwRunnable;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertTrue;
@KestraTest(startRunner = true)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@@ -54,14 +58,29 @@ class ConcurrencyLimitServiceTest {
@Test
@LoadFlows("flows/valids/flow-concurrency-queue.yml")
void unqueueExecution() throws QueueException, TimeoutException {
void unqueueExecution() throws QueueException, TimeoutException, InterruptedException {
// run a first flow so the second is queued
runnerUtils.runOneUntilRunning(TENANT_ID, TESTS_FLOW_NS, "flow-concurrency-queue");
Execution first = runnerUtils.runOneUntilRunning(TENANT_ID, TESTS_FLOW_NS, "flow-concurrency-queue");
Execution result = runUntilQueued(TESTS_FLOW_NS, "flow-concurrency-queue");
assertThat(result.getState().isQueued()).isTrue();
// await for the execution to be terminated
CountDownLatch terminated = new CountDownLatch(2);
Flux<Execution> receive = TestsUtils.receive(executionQueue, (either) -> {
if (either.getLeft().getId().equals(first.getId()) && either.getLeft().getState().isTerminated()) {
terminated.countDown();
}
if (either.getLeft().getId().equals(result.getId()) && either.getLeft().getState().isTerminated()) {
terminated.countDown();
}
});
Execution unqueued = concurrencyLimitService.unqueue(result, State.Type.RUNNING);
assertThat(unqueued.getState().isRunning()).isTrue();
executionQueue.emit(unqueued);
assertTrue(terminated.await(10, TimeUnit.SECONDS));
receive.blockLast();
}
@Test
@@ -73,7 +92,6 @@ class ConcurrencyLimitServiceTest {
assertThat(limit.get().getTenantId()).isEqualTo(execution.getTenantId());
assertThat(limit.get().getNamespace()).isEqualTo(execution.getNamespace());
assertThat(limit.get().getFlowId()).isEqualTo(execution.getFlowId());
assertThat(limit.get().getRunning()).isEqualTo(0);
}
@Test

View File

@@ -156,6 +156,26 @@ class DownloadTest {
assertThat(output.getUri().toString()).endsWith("filename.jpg");
}
@Test
void fileNameShouldOverrideContentDisposition() throws Exception {
EmbeddedServer embeddedServer = applicationContext.getBean(EmbeddedServer.class);
embeddedServer.start();
Download task = Download.builder()
.id(DownloadTest.class.getSimpleName())
.type(DownloadTest.class.getName())
.uri(Property.ofValue(embeddedServer.getURI() + "/content-disposition"))
.saveAs(Property.ofValue("hardcoded-filename.jpg"))
.build();
RunContext runContext = TestsUtils.mockRunContext(this.runContextFactory, task, ImmutableMap.of());
Download.Output output = task.run(runContext);
assertThat(output.getUri().toString()).endsWith("hardcoded-filename.jpg");
}
@Test
void contentDispositionWithPath() throws Exception {
EmbeddedServer embeddedServer = applicationContext.getBean(EmbeddedServer.class);

View File

@@ -0,0 +1,10 @@
id: flow-trigger-multiple-conditions-flow-a
namespace: io.kestra.tests.trigger.multiple.conditions
labels:
some: label
tasks:
- id: only
type: io.kestra.plugin.core.debug.Return
format: "from parents: {{execution.id}}"

View File

@@ -0,0 +1,23 @@
id: flow-trigger-multiple-conditions-flow-listen
namespace: io.kestra.tests.trigger.multiple.conditions
triggers:
- id: on_completion
type: io.kestra.plugin.core.trigger.Flow
states: [ SUCCESS ]
conditions:
- type: io.kestra.plugin.core.condition.ExecutionFlow
namespace: io.kestra.tests.trigger.multiple.conditions
flowId: flow-trigger-multiple-conditions-flow-a
- id: on_failure
type: io.kestra.plugin.core.trigger.Flow
states: [ FAILED ]
conditions:
- type: io.kestra.plugin.core.condition.ExecutionFlow
namespace: io.kestra.tests.trigger.multiple.conditions
flowId: flow-trigger-multiple-conditions-flow-a
tasks:
- id: only
type: io.kestra.plugin.core.debug.Return
format: "It works"

View File

@@ -1,4 +1,4 @@
version=1.1.0-SNAPSHOT
version=1.1.3
org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError
org.gradle.parallel=true

View File

@@ -12,7 +12,6 @@ import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
public abstract class AbstractJdbcExecutionQueuedStorage extends AbstractJdbcRepository {
protected io.kestra.jdbc.AbstractJdbcRepository<ExecutionQueued> jdbcRepository;
@@ -70,18 +69,12 @@ public abstract class AbstractJdbcExecutionQueuedStorage extends AbstractJdbcRep
this.jdbcRepository
.getDslContextWrapper()
.transaction(configuration -> {
var select = DSL
.using(configuration)
.select(AbstractJdbcRepository.field("value"))
.from(this.jdbcRepository.getTable())
.where(buildTenantCondition(execution.getTenantId()))
.and(field("key").eq(IdUtils.fromParts(execution.getTenantId(), execution.getNamespace(), execution.getFlowId(), execution.getId())))
.forUpdate();
Optional<ExecutionQueued> maybeExecution = this.jdbcRepository.fetchOne(select);
if (maybeExecution.isPresent()) {
this.jdbcRepository.delete(maybeExecution.get());
}
DSL
.using(configuration)
.deleteFrom(this.jdbcRepository.getTable())
.where(buildTenantCondition(execution.getTenantId()))
.and(field("key").eq(IdUtils.fromParts(execution.getTenantId(), execution.getNamespace(), execution.getFlowId(), execution.getId())))
.execute();
});
}
}

View File

@@ -1230,8 +1230,10 @@ public class JdbcExecutor implements ExecutorInterface {
private void processFlowTriggers(Execution execution) throws QueueException {
// directly process simple conditions
flowTriggerService.withFlowTriggersOnly(allFlows.stream())
.filter(f ->ListUtils.emptyOnNull(f.getTrigger().getConditions()).stream().noneMatch(c -> c instanceof MultipleCondition) && f.getTrigger().getPreconditions() == null)
.flatMap(f -> flowTriggerService.computeExecutionsFromFlowTriggers(execution, List.of(f.getFlow()), Optional.empty()).stream())
.filter(f -> ListUtils.emptyOnNull(f.getTrigger().getConditions()).stream().noneMatch(c -> c instanceof MultipleCondition) && f.getTrigger().getPreconditions() == null)
.map(f -> f.getFlow())
.distinct() // as computeExecutionsFromFlowTriggers is based on flow, we must map FlowWithFlowTrigger to a flow and distinct to avoid multiple execution for the same flow
.flatMap(f -> flowTriggerService.computeExecutionsFromFlowTriggers(execution, List.of(f), Optional.empty()).stream())
.forEach(throwConsumer(exec -> executionQueue.emit(exec)));
// send multiple conditions to the multiple condition queue for later processing

View File

@@ -4,6 +4,7 @@ import io.kestra.core.models.flows.FlowWithSource;
import io.kestra.core.models.triggers.Trigger;
import io.kestra.core.repositories.TriggerRepositoryInterface;
import io.kestra.core.runners.ScheduleContextInterface;
import io.kestra.core.runners.Scheduler;
import io.kestra.core.runners.SchedulerTriggerStateInterface;
import io.kestra.core.services.FlowListenersInterface;
import io.kestra.core.services.FlowService;
@@ -56,6 +57,9 @@ public class JdbcScheduler extends AbstractScheduler {
.forEach(abstractTrigger -> triggerRepository.delete(Trigger.of(flow, abstractTrigger)));
}
});
// No-op consumption of the trigger queue, so the events are purged from the queue
this.triggerQueue.receive(Scheduler.class, trigger -> { });
}
@Override

View File

@@ -25,7 +25,7 @@ import java.util.concurrent.atomic.AtomicBoolean;
@Slf4j
@Singleton
public class TestRunner implements Runnable, AutoCloseable {
@Setter private int workerThread = Math.max(3, Runtime.getRuntime().availableProcessors());
@Setter private int workerThread = Math.max(3, Runtime.getRuntime().availableProcessors()) * 16;
@Setter private boolean schedulerEnabled = true;
@Setter private boolean workerEnabled = true;

View File

@@ -35,16 +35,18 @@
<WeatherSunny v-else />
</el-button>
</div>
<div class="panelWrapper" :class="{panelTabResizing: resizing}" :style="{width: activeTab?.length ? `${panelWidth}px` : 0}">
<div class="panelWrapper" ref="panelWrapper" :class="{panelTabResizing: resizing}" :style="{width: activeTab?.length ? `${panelWidth}px` : 0}">
<div :style="{overflow: 'hidden'}">
<button v-if="activeTab.length" class="closeButton" @click="setActiveTab('')">
<Close />
</button>
<ContextDocs v-if="activeTab === 'docs'" />
<ContextNews v-else-if="activeTab === 'news'" />
<template v-else>
{{ activeTab }}
</template>
<KeepAlive>
<ContextDocs v-if="activeTab === 'docs'" />
<ContextNews v-else-if="activeTab === 'news'" />
<template v-else>
{{ activeTab }}
</template>
</KeepAlive>
</div>
</div>
</template>
@@ -96,6 +98,7 @@
});
const panelWidth = ref(640)
const panelWrapper = ref<HTMLDivElement | null>(null)
const {startResizing, resizing} = useResizablePanel(activeTab)

View File

@@ -4,14 +4,22 @@
<slot name="back-button" />
<h2>{{ title }}</h2>
</div>
<div class="content">
<div class="content" ref="contentRef">
<slot />
</div>
</div>
</template>
<script setup lang="ts">
import {ref} from "vue";
defineProps<{title:string}>();
const contentRef = ref<HTMLDivElement | null>(null);
defineExpose({
contentRef
});
</script>
<style scoped lang="scss">

View File

@@ -197,7 +197,6 @@
import {trackTabOpen, trackTabClose} from "../utils/tabTracking";
import {Panel, Tab, TabLive} from "../utils/multiPanelTypes";
import {usePanelDefaultSize} from "../composables/usePanelDefaultSize";
const {t} = useI18n();
const {showKeyShortcuts} = useKeyShortcuts();
@@ -449,7 +448,7 @@
}
}
const defaultSize = usePanelDefaultSize(panels);
const defaultSize = computed(() => panels.value.length === 0 ? 1 : (panels.value.reduce((acc, panel) => acc + panel.size, 0) / panels.value.length));
function newPanelDrop(_e: DragEvent, direction: "left" | "right") {
if (!movedTabInfo.value) return;

View File

@@ -298,6 +298,7 @@
<script setup lang="ts">
import _merge from "lodash/merge";
import {ref, computed, watch} from "vue";
import moment from "moment";
import {useI18n} from "vue-i18n";
import {useRoute} from "vue-router";
import {ElMessage} from "element-plus";
@@ -337,7 +338,6 @@
import SelectTable from "../layout/SelectTable.vue";
import TriggerAvatar from "../flows/TriggerAvatar.vue";
import KSFilter from "../filter/components/KSFilter.vue";
import useRestoreUrl from "../../composables/useRestoreUrl";
import MarkdownTooltip from "../layout/MarkdownTooltip.vue";
import useRouteContext from "../../composables/useRouteContext";
@@ -436,8 +436,6 @@
.filter(Boolean) as ColumnConfig[]
);
const {saveRestoreUrl} = useRestoreUrl();
const loadData = (callback?: () => void) => {
const query = loadQuery({
size: parseInt(String(route.query?.size ?? "25")),
@@ -463,8 +461,7 @@
const {ready, onSort, onPageChanged, queryWithFilter, load} = useDataTableActions({
dataTableRef: dataTable,
loadData,
saveRestoreUrl
loadData
});
const {
@@ -696,7 +693,16 @@
};
const loadQuery = (base: any) => {
let queryFilter = queryWithFilter();
const queryFilter = queryWithFilter();
const timeRange = queryFilter["filters[timeRange][EQUALS]"];
if (timeRange) {
const end = new Date();
const start = new Date(end.getTime() - moment.duration(timeRange).asMilliseconds());
queryFilter["filters[startDate][GREATER_THAN_OR_EQUAL_TO]"] = start.toISOString();
queryFilter["filters[endDate][LESS_THAN_OR_EQUAL_TO]"] = end.toISOString();
delete queryFilter["filters[timeRange][EQUALS]"];
}
return _merge(base, queryFilter);
};

View File

@@ -18,7 +18,7 @@
</template>
<script setup lang="ts">
import {computed, onBeforeMount, ref, useTemplateRef} from "vue";
import {computed, onBeforeMount, ref, useTemplateRef, watch} from "vue";
import {stringify, parse} from "@kestra-io/ui-libs/flow-yaml-utils";
import type {Dashboard, Chart} from "./composables/useDashboards";
@@ -89,9 +89,16 @@
}
if (!props.isFlow && !props.isNamespace) {
// Preserve timeRange filter when switching dashboards
const preservedQuery = Object.fromEntries(
Object.entries(route.query).filter(([key]) =>
key.includes("timeRange")
)
);
router.replace({
params: {...route.params, dashboard: id},
query: route.params.dashboard !== id ? {} : {...route.query},
query: route.params.dashboard !== id ? preservedQuery : {...route.query},
});
}
@@ -102,8 +109,22 @@
onBeforeMount(() => {
const ID = getDashboard(route, "id");
if (props.isFlow && ID === "default") load("default", processFlowYaml(YAML_FLOW, route.params.namespace as string, route.params.id as string));
else if (props.isNamespace && ID === "default") load("default", YAML_NAMESPACE);
if (props.isFlow) {
load(ID, processFlowYaml(YAML_FLOW, route.params.namespace as string, route.params.id as string));
} else if (props.isNamespace) {
load(ID, YAML_NAMESPACE);
}
});
watch(() => getDashboard(route, "id"), (newId, oldId) => {
if (newId !== oldId) {
const defaultYAML = props.isFlow
? processFlowYaml(YAML_FLOW, route.params.namespace as string, route.params.id as string)
: props.isNamespace
? YAML_NAMESPACE
: YAML_MAIN;
load(newId, defaultYAML);
}
});
</script>

View File

@@ -28,33 +28,10 @@
}
}
async function loadChart(chart: any) {
const yamlChart = YAML_UTILS.stringify(chart);
const result: { error: string | null; data: null | {
id?: string;
name?: string;
type?: string;
chartOptions?: Record<string, any>;
dataFilters?: any[];
charts?: any[];
}; raw: any } = {
error: null,
data: null,
raw: {}
};
const errors = await dashboardStore.validateChart(yamlChart);
if (errors.constraints) {
result.error = errors.constraints;
} else {
result.data = {...chart, content: yamlChart, raw: chart};
}
return result;
}
async function updateChartPreview(event: any) {
const chart = YAML_UTILS.getChartAtPosition(event.model.getValue(), event.position);
if (chart) {
const result = await loadChart(chart);
const result = await dashboardStore.loadChart(chart);
dashboardStore.selectedChart = typeof result.data === "object"
? {
...result.data,

View File

@@ -1,6 +1,11 @@
<template>
<div class="button-top">
<ValidationError class="mx-3" tooltipPlacement="bottom-start" :errors="errors" />
<ValidationError
class="mx-3"
tooltipPlacement="bottom-start"
:errors="dashboardStore.errors"
:warnings="dashboardStore.warnings"
/>
<el-button
:icon="ContentSave"
@@ -17,6 +22,7 @@
import {useI18n} from "vue-i18n";
import ContentSave from "vue-material-design-icons/ContentSave.vue";
import ValidationError from "../../flows/ValidationError.vue";
import {useDashboardStore} from "../../../stores/dashboard";
const {t} = useI18n();
@@ -24,15 +30,11 @@
(e: "save"): void;
}>();
const props = defineProps<{
warnings?: string[];
errors?: string[];
disabled?: boolean;
}>();
const dashboardStore = useDashboardStore();
const saveButtonType = computed(() => {
if (props.errors) return "danger";
return props.warnings ? "warning" : "primary";
if (dashboardStore.errors) return "danger";
return dashboardStore.warnings ? "warning" : "primary";
});
</script>
<style lang="scss" scoped>

View File

@@ -37,6 +37,7 @@
FIELDNAME_INJECTION_KEY,
FULL_SCHEMA_INJECTION_KEY,
FULL_SOURCE_INJECTION_KEY,
ON_TASK_EDITOR_CLICK_INJECTION_KEY,
PARENT_PATH_INJECTION_KEY,
POSITION_INJECTION_KEY,
REF_PATH_INJECTION_KEY,
@@ -111,6 +112,15 @@
provide(BLOCK_SCHEMA_PATH_INJECTION_KEY, computed(() => props.blockSchemaPath ?? dashboardStore.schema.$ref ?? ""));
provide(FULL_SOURCE_INJECTION_KEY, computed(() => dashboardStore.sourceCode ?? ""));
provide(POSITION_INJECTION_KEY, props.position ?? "after");
provide(ON_TASK_EDITOR_CLICK_INJECTION_KEY, (elt) => {
const type = elt?.type;
dashboardStore.loadChart(elt);
if(type){
pluginsStore.updateDocumentation({type});
}else{
pluginsStore.updateDocumentation();
}
})
const pluginsStore = usePluginsStore();

View File

@@ -1,6 +1,7 @@
<template>
<div class="w-100 p-4">
<Sections
:key="dashboardStore.sourceCode"
:dashboard="{id: 'default', charts: []}"
:charts="charts.map(chart => chart.data).filter(chart => chart !== null)"
showDefault
@@ -9,11 +10,12 @@
</template>
<script lang="ts" setup>
import {onMounted, ref} from "vue";
import {ref, watch} from "vue";
import Sections from "../sections/Sections.vue";
import {Chart} from "../composables/useDashboards";
import {useDashboardStore} from "../../../stores/dashboard";
import * as YAML_UTILS from "@kestra-io/ui-libs/flow-yaml-utils";
import throttle from "lodash/throttle";
interface Result {
error: string[] | null;
@@ -23,21 +25,27 @@
const charts = ref<Result[]>([])
onMounted(async () => {
validateAndLoadAllCharts();
});
const dashboardStore = useDashboardStore();
function validateAndLoadAllCharts() {
charts.value = [];
const validateAndLoadAllChartsThrottled = throttle(validateAndLoadAllCharts, 500);
async function validateAndLoadAllCharts() {
const allCharts = YAML_UTILS.getAllCharts(dashboardStore.sourceCode) ?? [];
allCharts.forEach(async (chart: any) => {
const loadedChart = await loadChart(chart);
charts.value.push(loadedChart);
});
charts.value = await Promise.all(allCharts.map(async (chart: any) => {
return loadChart(chart);
}));
}
watch(
() => dashboardStore.sourceCode,
() => {
validateAndLoadAllChartsThrottled();
}
, {immediate: true}
);
async function loadChart(chart: any) {
const yamlChart = YAML_UTILS.stringify(chart);
const result: Result = {

View File

@@ -96,14 +96,19 @@
return [DEFAULT, ...dashboards.value].filter((d) => !search.value || d.title.toLowerCase().includes(search.value.toLowerCase()));
});
const ID = getDashboard(route, "id") as string;
const selected = ref(null);
const STORAGE_KEY = getDashboard(route, "key");
const selected = ref<string | null>(null);
const select = (dashboard: any) => {
selected.value = dashboard?.title;
if (dashboard?.id) localStorage.setItem(ID, dashboard.id)
else localStorage.removeItem(ID);
if (STORAGE_KEY) {
if (dashboard?.id) {
localStorage.setItem(STORAGE_KEY, dashboard.id);
} else {
localStorage.removeItem(STORAGE_KEY);
}
}
emits("dashboard", dashboard.id);
};
@@ -121,7 +126,7 @@
});
};
const fetchLast = () => localStorage.getItem(ID);
const getStoredDashboard = () => STORAGE_KEY ? localStorage.getItem(STORAGE_KEY) : null;
const fetchDashboards = () => {
dashboardStore
.list({})
@@ -129,13 +134,17 @@
dashboards.value = response.results;
const creation = Boolean(route.query.created);
const lastSelected = creation ? (route.params?.dashboard ?? fetchLast()) : (fetchLast() ?? route.params?.dashboard);
const lastSelected = creation
? (route.params?.dashboard ?? getStoredDashboard())
: (getStoredDashboard() ?? route.params?.dashboard);
if (lastSelected) {
const dashboard = dashboards.value.find((d) => d.id === lastSelected);
if (dashboard) select(dashboard);
else {
if (dashboard) {
selected.value = dashboard.title;
emits("dashboard", dashboard.id);
} else {
selected.value = null;
emits("dashboard", "default");
}
@@ -145,15 +154,19 @@
onBeforeMount(() => fetchDashboards());
const tenant = ref(route.params.tenant);
watch(route, (r) => {
if (tenant.value !== r.params.tenant) {
fetchDashboards();
tenant.value = r.params.tenant;
}
},
{deep: true},
);
const tenant = ref();
watch(() => route.params.tenant, (t) => {
if (tenant.value !== t) {
fetchDashboards();
tenant.value = t;
}
}, {immediate: true});
watch(() => route.params?.dashboard, (val) => {
if(route.name === "home" && STORAGE_KEY) {
localStorage.setItem(STORAGE_KEY, val as string);
}
}, {immediate: true});
</script>
<style scoped lang="scss">
@@ -161,14 +174,6 @@
span{
font-size: 14px;
}
:deep(svg){
color: var(--ks-content-tertiary);
font-size: 1.10rem;
position: absolute;
bottom: -0.10rem;
right: 0.08rem;
}
}
.dropdown {
width: 300px;

View File

@@ -1,12 +1,13 @@
<template>
<section id="charts" :class="{padding}">
<el-row :gutter="16">
<el-col
<div class="dashboard-sections-container">
<section id="charts" :class="{padding}">
<div
v-for="chart in props.charts"
:key="`chart__${chart.id}`"
:xs="24"
:sm="(chart.chartOptions?.width || 6) * 4"
:md="(chart.chartOptions?.width || 6) * 2"
class="dashboard-block"
:class="{
[`dash-width-${chart.chartOptions?.width || 6}`]: true
}"
>
<div class="d-flex flex-column">
<div class="d-flex justify-content-between">
@@ -64,9 +65,9 @@
/>
</div>
</div>
</el-col>
</el-row>
</section>
</div>
</section>
</div>
</template>
<script setup lang="ts">
@@ -133,14 +134,28 @@
<style scoped lang="scss">
@import "@kestra-io/ui-libs/src/scss/variables";
.dashboard-sections-container{
container-type: inline-size;
}
$smallMobile: 375px;
$tablet: 768px;
section#charts {
display: grid;
gap: 1rem;
grid-template-columns: repeat(3, 1fr);
@container (min-width: #{$smallMobile}) {
grid-template-columns: repeat(6, 1fr);
}
@container (min-width: #{$tablet}) {
grid-template-columns: repeat(12, 1fr);
}
&.padding {
padding: 0 2rem 1rem;
}
& .el-row .el-col {
margin-bottom: 1rem;
.dashboard-block {
& > div {
height: 100%;
padding: 1.5rem;
@@ -159,5 +174,24 @@ section#charts {
opacity: 1;
}
}
.dash-width-3, .dash-width-6, .dash-width-9, .dash-width-12 {
grid-column: span 3;
}
@container (min-width: #{$smallMobile}) {
.dash-width-6, .dash-width-9, .dash-width-12 {
grid-column: span 6;
}
}
@container (min-width: #{$tablet}) {
.dash-width-9 {
grid-column: span 9;
}
.dash-width-12 {
grid-column: span 12;
}
}
}
</style>

View File

@@ -1,5 +1,5 @@
<template>
<ContextInfoContent :title="routeInfo.title">
<ContextInfoContent :title="routeInfo.title" ref="contextInfoRef">
<template v-if="isOnline" #back-button>
<button
class="back-button"
@@ -26,7 +26,7 @@
<OpenInNew class="blank" />
</router-link>
</template>
<div ref="docWrapper" class="docs-controls">
<div class="docs-controls">
<template v-if="isOnline">
<ContextDocsSearch />
<DocsMenu />
@@ -42,7 +42,7 @@
</template>
<script setup lang="ts">
import {ref, watch, computed, getCurrentInstance, onUnmounted, onMounted, nextTick} from "vue";
import {ref, watch, computed, getCurrentInstance, onUnmounted, onMounted} from "vue";
import {useDocStore} from "../../stores/doc";
import {useI18n} from "vue-i18n";
import OpenInNew from "vue-material-design-icons/OpenInNew.vue";
@@ -55,7 +55,9 @@
import ContextInfoContent from "../ContextInfoContent.vue";
import ContextChildTableOfContents from "./ContextChildTableOfContents.vue";
import {useNetwork} from "@vueuse/core"
import {useScrollMemory} from "../../composables/useScrollMemory"
const {isOnline} = useNetwork()
import Markdown from "../../components/layout/Markdown.vue";
@@ -64,19 +66,18 @@
const docStore = useDocStore();
const {t} = useI18n({useScope: "global"});
const docWrapper = ref<HTMLDivElement | null>(null);
const contextInfoRef = ref<InstanceType<typeof ContextInfoContent> | null>(null);
const docHistory = ref<string[]>([]);
const currentHistoryIndex = ref(-1);
const ast = ref<any>(undefined);
const pageMetadata = computed(() => docStore.pageMetadata);
const docPath = computed(() => docStore.docPath);
const routeInfo = computed(() => ({
title: pageMetadata.value?.title ?? t("docs"),
}));
const canGoBack = computed(() => docHistory.value.length > 1 && currentHistoryIndex.value > 0);
const addToHistory = (path: string) => {
// Always store the path, even empty ones
const pathToAdd = path || "";
@@ -179,8 +180,10 @@
addToHistory(val);
refreshPage(val);
nextTick(() => docWrapper.value?.scrollTo(0, 0));
}, {immediate: true});
const scrollableElement = computed(() => contextInfoRef.value?.contentRef ?? null)
useScrollMemory(ref("context-panel-docs"), scrollableElement as any)
</script>
<style scoped lang="scss">
@@ -241,4 +244,4 @@
margin-bottom: 1rem;
}
}
</style>
</style>

View File

@@ -23,9 +23,15 @@
</template>
<script setup lang="ts">
import {ref} from "vue"
import {ref, computed} from "vue"
import {useRoute} from "vue-router";
import {useScrollMemory} from "../../composables/useScrollMemory";
const collapsed = ref(false);
const route = useRoute();
const scrollKey = computed(() => `docs:${route.fullPath}`);
useScrollMemory(scrollKey, undefined, true);
</script>
@@ -224,4 +230,4 @@
padding-bottom: 1px !important;
}
}
</style>
</style>

View File

@@ -84,7 +84,7 @@
import {useExecutionsStore} from "../../stores/executions";
import {useAuthStore} from "override/stores/auth";
const props = defineProps<{
const props = withDefaults(defineProps<{
component: string;
execution: {
id: string;
@@ -95,7 +95,10 @@
};
};
tooltipPosition: string;
}>();
}>(), {
component: "el-button",
tooltipPosition: "bottom"
});
const emit = defineEmits<{
follow: [];

View File

@@ -55,7 +55,7 @@
</template>
<template v-if="showStatChart()" #top>
<Sections ref="dashboardComponent" :dashboard="{id: 'default', charts: []}" :charts showDefault />
<Sections ref="dashboardComponent" :dashboard="{id: 'default', charts: []}" :charts showDefault class="mb-4" />
</template>
<template #table>
@@ -70,7 +70,7 @@
@selection-change="handleSelectionChange"
:selectable="!hidden?.includes('selection') && canCheck"
:no-data-text="$t('no_results.executions')"
:rowKey="(row: any) => `${row.namespace}-${row.id}`"
:rowKey="(row: any) => row.id"
>
<template #select-actions>
<BulkSelect
@@ -144,10 +144,7 @@
<el-form>
<ElFormItem :label="$t('execution labels')">
<LabelInput
:key="executionLabels.map((l) => l.key).join('-')"
v-model:labels="executionLabels"
/>
<LabelInput v-model:labels="executionLabels" />
</ElFormItem>
</el-form>
</el-dialog>
@@ -384,7 +381,7 @@
import _merge from "lodash/merge";
import {useI18n} from "vue-i18n";
import {useRoute, useRouter} from "vue-router";
import {ref, computed, onMounted, watch, h, useTemplateRef} from "vue";
import {ref, computed, watch, h, useTemplateRef} from "vue";
import * as YAML_UTILS from "@kestra-io/ui-libs/flow-yaml-utils";
import {ElMessageBox, ElSwitch, ElFormItem, ElAlert, ElCheckbox} from "element-plus";
@@ -424,18 +421,17 @@
import {filterValidLabels} from "./utils";
import {useToast} from "../../utils/toast";
import {storageKeys} from "../../utils/constants";
import {defaultNamespace} from "../../composables/useNamespaces";
import {humanizeDuration, invisibleSpace} from "../../utils/filters";
import Utils from "../../utils/utils";
import action from "../../models/action";
import permission from "../../models/permission";
import useRestoreUrl from "../../composables/useRestoreUrl";
import useRouteContext from "../../composables/useRouteContext";
import {useTableColumns} from "../../composables/useTableColumns";
import {useDataTableActions} from "../../composables/useDataTableActions";
import {useSelectTableActions} from "../../composables/useSelectTableActions";
import {useApplyDefaultFilter} from "../filter/composables/useDefaultFilter";
import {useFlowStore} from "../../stores/flow";
import {useAuthStore} from "override/stores/auth";
@@ -496,7 +492,6 @@
const selectedStatus = ref(undefined);
const lastRefreshDate = ref(new Date());
const unqueueDialogVisible = ref(false);
const isDefaultNamespaceAllow = ref(true);
const changeStatusDialogVisible = ref(false);
const actionOptions = ref<Record<string, any>>({});
const dblClickRouteName = ref("executions/update");
@@ -614,11 +609,6 @@
const routeInfo = computed(() => ({title: t("executions")}));
useRouteContext(routeInfo, props.embed);
const {saveRestoreUrl} = useRestoreUrl({
restoreUrl: true,
isDefaultNamespaceAllow: isDefaultNamespaceAllow.value
});
const dataTableRef = ref(null);
const selectTableRef = useTemplateRef<typeof SelectTable>("selectTable");
@@ -634,8 +624,7 @@
dblClickRouteName: dblClickRouteName.value,
embed: props.embed,
dataTableRef,
loadData: loadData,
saveRestoreUrl
loadData: loadData
});
const {
@@ -1043,29 +1032,10 @@
emit("state-count", {runningCount, totalCount});
};
onMounted(() => {
const query = {...route.query};
let queryHasChanged = false;
const queryKeys = Object.keys(query);
if (props.namespace === undefined && defaultNamespace() && !queryKeys.some(key => key.startsWith("filters[namespace]"))) {
query["filters[namespace][PREFIX]"] = defaultNamespace();
queryHasChanged = true;
}
if (!queryKeys.some(key => key.startsWith("filters[scope]"))) {
query["filters[scope][EQUALS]"] = "USER";
queryHasChanged = true;
}
if (queryHasChanged) {
router.replace({query});
}
if (route.name === "flows/update") {
optionalColumns.value = optionalColumns.value.
filter(col => col.prop !== "namespace" && col.prop !== "flowId");
}
useApplyDefaultFilter({
namespace: props.namespace,
includeTimeRange: true,
includeScope: true
});
watch(isOpenLabelsModal, (opening) => {

View File

@@ -3,8 +3,8 @@
v-if="!isExecutionStarted"
:execution="execution"
/>
<el-card id="gantt" shadow="never" v-else-if="execution && executionsStore.flow">
<template #header>
<el-card id="gantt" shadow="never" :class="{'no-border': !hasValidDate}" v-else-if="execution && executionsStore.flow">
<template #header v-if="hasValidDate">
<div class="d-flex">
<Duration class="th text-end" :histories="execution.state.histories" />
<span class="text-end" v-for="(date, i) in dates" :key="i">
@@ -234,6 +234,9 @@
isExecutionStarted() {
return this.execution?.state?.current && !["CREATED", "QUEUED"].includes(this.execution.state.current);
},
hasValidDate() {
return isFinite(this.delta());
},
},
methods: {
forwardEvent(type, event) {
@@ -443,6 +446,9 @@
}
}
.no-border {
border: none !important;
}
// To Separate through Line
:deep(.vue-recycle-scroller__item-view) {

View File

@@ -15,6 +15,7 @@
<script lang="ts" setup>
import {ref, computed, watch, PropType} from "vue";
import DateSelect from "./DateSelect.vue";
import {useI18n} from "vue-i18n";
interface TimePreset {
value?: string;
@@ -64,9 +65,11 @@
timeFilterPresets.value.map(preset => preset.value)
);
const {t} = useI18n();
const customAwarePlaceholder = computed<string | undefined>(() => {
if (props.placeholder) return props.placeholder;
return props.allowCustom ? "datepicker.custom" : undefined;
return props.allowCustom ? t("datepicker.custom") : undefined;
});
const onTimeRangeSelect = (range: string | undefined) => {
@@ -92,4 +95,4 @@
},
{immediate: true}
);
</script>
</script>

View File

@@ -531,8 +531,9 @@
}
.content-container {
height: calc(100vh - 0px);
overflow-y: auto !important;
overflow-y: scroll;
overflow-x: hidden;
scrollbar-gutter: stable;
word-wrap: break-word;
word-break: break-word;
position: relative;
@@ -541,19 +542,16 @@
: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) {
overflow-y: auto !important;
word-wrap: break-word;
word-break: break-word;
}

View File

@@ -0,0 +1,70 @@
import {onMounted} from "vue";
import {LocationQuery, useRoute, useRouter} from "vue-router";
import {useMiscStore} from "override/stores/misc";
import {defaultNamespace} from "../../../composables/useNamespaces";
interface DefaultFilterOptions {
namespace?: string;
includeTimeRange?: boolean;
includeScope?: boolean;
legacyQuery?: boolean;
}
const NAMESPACE_FILTER_PREFIX = "filters[namespace]";
const SCOPE_FILTER_PREFIX = "filters[scope]";
const TIME_RANGE_FILTER_PREFIX = "filters[timeRange]";
const hasFilterKey = (query: LocationQuery, prefix: string): boolean =>
Object.keys(query).some(key => key.startsWith(prefix));
export function applyDefaultFilters(
currentQuery: LocationQuery,
options: DefaultFilterOptions & {
configuration?: any;
route?: any
} = {}): { query: LocationQuery; hasChanges: boolean } {
const {configuration, route, namespace, includeTimeRange, includeScope, legacyQuery = false} = options;
const hasTimeRange = configuration && route
? configuration.keys?.some((k: any) => k.key === "timeRange") ?? false
: includeTimeRange ?? false;
const hasScope = configuration && route
? route?.name !== "logs/list" && (configuration.keys?.some((k: any) => k.key === "scope") ?? false)
: includeScope ?? false;
const query = {...currentQuery};
let hasChanges = false;
if (namespace === undefined && defaultNamespace() && !hasFilterKey(query, NAMESPACE_FILTER_PREFIX)) {
query[legacyQuery ? "namespace" : `${NAMESPACE_FILTER_PREFIX}[PREFIX]`] = defaultNamespace();
hasChanges = true;
}
if (hasScope && !hasFilterKey(query, SCOPE_FILTER_PREFIX)) {
query[legacyQuery ? "scope" : `${SCOPE_FILTER_PREFIX}[EQUALS]`] = "USER";
hasChanges = true;
}
const TIME_FILTER_KEYS = /startDate|endDate|timeRange/;
if (hasTimeRange && !Object.keys(query).some(key => TIME_FILTER_KEYS.test(key))) {
const defaultDuration = useMiscStore().configs?.chartDefaultDuration ?? "P30D";
query[legacyQuery ? "timeRange" : `${TIME_RANGE_FILTER_PREFIX}[EQUALS]`] = defaultDuration;
hasChanges = true;
}
return {query, hasChanges};
}
export function useApplyDefaultFilter(options?: DefaultFilterOptions) {
const router = useRouter();
const route = useRoute();
onMounted(() => {
const {query, hasChanges} = applyDefaultFilters(route.query, options);
if (hasChanges) {
router.replace({query});
}
});
}

View File

@@ -17,6 +17,7 @@ import {
KV_COMPARATORS
} from "../utils/filterTypes";
import {usePreAppliedFilters} from "./usePreAppliedFilters";
import {applyDefaultFilters} from "./useDefaultFilter";
export function useFilters(configuration: FilterConfiguration, showSearchInput = true, legacyQuery = false) {
const router = useRouter();
@@ -28,8 +29,7 @@ export function useFilters(configuration: FilterConfiguration, showSearchInput =
const {
markAsPreApplied,
hasPreApplied,
getPreApplied,
getAllPreApplied
getPreApplied
} = usePreAppliedFilters();
const appendQueryParam = (query: Record<string, any>, key: string, value: string) => {
@@ -367,13 +367,10 @@ export function useFilters(configuration: FilterConfiguration, showSearchInput =
updateRoute();
};
/**
* Resets all filters to their pre-applied state and clears the search query
*/
const resetToPreApplied = () => {
appliedFilters.value = getAllPreApplied();
const defaultQuery = applyDefaultFilters({}, {configuration, route, legacyQuery}).query;
searchQuery.value = "";
updateRoute();
router.push({query: defaultQuery});
};
return {

View File

@@ -43,7 +43,7 @@ export function useValues(label: string | undefined, t?: ReturnType<typeof useI1
{label: t("datepicker.last24hours"), value: "PT24H"},
{label: t("datepicker.last48hours"), value: "PT48H"},
{label: t("datepicker.last7days"), value: "PT168H"},
{label: t("datepicker.last30days"), value: "PT720H"},
{label: t("datepicker.last30days"), value: "P30D"},
{label: t("datepicker.last365days"), value: "PT8760H"},
];

View File

@@ -6,46 +6,50 @@ import {useNamespacesStore} from "override/stores/namespaces";
import {useAuthStore} from "override/stores/auth";
import {useValues} from "../composables/useValues";
import {useI18n} from "vue-i18n";
import {useRoute} from "vue-router";
export const useExecutionFilter = (): ComputedRef<FilterConfiguration> => computed(() => {
const {t} = useI18n();
const route = useRoute();
return {
title: t("filter.titles.execution_filters"),
searchPlaceholder: t("filter.search_placeholders.search_executions"),
keys: [
{
key: "namespace",
label: t("filter.namespace.label"),
description: t("filter.namespace.description"),
comparators: [
Comparators.IN,
Comparators.NOT_IN,
Comparators.CONTAINS,
Comparators.PREFIX,
],
valueType: "multi-select",
valueProvider: async () => {
const user = useAuthStore().user;
if (user && user.hasAnyActionOnAnyNamespace(permission.NAMESPACE, action.READ)) {
const namespacesStore = useNamespacesStore();
const namespaces = (await namespacesStore.loadAutocomplete()) as string[];
return [...new Set(namespaces
.flatMap(namespace => {
return namespace.split(".").reduce((current: string[], part: string) => {
const previousCombination = current?.[current.length - 1];
return [...current, `${(previousCombination ? previousCombination + "." : "")}${part}`];
}, []);
}))].map(namespace => ({
label: namespace,
value: namespace
}));
}
return [];
...(route.name !== "namespaces/update" ? [
{
key: "namespace",
label: t("filter.namespace.label"),
description: t("filter.namespace.description"),
comparators: [
Comparators.IN,
Comparators.NOT_IN,
Comparators.CONTAINS,
Comparators.PREFIX,
],
valueType: "multi-select" as const,
valueProvider: async () => {
const user = useAuthStore().user;
if (user && user.hasAnyActionOnAnyNamespace(permission.NAMESPACE, action.READ)) {
const namespacesStore = useNamespacesStore();
const namespaces = (await namespacesStore.loadAutocomplete()) as string[];
return [...new Set(namespaces
.flatMap(namespace => {
return namespace.split(".").reduce((current: string[], part: string) => {
const previousCombination = current?.[current.length - 1];
return [...current, `${(previousCombination ? previousCombination + "." : "")}${part}`];
}, []);
}))].map(namespace => ({
label: namespace,
value: namespace
}));
}
return [];
},
searchable: true
},
searchable: true
},
{
] : []) as any,
...(route.name !== "flows/update" ? [{
key: "flowId",
label: t("filter.flowId.label"),
description: t("filter.flowId.description"),
@@ -57,7 +61,7 @@ export const useExecutionFilter = (): ComputedRef<FilterConfiguration> => comput
Comparators.ENDS_WITH,
],
valueType: "text",
},
}] : []) as any,
{
key: "kind",
label: t("filter.kind.label"),

View File

@@ -6,45 +6,49 @@ import {useNamespacesStore} from "override/stores/namespaces";
import {useAuthStore} from "override/stores/auth";
import {useValues} from "../composables/useValues";
import {useI18n} from "vue-i18n";
import {useRoute} from "vue-router";
export const useFlowFilter = (): ComputedRef<FilterConfiguration> => computed(() => {
const {t} = useI18n();
const route = useRoute();
return {
title: t("filter.titles.flow_filters"),
searchPlaceholder: t("filter.search_placeholders.search_flows"),
keys: [
{
key: "namespace",
label: t("filter.namespace.label"),
description: t("filter.namespace.description"),
comparators: [
Comparators.IN,
Comparators.NOT_IN,
Comparators.CONTAINS,
Comparators.PREFIX,
],
valueType: "multi-select",
valueProvider: async () => {
const user = useAuthStore().user;
if (user && user.hasAnyActionOnAnyNamespace(permission.NAMESPACE, action.READ)) {
const namespacesStore = useNamespacesStore();
const namespaces = (await namespacesStore.loadAutocomplete()) as string[];
return [...new Set(namespaces
.flatMap(namespace => {
return namespace.split(".").reduce((current: string[], part: string) => {
const previousCombination = current?.[current.length - 1];
return [...current, `${(previousCombination ? previousCombination + "." : "")}${part}`];
}, []);
}))].map(namespace => ({
label: namespace,
value: namespace
}));
}
return [];
...(route.name !== "namespaces/update" ? [
{
key: "namespace",
label: t("filter.namespace.label"),
description: t("filter.namespace.description"),
comparators: [
Comparators.IN,
Comparators.NOT_IN,
Comparators.CONTAINS,
Comparators.PREFIX,
],
valueType: "multi-select" as const,
valueProvider: async () => {
const user = useAuthStore().user;
if (user && user.hasAnyActionOnAnyNamespace(permission.NAMESPACE, action.READ)) {
const namespacesStore = useNamespacesStore();
const namespaces = (await namespacesStore.loadAutocomplete()) as string[];
return [...new Set(namespaces
.flatMap(namespace => {
return namespace.split(".").reduce((current: string[], part: string) => {
const previousCombination = current?.[current.length - 1];
return [...current, `${(previousCombination ? previousCombination + "." : "")}${part}`];
}, []);
}))].map(namespace => ({
label: namespace,
value: namespace
}));
}
return [];
},
searchable: true
},
searchable: true
},
] : []) as any,
{
key: "scope",
label: t("filter.scope_flow.label"),

View File

@@ -3,47 +3,51 @@ import {Comparators, FilterConfiguration} from "../utils/filterTypes";
import {useI18n} from "vue-i18n";
import {useNamespacesStore} from "override/stores/namespaces";
import {useAuthStore} from "override/stores/auth";
import {useRoute} from "vue-router";
import permission from "../../../models/permission";
import action from "../../../models/action";
export const useKvFilter = (): ComputedRef<FilterConfiguration> => computed(() => {
const {t} = useI18n();
const route = useRoute();
return {
title: t("filter.titles.kv_filters"),
searchPlaceholder: t("filter.search_placeholders.search_kv"),
keys: [
{
key: "namespace",
label: t("filter.namespace.label"),
description: t("filter.namespace.description"),
comparators: [
Comparators.IN,
Comparators.NOT_IN,
Comparators.CONTAINS,
Comparators.PREFIX,
],
valueType: "multi-select",
valueProvider: async () => {
const user = useAuthStore().user;
if (user && user.hasAnyActionOnAnyNamespace(permission.NAMESPACE, action.READ)) {
const namespacesStore = useNamespacesStore();
const namespaces = (await namespacesStore.loadAutocomplete()) as string[];
return [...new Set(namespaces
.flatMap(namespace => {
return namespace.split(".").reduce((current: string[], part: string) => {
const previousCombination = current?.[current.length - 1];
return [...current, `${(previousCombination ? previousCombination + "." : "")}${part}`];
}, []);
}))].map(namespace => ({
label: namespace,
value: namespace
}));
}
return [];
},
searchable: true
}
...(route.name !== "namespaces/update" ? [
{
key: "namespace",
label: t("filter.namespace.label"),
description: t("filter.namespace.description"),
comparators: [
Comparators.IN,
Comparators.NOT_IN,
Comparators.CONTAINS,
Comparators.PREFIX,
],
valueType: "multi-select" as const,
valueProvider: async () => {
const user = useAuthStore().user;
if (user && user.hasAnyActionOnAnyNamespace(permission.NAMESPACE, action.READ)) {
const namespacesStore = useNamespacesStore();
const namespaces = (await namespacesStore.loadAutocomplete()) as string[];
return [...new Set(namespaces
.flatMap(namespace => {
return namespace.split(".").reduce((current: string[], part: string) => {
const previousCombination = current?.[current.length - 1];
return [...current, `${(previousCombination ? previousCombination + "." : "")}${part}`];
}, []);
}))].map(namespace => ({
label: namespace,
value: namespace
}));
}
return [];
},
searchable: true
}
] : []) as any,
],
};
});

View File

@@ -6,45 +6,49 @@ import {useNamespacesStore} from "override/stores/namespaces";
import {useAuthStore} from "override/stores/auth";
import {useValues} from "../composables/useValues";
import {useI18n} from "vue-i18n";
import {useRoute} from "vue-router";
export const useLogFilter = (): ComputedRef<FilterConfiguration> => computed(() => {
const {t} = useI18n();
const route = useRoute();
return {
title: t("filter.titles.log_filters"),
searchPlaceholder: t("filter.search_placeholders.search_logs"),
keys: [
{
key: "namespace",
label: t("filter.namespace.label"),
description: t("filter.namespace.description"),
comparators: [
Comparators.IN,
Comparators.NOT_IN,
Comparators.CONTAINS,
Comparators.PREFIX,
],
valueType: "multi-select",
valueProvider: async () => {
const user = useAuthStore().user;
if (user && user.hasAnyActionOnAnyNamespace(permission.NAMESPACE, action.READ)) {
const namespacesStore = useNamespacesStore();
const namespaces = (await namespacesStore.loadAutocomplete()) as string[];
return [...new Set(namespaces
.flatMap(namespace => {
return namespace.split(".").reduce((current: string[], part: string) => {
const previousCombination = current?.[current.length - 1];
return [...current, `${(previousCombination ? previousCombination + "." : "")}${part}`];
}, []);
}))].map(namespace => ({
label: namespace,
value: namespace
}));
}
return [];
...(route.name !== "namespaces/update" && route.name !== "flows/update" ? [
{
key: "namespace",
label: t("filter.namespace.label"),
description: t("filter.namespace.description"),
comparators: [
Comparators.IN,
Comparators.NOT_IN,
Comparators.CONTAINS,
Comparators.PREFIX,
],
valueType: "multi-select" as const,
valueProvider: async () => {
const user = useAuthStore().user;
if (user && user.hasAnyActionOnAnyNamespace(permission.NAMESPACE, action.READ)) {
const namespacesStore = useNamespacesStore();
const namespaces = (await namespacesStore.loadAutocomplete()) as string[];
return [...new Set(namespaces
.flatMap(namespace => {
return namespace.split(".").reduce((current: string[], part: string) => {
const previousCombination = current?.[current.length - 1];
return [...current, `${(previousCombination ? previousCombination + "." : "")}${part}`];
}, []);
}))].map(namespace => ({
label: namespace,
value: namespace
}));
}
return [];
},
searchable: true
},
searchable: true
},
] : []) as any,
{
key: "level",
label: t("filter.level.label"),
@@ -95,7 +99,7 @@ export const useLogFilter = (): ComputedRef<FilterConfiguration> => computed(()
],
valueType: "text",
},
{
...(route.name !== "flows/update" ? [{
key: "flowId",
label: t("filter.flowId.label"),
description: t("filter.flowId.description"),
@@ -107,7 +111,7 @@ export const useLogFilter = (): ComputedRef<FilterConfiguration> => computed(()
Comparators.ENDS_WITH,
],
valueType: "text",
},
}] : []) as any,
]
};
});

View File

@@ -6,7 +6,7 @@ export const useNamespacesFilter = (): ComputedRef<FilterConfiguration> => compu
const {t} = useI18n();
return {
title: t("filter.titles.namespaces_filters"),
title: t("filter.titles.namespace_filters"),
searchPlaceholder: t("filter.search_placeholders.search_namespaces"),
keys: [],
};

View File

@@ -5,45 +5,49 @@ import action from "../../../models/action";
import {useNamespacesStore} from "override/stores/namespaces";
import {useAuthStore} from "override/stores/auth";
import {useI18n} from "vue-i18n";
import {useRoute} from "vue-router";
export const useSecretsFilter = (): ComputedRef<FilterConfiguration> => computed(() => {
const {t} = useI18n();
const route = useRoute();
return {
title: t("filter.titles.secret_filters"),
searchPlaceholder: t("filter.search_placeholders.search_secrets"),
keys: [
{
key: "namespace",
label: t("filter.namespace.label"),
description: t("filter.namespace.description"),
comparators: [
Comparators.IN,
Comparators.NOT_IN,
Comparators.CONTAINS,
Comparators.PREFIX,
],
valueType: "multi-select",
valueProvider: async () => {
const user = useAuthStore().user;
if (user && user.hasAnyActionOnAnyNamespace(permission.NAMESPACE, action.READ)) {
const namespacesStore = useNamespacesStore();
const namespaces = (await namespacesStore.loadAutocomplete()) as string[];
return [...new Set(namespaces
.flatMap(namespace => {
return namespace.split(".").reduce((current: string[], part: string) => {
const previousCombination = current?.[current.length - 1];
return [...current, `${(previousCombination ? previousCombination + "." : "")}${part}`];
}, []);
}))].map(namespace => ({
label: namespace,
value: namespace
}));
}
return [];
...(route.name !== "namespaces/update" ? [
{
key: "namespace",
label: t("filter.namespace.label"),
description: t("filter.namespace.description"),
comparators: [
Comparators.IN,
Comparators.NOT_IN,
Comparators.CONTAINS,
Comparators.PREFIX,
],
valueType: "multi-select" as const,
valueProvider: async () => {
const user = useAuthStore().user;
if (user && user.hasAnyActionOnAnyNamespace(permission.NAMESPACE, action.READ)) {
const namespacesStore = useNamespacesStore();
const namespaces = (await namespacesStore.loadAutocomplete()) as string[];
return [...new Set(namespaces
.flatMap(namespace => {
return namespace.split(".").reduce((current: string[], part: string) => {
const previousCombination = current?.[current.length - 1];
return [...current, `${(previousCombination ? previousCombination + "." : "")}${part}`];
}, []);
}))].map(namespace => ({
label: namespace,
value: namespace
}));
}
return [];
},
searchable: true
},
searchable: true
},
] : []) as any,
],
};
});

View File

@@ -6,46 +6,50 @@ import {useNamespacesStore} from "override/stores/namespaces";
import {useAuthStore} from "override/stores/auth";
import {useValues} from "../composables/useValues";
import {useI18n} from "vue-i18n";
import {useRoute} from "vue-router";
export const useTriggerFilter = (): ComputedRef<FilterConfiguration> => computed(() => {
const {t} = useI18n();
const route = useRoute();
return {
title: t("filter.titles.trigger_filters"),
searchPlaceholder: t("filter.search_placeholders.search_triggers"),
keys: [
{
key: "namespace",
label: t("filter.namespace.label"),
description: t("filter.namespace.description"),
comparators: [
Comparators.IN,
Comparators.NOT_IN,
Comparators.CONTAINS,
Comparators.PREFIX,
],
valueType: "multi-select",
valueProvider: async () => {
const user = useAuthStore().user;
if (user && user.hasAnyActionOnAnyNamespace(permission.NAMESPACE, action.READ)) {
const namespacesStore = useNamespacesStore();
const namespaces = (await namespacesStore.loadAutocomplete()) as string[];
return [...new Set(namespaces
.flatMap(namespace => {
return namespace.split(".").reduce((current: string[], part: string) => {
const previousCombination = current?.[current.length - 1];
return [...current, `${(previousCombination ? previousCombination + "." : "")}${part}`];
}, []);
}))].map(namespace => ({
label: namespace,
value: namespace
}));
}
return [];
...(route.name !== "namespaces/update" ? [
{
key: "namespace",
label: t("filter.namespace.label"),
description: t("filter.namespace.description"),
comparators: [
Comparators.IN,
Comparators.NOT_IN,
Comparators.CONTAINS,
Comparators.PREFIX,
],
valueType: "multi-select" as const,
valueProvider: async () => {
const user = useAuthStore().user;
if (user && user.hasAnyActionOnAnyNamespace(permission.NAMESPACE, action.READ)) {
const namespacesStore = useNamespacesStore();
const namespaces = (await namespacesStore.loadAutocomplete()) as string[];
return [...new Set(namespaces
.flatMap(namespace => {
return namespace.split(".").reduce((current: string[], part: string) => {
const previousCombination = current?.[current.length - 1];
return [...current, `${(previousCombination ? previousCombination + "." : "")}${part}`];
}, []);
}))].map(namespace => ({
label: namespace,
value: namespace
}));
}
return [];
},
searchable: true
},
searchable: true
},
{
] : []) as any,
...(route.name !== "flows/update" ? [{
key: "flowId",
label: t("filter.flowId.label"),
description: t("filter.flowId.description"),
@@ -57,7 +61,7 @@ export const useTriggerFilter = (): ComputedRef<FilterConfiguration> => computed
Comparators.ENDS_WITH,
],
valueType: "text",
},
}] : []) as any,
{
key: "timeRange",
label: t("filter.timeRange_trigger.label"),

View File

@@ -39,7 +39,7 @@ export const decodeSearchParams = (query: LocationQuery) =>
operation
};
})
.filter(Boolean);
.filter(v => v !== null);
type Filter = Pick<AppliedFilter, "key" | "comparator" | "value">;

View File

@@ -3,7 +3,6 @@
:namespace="flowStore.flow?.namespace"
:flowId="flowStore.flow?.id"
:topbar="false"
:restoreUrl="false"
filter
/>
</template>

View File

@@ -33,7 +33,7 @@
import FlowRootTopBar from "./FlowRootTopBar.vue";
import FlowConcurrency from "./FlowConcurrency.vue";
import DemoAuditLogs from "../demo/AuditLogs.vue";
import {useAuthStore} from "override/stores/auth"
import {useAuthStore} from "override/stores/auth";
import {useMiscStore} from "override/stores/misc";
export default {
@@ -59,13 +59,12 @@
"$route.params.tab": {
immediate: true,
handler: function (newTab) {
if (newTab === "overview") {
if (newTab === "overview" || newTab === "executions") {
const dateTimeKeys = ["startDate", "endDate", "timeRange"];
if (!Object.keys(this.$route.query).some((key) => dateTimeKeys.some((dateTimeKey) => key.includes(dateTimeKey)))) {
const miscStore = useMiscStore();
const defaultDuration = miscStore.configs?.chartDefaultDuration || "P30D";
const newQuery = {...this.$route.query, "filters[timeRange][EQUALS]": defaultDuration};
const DEFAULT_DURATION = this.miscStore.configs?.chartDefaultDuration ?? "P30D";
const newQuery = {...this.$route.query, "filters[timeRange][EQUALS]": DEFAULT_DURATION};
this.$router.replace({name: this.$route.name, params: this.$route.params, query: newQuery});
}
}
@@ -314,7 +313,7 @@
}
},
computed: {
...mapStores(useCoreStore, useFlowStore, useAuthStore),
...mapStores(useCoreStore, useFlowStore, useAuthStore, useMiscStore),
routeInfo() {
return {
title: this.$route.params.id,

View File

@@ -472,7 +472,7 @@
backfill: cleanBackfill.value
})
.then((newTrigger: any) => {
(window as any).$toast().saved(newTrigger.id);
toast.saved(newTrigger.triggerId);
triggers.value = triggers.value.map((t: any) => {
if (t.id === newTrigger.id) {
return newTrigger
@@ -493,7 +493,7 @@
const pauseBackfill = (trigger: any) => {
triggerStore.pauseBackfill(trigger)
.then((newTrigger: any) => {
toast.saved(newTrigger.id);
toast.saved(newTrigger.triggerId);
triggers.value = triggers.value.map((t: any) => {
if (t.id === newTrigger.id) {
return newTrigger
@@ -506,7 +506,7 @@
const unpauseBackfill = (trigger: any) => {
triggerStore.unpauseBackfill(trigger)
.then((newTrigger: any) => {
toast.saved(newTrigger.id);
toast.saved(newTrigger.triggerId);
triggers.value = triggers.value.map((t: any) => {
if (t.id === newTrigger.id) {
return newTrigger
@@ -519,7 +519,7 @@
const deleteBackfill = (trigger: any) => {
triggerStore.deleteBackfill(trigger)
.then((newTrigger: any) => {
toast.saved(newTrigger.id);
toast.saved(newTrigger.triggerId);
triggers.value = triggers.value.map((t: any) => {
if (t.id === newTrigger.id) {
return newTrigger
@@ -532,7 +532,7 @@
const setDisabled = (trigger: any, value: boolean) => {
triggerStore.update({...trigger, disabled: !value})
.then((newTrigger: any) => {
toast.saved(newTrigger.id);
toast.saved(newTrigger.triggerId);
triggers.value = triggers.value.map((t: any) => {
if (t.id === newTrigger.id) {
return newTrigger
@@ -548,7 +548,7 @@
flowId: trigger.flowId,
triggerId: trigger.triggerId
}).then((newTrigger: any) => {
toast.saved(newTrigger.id);
toast.saved(newTrigger.triggerId);
triggers.value = triggers.value.map((t: any) => {
if (t.id === newTrigger.id) {
return newTrigger
@@ -564,7 +564,7 @@
flowId: trigger.flowId,
triggerId: trigger.triggerId
}).then((newTrigger: any) => {
toast.saved(newTrigger.id);
toast.saved(newTrigger.triggerId);
triggers.value = triggers.value.map((t: any) => {
if (t.id === newTrigger.id) {
return newTrigger

View File

@@ -204,6 +204,7 @@
<template #default="scope">
<TimeSeries
:chart="mappedChart(scope.row.id, scope.row.namespace)"
:filters="chartFilters()"
showDefault
short
/>
@@ -248,8 +249,8 @@
<script setup lang="ts">
import {ref, computed, onMounted, useTemplateRef} from "vue";
import {useRoute, useRouter} from "vue-router";
import {ref, computed, useTemplateRef} from "vue";
import {useRoute} from "vue-router";
import {useI18n} from "vue-i18n";
import _merge from "lodash/merge";
import * as FILTERS from "../../utils/filters";
@@ -283,16 +284,16 @@
import permission from "../../models/permission";
import {useToast} from "../../utils/toast";
import {defaultNamespace} from "../../composables/useNamespaces";
import {useFlowStore} from "../../stores/flow";
import {useAuthStore} from "override/stores/auth";
import {useMiscStore} from "override/stores/misc";
import {useExecutionsStore} from "../../stores/executions";
import {useTableColumns} from "../../composables/useTableColumns";
import {DataTableRef, useDataTableActions} from "../../composables/useDataTableActions";
import {useSelectTableActions} from "../../composables/useSelectTableActions";
import {useApplyDefaultFilter} from "../filter/composables/useDefaultFilter";
const props = withDefaults(defineProps<{
topbar?: boolean;
@@ -307,9 +308,9 @@
const flowStore = useFlowStore();
const authStore = useAuthStore();
const executionsStore = useExecutionsStore();
const miscStore = useMiscStore();
const route = useRoute();
const router = useRouter();
const {t} = useI18n();
const toast = useToast()
@@ -494,6 +495,11 @@
updateVisibleColumns(newColumns);
}
useApplyDefaultFilter({
namespace: props.namespace,
includeScope: true
});
function exportFlows() {
toast.confirm(
t("flow export", {flowCount: queryBulkAction.value ? flowStore.total : selection.value.length}),
@@ -622,24 +628,14 @@
return MAPPED_CHARTS;
}
onMounted(() => {
const query = {...route.query};
const queryKeys = Object.keys(query);
let queryHasChanged = false;
if (props.namespace === undefined && defaultNamespace() && !queryKeys.some(key => key.startsWith("filters[namespace]"))) {
query["filters[namespace][PREFIX]"] = defaultNamespace();
queryHasChanged = true;
}
if (!queryKeys.some(key => key.startsWith("filters[scope]"))) {
query["filters[scope][EQUALS]"] = "USER";
queryHasChanged = true;
}
if (queryHasChanged) router.replace({query});
});
function chartFilters() {
const DEFAULT_DURATION = miscStore.configs?.chartDefaultDuration ?? "P30D";
return [{
field: "timeRange",
value: DEFAULT_DURATION,
operation: "EQUALS"
}];
}
</script>
<style scoped lang="scss">

View File

@@ -56,7 +56,6 @@
import DataTable from "../layout/DataTable.vue";
import SearchField from "../layout/SearchField.vue";
import NamespaceSelect from "../namespaces/components/NamespaceSelect.vue";
import useRestoreUrl from "../../composables/useRestoreUrl";
import useRouteContext from "../../composables/useRouteContext";
import {useDataTableActions} from "../../composables/useDataTableActions";
@@ -77,11 +76,9 @@
}));
useRouteContext(routeInfo);
const {saveRestoreUrl} = useRestoreUrl({restoreUrl: true, isDefaultNamespaceAllow: true});
const {onPageChanged, onDataTableValue, queryWithFilter, ready} = useDataTableActions({
loadData,
saveRestoreUrl
loadData
});
const namespace = computed({

View File

@@ -83,6 +83,7 @@
/* eslint-disable vue/enforce-style-attribute */
import {computed, onMounted, ref, shallowRef, watch} from "vue";
import {useI18n} from "vue-i18n";
import {useThrottleFn} from "@vueuse/core";
import UnfoldLessHorizontal from "vue-material-design-icons/UnfoldLessHorizontal.vue";
import UnfoldMoreHorizontal from "vue-material-design-icons/UnfoldMoreHorizontal.vue";
import Help from "vue-material-design-icons/Help.vue";
@@ -94,6 +95,7 @@
import {TabFocus} from "monaco-editor/esm/vs/editor/browser/config/tabFocus";
import MonacoEditor from "./MonacoEditor.vue";
import type * as monaco from "monaco-editor/esm/vs/editor/editor.api";
import {useScrollMemory} from "../../composables/useScrollMemory";
const {t} = useI18n()
@@ -123,6 +125,7 @@
shouldFocus: {type: Boolean, default: true},
showScroll: {type: Boolean, default: false},
diffOverviewBar: {type: Boolean, default: true},
scrollKey: {type: String, default: undefined},
})
defineOptions({
@@ -312,6 +315,29 @@
return
}
const codeEditor = editor as monaco.editor.IStandaloneCodeEditor;
const scrollMemory = props.scrollKey ? useScrollMemory(ref(props.scrollKey)) : null;
if (props.scrollKey && scrollMemory) {
const savedState = scrollMemory.loadData<monaco.editor.ICodeEditorViewState>("viewState");
if (savedState) {
codeEditor.restoreViewState(savedState);
codeEditor.revealLineInCenterIfOutsideViewport?.(codeEditor.getPosition()?.lineNumber ?? 1);
}
const top = scrollMemory.loadData<number>("scrollTop", 0);
if (typeof top === "number") {
codeEditor.setScrollTop(top);
}
const throttledSave = useThrottleFn(() => {
scrollMemory.saveData(codeEditor.saveViewState(), "viewState");
scrollMemory.saveData(codeEditor.getScrollTop(), "scrollTop");
}, 100);
codeEditor.onDidScrollChange?.(throttledSave);
}
if (!isDiff.value) {
editor.onDidBlurEditorWidget?.(() => {
emit("focusout", isCodeEditor(editor)
@@ -468,6 +494,10 @@
position: position,
model: model,
});
// Save view state when cursor changes
if (scrollMemory) {
scrollMemory.saveData(codeEditor.saveViewState(), "viewState");
}
}, 100) as unknown as number;
highlightPebble();
});

View File

@@ -59,12 +59,12 @@
const {t} = useI18n();
const exportYaml = () => {
const src = flowStore.flowYaml
if(!src) {
return;
}
const blob = new Blob([src], {type: "text/yaml"});
localUtils.downloadUrl(window.URL.createObjectURL(blob), "flow.yaml");
if(!flowStore.flow || !flowStore.flowYaml) return;
const {id, namespace} = flowStore.flow;
const blob = new Blob([flowStore.flowYaml], {type: "text/yaml"});
localUtils.downloadUrl(window.URL.createObjectURL(blob), `${namespace}.${id}.yaml`);
};
const flowStore = useFlowStore();
@@ -109,24 +109,31 @@
const onSaveAll = inject(FILES_SAVE_ALL_INJECTION_KEY);
async function save(){
// Save the isCreating before saving.
// saveAll can change its value.
const isCreating = flowStore.isCreating
await flowStore.saveAll()
try {
// Save the isCreating before saving.
// saveAll can change its value.
const isCreating = flowStore.isCreating
await flowStore.saveAll()
if(isCreating){
await router.push({
name: "flows/update",
params: {
id: flowStore.flow?.id,
namespace: flowStore.flow?.namespace,
tab: "edit",
tenant: routeParams.value.tenant,
},
});
if(isCreating){
await router.push({
name: "flows/update",
params: {
id: flowStore.flow?.id,
namespace: flowStore.flow?.namespace,
tab: "edit",
tenant: routeParams.value.tenant,
},
});
}
onSaveAll?.();
} catch (error: any) {
if (error?.status === 401) {
toast.error("401 Unauthorized", undefined, {duration: 2000});
return;
}
}
onSaveAll?.();
}
const deleteFlow = () => {

View File

@@ -19,6 +19,7 @@
:creating="isCreating"
:path="path"
:diffOverviewBar="false"
:scrollKey="editorScrollKey"
@update:model-value="editorUpdate"
@cursor="updatePluginDocumentation"
@save="flow ? saveFlowYaml(): saveFileContent()"
@@ -224,6 +225,19 @@
const namespacesStore = useNamespacesStore();
const miscStore = useMiscStore();
const editorScrollKey = computed(() => {
if (props.flow) {
const ns = flowStore.flow?.namespace ?? "";
const id = flowStore.flow?.id ?? "";
return `flow:${ns}/${id}:code`;
}
const ns = namespace.value;
if (ns && props.path) {
return `file:${ns}:${props.path}`;
}
return undefined;
});
function loadPluginsHash() {
miscStore.loadConfigs().then(config => {
hash.value = config.pluginsHash;

View File

@@ -463,7 +463,7 @@
for (const item of itemsArr) {
const fullPath = `${parentPath}${item.fileName}`;
result.push({path: fullPath, fileName: item.fileName, id: item.id});
if (isDirectory(item) && item.children.length > 0) {
if (isDirectory(item) && item.children?.length > 0) {
result.push(...flattenTree(item.children, `${fullPath}/`));
}
}
@@ -688,21 +688,22 @@
async function removeItems() {
if(confirmation.value.nodes === undefined) return;
for (const node of confirmation.value.nodes) {
await Promise.all(confirmation.value.nodes.map(async (node, i) => {
const path = filesStore.getPath(node.id) ?? "";
try {
await namespacesStore.deleteFileDirectory({
namespace: props.currentNS ?? route.params.namespace as string,
path: filesStore.getPath(node) ?? "",
path,
});
tree.value.remove(node.id);
closeTab?.({
path: filesStore.getPath(node) ?? "",
path,
});
} catch (error) {
console.error(`Failed to delete file: ${node.fileName}`, error);
toast.error(`Failed to delete file: ${node.fileName}`);
}
}
}));
confirmation.value = {visible: false, nodes: []};
toast.success("Selected files deleted successfully.");
}

View File

@@ -235,7 +235,7 @@
import {useI18n} from "vue-i18n";
import {useRoute} from "vue-router";
import _groupBy from "lodash/groupBy";
import {computed, ref, useTemplateRef, watch} from "vue";
import {computed, nextTick, ref, useTemplateRef, watch} from "vue";
import Check from "vue-material-design-icons/Check.vue";
import Delete from "vue-material-design-icons/Delete.vue";
@@ -272,7 +272,6 @@
import DataTable from "../layout/DataTable.vue";
import _merge from "lodash/merge";
import {type DataTableRef, useDataTableActions} from "../../composables/useDataTableActions.ts";
const dataTable = useTemplateRef<DataTableRef>("dataTable");
const loadData = async (callback?: () => void) => {
@@ -491,6 +490,8 @@
kv.value.key = entry.key;
const {type, value} = await namespacesStore.kv({namespace: entry.namespace, key: entry.key});
kv.value.type = type;
// Force the type reset before setting the value
await nextTick();
if (type === "JSON") {
kv.value.value = JSON.stringify(value);
} else if (type === "BOOLEAN") {
@@ -504,7 +505,7 @@
}
function removeKv(namespace: string, key: string) {
toast.confirm("delete confirm", async () => {
toast.confirm(t("delete confirm"), async () => {
return namespacesStore
.deleteKv({namespace, key: key})
.then(() => {
@@ -543,14 +544,16 @@
const type = kv.value.type;
let value: any = kv.value.value;
if (type === "STRING" || type === "DURATION") {
if (type === "STRING") {
value = JSON.stringify(value);
} else if (["DURATION", "JSON"].includes(type)) {
value = value || "";
} else if (type === "DATETIME") {
value = new Date(value!).toISOString();
} else if (type === "DATE") {
value = new Date(value!).toISOString().split("T")[0];
} else if (["NUMBER", "BOOLEAN", "JSON"].includes(type)) {
value = JSON.stringify(value);
} else {
value = String(value);
}
const contentType = "text/plain";
@@ -605,10 +608,9 @@
const formRef = ref();
watch(() => kv.value.type, () => {
if (formRef.value) {
(formRef.value as any).clearValidate("value");
}
watch(() => kv.value.type, (newType) => {
formRef.value?.clearValidate("value");
if (newType === "BOOLEAN") kv.value.value = false;
});
defineExpose({

View File

@@ -72,7 +72,7 @@
if (props.labels.length === 0) {
addItem();
} else {
locals.value = [...props.labels];
locals.value = props.labels;
if (locals.value.length === 0) {
addItem();
}

View File

@@ -1,5 +1,5 @@
<template>
<ContextInfoContent :title="t('feeds.title')">
<ContextInfoContent ref="contextInfoRef" :title="t('feeds.title')">
<div
class="post"
:class="{
@@ -46,9 +46,10 @@
</template>
<script setup lang="ts">
import {computed, onMounted, reactive} from "vue";
import {computed, onMounted, reactive, ref} from "vue";
import {useI18n} from "vue-i18n";
import {useStorage} from "@vueuse/core"
import {useScrollMemory} from "../../composables/useScrollMemory"
import OpenInNew from "vue-material-design-icons/OpenInNew.vue";
import MenuDown from "vue-material-design-icons/MenuDown.vue";
@@ -62,6 +63,7 @@
const apiStore = useApiStore();
const {t} = useI18n({useScope: "global"});
const contextInfoRef = ref<InstanceType<typeof ContextInfoContent> | null>(null);
const feeds = computed(() => apiStore.feeds);
const expanded = reactive<Record<string, boolean>>({});
@@ -70,6 +72,9 @@
onMounted(() => {
lastNewsReadDate.value = feeds.value[0].publicationDate;
});
const scrollableElement = computed(() => contextInfoRef.value?.contentRef || null)
useScrollMemory(ref("context-panel-news"), scrollableElement as any)
</script>
<style scoped lang="scss">

View File

@@ -2,7 +2,7 @@
<TopNavBar v-if="!embed" :title="routeInfo.title" />
<section v-bind="$attrs" :class="{'container': !embed}" class="log-panel">
<div class="log-content">
<DataTable @page-changed="onPageChanged" ref="dataTable" :total="logsStore.total" :size="pageSize" :page="pageNumber" :embed="embed">
<DataTable @page-changed="onPageChanged" ref="dataTable" :total="logsStore.total" :size="internalPageSize" :page="internalPageNumber" :embed="embed">
<template #navbar v-if="!embed || showFilters">
<KSFilter
:configuration="logFilter"
@@ -15,12 +15,12 @@
</template>
<template v-if="showStatChart()" #top>
<Sections ref="dashboard" :charts :dashboard="{id: 'default', charts: []}" showDefault />
<Sections ref="dashboardRef" :charts :dashboard="{id: 'default', charts: []}" showDefault class="mb-4" />
</template>
<template #table>
<div v-loading="isLoading">
<div v-if="logsStore.logs !== undefined && logsStore.logs.length > 0" class="logs-wrapper">
<div v-if="logsStore.logs !== undefined && logsStore.logs?.length > 0" class="logs-wrapper">
<LogLine
v-for="(log, i) in logsStore.logs"
:key="`${log.taskRunId}-${i}`"
@@ -42,6 +42,11 @@
</template>
<script setup lang="ts">
import {ref, computed, onMounted, watch} from "vue";
import {useRoute} from "vue-router";
import {useI18n} from "vue-i18n";
import _merge from "lodash/merge";
import moment from "moment";
import {useLogFilter} from "../filter/configurations";
import KSFilter from "../filter/components/KSFilter.vue";
import Sections from "../dashboard/sections/Sections.vue";
@@ -49,193 +54,151 @@
import TopNavBar from "../../components/layout/TopNavBar.vue";
import LogLine from "../logs/LogLine.vue";
import NoData from "../layout/NoData.vue";
const logFilter = useLogFilter();
</script>
<script lang="ts">
import {mapStores} from "pinia";
import RouteContext from "../../mixins/routeContext";
import RestoreUrl from "../../mixins/restoreUrl";
import DataTableActions from "../../mixins/dataTableActions";
import _merge from "lodash/merge";
import {storageKeys} from "../../utils/constants";
import {decodeSearchParams} from "../filter/utils/helpers";
import * as YAML_UTILS from "@kestra-io/ui-libs/flow-yaml-utils";
import YAML_CHART from "../dashboard/assets/logs_timeseries_chart.yaml?raw";
import {useLogsStore} from "../../stores/logs";
import {defaultNamespace} from "../../composables/useNamespaces";
import {defineComponent} from "vue";
import {useDataTableActions} from "../../composables/useDataTableActions";
import useRouteContext from "../../composables/useRouteContext";
export default defineComponent({
mixins: [RouteContext, RestoreUrl, DataTableActions],
props: {
logLevel: {
type: String,
default: undefined
},
embed: {
type: Boolean,
default: false
},
showFilters: {
type: Boolean,
default: false
},
filters: {
type: Object,
default: null
},
reloadLogs: {
type: Number,
default: undefined
}
},
data() {
return {
isDefaultNamespaceAllow: true,
task: undefined,
isLoading: false,
lastRefreshDate: new Date(),
canAutoRefresh: false,
showChart: localStorage.getItem(storageKeys.SHOW_LOGS_CHART) !== "false",
};
},
computed: {
storageKeys() {
return storageKeys
},
...mapStores(useLogsStore),
routeInfo() {
return {
title: this.$t("logs"),
};
},
isFlowEdit() {
return this.$route.name === "flows/update"
},
isNamespaceEdit() {
return this.$route.name === "namespaces/update"
},
selectedLogLevel() {
const decodedParams = decodeSearchParams(this.$route.query);
const levelFilters = decodedParams.filter(item => item?.field === "level");
const decoded = levelFilters.length > 0 ? levelFilters[0]?.value : "INFO";
return this.logLevel || decoded || localStorage.getItem("defaultLogLevel") || "INFO";
},
endDate() {
if (this.$route.query.endDate) {
return this.$route.query.endDate;
}
return undefined;
},
startDate() {
// we mention the last refresh date here to trick
// VueJs fine grained reactivity system and invalidate
// computed property startDate
if (this.$route.query.startDate && this.lastRefreshDate) {
return this.$route.query.startDate;
}
if (this.$route.query.timeRange) {
return this.$moment().subtract(this.$moment.duration(this.$route.query.timeRange).as("milliseconds")).toISOString(true);
}
const props = withDefaults(defineProps<{
logLevel?: string;
embed?: boolean;
showFilters?: boolean;
filters?: Record<string, any>;
reloadLogs?: number;
}>(), {
embed: false,
showFilters: false,
filters: undefined,
logLevel: undefined,
reloadLogs: undefined
});
// the default is PT30D
return this.$moment().subtract(7, "days").toISOString(true);
},
namespace() {
return this.$route.params.namespace ?? this.$route.params.id;
},
flowId() {
return this.$route.params.id;
},
charts() {
return [
{...YAML_UTILS.parse(YAML_CHART), content: YAML_CHART}
];
}
},
beforeRouteEnter(to: any, _: any, next: (route?: any) => void) {
const query = {...to.query};
let queryHasChanged = false;
const route = useRoute();
const {t} = useI18n();
const logsStore = useLogsStore();
const logFilter = useLogFilter();
const queryKeys = Object.keys(query);
if (defaultNamespace() && !queryKeys.some(key => key.startsWith("filters[namespace]"))) {
query["filters[namespace][PREFIX]"] = defaultNamespace();
queryHasChanged = true;
}
const routeInfo = computed(() => ({
title: t("logs"),
}));
useRouteContext(routeInfo, props.embed);
if (queryHasChanged) {
next({
...to,
query,
replace: true
});
} else {
next();
}
},
methods: {
showStatChart() {
return this.showChart;
},
onShowChartChange(value: boolean) {
this.showChart = value;
localStorage.setItem(storageKeys.SHOW_LOGS_CHART, value.toString());
if (this.showStatChart()) {
this.load();
}
},
refresh() {
this.lastRefreshDate = new Date();
if (this.$refs.dashboard) {
this.$refs.dashboard.refreshCharts();
}
this.load();
},
loadQuery(base: any) {
let queryFilter = this.filters ?? this.queryWithFilter();
const isLoading = ref(false);
const lastRefreshDate = ref(new Date());
const showChart = ref(localStorage.getItem(storageKeys.SHOW_LOGS_CHART) !== "false");
const dashboardRef = ref();
if (this.isFlowEdit) {
queryFilter["filters[namespace][EQUALS]"] = this.namespace;
queryFilter["filters[flowId][EQUALS]"] = this.flowId;
} else if (this.isNamespaceEdit) {
queryFilter["filters[namespace][EQUALS]"] = this.namespace;
}
const isFlowEdit = computed(() => route.name === "flows/update");
const isNamespaceEdit = computed(() => route.name === "namespaces/update");
const selectedLogLevel = computed(() => {
const decodedParams = decodeSearchParams(route.query);
const levelFilters = decodedParams.filter(item => item?.field === "level");
const decoded = levelFilters.length > 0 ? levelFilters[0]?.value : "INFO";
return props.logLevel || decoded || localStorage.getItem("defaultLogLevel") || "INFO";
});
const endDate = computed(() => {
if (route.query.endDate) {
return route.query.endDate;
}
return undefined;
});
const startDate = computed(() => {
// we mention the last refresh date here to trick
// VueJs fine grained reactivity system and invalidate
// computed property startDate
if (route.query.startDate && lastRefreshDate.value) {
return route.query.startDate;
}
if (route.query.timeRange) {
return moment().subtract(moment.duration(route.query.timeRange as string).as("milliseconds")).toISOString(true);
}
if (!queryFilter["startDate"] || !queryFilter["endDate"]) {
queryFilter["startDate"] = this.startDate;
queryFilter["endDate"] = this.endDate;
}
// the default is PT30D
return moment().subtract(7, "days").toISOString(true);
});
const flowId = computed(() => route.params.id);
const namespace = computed(() => route.params.namespace ?? route.params.id);
const charts = computed(() => [
{...YAML_UTILS.parse(YAML_CHART), content: YAML_CHART}
]);
delete queryFilter["level"];
const loadQuery = (base: any) => {
let queryFilter = props.filters ?? queryWithFilter();
return _merge(base, queryFilter)
},
load() {
this.isLoading = true
if (isFlowEdit.value) {
queryFilter["filters[namespace][EQUALS]"] = namespace.value;
queryFilter["filters[flowId][EQUALS]"] = flowId.value;
} else if (isNamespaceEdit.value) {
queryFilter["filters[namespace][EQUALS]"] = namespace.value;
}
const data = {
page: this.filters ? this.internalPageNumber : this.$route.query.page || this.internalPageNumber,
size: this.filters ? this.internalPageSize : this.$route.query.size || this.internalPageSize,
...this.filters
};
this.logsStore.findLogs(this.loadQuery({
...data,
minLevel: this.filters ? null : this.selectedLogLevel,
sort: "timestamp:desc"
}))
.finally(() => {
this.isLoading = false
this.saveRestoreUrl();
});
if (!queryFilter["startDate"] || !queryFilter["endDate"]) {
queryFilter["startDate"] = startDate.value;
queryFilter["endDate"] = endDate.value;
}
},
},
watch: {
reloadLogs(newValue) {
if(newValue) this.refresh();
},
delete queryFilter["level"];
return _merge(base, queryFilter);
};
const loadData = (callback?: () => void) => {
isLoading.value = true;
const data = {
page: props.filters ? internalPageNumber.value : route.query.page || internalPageNumber.value,
size: props.filters ? internalPageSize.value : route.query.size || internalPageSize.value,
...props.filters
};
logsStore.findLogs(loadQuery({
...data,
minLevel: props.filters ? null : selectedLogLevel.value,
sort: "timestamp:desc"
}))
.finally(() => {
isLoading.value = false;
if (callback) callback();
});
};
const {onPageChanged, queryWithFilter, internalPageNumber, internalPageSize} = useDataTableActions({
loadData
});
const showStatChart = () => showChart.value;
const onShowChartChange = (value: boolean) => {
showChart.value = value;
localStorage.setItem(storageKeys.SHOW_LOGS_CHART, value.toString());
if (showStatChart()) {
loadData();
}
};
const refresh = () => {
lastRefreshDate.value = new Date();
if (dashboardRef.value) {
dashboardRef.value.refreshCharts();
}
loadData();
};
watch(() => route.query, () => {
loadData();
}, {deep: true});
watch(() => props.reloadLogs, (newValue) => {
if (newValue) refresh();
});
onMounted(() => {
// Load data on mount if not embedded
if (!props.embed) {
loadData();
}
});
</script>

View File

@@ -31,6 +31,7 @@
const namespace = computed(() => route.params?.id) as Ref<string>;
const miscStore = useMiscStore();
const namespacesStore = useNamespacesStore();
watch(namespace, (newID) => {
@@ -40,13 +41,12 @@
});
watch(() => route.params.tab, (newTab) => {
if (newTab === "overview") {
if (newTab === "overview" || newTab === "executions") {
const dateTimeKeys = ["startDate", "endDate", "timeRange"];
if (!Object.keys(route.query).some((key) => dateTimeKeys.some((dateTimeKey) => key.includes(dateTimeKey)))) {
const miscStore = useMiscStore();
const defaultDuration = miscStore.configs?.chartDefaultDuration || "P30D";
const newQuery = {...route.query, "filters[timeRange][EQUALS]": defaultDuration};
const DEFAULT_DURATION = miscStore.configs?.chartDefaultDuration ?? "P30D";
const newQuery = {...route.query, "filters[timeRange][EQUALS]": DEFAULT_DURATION};
router.replace({name: route.name, params: route.params, query: newQuery});
}
}

View File

@@ -1,5 +1,5 @@
<template>
<div class="no-code">
<div class="no-code" ref="scrollContainer">
<div class="p-4">
<Task
v-if="creatingTask || editingTask"
@@ -64,6 +64,7 @@
import {usePluginsStore} from "../../stores/plugins";
import {useKeyboardSave} from "./utils/useKeyboardSave";
import {deepEqual} from "../../utils/utils";
import {useScrollMemory} from "../../composables/useScrollMemory";
const props = defineProps<NoCodeProps>();
@@ -195,6 +196,28 @@
emit("editTask", parentPath, blockSchemaPath, refPath)
})
// Scroll position persistence for No-code editor
const scrollContainer = ref<HTMLDivElement | null>(null);
const flowIdentity = computed(() => {
const namespace = flowStore.flow?.namespace ?? "";
const flowId = flowStore.flow?.id ?? "";
return `${namespace}/${flowId}`;
});
const scrollKey = computed(() => {
const base = `nocode:${flowIdentity.value}`;
// home screen
if (!props.creatingTask && !props.editingTask) return `${base}:home`;
// task-specific
const action = props.creatingTask ? "create" : "edit";
const parentPath = props.parentPath ?? "";
const refPath = props.refPath ?? "";
const fieldName = props.fieldName ?? "";
return `${base}:task:${action}:parentPath:${parentPath}:refPath:${refPath}:fieldName:${fieldName}`;
});
useScrollMemory(scrollKey, scrollContainer);
</script>

View File

@@ -1,6 +1,6 @@
<template>
<div v-if="playgroundStore.enabled && isTask && taskObject?.id" class="flow-playground">
<PlaygroundRunTaskButton :taskId="taskObject?.id" />
<div v-if="playgroundStore.enabled && isTask && taskModel?.id" class="flow-playground">
<PlaygroundRunTaskButton :taskId="taskModel?.id" />
</div>
<el-form v-if="isTaskDefinitionBasedOnType" labelPosition="top">
<el-form-item>
@@ -17,12 +17,12 @@
/>
</el-form-item>
</el-form>
<div @click="isPlugin && pluginsStore.updateDocumentation(taskObject as Parameters<typeof pluginsStore.updateDocumentation>[0])">
<div @click="() => onTaskEditorClick(taskModel)">
<TaskObject
v-loading="isLoading"
v-if="(selectedTaskType || !isTaskDefinitionBasedOnType) && schema"
name="root"
:modelValue="taskObject"
:modelValue="taskModel"
@update:model-value="onTaskInput"
:schema
:properties
@@ -43,6 +43,7 @@
FULL_SCHEMA_INJECTION_KEY,
SCHEMA_DEFINITIONS_INJECTION_KEY,
DATA_TYPES_MAP_INJECTION_KEY,
ON_TASK_EDITOR_CLICK_INJECTION_KEY,
} from "../injectionKeys";
import {removeNullAndUndefined} from "../utils/cleanUp";
import {removeRefPrefix, usePluginsStore} from "../../../stores/plugins";
@@ -63,9 +64,9 @@
const pluginsStore = usePluginsStore();
const playgroundStore = usePlaygroundStore();
type PartialCodeElement = Partial<NoCodeElement>;
type PartialNoCodeElement = Partial<NoCodeElement>;
const taskObject = ref<PartialCodeElement | undefined>({});
const taskModel = ref<PartialNoCodeElement | undefined>({});
const selectedTaskType = ref<string>();
const isLoading = ref(false);
@@ -108,7 +109,7 @@
watch(modelValue, (v) => {
if (!v) {
taskObject.value = {};
taskModel.value = {};
selectedTaskType.value = undefined;
} else {
setup()
@@ -150,20 +151,20 @@
});
function setup() {
const parsed = YAML_UTILS.parse<PartialCodeElement>(modelValue.value);
const parsed = YAML_UTILS.parse<PartialNoCodeElement>(modelValue.value);
if(isPluginDefaults.value){
const {forced, type, values} = parsed as any;
taskObject.value = {...values, forced, type};
taskModel.value = {...values, forced, type};
}else{
taskObject.value = parsed;
taskModel.value = parsed;
}
selectedTaskType.value = taskObject.value?.type;
selectedTaskType.value = taskModel.value?.type;
}
// when tab is opened, load the documentation
onActivated(() => {
if(selectedTaskType.value && parentPath !== "inputs"){
pluginsStore.updateDocumentation(taskObject.value as Parameters<typeof pluginsStore.updateDocumentation>[0]);
pluginsStore.updateDocumentation(taskModel.value as Parameters<typeof pluginsStore.updateDocumentation>[0]);
}
});
@@ -218,7 +219,7 @@
const resolvedType = computed<string>(() => {
if(resolvedTypes.value.length > 1 && selectedTaskType.value){
// find the resolvedType that match the current dataType
const dataType = taskObject.value?.data?.type;
const dataType = taskModel.value?.data?.type;
if(dataType){
for(const typeLocal of resolvedTypes.value){
const schema = definitions.value?.[typeLocal];
@@ -330,13 +331,13 @@
watch([selectedTaskType, fullSchema], ([task]) => {
if (task) {
if(isPlugin.value){
pluginsStore.updateDocumentation(taskObject.value as Parameters<typeof pluginsStore.updateDocumentation>[0]);
pluginsStore.updateDocumentation(taskModel.value as Parameters<typeof pluginsStore.updateDocumentation>[0]);
}
}
}, {immediate: true});
function onTaskInput(val: PartialCodeElement | undefined) {
taskObject.value = val;
function onTaskInput(val: PartialNoCodeElement | undefined) {
taskModel.value = val;
if(fieldName){
val = {
[fieldName]: val,
@@ -362,12 +363,21 @@
}
function onTaskTypeSelect() {
const value: PartialCodeElement = {
const value: PartialNoCodeElement = {
type: selectedTaskType.value ?? ""
};
onTaskInput(value);
}
const onTaskEditorClick = inject(ON_TASK_EDITOR_CLICK_INJECTION_KEY, (elt?: PartialNoCodeElement) => {
const type = elt?.type;
if(isPlugin.value && type){
pluginsStore.updateDocumentation({type});
}else{
pluginsStore.updateDocumentation();
}
});
</script>
<style scoped lang="scss">

View File

@@ -1,5 +1,5 @@
import type {ComputedRef, InjectionKey, Ref} from "vue"
import {TopologyClickParams} from "./utils/types"
import {NoCodeElement, TopologyClickParams} from "./utils/types"
import {Panel} from "../../utils/multiPanelTypes"
export const BLOCK_SCHEMA_PATH_INJECTION_KEY = Symbol("block-schema-path-injection-key") as InjectionKey<ComputedRef<string>>
@@ -90,4 +90,6 @@ export const FULL_SCHEMA_INJECTION_KEY = Symbol("full-schema-injection-key") as
export const SCHEMA_DEFINITIONS_INJECTION_KEY = Symbol("schema-definitions-injection-key") as InjectionKey<ComputedRef<Record<string, any>>>
export const DATA_TYPES_MAP_INJECTION_KEY = Symbol("data-types-injection-key") as InjectionKey<ComputedRef<Record<string, string[] | undefined>>>
export const DATA_TYPES_MAP_INJECTION_KEY = Symbol("data-types-injection-key") as InjectionKey<ComputedRef<Record<string, string[] | undefined>>>
export const ON_TASK_EDITOR_CLICK_INJECTION_KEY = Symbol("on-task-editor-click-injection-key") as InjectionKey<(elt?: Partial<NoCodeElement>) => void>;

View File

@@ -62,7 +62,7 @@
</template>
<script setup lang="ts">
import {ref, computed, onBeforeMount} from "vue";
import {ref, computed, onBeforeMount, watch} from "vue";
import {useRoute, useRouter} from "vue-router";
import {isEntryAPluginElementPredicate, TaskIcon} from "@kestra-io/ui-libs";
import DottedLayout from "../layout/DottedLayout.vue";
@@ -71,6 +71,7 @@
import headerImage from "../../assets/icons/plugin.svg";
import headerImageDark from "../../assets/icons/plugin-dark.svg";
import {usePluginsStore} from "../../stores/plugins";
import useRestoreUrl from "../../composables/useRestoreUrl";
const route = useRoute();
const router = useRouter();
@@ -85,13 +86,23 @@
embed: false
});
const {saveRestoreUrl} = useRestoreUrl();
const icons = ref<Record<string, any>>({});
const searchText = ref("");
const handleSearch = (query: string) => {
searchText.value = query;
const newQuery: Record<string, any> = {...route.query};
if (query !== undefined && query !== null && String(query).trim() !== "") {
newQuery.q = query;
} else {
// remove an empty `q=` in the URL on plugins/view
delete newQuery.q;
}
router.push({
query: {...route.query, q: query || undefined}
query: newQuery
});
};
@@ -144,7 +155,13 @@
if (!cls) {
return;
}
router.push({name: "plugins/view", params: {cls: cls}})
router.push({
name: "plugins/view",
params: {
...route.params,
cls: cls
}
})
};
const isVisible = (plugin: any) => {
@@ -171,6 +188,11 @@
loadPluginIcons();
searchText.value = String(route.query?.q ?? "");
});
watch(() => route.query.q, (newQ) => {
searchText.value = String(newQ ?? "");
saveRestoreUrl();
});
</script>
<style scoped lang="scss">

View File

@@ -28,7 +28,7 @@
</el-breadcrumb>
</div>
<div v-if="currentView === 'list'" class="list">
<div v-if="currentView === 'list'" class="list" ref="listRef">
<div
v-for="plugin in sortedPlugins"
:key="`${plugin.group}-${plugin.title}`"
@@ -48,7 +48,7 @@
</div>
</div>
<div v-else-if="currentView === 'group'" class="group-view">
<div v-else-if="currentView === 'group'" class="group-view" ref="groupRef">
<PluginUnified
:group="currentGroup"
:subgroup="currentSubgroup"
@@ -57,7 +57,7 @@
/>
</div>
<div v-else-if="currentView === 'documentation'" :class="['doc-view', {'no-padding': !currentDocumentationPlugin}]">
<div v-else-if="currentView === 'documentation'" :class="['doc-view', {'no-padding': !currentDocumentationPlugin}]" ref="docRef">
<PluginDocumentation
:plugin="currentDocumentationPlugin"
/>
@@ -72,6 +72,7 @@
import PluginUnified from "./PluginUnified.vue";
import PluginDocumentation from "./PluginDocumentation.vue";
import {usePluginsStore} from "../../stores/plugins";
import {useScrollMemory} from "../../composables/useScrollMemory";
import {capitalize, formatPluginTitle} from "../../utils/global";
interface Props {
@@ -94,6 +95,18 @@
const navigationStack = ref<NavigationItem[]>([]);
const currentDocumentationPlugin = ref<any>(null);
const currentView = ref<"list" | "group" | "documentation">("documentation");
const listRef = ref<HTMLDivElement | null>(null);
const groupRef = ref<HTMLDivElement | null>(null);
const docRef = ref<HTMLDivElement | null>(null);
const scrollKeyBase = "plugins:documentation";
const listScrollKey = computed(() => `${scrollKeyBase}:list`);
const groupScrollKey = computed(() => `${scrollKeyBase}:group`);
const docScrollKey = computed(() => `${scrollKeyBase}:documentation`);
useScrollMemory(listScrollKey, listRef);
useScrollMemory(groupScrollKey, groupRef);
useScrollMemory(docScrollKey, docRef);
const getSimpleType = (item: string) => item.split(".").pop() || item;
@@ -275,7 +288,9 @@
}
}, {immediate: true, deep: true});
onMounted(loadPluginIcons);
onMounted(async () => {
await loadPluginIcons();
});
</script>
<style scoped lang="scss">

View File

@@ -148,7 +148,7 @@
>
<NamespaceSelect
v-model="secret.namespace"
:readonly="secret.update"
:readOnly="secret.update"
:includeSystemNamespace="true"
all
/>

View File

@@ -3,6 +3,7 @@ import {useRoute, useRouter} from "vue-router";
import _merge from "lodash/merge";
import _cloneDeep from "lodash/cloneDeep";
import _isEqual from "lodash/isEqual";
import useRestoreUrl from "./useRestoreUrl";
interface SortItem {
prop?: string;
@@ -26,7 +27,6 @@ interface DataTableActionsOptions {
embed?: boolean;
dataTableRef?: Ref<DataTableRef | null>;
loadData?: (callback?: () => void) => void;
saveRestoreUrl?: () => void;
}
export function useDataTableActions(options: DataTableActionsOptions = {}) {
@@ -35,7 +35,6 @@ export function useDataTableActions(options: DataTableActionsOptions = {}) {
const sort = ref("");
const dblClickRouteName = ref(options.dblClickRouteName);
const loadInit = ref(true);
const ready = ref(false);
const internalPageSize = ref(25);
const internalPageNumber = ref(1);
@@ -47,6 +46,8 @@ export function useDataTableActions(options: DataTableActionsOptions = {}) {
const embed = computed(() => options.embed);
const dataTableRef = computed(() => options.dataTableRef?.value);
const {loadInit, saveRestoreUrl} = useRestoreUrl({restoreUrl: true});
const sortString = (sortItem: SortItem, sortKeyMapper: (k: string) => string): string | undefined => {
if (sortItem && sortItem.prop && sortItem.order) {
return `${sortKeyMapper(sortItem.prop)}:${sortItem.order === "descending" ? "desc" : "asc"}`;
@@ -149,9 +150,7 @@ export function useDataTableActions(options: DataTableActionsOptions = {}) {
ready.value = true;
loadInit.value = true;
if (options.saveRestoreUrl) {
options.saveRestoreUrl();
}
saveRestoreUrl();
if (dataTableRef.value) {
dataTableRef.value.isLoading = false;

View File

@@ -47,6 +47,11 @@ export default function useRestoreUrl(options: UseRestoreUrlOptions = {}) {
}
};
/**
* Merges saved URL query parameters from sessionStorage with current route.
* Only adds missing parameters to avoid overwriting user changes.
* Updates route only when changes are made.
*/
const goToRestoreUrl = () => {
if (!restoreUrl) {
return;
@@ -84,9 +89,12 @@ export default function useRestoreUrl(options: UseRestoreUrlOptions = {}) {
}
};
// Automatically call goToRestoreUrl on mount if needed (equivalent to created() hook)
/**
* Automatically restores saved URL state from sessionStorage on mount.
* Only triggers when restoreUrl is enabled and saved state exists.
*/
onMounted(() => {
if (Object.keys(route.query).length === 0 && restoreUrl) {
if (restoreUrl && localStorageValue.value) {
loadInit.value = false;
goToRestoreUrl();
}

View File

@@ -0,0 +1,49 @@
import {watch, nextTick, ref, Ref, onActivated} from "vue"
import {useScroll, useThrottleFn, useWindowScroll} from "@vueuse/core"
import {storageKeys} from "../utils/constants"
export function useScrollMemory(keyRef: Ref<string>, elementRef?: Ref<HTMLElement | null>, useWindow = false): {
saveData: (value: any, suffix?: string) => void;
loadData: <T = any>(suffix?: string, defaultValue?: T) => T | undefined;
} {
const getStorageKey = (suffix = "") => `${storageKeys.SCROLL_MEMORY_PREFIX}-${keyRef.value}${suffix}`
const saveToStorage = (value: any, suffix = "") => {
sessionStorage?.setItem(getStorageKey(suffix), JSON.stringify(value))
}
const loadFromStorage = <T = any>(suffix = "", defaultValue?: T): T | undefined => {
const saved = sessionStorage?.getItem(getStorageKey(suffix))
return saved ? JSON.parse(saved) : defaultValue
}
const saveScroll = (value: number) => saveToStorage(value)
const loadScroll = (): number => loadFromStorage("", 0) || 0
const restoreScroll = () => {
const scrollTop = loadScroll()
const applyScroll = useWindow
? () => window.scrollTo({top: scrollTop, behavior: "smooth"})
: () => { if (elementRef?.value) elementRef.value.scrollTo({top: scrollTop, behavior: "smooth"}) }
setTimeout(applyScroll, 10)
}
const throttledSave = useThrottleFn((top: number) => saveScroll(top), 100)
if (useWindow) {
const {y} = useWindowScroll({throttle: 16, onScroll: () => throttledSave(y.value)})
watch(keyRef, () => nextTick(restoreScroll), {immediate: true})
onActivated(() => nextTick(restoreScroll))
} else {
useScroll(elementRef || ref(null), {
throttle: 16,
onScroll: () => { if (elementRef?.value) throttledSave(elementRef.value.scrollTop) }
})
watch([keyRef, () => elementRef?.value], ([newKey, newElement]) => {
if (newElement && newKey) nextTick(restoreScroll)
}, {immediate: true})
onActivated(() => nextTick(restoreScroll))
}
return {saveData: saveToStorage, loadData: loadFromStorage}
}

View File

@@ -1,4 +1,8 @@
<template>
<Dashboards
v-if="tab === 'overview' && ALLOWED_CREATION_ROUTES.includes(String(route.name))"
@dashboard="onSelectDashboard"
/>
<Action
v-if="deleted"
type="default"
@@ -34,12 +38,20 @@
import Action from "../../../components/namespaces/components/buttons/Action.vue";
// @ts-expect-error does not have types
import TriggerFlow from "../../../components/flows/TriggerFlow.vue";
import Dashboards from "../../../components/dashboard/components/selector/Selector.vue";
import {ALLOWED_CREATION_ROUTES} from "../../../components/dashboard/composables/useDashboards";
import permission from "../../../models/permission";
import action from "../../../models/action";
import {useAuthStore} from "override/stores/auth";
const {t} = useI18n();
const onSelectDashboard = (value: any) => {
router.replace({
params: {...route.params, dashboard: value}
});
};
const coreStore = useCoreStore();
const flowStore = useFlowStore();
const router = useRouter();

View File

@@ -107,7 +107,6 @@
import {useDocStore} from "../../../../stores/doc";
import {canCreate} from "override/composables/blueprintsPermissions";
import {useDataTableActions} from "../../../../composables/useDataTableActions";
import useRestoreUrl from "../../../../composables/useRestoreUrl";
import {useBlueprintFilter} from "../../../../components/filter/configurations";
const blueprintFilter = useBlueprintFilter();
@@ -128,8 +127,6 @@
const {onPageChanged, onDataLoaded, load, ready, internalPageNumber, internalPageSize} = useDataTableActions({loadData});
useRestoreUrl();
const emit = defineEmits(["goToDetail", "loaded"]);
const route = useRoute();
@@ -273,15 +270,13 @@
docStore.docId = `blueprints.${props.blueprintType}`;
});
watch(route,
(newValue, oldValue) => {
if (oldValue.name === newValue.name) {
selectedTags.value = initSelectedTags();
searchText.value = route.query.q || "";
load(onDataLoaded);
}
}
);
watch(route, (newRoute, oldRoute) => {
if (newRoute.name === oldRoute.name) {
selectedTags.value = initSelectedTags();
searchText.value = newRoute.query.q || "";
load(onDataLoaded);
}
});
watch(searchText, () => {
load(onDataLoaded);

View File

@@ -1,4 +1,9 @@
<template>
<Dashboards
v-if="tab === 'overview' && ALLOWED_CREATION_ROUTES.includes(String(route.name))"
@dashboard="onSelectDashboard"
/>
<Action
v-if="tab === 'flows'"
:label="t('create_flow')"
@@ -21,17 +26,25 @@
<script setup lang="ts">
import {computed, Ref} from "vue";
import {useRoute} from "vue-router";
import {useRoute, useRouter} from "vue-router";
import {useI18n} from "vue-i18n";
import {useNamespacesStore} from "override/stores/namespaces";
import Action from "../../../components/namespaces/components/buttons/Action.vue";
import Dashboards from "../../../components/dashboard/components/selector/Selector.vue";
import {ALLOWED_CREATION_ROUTES} from "../../../components/dashboard/composables/useDashboards";
import FamilyTree from "vue-material-design-icons/FamilyTree.vue";
const route = useRoute();
const router = useRouter();
const {t} = useI18n({useScope: "global"});
const namespacesStore = useNamespacesStore();
const onSelectDashboard = (value: any) => {
router.replace({
params: {...route.params, dashboard: value}
});
};
const tab = computed(() => route.params?.tab);
const namespace = computed(() => route.params?.id) as Ref<string>;
</script>

View File

@@ -89,11 +89,14 @@
import permission from "../../../models/permission";
import action from "../../../models/action";
import useRestoreUrl from "../../../composables/useRestoreUrl";
import DotsSquare from "vue-material-design-icons/DotsSquare.vue";
import TextSearch from "vue-material-design-icons/TextSearch.vue";
import {useAuthStore} from "override/stores/auth";
const namespacesFilter = useNamespacesFilter();
const {saveRestoreUrl} = useRestoreUrl({restoreUrl: true});
interface Node {
id: string;
@@ -127,8 +130,12 @@
onMounted(() => loadData());
watch(
() => route.query,
() => loadData(),
() => route.query.q,
() => {
loadData();
saveRestoreUrl();
},
{immediate: true}
);
const miscStore = useMiscStore();

View File

@@ -7,21 +7,23 @@ import DemoAuditLogs from "../components/demo/AuditLogs.vue"
import DemoInstance from "../components/demo/Instance.vue"
import DemoApps from "../components/demo/Apps.vue"
import DemoTests from "../components/demo/Tests.vue"
import {useMiscStore} from "override/stores/misc";
import {applyDefaultFilters} from "../components/filter/composables/useDefaultFilter";
function maybeAddTimeRangeFilter(to) {
const dateTimeKeys = ["startDate", "endDate", "timeRange"];
// Default to the configured duration if no time range is set
if (!Object.keys(to.query).some((key) => dateTimeKeys.some((dateTimeKey) => key.includes(dateTimeKey)))) {
const miscStore = useMiscStore();
const defaultDuration = miscStore.configs?.chartDefaultDuration || "P30D"; // Fallback to 30 days
to.query["filters[timeRange][EQUALS]"] = defaultDuration;
return true;
}
return false;
export function applyBeforeEnterFilter(options) {
return (to, _from, next) => {
const {query, hasChanges} = applyDefaultFilters(to.query, options);
if (hasChanges) {
next({
name: to.name,
params: to.params,
query,
});
return;
}
next();
};
}
export default [
@@ -35,15 +37,6 @@ export default [
path: "/:tenant?/dashboards/:dashboard?",
component: () => import("../components/dashboard/Dashboard.vue"),
beforeEnter: (to, from, next) => {
if (maybeAddTimeRangeFilter(to)) {
next({
name: to.name,
params: to.params,
query: to.query,
});
return;
}
if (!to.params.dashboard) {
next({
name: "home",
@@ -53,16 +46,21 @@ export default [
},
query: to.query,
});
} else {
next();
return;
}
applyBeforeEnterFilter({includeTimeRange: true, includeScope: false})(to, from, next);
},
},
{name: "dashboards/create", path: "/:tenant?/dashboards/new", component: () => import("../components/dashboard/components/Create.vue")},
{name: "dashboards/update", path: "/:tenant?/dashboards/:dashboard/edit", component: () => import("override/components/dashboard/Edit.vue")},
//Flows
{name: "flows/list", path: "/:tenant?/flows", component: () => import("../components/flows/Flows.vue")},
{
name: "flows/list",
path: "/:tenant?/flows",
component: () => import("../components/flows/Flows.vue"),
beforeEnter: applyBeforeEnterFilter({includeTimeRange: false, includeScope: true}),
},
{name: "flows/search", path: "/:tenant?/flows/search", component: () => import("../components/flows/FlowsSearch.vue")},
{name: "flows/create", path: "/:tenant?/flows/new", component: () => import("../components/flows/FlowCreate.vue")},
{name: "flows/update", path: "/:tenant?/flows/edit/:namespace/:id/:tab?", component: () => import("../components/flows/FlowRoot.vue")},
@@ -72,18 +70,7 @@ export default [
name: "executions/list",
path: "/:tenant?/executions",
component: () => import("../components/executions/Executions.vue"),
beforeEnter: (to, from, next) => {
if (maybeAddTimeRangeFilter(to)) {
next({
name: to.name,
params: to.params,
query: to.query,
});
return;
}
next();
}
beforeEnter: applyBeforeEnterFilter({includeTimeRange: true, includeScope: true}),
},
{name: "executions/update", path: "/:tenant?/executions/:namespace/:flowId/:id/:tab?", component: () => import("../components/executions/ExecutionRoot.vue")},
@@ -111,18 +98,7 @@ export default [
name: "logs/list",
path: "/:tenant?/logs",
component: () => import("../components/logs/LogsWrapper.vue"),
beforeEnter: (to, from, next) => {
if (maybeAddTimeRangeFilter(to)) {
next({
name: to.name,
params: to.params,
query: to.query,
});
return;
}
next();
}
beforeEnter: applyBeforeEnterFilter({includeTimeRange: true, includeScope: false}),
},
//Namespaces

View File

@@ -42,7 +42,7 @@ export const useBlueprintsStore = defineStore("blueprints", () => {
const PARAMS = {params: options.params, ...VALIDATE};
const COMMUNITY = `${API_URL}/blueprints/kinds/${options.kind}/versions/${version}${edition === "OSS" ? "?ee=false" : ""}`;
const CUSTOM = `${apiUrl()}/blueprints/${options.type}${options.kind}`;
const CUSTOM = `${apiUrl()}/blueprints/${options.type}`;
const response = await axios.get(options.type === "community" ? COMMUNITY : CUSTOM, PARAMS);
@@ -52,7 +52,7 @@ export const useBlueprintsStore = defineStore("blueprints", () => {
const getBlueprint = async (options: Options) => {
const COMMUNITY = `${API_URL}/blueprints/kinds/${options.kind}/${options.id}/versions/${version}`;
const CUSTOM = `${apiUrl()}/blueprints/${options.type}${options.kind}/${options.id}`;
const CUSTOM = `${apiUrl()}/blueprints/${options.type}/${options.id}`;
const response = await axios.get(options.type == "community" ? COMMUNITY : CUSTOM);
@@ -66,7 +66,7 @@ export const useBlueprintsStore = defineStore("blueprints", () => {
const getBlueprintSource = async (options: Options) => {
const COMMUNITY = `${API_URL}/blueprints/kinds/${options.kind}/${options.id}/versions/${version}/source`;
const CUSTOM = `${apiUrl()}/blueprints/${options.type}${options.kind}/${options.id}/source`;
const CUSTOM = `${apiUrl()}/blueprints/${options.type}/${options.id}/source`;
const response = await axios.get(options.type == "community" ? COMMUNITY : CUSTOM);
@@ -76,7 +76,7 @@ export const useBlueprintsStore = defineStore("blueprints", () => {
const getBlueprintGraph = async (options: Options) => {
const COMMUNITY = `${API_URL}/blueprints/kinds/${options.kind}/${options.id}/versions/${version}/graph`;
const CUSTOM = `${apiUrl()}/blueprints/${options.type}${options.kind}/${options.id}/graph`;
const CUSTOM = `${apiUrl()}/blueprints/${options.type}/${options.id}/graph`;
const response = await axios.get(options.type == "community" ? COMMUNITY : CUSTOM);
@@ -88,7 +88,7 @@ export const useBlueprintsStore = defineStore("blueprints", () => {
const PARAMS = {params: options.params, ...VALIDATE};
const COMMUNITY = `${API_URL}/blueprints/kinds/${options.kind}/versions/${version}/tags`;
const CUSTOM = `${apiUrl()}/blueprints/${options.type}${options.kind}/tags`;
const CUSTOM = `${apiUrl()}/blueprints/${options.type}/tags`;
const response = await axios.get(options.type == "community" ? COMMUNITY : CUSTOM, PARAMS);

View File

@@ -1,4 +1,4 @@
import {computed, ref} from "vue";
import {computed, nextTick, ref, watch} from "vue";
import {defineStore} from "pinia";
import type {AxiosRequestConfig, AxiosResponse} from "axios";
@@ -21,146 +21,230 @@ import type {Dashboard, Chart, Request, Parameters} from "../components/dashboar
import {useAxios} from "../utils/axios";
import {removeRefPrefix, usePluginsStore} from "./plugins";
import * as YAML_UTILS from "@kestra-io/ui-libs/flow-yaml-utils";
import _throttle from "lodash/throttle";
import {useCoreStore} from "./core";
import {useI18n} from "vue-i18n";
export const useDashboardStore = defineStore("dashboard", () => {
const selectedChart = ref<Chart>();
const dashboard = ref<Dashboard>();
const chartErrors = ref<string[]>([]);
const isCreating = ref<boolean>(false);
const selectedChart = ref<Chart>();
const dashboard = ref<Dashboard>();
const chartErrors = ref<string[]>([]);
const isCreating = ref<boolean>(false);
const sourceCode = ref("")
const parsedSource = computed<{ id?: string, [key:string]: any } | undefined>((previous) => {
try {
return YAML_UTILS.parse(sourceCode.value);
} catch {
return previous;
}
})
const axios = useAxios();
async function list(options: Record<string, any>) {
const {sort, ...params} = options;
const response = await axios.get(`${apiUrl()}/dashboards?size=100${sort ? `&sort=${sort}` : ""}`, {params});
return response.data;
const sourceCode = ref("")
const parsedSource = computed<{ id?: string, [key:string]: any } | undefined>((previous) => {
try {
return YAML_UTILS.parse(sourceCode.value);
} catch {
return previous;
}
})
async function load(id: Dashboard["id"]) {
const response = await axios.get(`${apiUrl()}/dashboards/${id}`, {validateStatus});
let dashboardLoaded: Dashboard;
const axios = useAxios();
if (response.status === 200) dashboardLoaded = response.data;
else dashboardLoaded = {title: "Default", id, charts: [], sourceCode: ""};
async function list(options: Record<string, any>) {
const {sort, ...params} = options;
const response = await axios.get(`${apiUrl()}/dashboards?size=100${sort ? `&sort=${sort}` : ""}`, {params});
dashboard.value = dashboardLoaded;
sourceCode.value = dashboardLoaded.sourceCode ?? ""
return response.data;
}
return dashboardLoaded;
async function load(id: Dashboard["id"]) {
const response = await axios.get(`${apiUrl()}/dashboards/${id}`, {validateStatus});
let dashboardLoaded: Dashboard;
if (response.status === 200) dashboardLoaded = response.data;
else dashboardLoaded = {title: "Default", id, charts: [], sourceCode: ""};
dashboard.value = dashboardLoaded;
sourceCode.value = dashboardLoaded.sourceCode ?? ""
return dashboardLoaded;
}
async function create(source: Dashboard["sourceCode"]) {
const response = await axios.post(`${apiUrl()}/dashboards`, source, header);
return response.data;
}
async function update({id, source}: {id: Dashboard["id"]; source: Dashboard["sourceCode"];}) {
const response = await axios.put(`${apiUrl()}/dashboards/${id}`, source, header);
return response.data;
}
async function deleteDashboard(id: Dashboard["id"]) {
const response = await axios.delete(`${apiUrl()}/dashboards/${id}`);
return response.data;
}
async function validateDashboard(source: Dashboard["sourceCode"]) {
const response = await axios.post(`${apiUrl()}/dashboards/validate`, source, header);
return response.data;
}
async function generate(id: Dashboard["id"], chartId: Chart["id"], parameters: Parameters) {
const response = await axios.post(`${apiUrl()}/dashboards/${id}/charts/${chartId}`, parameters, {validateStatus});
return response.data;
}
async function validateChart(source: string) {
const response = await axios.post(`${apiUrl()}/dashboards/validate/chart`, source, header);
chartErrors.value = response.data;
return response.data;
}
async function chartPreview(request: Request) {
const response = await axios.post(`${apiUrl()}/dashboards/charts/preview`, request);
return response.data;
}
async function exportDashboard(dashboard: Dashboard, chart: Chart, parameters: Parameters) {
const isDefault = dashboard.id === "default";
const path = isDefault ? "/charts/export/to-csv" : `/${dashboard.id}/charts/${chart.id}/export/to-csv`;
const payload = isDefault ? {chart: chart.content, globalFilter: parameters} : parameters;
const filename = `chart__${chart.id}`;
return axios
.post(`${apiUrl()}/dashboards${path}`, payload, response)
.then((res) => downloadHandler(res, filename));
}
const pluginsStore = usePluginsStore();
const InitialSchema = {}
const schema = computed<{
definitions: any,
$ref: string,
}>(() => {
return pluginsStore.schemaType?.dashboard ?? InitialSchema;
})
const definitions = computed<Record<string, any>>(() => {
return schema.value.definitions ?? {};
});
function recursivelyLoopUpSchemaRef(a: any, definitions: Record<string, any>): any {
if (a.$ref) {
const ref = removeRefPrefix(a.$ref);
return recursivelyLoopUpSchemaRef(definitions[ref], definitions);
}
return a;
}
async function create(source: Dashboard["sourceCode"]) {
const response = await axios.post(`${apiUrl()}/dashboards`, source, header);
return response.data;
const rootSchema = computed<Record<string, any> | undefined>(() => {
return recursivelyLoopUpSchemaRef(schema.value, definitions.value);
});
const rootProperties = computed<Record<string, any> | undefined>(() => {
return rootSchema.value?.properties;
});
async function loadChart(chart: any) {
const yamlChart = YAML_UTILS.stringify(chart);
if(selectedChart.value?.content === yamlChart){
return {
error: chartErrors.value.length > 0 ? chartErrors.value[0] : null,
data: selectedChart.value ? {...selectedChart.value, raw: chart} : null,
raw: chart
};
}
async function update({id, source}: {id: Dashboard["id"]; source: Dashboard["sourceCode"];}) {
const response = await axios.put(`${apiUrl()}/dashboards/${id}`, source, header);
return response.data;
}
async function deleteDashboard(id: Dashboard["id"]) {
const response = await axios.delete(`${apiUrl()}/dashboards/${id}`);
return response.data;
}
async function validateDashboard(source: Dashboard["sourceCode"]) {
const response = await axios.post(`${apiUrl()}/dashboards/validate`, source, header);
return response.data;
}
async function generate(id: Dashboard["id"], chartId: Chart["id"], parameters: Parameters) {
const response = await axios.post(`${apiUrl()}/dashboards/${id}/charts/${chartId}`, parameters, {validateStatus});
return response.data;
}
async function validateChart(source: string) {
const response = await axios.post(`${apiUrl()}/dashboards/validate/chart`, source, header);
chartErrors.value = response.data;
return response.data;
}
async function chartPreview(request: Request) {
const response = await axios.post(`${apiUrl()}/dashboards/charts/preview`, request);
return response.data;
}
async function exportDashboard(dashboard: Dashboard, chart: Chart, parameters: Parameters) {
const isDefault = dashboard.id === "default";
const path = isDefault ? "/charts/export/to-csv" : `/${dashboard.id}/charts/${chart.id}/export/to-csv`;
const payload = isDefault ? {chart: chart.content, globalFilter: parameters} : parameters;
const filename = `chart__${chart.id}`;
return axios
.post(`${apiUrl()}/dashboards${path}`, payload, response)
.then((res) => downloadHandler(res, filename));
}
const pluginsStore = usePluginsStore();
const InitialSchema = {}
const schema = computed<{
definitions: any,
$ref: string,
}>(() => {
return pluginsStore.schemaType?.dashboard ?? InitialSchema;
})
const definitions = computed<Record<string, any>>(() => {
return schema.value.definitions ?? {};
});
function recursivelyLoopUpSchemaRef(a: any, definitions: Record<string, any>): any {
if (a.$ref) {
const ref = removeRefPrefix(a.$ref);
return recursivelyLoopUpSchemaRef(definitions[ref], definitions);
}
return a;
}
const rootSchema = computed<Record<string, any> | undefined>(() => {
return recursivelyLoopUpSchemaRef(schema.value, definitions.value);
});
const rootProperties = computed<Record<string, any> | undefined>(() => {
return rootSchema.value?.properties;
});
return {
dashboard,
chartErrors,
isCreating,
selectedChart,
list,
load,
create,
update,
delete: deleteDashboard,
validateDashboard,
generate,
validateChart,
chartPreview,
export: exportDashboard,
schema,
definitions,
rootSchema,
rootProperties,
sourceCode,
parsedSource,
const result: { error: string | null; data: null | {
id?: string;
name?: string;
type?: string;
chartOptions?: Record<string, any>;
dataFilters?: any[];
charts?: any[];
}; raw: any } = {
error: null,
data: null,
raw: {}
};
const errors = await validateChart(yamlChart);
if (errors.constraints) {
result.error = errors.constraints;
} else {
result.data = {...chart, content: yamlChart, raw: chart};
}
selectedChart.value = typeof result.data === "object"
? {
...result.data,
chartOptions: {
...result.data?.chartOptions,
width: 12
}
} as any
: undefined;
chartErrors.value = [result.error].filter(e => e !== null);
return result;
}
const errors = ref<string[] | undefined>();
const warnings = ref<string[] | undefined>();
const coreStore = useCoreStore();
const {t} = useI18n()
watch(sourceCode, _throttle(async () => {
const errorsResult = await validateDashboard(sourceCode.value);
const dbId = dashboard.value?.id;
if (errorsResult.constraints) {
errors.value = [errorsResult.constraints];
} else {
errors.value = undefined;
}
if (dbId !== undefined && YAML_UTILS.parse(sourceCode.value).id !== dbId) {
coreStore.message = {
variant: "error",
title: t("readonly property"),
message: t("dashboards.edition.id readonly"),
};
await nextTick();
if(sourceCode.value && dbId){
sourceCode.value = YAML_UTILS.replaceBlockWithPath({
source: sourceCode.value,
path: "id",
newContent: dbId,
});
}
}
}, 300, {trailing: true, leading: false}));
return {
dashboard,
chartErrors,
isCreating,
selectedChart,
list,
load,
create,
update,
delete: deleteDashboard,
validateDashboard,
generate,
validateChart,
chartPreview,
export: exportDashboard,
loadChart,
errors,
warnings,
schema,
definitions,
rootSchema,
rootProperties,
sourceCode,
parsedSource,
};
});

View File

@@ -220,10 +220,10 @@ export const useFileExplorerStore = defineStore("fileExplorer", () => {
for (const item of array) {
const folderPath = `${basePath}${item.fileName}`;
if (folderPath === parentPath && isDirectory(item)) {
item.children = sorted([...item.children, NEW]);
item.children = sorted([...(item.children ?? []), NEW]);
return true;
}
if (isDirectory(item) && pushItemToFolder(`${folderPath}/`, item.children, pathParts.slice(1))) {
if (isDirectory(item) && pushItemToFolder(`${folderPath}/`, item.children ?? [], pathParts.slice(1))) {
return true;
}
}
@@ -249,6 +249,7 @@ export const useFileExplorerStore = defineStore("fileExplorer", () => {
function getPath(uid: string ) {
// first, use the node unique id to find it in all the subtrees of the fileTree
const findPath = (array: TreeNode[], currentPath = ""): string | undefined => {
if (!Array.isArray(array)) return undefined;
for (const item of array) {
const newPath = currentPath ? `${currentPath}/${item.fileName}` : item.fileName;
if (item.id === uid) {

View File

@@ -23,6 +23,8 @@ const textYamlHeader = {
}
}
const VALIDATE = {validateStatus: (status: number) => status === 200 || status === 401};
interface Trigger {
id: string;
type: string;
@@ -401,10 +403,13 @@ export const useFlowStore = defineStore("flow", () => {
}
function saveFlow(options: { flow: string }) {
const flowData = YAML_UTILS.parse(options.flow)
return axios.put(`${apiUrl()}/flows/${flowData.namespace}/${flowData.id}`, options.flow, textYamlHeader)
return axios.put(`${apiUrl()}/flows/${flowData.namespace}/${flowData.id}`, options.flow, {
...textYamlHeader,
...VALIDATE
})
.then(response => {
if (response.status >= 300) {
return Promise.reject(new Error("Server error on flow save"))
return Promise.reject(response)
} else {
flow.value = response.data;
@@ -427,7 +432,13 @@ export const useFlowStore = defineStore("flow", () => {
}
function createFlow(options: { flow: string }) {
return axios.post(`${apiUrl()}/flows`, options.flow, textYamlHeader).then(response => {
return axios.post(`${apiUrl()}/flows`, options.flow, {
...textYamlHeader,
...VALIDATE
}).then(response => {
if (response.status >= 300) {
return Promise.reject(response)
}
const creationPanels = localStorage.getItem(`el-fl-creation-${creationId.value}`) ?? YAML_UTILS.stringify([]);
localStorage.setItem(`el-fl-${flow.value!.namespace}-${flow.value!.id}`, creationPanels);

View File

@@ -107,25 +107,11 @@
:deep(.alert-info) {
display: flex;
gap: 12px;
padding: 16px 16px 0 16px;
padding: .5rem !important;
background-color: var(--ks-background-info);
border: 1px solid var(--ks-border-info);
border-left-width: 5px;
border-radius: 8px;
&::before {
content: '!';
min-width: 20px;
height: 20px;
margin-top: 4px;
border-radius: 50%;
background: var(--ks-content-info);
border: 1px solid var(--ks-border-info);
color: var(--ks-content-inverse);
font: 600 13px/20px sans-serif;
text-align: center;
}
border-left-width: 0.25rem;
border-radius: 0.5rem;
p { color: var(--ks-content-info); }
}
@@ -135,7 +121,7 @@
color: var(--ks-content-info);
border: 1px solid var(--ks-border-info);
font-family: 'Courier New', Courier, monospace;
white-space: nowrap; // Prevent button text from wrapping
white-space: nowrap;
.material-design-icon {
position: absolute;

View File

@@ -40,6 +40,7 @@ export const storageKeys = {
TIMEZONE_STORAGE_KEY: "timezone",
SAVED_FILTERS_PREFIX: "saved_filters",
FILTER_ORDER_PREFIX: "filter-order",
SCROLL_MEMORY_PREFIX: "scroll",
}
export const executeFlowBehaviours = {

View File

@@ -0,0 +1,28 @@
import LabelInput from "../../../../src/components/labels/LabelInput.vue";
import {ref} from "vue";
import {Meta, StoryFn} from "@storybook/vue3";
export default {
title: "Components/Labels/LabelInput",
component: LabelInput,
} as Meta<typeof LabelInput>;
const Template: StoryFn<typeof LabelInput> = (args) => ({
setup() {
const model = ref(args.labels);
return () => <LabelInput {...args} labels={model.value} onUpdate:labels={(labs) => model.value = labs}/>;
}
});
export const Default = Template.bind({});
Default.args = {
labels: [],
};
export const WithValue = Template.bind({});
WithValue.args = {
labels: [{
key: "example-label",
value: "example-value",
}],
};

View File

@@ -531,7 +531,7 @@ class FlowControllerTest {
List<String> namespaces = client.toBlocking().retrieve(
HttpRequest.GET("/api/v1/main/flows/distinct-namespaces"), Argument.listOf(String.class));
assertThat(namespaces.size()).isEqualTo(11);
assertThat(namespaces.size()).isEqualTo(12);
}
@Test

View File

@@ -202,24 +202,24 @@ class KVControllerTest {
static Stream<Arguments> kvSetKeyValueArgs() {
return Stream.of(
Arguments.of("{\"hello\":\"world\"}", Map.class),
Arguments.of("[\"hello\",\"world\"]", List.class),
Arguments.of("\"hello\"", String.class),
Arguments.of("1", Integer.class),
Arguments.of("1.0", BigDecimal.class),
Arguments.of("true", Boolean.class),
Arguments.of("false", Boolean.class),
Arguments.of("2021-09-01", LocalDate.class),
Arguments.of("2021-09-01T01:02:03Z", Instant.class),
Arguments.of("\"PT5S\"", Duration.class)
Arguments.of(MediaType.TEXT_PLAIN, "{\"hello\":\"world\"}", Map.class),
Arguments.of(MediaType.TEXT_PLAIN, "[\"hello\",\"world\"]", List.class),
Arguments.of(MediaType.TEXT_PLAIN, "\"hello\"", String.class),
Arguments.of(MediaType.TEXT_PLAIN, "1", Integer.class),
Arguments.of(MediaType.TEXT_PLAIN, "1.0", BigDecimal.class),
Arguments.of(MediaType.TEXT_PLAIN, "true", Boolean.class),
Arguments.of(MediaType.TEXT_PLAIN, "false", Boolean.class),
Arguments.of(MediaType.TEXT_PLAIN, "2021-09-01", LocalDate.class),
Arguments.of(MediaType.TEXT_PLAIN, "2021-09-01T01:02:03Z", Instant.class),
Arguments.of(MediaType.TEXT_PLAIN, "\"PT5S\"", Duration.class)
);
}
@ParameterizedTest
@MethodSource("kvSetKeyValueArgs")
void setKeyValue(String value, Class<?> expectedClass) throws IOException, ResourceExpiredException {
void setKeyValue(MediaType mediaType, String value, Class<?> expectedClass) throws IOException, ResourceExpiredException {
String myDescription = "myDescription";
client.toBlocking().exchange(HttpRequest.PUT("/api/v1/main/namespaces/" + NAMESPACE + "/kv/my-key", value).header("ttl", "PT5M").header("description", myDescription));
client.toBlocking().exchange(HttpRequest.PUT("/api/v1/main/namespaces/" + NAMESPACE + "/kv/my-key", value).contentType(mediaType).header("ttl", "PT5M").header("description", myDescription));
KVStore kvStore = kvStore();
Class<?> valueClazz = kvStore.getValue("my-key").get().value().getClass();
@@ -294,7 +294,7 @@ class KVControllerTest {
assertThat(httpClientResponseException.getStatus().getCode()).isEqualTo(HttpStatus.UNPROCESSABLE_ENTITY.getCode());
assertThat(httpClientResponseException.getMessage()).isEqualTo(expectedErrorMessage);
httpClientResponseException = Assertions.assertThrows(HttpClientResponseException.class, () -> client.toBlocking().exchange(HttpRequest.PUT("/api/v1/main/namespaces/" + NAMESPACE + "/kv/bad$key", "\"content\"")));
httpClientResponseException = Assertions.assertThrows(HttpClientResponseException.class, () -> client.toBlocking().exchange(HttpRequest.PUT("/api/v1/main/namespaces/" + NAMESPACE + "/kv/bad$key", "\"content\"").contentType(MediaType.TEXT_PLAIN)));
assertThat(httpClientResponseException.getStatus().getCode()).isEqualTo(HttpStatus.UNPROCESSABLE_ENTITY.getCode());
assertThat(httpClientResponseException.getMessage()).isEqualTo(expectedErrorMessage);