Compare commits

...

61 Commits

Author SHA1 Message Date
github-actions[bot]
ccf9d9b303 chore(version): update to version '1.1.4' 2025-11-18 13:10:38 +00:00
Miloš Paunović
25dbdbd713 chore(core): improve handling of local and cdn-loaded fonts (#13020)
Related to https://github.com/kestra-io/kestra/pull/11448#issuecomment-3510236629.

Closes https://github.com/kestra-io/kestra/issues/13019.
2025-11-18 13:26:32 +01:00
Loïc Mathieu
d54477051f fix(execution): use jdbcRepository.findOne to be tolerant of multiple results
It uses findAny() under the cover which does not throw if more than one result is returned.

Fixes #12943
2025-11-18 10:23:55 +01:00
Florian Hussonnois
54a63d1b04 fix(scheduler): mysql convert 'now' to UTC to avoid any offset error on next_execution_date
Fixed a previous commit to only apply the change for MySQL

Related-to: kestra-io/kestra-ee#5611
2025-11-18 09:59:12 +01:00
YannC
6f271e5694 feat: add annotation for multipart body on resumeExecution to have it inside SDK (#13003) 2025-11-18 09:38:28 +01:00
YannC
0a718dab30 feat: allows importFlows endpoint to be able to throw when having an invalid flow (#12995) 2025-11-18 09:38:28 +01:00
Piyush Bhaskar
ec522a6d44 fix(core): add resize observer for editor container (#12991) 2025-11-17 13:55:32 +05:30
Loïc Mathieu
ad73a46b0c fix(flow): flow trigger with both conditions and preconditions
When a flow have both a condition and a precondition, the condition was evaluated twice which lead to double execution triggered.

Fixes
2025-11-14 18:12:11 +01:00
Piyush Bhaskar
ca56559c49 refactor(core): remove i18n console error (#12958) 2025-11-14 16:34:13 +05:30
Piyush Bhaskar
ed739ec257 fix(core): make the pagination work for ns executions (#12965) 2025-11-14 16:33:43 +05:30
Piyush Bhaskar
9effef9fcd fix(core): show data on page when label checked from another page (#12944) 2025-11-14 14:26:43 +05:30
Miloš Paunović
ffc61b2482 chore(core): count only direct dependencies for badge number (#12818)
Closes https://github.com/kestra-io/kestra/issues/12817.
2025-11-14 08:17:15 +01:00
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
119 changed files with 2550 additions and 1742 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,18 @@ 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/flow-trigger-mixed-conditions-flow-a.yaml", "flows/valids/flow-trigger-mixed-conditions-flow-listen.yaml"})
void flowTriggerMixedConditions() throws Exception {
multipleConditionTriggerCaseTest.flowTriggerMixedConditions();
}
@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,44 @@ 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)));
}
public void flowTriggerMixedConditions() throws TimeoutException, QueueException {
Execution execution = runnerUtils.runOne(MAIN_TENANT, "io.kestra.tests.trigger.mixed.conditions",
"flow-trigger-mixed-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.mixed.conditions", "flow-trigger-mixed-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.mixed.conditions", "flow-trigger-mixed-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-mixed-conditions-flow-a
namespace: io.kestra.tests.trigger.mixed.conditions
labels:
some: label
tasks:
- id: only
type: io.kestra.plugin.core.debug.Return
format: "from parents: {{execution.id}}"

View File

@@ -0,0 +1,25 @@
id: flow-trigger-mixed-conditions-flow-listen
namespace: io.kestra.tests.trigger.mixed.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.mixed.conditions
flowId: flow-trigger-mixed-conditions-flow-a
- id: on_failure
type: io.kestra.plugin.core.trigger.Flow
states: [ FAILED ]
preconditions:
id: flowsFailure
flows:
- namespace: io.kestra.tests.trigger.multiple.conditions
flowId: flow-trigger-multiple-conditions-flow-a
states: [FAILED]
tasks:
- id: only
type: io.kestra.plugin.core.debug.Return
format: "It works"

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

@@ -50,16 +50,147 @@ public class FlowTriggerService {
.map(io.kestra.plugin.core.trigger.Flow.class::cast);
}
public List<Execution> computeExecutionsFromFlowTriggers(Execution execution, List<? extends Flow> allFlows, Optional<MultipleConditionStorageInterface> multipleConditionStorage) {
List<FlowWithFlowTrigger> validTriggersBeforeMultipleConditionEval = allFlows.stream()
/**
* This method computes executions to trigger from flow triggers from a given execution.
* It only computes those depending on standard (non-multiple / non-preconditions) conditions, so it must be used
* in conjunction with {@link #computeExecutionsFromFlowTriggerPreconditions(Execution, Flow, MultipleConditionStorageInterface)}.
*/
public List<Execution> computeExecutionsFromFlowTriggerConditions(Execution execution, Flow flow) {
List<FlowWithFlowTrigger> flowWithFlowTriggers = computeFlowTriggers(execution, flow)
.stream()
// we must filter on no multiple conditions and no preconditions to avoid evaluating two times triggers that have standard conditions and multiple conditions
.filter(it -> it.getTrigger().getPreconditions() == null && ListUtils.emptyOnNull(it.getTrigger().getConditions()).stream().noneMatch(MultipleCondition.class::isInstance))
.toList();
// short-circuit empty triggers to evaluate
if (flowWithFlowTriggers.isEmpty()) {
return Collections.emptyList();
}
// compute all executions to create from flow triggers without taken into account multiple conditions
return flowWithFlowTriggers.stream()
.map(f -> f.getTrigger().evaluate(
Optional.empty(),
runContextFactory.of(f.getFlow(), execution),
f.getFlow(),
execution
))
.filter(Optional::isPresent)
.map(Optional::get)
.toList();
}
/**
* This method computes executions to trigger from flow triggers from a given execution.
* It only computes those depending on multiple conditions and preconditions, so it must be used
* in conjunction with {@link #computeExecutionsFromFlowTriggerConditions(Execution, Flow)}.
*/
public List<Execution> computeExecutionsFromFlowTriggerPreconditions(Execution execution, Flow flow, MultipleConditionStorageInterface multipleConditionStorage) {
List<FlowWithFlowTrigger> flowWithFlowTriggers = computeFlowTriggers(execution, flow)
.stream()
// we must filter on multiple conditions or preconditions to avoid evaluating two times triggers that only have standard conditions
.filter(flowWithFlowTrigger -> flowWithFlowTrigger.getTrigger().getPreconditions() != null || ListUtils.emptyOnNull(flowWithFlowTrigger.getTrigger().getConditions()).stream().anyMatch(MultipleCondition.class::isInstance))
.toList();
// short-circuit empty triggers to evaluate
if (flowWithFlowTriggers.isEmpty()) {
return Collections.emptyList();
}
List<FlowWithFlowTriggerAndMultipleCondition> flowWithMultipleConditionsToEvaluate = flowWithFlowTriggers.stream()
.flatMap(flowWithFlowTrigger -> flowTriggerMultipleConditions(flowWithFlowTrigger)
.map(multipleCondition -> new FlowWithFlowTriggerAndMultipleCondition(
flowWithFlowTrigger.getFlow(),
multipleConditionStorage.getOrCreate(flowWithFlowTrigger.getFlow(), multipleCondition, execution.getOutputs()),
flowWithFlowTrigger.getTrigger(),
multipleCondition
)
)
)
// avoid evaluating expired windows (for ex for daily time window or deadline)
.filter(flowWithFlowTriggerAndMultipleCondition -> flowWithFlowTriggerAndMultipleCondition.getMultipleConditionWindow().isValid(ZonedDateTime.now()))
.toList();
// evaluate multiple conditions
Map<FlowWithFlowTriggerAndMultipleCondition, MultipleConditionWindow> multipleConditionWindowsByFlow = flowWithMultipleConditionsToEvaluate.stream().map(f -> {
Map<String, Boolean> results = f.getMultipleCondition()
.getConditions()
.entrySet()
.stream()
.map(e -> new AbstractMap.SimpleEntry<>(
e.getKey(),
conditionService.isValid(e.getValue(), f.getFlow(), execution)
))
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
return Map.entry(f, f.getMultipleConditionWindow().with(results));
})
.filter(e -> !e.getValue().getResults().isEmpty())
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
// persist results
multipleConditionStorage.save(new ArrayList<>(multipleConditionWindowsByFlow.values()));
// compute all executions to create from flow triggers now that multiple conditions storage is populated
List<Execution> executions = flowWithFlowTriggers.stream()
// will evaluate conditions
.filter(flowWithFlowTrigger ->
conditionService.isValid(
flowWithFlowTrigger.getTrigger(),
flowWithFlowTrigger.getFlow(),
execution,
multipleConditionStorage
)
)
// will evaluate preconditions
.filter(flowWithFlowTrigger ->
conditionService.isValid(
flowWithFlowTrigger.getTrigger().getPreconditions(),
flowWithFlowTrigger.getFlow(),
execution,
multipleConditionStorage
)
)
.map(f -> f.getTrigger().evaluate(
Optional.of(multipleConditionStorage),
runContextFactory.of(f.getFlow(), execution),
f.getFlow(),
execution
))
.filter(Optional::isPresent)
.map(Optional::get)
.toList();
// purge fulfilled or expired multiple condition windows
Stream.concat(
multipleConditionWindowsByFlow.entrySet().stream()
.map(e -> Map.entry(
e.getKey().getMultipleCondition(),
e.getValue()
))
.filter(e -> !Boolean.FALSE.equals(e.getKey().getResetOnSuccess()) &&
e.getKey().getConditions().size() == Optional.ofNullable(e.getValue().getResults()).map(Map::size).orElse(0)
)
.map(Map.Entry::getValue),
multipleConditionStorage.expired(execution.getTenantId()).stream()
).forEach(multipleConditionStorage::delete);
return executions;
}
private List<FlowWithFlowTrigger> computeFlowTriggers(Execution execution, Flow flow) {
if (
// prevent recursive flow triggers
.filter(flow -> flowService.removeUnwanted(flow, execution))
// filter out Test Executions
.filter(flow -> execution.getKind() == null)
// ensure flow & triggers are enabled
.filter(flow -> !flow.isDisabled() && !(flow instanceof FlowWithException))
.filter(flow -> flow.getTriggers() != null && !flow.getTriggers().isEmpty())
.flatMap(flow -> flowTriggers(flow).map(trigger -> new FlowWithFlowTrigger(flow, trigger)))
!flowService.removeUnwanted(flow, execution) ||
// filter out Test Executions
execution.getKind() != null ||
// ensure flow & triggers are enabled
flow.isDisabled() || flow instanceof FlowWithException ||
flow.getTriggers() == null || flow.getTriggers().isEmpty()) {
return Collections.emptyList();
}
return flowTriggers(flow).map(trigger -> new FlowWithFlowTrigger(flow, trigger))
// filter on the execution state the flow listen to
.filter(flowWithFlowTrigger -> flowWithFlowTrigger.getTrigger().getStates().contains(execution.getState().getCurrent()))
// validate flow triggers conditions excluding multiple conditions
@@ -74,96 +205,6 @@ public class FlowTriggerService {
execution
)
)).toList();
// short-circuit empty triggers to evaluate
if (validTriggersBeforeMultipleConditionEval.isEmpty()) {
return Collections.emptyList();
}
Map<FlowWithFlowTriggerAndMultipleCondition, MultipleConditionWindow> multipleConditionWindowsByFlow = null;
if (multipleConditionStorage.isPresent()) {
List<FlowWithFlowTriggerAndMultipleCondition> flowWithMultipleConditionsToEvaluate = validTriggersBeforeMultipleConditionEval.stream()
.flatMap(flowWithFlowTrigger -> flowTriggerMultipleConditions(flowWithFlowTrigger)
.map(multipleCondition -> new FlowWithFlowTriggerAndMultipleCondition(
flowWithFlowTrigger.getFlow(),
multipleConditionStorage.get().getOrCreate(flowWithFlowTrigger.getFlow(), multipleCondition, execution.getOutputs()),
flowWithFlowTrigger.getTrigger(),
multipleCondition
)
)
)
// avoid evaluating expired windows (for ex for daily time window or deadline)
.filter(flowWithFlowTriggerAndMultipleCondition -> flowWithFlowTriggerAndMultipleCondition.getMultipleConditionWindow().isValid(ZonedDateTime.now()))
.toList();
// evaluate multiple conditions
multipleConditionWindowsByFlow = flowWithMultipleConditionsToEvaluate.stream().map(f -> {
Map<String, Boolean> results = f.getMultipleCondition()
.getConditions()
.entrySet()
.stream()
.map(e -> new AbstractMap.SimpleEntry<>(
e.getKey(),
conditionService.isValid(e.getValue(), f.getFlow(), execution)
))
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
return Map.entry(f, f.getMultipleConditionWindow().with(results));
})
.filter(e -> !e.getValue().getResults().isEmpty())
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
// persist results
multipleConditionStorage.get().save(new ArrayList<>(multipleConditionWindowsByFlow.values()));
}
// compute all executions to create from flow triggers now that multiple conditions storage is populated
List<Execution> executions = validTriggersBeforeMultipleConditionEval.stream()
// will evaluate conditions
.filter(flowWithFlowTrigger ->
conditionService.isValid(
flowWithFlowTrigger.getTrigger(),
flowWithFlowTrigger.getFlow(),
execution,
multipleConditionStorage.orElse(null)
)
)
// will evaluate preconditions
.filter(flowWithFlowTrigger ->
conditionService.isValid(
flowWithFlowTrigger.getTrigger().getPreconditions(),
flowWithFlowTrigger.getFlow(),
execution,
multipleConditionStorage.orElse(null)
)
)
.map(f -> f.getTrigger().evaluate(
multipleConditionStorage,
runContextFactory.of(f.getFlow(), execution),
f.getFlow(),
execution
))
.filter(Optional::isPresent)
.map(Optional::get)
.toList();
if (multipleConditionStorage.isPresent()) {
// purge fulfilled or expired multiple condition windows
Stream.concat(
multipleConditionWindowsByFlow.entrySet().stream()
.map(e -> Map.entry(
e.getKey().getMultipleCondition(),
e.getValue()
))
.filter(e -> !Boolean.FALSE.equals(e.getKey().getResetOnSuccess()) &&
e.getKey().getConditions().size() == Optional.ofNullable(e.getValue().getResults()).map(Map::size).orElse(0)
)
.map(Map.Entry::getValue),
multipleConditionStorage.get().expired(execution.getTenantId()).stream()
).forEach(multipleConditionStorage.get()::delete);
}
return executions;
}
private Stream<MultipleCondition> flowTriggerMultipleConditions(FlowWithFlowTrigger flowWithFlowTrigger) {

View File

@@ -25,8 +25,7 @@ import static org.assertj.core.api.Assertions.assertThat;
@KestraTest
class FlowTriggerServiceTest {
public static final List<Label> EMPTY_LABELS = List.of();
public static final Optional<MultipleConditionStorageInterface> EMPTY_MULTIPLE_CONDITION_STORAGE = Optional.empty();
private static final List<Label> EMPTY_LABELS = List.of();
@Inject
private TestRunContextFactory runContextFactory;
@@ -56,14 +55,27 @@ class FlowTriggerServiceTest {
var simpleFlowExecution = Execution.newExecution(simpleFlow, EMPTY_LABELS).withState(State.Type.SUCCESS);
var resultingExecutionsToRun = flowTriggerService.computeExecutionsFromFlowTriggers(
var resultingExecutionsToRun = flowTriggerService.computeExecutionsFromFlowTriggerConditions(
simpleFlowExecution,
List.of(simpleFlow, flowWithFlowTrigger),
EMPTY_MULTIPLE_CONDITION_STORAGE
flowWithFlowTrigger
);
assertThat(resultingExecutionsToRun).size().isEqualTo(1);
assertThat(resultingExecutionsToRun.get(0).getFlowId()).isEqualTo(flowWithFlowTrigger.getId());
assertThat(resultingExecutionsToRun.getFirst().getFlowId()).isEqualTo(flowWithFlowTrigger.getId());
}
@Test
void computeExecutionsFromFlowTriggers_none() {
var simpleFlow = aSimpleFlow();
var simpleFlowExecution = Execution.newExecution(simpleFlow, EMPTY_LABELS).withState(State.Type.SUCCESS);
var resultingExecutionsToRun = flowTriggerService.computeExecutionsFromFlowTriggerConditions(
simpleFlowExecution,
simpleFlow
);
assertThat(resultingExecutionsToRun).isEmpty();
}
@Test
@@ -81,10 +93,9 @@ class FlowTriggerServiceTest {
var simpleFlowExecution = Execution.newExecution(simpleFlow, EMPTY_LABELS).withState(State.Type.CREATED);
var resultingExecutionsToRun = flowTriggerService.computeExecutionsFromFlowTriggers(
var resultingExecutionsToRun = flowTriggerService.computeExecutionsFromFlowTriggerConditions(
simpleFlowExecution,
List.of(simpleFlow, flowWithFlowTrigger),
EMPTY_MULTIPLE_CONDITION_STORAGE
flowWithFlowTrigger
);
assertThat(resultingExecutionsToRun).size().isEqualTo(0);
@@ -109,10 +120,9 @@ class FlowTriggerServiceTest {
.kind(ExecutionKind.TEST)
.build();
var resultingExecutionsToRun = flowTriggerService.computeExecutionsFromFlowTriggers(
var resultingExecutionsToRun = flowTriggerService.computeExecutionsFromFlowTriggerConditions(
simpleFlowExecutionComingFromATest,
List.of(simpleFlow, flowWithFlowTrigger),
EMPTY_MULTIPLE_CONDITION_STORAGE
flowWithFlowTrigger
);
assertThat(resultingExecutionsToRun).size().isEqualTo(0);

View File

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

View File

@@ -1,8 +1,10 @@
package io.kestra.repository.mysql;
import io.kestra.core.models.triggers.Trigger;
import io.kestra.core.runners.ScheduleContextInterface;
import io.kestra.core.utils.DateUtils;
import io.kestra.jdbc.repository.AbstractJdbcTriggerRepository;
import io.kestra.jdbc.runner.JdbcSchedulerContext;
import io.kestra.jdbc.services.JdbcFilterService;
import jakarta.inject.Inject;
import jakarta.inject.Named;
@@ -11,6 +13,10 @@ import org.jooq.Condition;
import org.jooq.Field;
import org.jooq.impl.DSL;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.temporal.Temporal;
import java.util.Date;
import java.util.List;
@@ -45,4 +51,11 @@ public class MysqlTriggerRepository extends AbstractJdbcTriggerRepository {
throw new IllegalArgumentException("Unsupported GroupType: " + groupType);
}
}
@Override
protected Temporal toNextExecutionTime(ZonedDateTime now) {
// next_execution_date in the table is stored in UTC
// convert 'now' to UTC LocalDateTime to avoid any timezone/offset interpretation by the database.
return now.withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime();
}
}

View File

@@ -32,6 +32,7 @@ import reactor.core.publisher.Flux;
import reactor.core.publisher.FluxSink;
import java.time.ZonedDateTime;
import java.time.temporal.Temporal;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
@@ -151,7 +152,7 @@ public abstract class AbstractJdbcTriggerRepository extends AbstractJdbcReposito
.select(field("value"))
.from(this.jdbcRepository.getTable())
.where(
(field("next_execution_date").lessThan(now.toOffsetDateTime())
(field("next_execution_date").lessThan(toNextExecutionTime(now))
// we check for null for backwards compatibility
.or(field("next_execution_date").isNull()))
.and(field("execution_id").isNull())
@@ -162,14 +163,14 @@ public abstract class AbstractJdbcTriggerRepository extends AbstractJdbcReposito
.fetch()
.map(r -> this.jdbcRepository.deserialize(r.get("value", String.class)));
}
public List<Trigger> findByNextExecutionDateReadyButLockedTriggers(ZonedDateTime now) {
return this.jdbcRepository.getDslContextWrapper()
.transactionResult(configuration -> DSL.using(configuration)
.select(field("value"))
.from(this.jdbcRepository.getTable())
.where(
(field("next_execution_date").lessThan(now.toOffsetDateTime())
(field("next_execution_date").lessThan(toNextExecutionTime(now))
// we check for null for backwards compatibility
.or(field("next_execution_date").isNull()))
.and(field("execution_id").isNotNull())
@@ -178,6 +179,10 @@ public abstract class AbstractJdbcTriggerRepository extends AbstractJdbcReposito
.fetch()
.map(r -> this.jdbcRepository.deserialize(r.get("value", String.class))));
}
protected Temporal toNextExecutionTime(ZonedDateTime now) {
return now.toOffsetDateTime();
}
public Trigger save(Trigger trigger, ScheduleContextInterface scheduleContextInterface) {
JdbcSchedulerContext jdbcSchedulerContext = (JdbcSchedulerContext) scheduleContextInterface;

View File

@@ -22,10 +22,10 @@ public class AbstractJdbcConcurrencyLimitStorage extends AbstractJdbcRepository
}
/**
* Fetch the concurrency limit counter then process the count using the consumer function.
* It locked the raw and is wrapped in a transaction so the consumer should use the provided dslContext for any database access.
* Fetch the concurrency limit counter, then process the count using the consumer function.
* It locked the raw and is wrapped in a transaction, so the consumer should use the provided dslContext for any database access.
* <p>
* Note that to avoid a race when no concurrency limit counter exists, it first always try to insert a 0 counter.
* Note that to avoid a race when no concurrency limit counter exists, it first always tries to insert a 0 counter.
*/
public ExecutionRunning countThenProcess(FlowInterface flow, BiFunction<DSLContext, ConcurrencyLimit, Pair<ExecutionRunning, ConcurrencyLimit>> consumer) {
return this.jdbcRepository
@@ -97,7 +97,7 @@ public class AbstractJdbcConcurrencyLimitStorage extends AbstractJdbcRepository
}
/**
* Returns all concurrency limit from the database
* Returns all concurrency limits from the database
*/
public List<ConcurrencyLimit> find(String tenantId) {
return this.jdbcRepository
@@ -132,8 +132,7 @@ public class AbstractJdbcConcurrencyLimitStorage extends AbstractJdbcRepository
.and(field("namespace").eq(flow.getNamespace()))
.and(field("flow_id").eq(flow.getId()));
return Optional.ofNullable(select.forUpdate().fetchOne())
.map(record -> this.jdbcRepository.map(record));
return this.jdbcRepository.fetchOne(select.forUpdate());
}
private void update(DSLContext dslContext, ConcurrencyLimit concurrencyLimit) {

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

@@ -424,7 +424,7 @@ public class JdbcExecutor implements ExecutorInterface {
MultipleConditionEvent multipleConditionEvent = either.getLeft();
flowTriggerService.computeExecutionsFromFlowTriggers(multipleConditionEvent.execution(), List.of(multipleConditionEvent.flow()), Optional.of(multipleConditionStorage))
flowTriggerService.computeExecutionsFromFlowTriggerPreconditions(multipleConditionEvent.execution(), multipleConditionEvent.flow(), multipleConditionStorage)
.forEach(exec -> {
try {
executionQueue.emit(exec);
@@ -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.computeExecutionsFromFlowTriggerConditions(execution, f).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

@@ -26,6 +26,26 @@
document.getElementsByTagName("html")[0].classList.add(localStorage.getItem("theme"));
}
</script>
<!-- Optional but recommended for faster connection -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<!-- Load Google Fonts non-blocking -->
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Public+Sans:wght@300;400;700;800&family=Source+Code+Pro:wght@400;700;800&display=swap"
media="print"
onload="this.media='all'"
>
<!-- Fallback for when JavaScript is disabled -->
<noscript>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Public+Sans:wght@300;400;700;800&family=Source+Code+Pro:wght@400;700;800&display=swap"
>
</noscript>
</head>
<body>
<noscript>

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

@@ -150,7 +150,7 @@ export function useExecutionRoot() {
follow();
window.addEventListener("popstate", follow);
dependenciesCount.value = (await flowStore.loadDependencies({namespace: route.params.namespace as string, id: route.params.flowId as string, subtype: "FLOW"})).count;
dependenciesCount.value = (await flowStore.loadDependencies({namespace: route.params.namespace as string, id: route.params.flowId as string, subtype: "FLOW"}, true)).count;
previousExecutionId.value = route.params.id as string;
});

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

@@ -2,13 +2,15 @@ import {computed, ComputedRef} from "vue";
import {FilterConfiguration} from "../utils/filterTypes";
import {useI18n} from "vue-i18n";
export const useBlueprintFilter = (): ComputedRef<FilterConfiguration> => computed(() => {
export const useBlueprintFilter = (): ComputedRef<FilterConfiguration> => {
const {t} = useI18n();
return {
title: t("filter.titles.blueprint_filters"),
searchPlaceholder: t("filter.search_placeholders.search_blueprints"),
keys: [
]
};
});
return computed(() => {
return {
title: t("filter.titles.blueprint_filters"),
searchPlaceholder: t("filter.search_placeholders.search_blueprints"),
keys: [
]
};
});
};

View File

@@ -7,152 +7,160 @@ import {useAuthStore} from "override/stores/auth";
import {useValues} from "../composables/useValues";
import {useI18n} from "vue-i18n";
export const useDashboardFilter = (): ComputedRef<FilterConfiguration> => computed(() => {
export const useDashboardFilter = (): ComputedRef<FilterConfiguration> => {
const {t} = useI18n();
return {
title: t("filter.titles.dashboard_filters"),
searchPlaceholder: t("filter.search_placeholders.search_dashboards"),
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 computed(() => {
return {
title: t("filter.titles.dashboard_filters"),
searchPlaceholder: t("filter.search_placeholders.search_dashboards"),
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
},
{
key: "timeRange",
label: t("filter.timeRange_dashboard.label"),
description: t("filter.timeRange_dashboard.description"),
comparators: [Comparators.EQUALS],
valueType: "select",
valueProvider: async () => {
const {VALUES} = useValues("dashboard");
return VALUES.RELATIVE_DATE;
}
return [];
},
searchable: true
},
{
key: "timeRange",
label: t("filter.timeRange_dashboard.label"),
description: t("filter.timeRange_dashboard.description"),
comparators: [Comparators.EQUALS],
valueType: "select",
valueProvider: async () => {
const {VALUES} = useValues("dashboard");
return VALUES.RELATIVE_DATE;
{
key: "state",
label: t("filter.state.label"),
description: t("filter.state.description"),
comparators: [Comparators.IN, Comparators.NOT_IN],
valueType: "multi-select",
valueProvider: async () => {
const {VALUES} = useValues("executions");
return VALUES.EXECUTION_STATES;
},
showComparatorSelection: true
},
{
key: "labels",
label: t("filter.labels.label"),
description: t("filter.labels.description"),
comparators: [Comparators.EQUALS, Comparators.NOT_EQUALS],
valueType: "text",
}
},
{
key: "state",
label: t("filter.state.label"),
description: t("filter.state.description"),
comparators: [Comparators.IN, Comparators.NOT_IN],
valueType: "multi-select",
valueProvider: async () => {
const {VALUES} = useValues("executions");
return VALUES.EXECUTION_STATES;
},
showComparatorSelection: true
},
{
key: "labels",
label: t("filter.labels.label"),
description: t("filter.labels.description"),
comparators: [Comparators.EQUALS, Comparators.NOT_EQUALS],
valueType: "text",
}
]
};
});
]
};
});
};
export const useNamespaceDashboardFilter = (): ComputedRef<FilterConfiguration> => computed(() => {
export const useNamespaceDashboardFilter = (): ComputedRef<FilterConfiguration> => {
const {t} = useI18n();
return {
title: t("filter.titles.namespace_dashboard_filters"),
searchPlaceholder: t("filter.search_placeholders.search_dashboards"),
keys: [
{
key: "flowId",
label: t("filter.flowId.label"),
description: t("filter.flowId.description"),
comparators: [
Comparators.EQUALS,
Comparators.NOT_EQUALS,
Comparators.CONTAINS,
Comparators.STARTS_WITH,
Comparators.ENDS_WITH,
],
valueType: "text",
// valueProvider: async () => {
// const flowStore = useFlowStore();
return computed(() => {
// const flowIds = await flowStore.loadDistinctFlowIds();
// return flowIds.map((flowId: string) => ({label: flowId, value: flowId}));
// },
searchable: true
},
{
key: "timeRange",
label: t("filter.timeRange_dashboard.label"),
description: t("filter.timeRange_dashboard.description"),
comparators: [Comparators.EQUALS],
valueType: "select",
valueProvider: async () => {
const {VALUES} = useValues("dashboard");
return VALUES.RELATIVE_DATE;
return {
title: t("filter.titles.namespace_dashboard_filters"),
searchPlaceholder: t("filter.search_placeholders.search_dashboards"),
keys: [
{
key: "flowId",
label: t("filter.flowId.label"),
description: t("filter.flowId.description"),
comparators: [
Comparators.EQUALS,
Comparators.NOT_EQUALS,
Comparators.CONTAINS,
Comparators.STARTS_WITH,
Comparators.ENDS_WITH,
],
valueType: "text",
// valueProvider: async () => {
// const flowStore = useFlowStore();
// const flowIds = await flowStore.loadDistinctFlowIds();
// return flowIds.map((flowId: string) => ({label: flowId, value: flowId}));
// },
searchable: true
},
{
key: "timeRange",
label: t("filter.timeRange_dashboard.label"),
description: t("filter.timeRange_dashboard.description"),
comparators: [Comparators.EQUALS],
valueType: "select",
valueProvider: async () => {
const {VALUES} = useValues("dashboard");
return VALUES.RELATIVE_DATE;
}
},
{
key: "labels",
label: t("filter.labels.label"),
description: "Filter by labels",
comparators: [Comparators.EQUALS, Comparators.NOT_EQUALS],
valueType: "text",
}
},
{
key: "labels",
label: t("filter.labels.label"),
description: "Filter by labels",
comparators: [Comparators.EQUALS, Comparators.NOT_EQUALS],
valueType: "text",
}
]
};
});
]
};
});
};
export const useFlowDashboardFilter = (): ComputedRef<FilterConfiguration> => computed(() => {
export const useFlowDashboardFilter = (): ComputedRef<FilterConfiguration> => {
const {t} = useI18n();
return {
title: t("filter.titles.flow_dashboard_filters"),
searchPlaceholder: t("filter.search_placeholders.search_dashboards"),
keys: [
{
key: "timeRange",
label: t("filter.timeRange_dashboard.label"),
description: t("filter.timeRange_dashboard.description"),
comparators: [Comparators.EQUALS],
valueType: "select",
valueProvider: async () => {
const {VALUES} = useValues("dashboard");
return VALUES.RELATIVE_DATE;
return computed(() => {
return {
title: t("filter.titles.flow_dashboard_filters"),
searchPlaceholder: t("filter.search_placeholders.search_dashboards"),
keys: [
{
key: "timeRange",
label: t("filter.timeRange_dashboard.label"),
description: t("filter.timeRange_dashboard.description"),
comparators: [Comparators.EQUALS],
valueType: "select",
valueProvider: async () => {
const {VALUES} = useValues("dashboard");
return VALUES.RELATIVE_DATE;
}
},
{
key: "labels",
label: t("filter.labels.label"),
description: t("filter.labels.description"),
comparators: [Comparators.EQUALS, Comparators.NOT_EQUALS],
valueType: "text",
}
},
{
key: "labels",
label: t("filter.labels.label"),
description: t("filter.labels.description"),
comparators: [Comparators.EQUALS, Comparators.NOT_EQUALS],
valueType: "text",
}
]
};
});
]
};
});
};

View File

@@ -6,137 +6,143 @@ 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(() => {
export const useExecutionFilter = (): ComputedRef<FilterConfiguration> => {
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 computed(() => {
return {
title: t("filter.titles.execution_filters"),
searchPlaceholder: t("filter.search_placeholders.search_executions"),
keys: [
...(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,
...(route.name !== "flows/update" ? [{
key: "flowId",
label: t("filter.flowId.label"),
description: t("filter.flowId.description"),
comparators: [
Comparators.EQUALS,
Comparators.NOT_EQUALS,
Comparators.CONTAINS,
Comparators.STARTS_WITH,
Comparators.ENDS_WITH,
],
valueType: "text",
}] : []) as any,
{
key: "kind",
label: t("filter.kind.label"),
description: t("filter.kind.description"),
comparators: [Comparators.EQUALS],
valueType: "radio",
valueProvider: async () => {
const {VALUES} = useValues("executions");
return VALUES.KINDS;
}
return [];
},
searchable: true
},
{
key: "flowId",
label: t("filter.flowId.label"),
description: t("filter.flowId.description"),
comparators: [
Comparators.EQUALS,
Comparators.NOT_EQUALS,
Comparators.CONTAINS,
Comparators.STARTS_WITH,
Comparators.ENDS_WITH,
],
valueType: "text",
},
{
key: "kind",
label: t("filter.kind.label"),
description: t("filter.kind.description"),
comparators: [Comparators.EQUALS],
valueType: "radio",
valueProvider: async () => {
const {VALUES} = useValues("executions");
return VALUES.KINDS;
}
},
{
key: "state",
label: t("filter.state.label"),
description: t("filter.state.description"),
comparators: [Comparators.IN, Comparators.NOT_IN],
valueType: "multi-select",
valueProvider: async () => {
const {VALUES} = useValues("executions");
return VALUES.EXECUTION_STATES;
{
key: "state",
label: t("filter.state.label"),
description: t("filter.state.description"),
comparators: [Comparators.IN, Comparators.NOT_IN],
valueType: "multi-select",
valueProvider: async () => {
const {VALUES} = useValues("executions");
return VALUES.EXECUTION_STATES;
},
showComparatorSelection: true,
searchable: true
},
showComparatorSelection: true,
searchable: true
},
{
key: "scope",
label: t("filter.scope.label"),
description: t("filter.scope.description"),
comparators: [Comparators.EQUALS, Comparators.NOT_EQUALS],
valueType: "radio",
valueProvider: async () => {
const {VALUES} = useValues("executions");
return VALUES.SCOPES;
{
key: "scope",
label: t("filter.scope.label"),
description: t("filter.scope.description"),
comparators: [Comparators.EQUALS, Comparators.NOT_EQUALS],
valueType: "radio",
valueProvider: async () => {
const {VALUES} = useValues("executions");
return VALUES.SCOPES;
},
showComparatorSelection: false
},
showComparatorSelection: false
},
{
key: "childFilter",
label: t("filter.childFilter.label"),
description: t("filter.childFilter.description"),
comparators: [Comparators.EQUALS],
valueType: "radio",
valueProvider: async () => {
const {VALUES} = useValues("executions");
return VALUES.CHILDS;
{
key: "childFilter",
label: t("filter.childFilter.label"),
description: t("filter.childFilter.description"),
comparators: [Comparators.EQUALS],
valueType: "radio",
valueProvider: async () => {
const {VALUES} = useValues("executions");
return VALUES.CHILDS;
}
},
{
key: "timeRange",
label: t("filter.timeRange.label"),
description: t("filter.timeRange.description"),
comparators: [Comparators.EQUALS],
valueType: "select",
valueProvider: async () => {
const {VALUES} = useValues("executions");
return VALUES.RELATIVE_DATE;
}
},
{
key: "labels",
label: t("filter.labels_execution.label"),
description: t("filter.labels_execution.description"),
comparators: [Comparators.EQUALS, Comparators.NOT_EQUALS],
valueType: "text",
},
{
key: "triggerExecutionId",
label: t("filter.triggerExecutionId.label"),
description: t("filter.triggerExecutionId.description"),
comparators: [
Comparators.EQUALS,
Comparators.NOT_EQUALS,
Comparators.CONTAINS,
Comparators.STARTS_WITH,
Comparators.ENDS_WITH
],
valueType: "text",
searchable: true
}
},
{
key: "timeRange",
label: t("filter.timeRange.label"),
description: t("filter.timeRange.description"),
comparators: [Comparators.EQUALS],
valueType: "select",
valueProvider: async () => {
const {VALUES} = useValues("executions");
return VALUES.RELATIVE_DATE;
}
},
{
key: "labels",
label: t("filter.labels_execution.label"),
description: t("filter.labels_execution.description"),
comparators: [Comparators.EQUALS, Comparators.NOT_EQUALS],
valueType: "text",
},
{
key: "triggerExecutionId",
label: t("filter.triggerExecutionId.label"),
description: t("filter.triggerExecutionId.description"),
comparators: [
Comparators.EQUALS,
Comparators.NOT_EQUALS,
Comparators.CONTAINS,
Comparators.STARTS_WITH,
Comparators.ENDS_WITH
],
valueType: "text",
searchable: true
}
]
};
});
]
};
});
};

View File

@@ -3,90 +3,92 @@ import {FilterConfiguration, Comparators} from "../utils/filterTypes";
import {useValues} from "../composables/useValues";
import {useI18n} from "vue-i18n";
export const useFlowExecutionFilter = (): ComputedRef<FilterConfiguration> => computed(() => {
export const useFlowExecutionFilter = (): ComputedRef<FilterConfiguration> => {
const {t} = useI18n();
return {
title: t("filter.titles.flow_execution_filters"),
searchPlaceholder: t("filter.search_placeholders.search_executions"),
keys: [
{
key: "state",
label: t("filter.state.label"),
description: t("filter.state.description"),
comparators: [Comparators.IN, Comparators.NOT_IN],
valueType: "multi-select",
valueProvider: async () => {
const {VALUES} = useValues("executions");
return VALUES.EXECUTION_STATES;
}
},
{
key: "scope",
label: t("filter.scope.label"),
description: t("filter.scope.description"),
comparators: [Comparators.EQUALS, Comparators.NOT_EQUALS],
valueType: "radio",
valueProvider: async () => {
const {VALUES} = useValues("executions");
return VALUES.SCOPES;
return computed(() => {
return {
title: t("filter.titles.flow_execution_filters"),
searchPlaceholder: t("filter.search_placeholders.search_executions"),
keys: [
{
key: "state",
label: t("filter.state.label"),
description: t("filter.state.description"),
comparators: [Comparators.IN, Comparators.NOT_IN],
valueType: "multi-select",
valueProvider: async () => {
const {VALUES} = useValues("executions");
return VALUES.EXECUTION_STATES;
}
},
showComparatorSelection: false
},
{
key: "childFilter",
label: t("filter.childFilter.label"),
description: t("filter.childFilter.description"),
comparators: [Comparators.EQUALS],
valueType: "radio",
valueProvider: async () => {
const {VALUES} = useValues("executions");
return VALUES.CHILDS;
{
key: "scope",
label: t("filter.scope.label"),
description: t("filter.scope.description"),
comparators: [Comparators.EQUALS, Comparators.NOT_EQUALS],
valueType: "radio",
valueProvider: async () => {
const {VALUES} = useValues("executions");
return VALUES.SCOPES;
},
showComparatorSelection: false
},
{
key: "childFilter",
label: t("filter.childFilter.label"),
description: t("filter.childFilter.description"),
comparators: [Comparators.EQUALS],
valueType: "radio",
valueProvider: async () => {
const {VALUES} = useValues("executions");
return VALUES.CHILDS;
}
},
{
key: "kind",
label: t("filter.kind.label"),
description: t("filter.kind.description"),
comparators: [Comparators.EQUALS],
valueType: "radio",
valueProvider: async () => {
const {VALUES} = useValues("executions");
return VALUES.KINDS;
}
},
{
key: "timeRange",
label: t("filter.timeRange.label"),
description: t("filter.timeRange.description"),
comparators: [Comparators.EQUALS],
valueType: "select",
valueProvider: async () => {
const {VALUES} = useValues("executions");
return VALUES.RELATIVE_DATE;
}
},
{
key: "labels",
label: t("filter.labels_execution.label"),
description: t("filter.labels_execution.description"),
comparators: [Comparators.EQUALS, Comparators.NOT_EQUALS],
valueType: "text",
},
{
key: "triggerExecutionId",
label: t("filter.triggerExecutionId.label"),
description: t("filter.triggerExecutionId.description"),
comparators: [
Comparators.EQUALS,
Comparators.NOT_EQUALS,
Comparators.CONTAINS,
Comparators.STARTS_WITH,
Comparators.ENDS_WITH
],
valueType: "text",
searchable: true
}
},
{
key: "kind",
label: t("filter.kind.label"),
description: t("filter.kind.description"),
comparators: [Comparators.EQUALS],
valueType: "radio",
valueProvider: async () => {
const {VALUES} = useValues("executions");
return VALUES.KINDS;
}
},
{
key: "timeRange",
label: t("filter.timeRange.label"),
description: t("filter.timeRange.description"),
comparators: [Comparators.EQUALS],
valueType: "select",
valueProvider: async () => {
const {VALUES} = useValues("executions");
return VALUES.RELATIVE_DATE;
}
},
{
key: "labels",
label: t("filter.labels_execution.label"),
description: t("filter.labels_execution.description"),
comparators: [Comparators.EQUALS, Comparators.NOT_EQUALS],
valueType: "text",
},
{
key: "triggerExecutionId",
label: t("filter.triggerExecutionId.label"),
description: t("filter.triggerExecutionId.description"),
comparators: [
Comparators.EQUALS,
Comparators.NOT_EQUALS,
Comparators.CONTAINS,
Comparators.STARTS_WITH,
Comparators.ENDS_WITH
],
valueType: "text",
searchable: true
}
]
};
});
]
};
});
};

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,53 @@ 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(() => {
export const useKvFilter = (): ComputedRef<FilterConfiguration> => {
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 computed(() => {
return {
title: t("filter.titles.kv_filters"),
searchPlaceholder: t("filter.search_placeholders.search_kv"),
keys: [
...(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
}
return [];
},
searchable: true
}
],
};
});
] : []) as any,
],
};
});
};

View File

@@ -3,24 +3,26 @@ import {FilterConfiguration, Comparators} from "../utils/filterTypes";
import {useValues} from "../composables/useValues";
import {useI18n} from "vue-i18n";
export const useLogExecutionsFilter = (): ComputedRef<FilterConfiguration> => computed(() => {
export const useLogExecutionsFilter = (): ComputedRef<FilterConfiguration> => {
const {t} = useI18n();
return {
title: t("filter.titles.log_filters"),
searchPlaceholder: t("filter.search_placeholders.search_logs"),
keys: [
{
key: "level",
label: t("filter.level.label"),
description: t("filter.level.description"),
comparators: [Comparators.EQUALS, Comparators.NOT_EQUALS],
valueType: "select",
valueProvider: async () => {
const {VALUES} = useValues("logs");
return VALUES.LEVELS;
},
}
]
};
});
return computed(() => {
return {
title: t("filter.titles.log_filters"),
searchPlaceholder: t("filter.search_placeholders.search_logs"),
keys: [
{
key: "level",
label: t("filter.level.label"),
description: t("filter.level.description"),
comparators: [Comparators.EQUALS, Comparators.NOT_EQUALS],
valueType: "select",
valueProvider: async () => {
const {VALUES} = useValues("logs");
return VALUES.LEVELS;
},
}
]
};
});
};

View File

@@ -6,108 +6,114 @@ 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(() => {
export const useLogFilter = (): ComputedRef<FilterConfiguration> => {
const {t} = useI18n();
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 [];
},
searchable: true
},
{
key: "level",
label: t("filter.level.label"),
description: t("filter.level.description"),
comparators: [Comparators.EQUALS, Comparators.NOT_EQUALS],
valueType: "select",
valueProvider: async () => {
const {VALUES} = useValues("logs");
return VALUES.LEVELS;
},
showComparatorSelection: true
},
{
key: "timeRange",
label: t("filter.timeRange_log.label"),
description: t("filter.timeRange_log.description"),
comparators: [Comparators.EQUALS],
valueType: "select",
valueProvider: async () => {
const {VALUES} = useValues("logs");
return VALUES.RELATIVE_DATE;
}
},
{
key: "scope",
label: t("filter.scope_log.label"),
description: t("filter.scope_log.description"),
comparators: [Comparators.EQUALS, Comparators.NOT_EQUALS],
valueType: "radio",
valueProvider: async () => {
const {VALUES} = useValues("logs");
return VALUES.SCOPES;
},
showComparatorSelection: false
},
{
key: "triggerId",
label: t("filter.triggerId.label"),
description: t("filter.triggerId.description"),
comparators: [
// Comparators.IN,
// Comparators.NOT_IN,
Comparators.EQUALS,
Comparators.NOT_EQUALS,
Comparators.CONTAINS,
Comparators.STARTS_WITH,
Comparators.ENDS_WITH
],
valueType: "text",
},
{
key: "flowId",
label: t("filter.flowId.label"),
description: t("filter.flowId.description"),
comparators: [
Comparators.EQUALS,
Comparators.NOT_EQUALS,
Comparators.CONTAINS,
Comparators.STARTS_WITH,
Comparators.ENDS_WITH,
],
valueType: "text",
},
]
};
});
const route = useRoute();
return computed(() => {
return {
title: t("filter.titles.log_filters"),
searchPlaceholder: t("filter.search_placeholders.search_logs"),
keys: [
...(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
},
] : []) as any,
{
key: "level",
label: t("filter.level.label"),
description: t("filter.level.description"),
comparators: [Comparators.EQUALS, Comparators.NOT_EQUALS],
valueType: "select",
valueProvider: async () => {
const {VALUES} = useValues("logs");
return VALUES.LEVELS;
},
showComparatorSelection: true
},
{
key: "timeRange",
label: t("filter.timeRange_log.label"),
description: t("filter.timeRange_log.description"),
comparators: [Comparators.EQUALS],
valueType: "select",
valueProvider: async () => {
const {VALUES} = useValues("logs");
return VALUES.RELATIVE_DATE;
}
},
{
key: "scope",
label: t("filter.scope_log.label"),
description: t("filter.scope_log.description"),
comparators: [Comparators.EQUALS, Comparators.NOT_EQUALS],
valueType: "radio",
valueProvider: async () => {
const {VALUES} = useValues("logs");
return VALUES.SCOPES;
},
showComparatorSelection: false
},
{
key: "triggerId",
label: t("filter.triggerId.label"),
description: t("filter.triggerId.description"),
comparators: [
// Comparators.IN,
// Comparators.NOT_IN,
Comparators.EQUALS,
Comparators.NOT_EQUALS,
Comparators.CONTAINS,
Comparators.STARTS_WITH,
Comparators.ENDS_WITH
],
valueType: "text",
},
...(route.name !== "flows/update" ? [{
key: "flowId",
label: t("filter.flowId.label"),
description: t("filter.flowId.description"),
comparators: [
Comparators.EQUALS,
Comparators.NOT_EQUALS,
Comparators.CONTAINS,
Comparators.STARTS_WITH,
Comparators.ENDS_WITH,
],
valueType: "text",
}] : []) as any,
]
};
});
};

View File

@@ -5,94 +5,98 @@ import {useFlowStore} from "../../../stores/flow";
import {useI18n} from "vue-i18n";
import {useExecutionsStore} from "../../../stores/executions";
export const useMetricFilter = (): ComputedRef<FilterConfiguration> => computed(() => {
export const useMetricFilter = (): ComputedRef<FilterConfiguration> => {
const {t} = useI18n();
return {
title: t("filter.titles.metric_filters"),
searchPlaceholder: t("filter.search_placeholders.search_metrics"),
keys: [
{
key: "metric",
label: t("filter.metric.label"),
description: t("filter.metric.description"),
comparators: [Comparators.EQUALS],
valueType: "select",
valueProvider: async () => {
const executionsStore = useExecutionsStore();
const taskRuns = executionsStore.execution?.taskRunList ?? [];
return taskRuns.map(taskRun => ({
label: taskRun.taskId + (taskRun.value ? ` - ${taskRun.value}` : ""),
value: taskRun.id
}));
},
searchable: true
}
]
};
});
return computed(() => {
return {
title: t("filter.titles.metric_filters"),
searchPlaceholder: t("filter.search_placeholders.search_metrics"),
keys: [
{
key: "metric",
label: t("filter.metric.label"),
description: t("filter.metric.description"),
comparators: [Comparators.EQUALS],
valueType: "select",
valueProvider: async () => {
const executionsStore = useExecutionsStore();
const taskRuns = executionsStore.execution?.taskRunList ?? [];
return taskRuns.map(taskRun => ({
label: taskRun.taskId + (taskRun.value ? ` - ${taskRun.value}` : ""),
value: taskRun.id
}));
},
searchable: true
}
]
};
});
};
export const useFlowMetricFilter = (): ComputedRef<FilterConfiguration> => computed(() => {
export const useFlowMetricFilter = (): ComputedRef<FilterConfiguration> => {
const {t} = useI18n();
return {
title: t("filter.titles.flow_metric_filters"),
searchPlaceholder: t("filter.search_placeholders.search_metrics"),
keys: [
{
key: "task",
label: t("filter.task.label"),
description: t("filter.task.description"),
comparators: [
Comparators.EQUALS,
],
valueType: "select",
valueProvider: async () => {
return (useFlowStore().tasksWithMetrics as string[]).map((value) => ({
label: value,
value
}));
return computed(() => {
return {
title: t("filter.titles.flow_metric_filters"),
searchPlaceholder: t("filter.search_placeholders.search_metrics"),
keys: [
{
key: "task",
label: t("filter.task.label"),
description: t("filter.task.description"),
comparators: [
Comparators.EQUALS,
],
valueType: "select",
valueProvider: async () => {
return (useFlowStore().tasksWithMetrics as string[]).map((value) => ({
label: value,
value
}));
},
searchable: true
},
searchable: true
},
{
key: "metric",
label: t("filter.metric.label"),
description: t("filter.metric.description"),
comparators: [
Comparators.EQUALS
],
valueType: "select",
valueProvider: async () => {
return (useFlowStore().metrics as string[]).map((value) => ({
label: value,
value
}));
{
key: "metric",
label: t("filter.metric.label"),
description: t("filter.metric.description"),
comparators: [
Comparators.EQUALS
],
valueType: "select",
valueProvider: async () => {
return (useFlowStore().metrics as string[]).map((value) => ({
label: value,
value
}));
},
searchable: true
},
searchable: true
},
{
key: "aggregation",
label: t("filter.aggregation.label"),
description: t("filter.aggregation.description"),
comparators: [Comparators.EQUALS],
valueType: "select",
valueProvider: async () => {
const {VALUES} = useValues("metrics");
return [...VALUES.AGGREGATIONS, {label: "Count", value: "COUNT"}];
{
key: "aggregation",
label: t("filter.aggregation.label"),
description: t("filter.aggregation.description"),
comparators: [Comparators.EQUALS],
valueType: "select",
valueProvider: async () => {
const {VALUES} = useValues("metrics");
return [...VALUES.AGGREGATIONS, {label: "Count", value: "COUNT"}];
}
},
{
key: "timeRange",
label: t("filter.timeRange_metric.label"),
description: t("filter.timeRange_metric.description"),
comparators: [Comparators.EQUALS],
valueType: "select",
valueProvider: async () => {
const {VALUES} = useValues("metrics");
return VALUES.RELATIVE_DATE;
}
}
},
{
key: "timeRange",
label: t("filter.timeRange_metric.label"),
description: t("filter.timeRange_metric.description"),
comparators: [Comparators.EQUALS],
valueType: "select",
valueProvider: async () => {
const {VALUES} = useValues("metrics");
return VALUES.RELATIVE_DATE;
}
}
]
};
});
]
};
});
};

View File

@@ -2,12 +2,14 @@ import {computed, ComputedRef} from "vue";
import {FilterConfiguration} from "../../../components/filter/utils/filterTypes";
import {useI18n} from "vue-i18n";
export const useNamespacesFilter = (): ComputedRef<FilterConfiguration> => computed(() => {
export const useNamespacesFilter = (): ComputedRef<FilterConfiguration> => {
const {t} = useI18n();
return {
title: t("filter.titles.namespaces_filters"),
searchPlaceholder: t("filter.search_placeholders.search_namespaces"),
keys: [],
};
});
return computed(() => {
return {
title: t("filter.titles.namespace_filters"),
searchPlaceholder: t("filter.search_placeholders.search_namespaces"),
keys: [],
};
});
};

View File

@@ -2,12 +2,14 @@ import {computed, ComputedRef} from "vue";
import {FilterConfiguration} from "../../../components/filter/utils/filterTypes";
import {useI18n} from "vue-i18n";
export const usePluginFilter = (): ComputedRef<FilterConfiguration> => computed(() => {
export const usePluginFilter = (): ComputedRef<FilterConfiguration> => {
const {t} = useI18n();
return {
title: t("filter.titles.plugin_filters"),
searchPlaceholder: t("filter.search_placeholders.search_plugins", {count: 900}),
keys: [],
};
});
return computed(() => {
return {
title: t("filter.titles.plugin_filters"),
searchPlaceholder: t("filter.search_placeholders.search_plugins", {count: 900}),
keys: [],
};
});
};

View File

@@ -5,45 +5,51 @@ 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(() => {
export const useSecretsFilter = (): ComputedRef<FilterConfiguration> => {
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 [];
},
searchable: true
},
],
};
});
return computed(() => {
return {
title: t("filter.titles.secret_filters"),
searchPlaceholder: t("filter.search_placeholders.search_secrets"),
keys: [
...(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",
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,113 +6,118 @@ 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(() => {
export const useTriggerFilter = (): ComputedRef<FilterConfiguration> => {
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 computed(() => {
return {
title: t("filter.titles.trigger_filters"),
searchPlaceholder: t("filter.search_placeholders.search_triggers"),
keys: [
...(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,
...(route.name !== "flows/update" ? [{
key: "flowId",
label: t("filter.flowId.label"),
description: t("filter.flowId.description"),
comparators: [
Comparators.EQUALS,
Comparators.NOT_EQUALS,
Comparators.CONTAINS,
Comparators.STARTS_WITH,
Comparators.ENDS_WITH,
],
valueType: "text",
}] : []) as any,
{
key: "timeRange",
label: t("filter.timeRange_trigger.label"),
description: t("filter.timeRange_trigger.description"),
comparators: [Comparators.EQUALS],
valueType: "select",
valueProvider: async () => {
const {VALUES} = useValues("triggers");
return VALUES.RELATIVE_DATE;
}
return [];
},
searchable: true
},
{
key: "flowId",
label: t("filter.flowId.label"),
description: t("filter.flowId.description"),
comparators: [
Comparators.EQUALS,
Comparators.NOT_EQUALS,
Comparators.CONTAINS,
Comparators.STARTS_WITH,
Comparators.ENDS_WITH,
],
valueType: "text",
},
{
key: "timeRange",
label: t("filter.timeRange_trigger.label"),
description: t("filter.timeRange_trigger.description"),
comparators: [Comparators.EQUALS],
valueType: "select",
valueProvider: async () => {
const {VALUES} = useValues("triggers");
return VALUES.RELATIVE_DATE;
{
key: "scope",
label: t("filter.scope_trigger.label"),
description: t("filter.scope_trigger.description"),
comparators: [Comparators.EQUALS, Comparators.NOT_EQUALS],
valueType: "radio",
valueProvider: async () => {
const {VALUES} = useValues("triggers");
return VALUES.SCOPES;
},
showComparatorSelection: false
},
{
key: "triggerId",
label: t("filter.triggerId_trigger.label"),
description: t("filter.triggerId_trigger.description"),
comparators: [
Comparators.IN,
Comparators.NOT_IN,
Comparators.EQUALS,
Comparators.NOT_EQUALS,
Comparators.CONTAINS,
Comparators.STARTS_WITH,
Comparators.ENDS_WITH
],
valueType: "text",
},
{
key: "workerId",
label: t("filter.workerId.label"),
description: t("filter.workerId.description"),
comparators: [
Comparators.IN,
Comparators.NOT_IN,
Comparators.EQUALS,
Comparators.NOT_EQUALS,
Comparators.CONTAINS,
Comparators.STARTS_WITH,
Comparators.ENDS_WITH
],
valueType: "text",
searchable: true,
}
},
{
key: "scope",
label: t("filter.scope_trigger.label"),
description: t("filter.scope_trigger.description"),
comparators: [Comparators.EQUALS, Comparators.NOT_EQUALS],
valueType: "radio",
valueProvider: async () => {
const {VALUES} = useValues("triggers");
return VALUES.SCOPES;
},
showComparatorSelection: false
},
{
key: "triggerId",
label: t("filter.triggerId_trigger.label"),
description: t("filter.triggerId_trigger.description"),
comparators: [
Comparators.IN,
Comparators.NOT_IN,
Comparators.EQUALS,
Comparators.NOT_EQUALS,
Comparators.CONTAINS,
Comparators.STARTS_WITH,
Comparators.ENDS_WITH
],
valueType: "text",
},
{
key: "workerId",
label: t("filter.workerId.label"),
description: t("filter.workerId.description"),
comparators: [
Comparators.IN,
Comparators.NOT_IN,
Comparators.EQUALS,
Comparators.NOT_EQUALS,
Comparators.CONTAINS,
Comparators.STARTS_WITH,
Comparators.ENDS_WITH
],
valueType: "text",
// valueProvider: async () => {},
searchable: true,
}
]
};
});
]
};
});
};

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

@@ -304,6 +304,7 @@
const suggestWidgetResizeObserver = ref<MutationObserver>()
const suggestWidgetObserver = ref<MutationObserver>()
const suggestWidget = ref<HTMLElement>()
const resizeObserver = ref<ResizeObserver>()
defineExpose({
focus,
@@ -871,6 +872,20 @@
setTimeout(() => monaco.editor.remeasureFonts(), 1)
emit("editorDidMount", editorResolved.value);
/* Hhandle resizing. */
resizeObserver.value = new ResizeObserver(() => {
if (localEditor.value) {
localEditor.value.layout();
}
if (localDiffEditor.value) {
localDiffEditor.value.getModifiedEditor().layout();
localDiffEditor.value.getOriginalEditor().layout();
}
});
if (editorRef.value) {
resizeObserver.value.observe(editorRef.value);
}
highlightLine();
}
@@ -928,6 +943,8 @@
function destroy() {
disposeObservers();
disposeCompletions.value?.();
resizeObserver.value?.disconnect();
resizeObserver.value = undefined;
if (localDiffEditor.value !== undefined) {
localDiffEditor.value?.dispose();
localDiffEditor.value?.getModel()?.modified?.dispose();

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

@@ -53,10 +53,11 @@
if (isChecked(label)) {
const replacementQuery = {...route.query};
delete replacementQuery[getKey(label.key)];
replacementQuery.page = "1";
router.replace({query: replacementQuery});
} else {
router.replace({
query: {...route.query, [getKey(label.key)]: label.value},
query: {...route.query, [getKey(label.key)]: label.value, page: "1"},
});
}
};

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

@@ -13,7 +13,6 @@ export interface Tab {
locked?: boolean;
disabled?: boolean;
maximized?: boolean;
name: string;
title: string;
component: Component;
@@ -76,20 +75,19 @@ export function useHelpers() {
},
disabled: index === parts.value.length - 1,
})),
] ,
],
}));
const tabs: Tab[] = [
// If it's a system namespace, include the blueprints tab
...(namespace.value === "system"
? [
{
name: "blueprints",
title: t("blueprints.title"),
component: BlueprintsBrowser,
props: {tab: "community", system: true},
},
]
...(namespace.value === "system" ? [
{
name: "blueprints",
title: t("blueprints.title"),
component: BlueprintsBrowser,
props: {tab: "community", system: true},
},
]
: []),
{
name: "overview",
@@ -111,6 +109,7 @@ export function useHelpers() {
namespace: namespace.value,
topbar: false,
visibleCharts: true,
embed: false,
},
},
{

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

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