Compare commits

...

74 Commits

Author SHA1 Message Date
github-actions[bot]
5f6a1cf377 chore(version): update to version '1.1.5' 2025-11-25 12:53:19 +00:00
Miloš Paunović
420e081c69 fix(core): redirect welcome page action button to flow creation in the enterprise edition (#13136)
Closes https://github.com/kestra-io/kestra-ee/issues/5933.
2025-11-25 08:15:49 +01:00
Loïc Mathieu
0a7fffe1c5 fix(system): WorkerTask should not FAILED when interrupting so they would be resubmitted
When a Worker is stopping, it will first wait for all running tasks to stop, then kill them. For those that didn't implement kill their thread would be interrupted.

But if the task is properly killed, or support interrupts (like the Sleep task), it would ends in FAILED then a WorkerTaskWould be send that would fail the flow preventing the WorkerTask to be resubmitted.

We nows check if the worker is terminating and should resubmit, in this case we didn't emit any WorkerTaskResult

Fixes #13108
Part-of: https://github.com/kestra-io/kestra-ee/issues/5556
2025-11-24 12:26:37 +01:00
Bart Ledoux
48d14c9ed9 make restoreurl work again 2025-11-20 16:00:59 +01:00
Bart Ledoux
21a42a072a refactor: avoid a few dev warning and errors 2025-11-20 15:57:39 +01:00
Bart Ledoux
4f48ea0c21 refactor: remove build warning 2025-11-20 15:51:07 +01:00
Barthélémy Ledoux
890fa791e8 fix: add defaultScope and defaultTimeRange props to various components (#13097) 2025-11-20 15:44:12 +01:00
Barthélémy Ledoux
5e57de5cdf test(e2e): make e2e tests pass again with restoreUrl (#12887) 2025-11-20 15:41:10 +01:00
Bart Ledoux
cf2c6cd2b1 Revert "fix(core): bring the usage of restore url (#12762)"
This reverts commit 559f3f2634.
2025-11-20 15:38:13 +01:00
Piyush Bhaskar
b688dbc30b fix(core): clear the selection properly and refactor (#13012) 2025-11-20 18:10:14 +05:30
YannC
40877cc1cc fix: make sure variables from ExecutionTrigger has AdditionalPropertiesValue to true (#13096) 2025-11-20 11:36:11 +01:00
Loïc Mathieu
c0f178a159 fix(execution): improve property skip cache
When using Property.ofExpression(), the cache should never be used as this is usually used as providing a default value inside a task, which can change from rendering to rendering as it's an expression.

Also retain skipCache in a boolean so it can be rendered more than 2 times ans still skip the cache.

It should prevent future issues like #13027
2025-11-20 10:40:09 +01:00
YannC
c64a083ac7 chore(API): apiResponse annotation for type return (#13088) 2025-11-20 09:48:09 +01:00
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
136 changed files with 2881 additions and 1995 deletions

View File

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

View File

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

View File

@@ -1,7 +1,9 @@
package io.kestra.cli.commands.servers; package io.kestra.cli.commands.servers;
import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableMap;
import io.kestra.cli.services.TenantIdSelectorService;
import io.kestra.core.models.ServerType; import io.kestra.core.models.ServerType;
import io.kestra.core.repositories.LocalFlowRepositoryLoader;
import io.kestra.core.runners.ExecutorInterface; import io.kestra.core.runners.ExecutorInterface;
import io.kestra.core.services.SkipExecutionService; import io.kestra.core.services.SkipExecutionService;
import io.kestra.core.services.StartExecutorService; import io.kestra.core.services.StartExecutorService;
@@ -10,6 +12,8 @@ import io.micronaut.context.ApplicationContext;
import jakarta.inject.Inject; import jakarta.inject.Inject;
import picocli.CommandLine; import picocli.CommandLine;
import java.io.File;
import java.io.IOException;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -19,6 +23,9 @@ import java.util.Map;
description = "Start the Kestra executor" description = "Start the Kestra executor"
) )
public class ExecutorCommand extends AbstractServerCommand { public class ExecutorCommand extends AbstractServerCommand {
@CommandLine.Spec
CommandLine.Model.CommandSpec spec;
@Inject @Inject
private ApplicationContext applicationContext; private ApplicationContext applicationContext;
@@ -28,22 +35,28 @@ public class ExecutorCommand extends AbstractServerCommand {
@Inject @Inject
private StartExecutorService startExecutorService; 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(); 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(); 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(); 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(); 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(); 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(); private List<String> notStartExecutors = Collections.emptyList();
@SuppressWarnings("unused") @SuppressWarnings("unused")
@@ -64,6 +77,16 @@ public class ExecutorCommand extends AbstractServerCommand {
super.call(); 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); ExecutorInterface executorService = applicationContext.getBean(ExecutorInterface.class);
executorService.run(); executorService.run();

View File

@@ -23,7 +23,7 @@ public class IndexerCommand extends AbstractServerCommand {
@Inject @Inject
private SkipExecutionService skipExecutionService; 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(); private List<String> skipIndexerRecords = Collections.emptyList();
@SuppressWarnings("unused") @SuppressWarnings("unused")

View File

@@ -42,7 +42,7 @@ public class StandAloneCommand extends AbstractServerCommand {
@Nullable @Nullable
private FileChangedEventListener fileWatcher; 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; private File flowPath;
@CommandLine.Option(names = "--tenant", description = "Tenant identifier, Required to load flows from path with the enterprise edition") @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.") @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(); 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(); 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(); 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(); 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(); 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(); private List<String> skipIndexerRecords = Collections.emptyList();
@CommandLine.Option(names = {"--no-tutorials"}, description = "Flag to disable auto-loading of tutorial flows.") @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.") @Option(names = {"--no-indexer"}, description = "Flag to disable starting an embedded indexer.")
private boolean indexerDisabled = false; 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(); private List<String> skipIndexerRecords = Collections.emptyList();
@Override @Override

View File

@@ -30,15 +30,15 @@ micronaut:
read-idle-timeout: 60m read-idle-timeout: 60m
write-idle-timeout: 60m write-idle-timeout: 60m
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: responses:
file: file:
cache-seconds: 86400 cache-seconds: 86400
cache-control: cache-control:
public: true 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 log configuration, see https://docs.micronaut.io/latest/guide/index.html#accessLogger
access-logger: access-logger:

View File

@@ -68,7 +68,8 @@ class NoConfigCommandTest {
assertThat(exitCode).isNotZero(); 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"); 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.models.dashboards.filters.AbstractFilter;
import io.kestra.core.repositories.QueryBuilderInterface; import io.kestra.core.repositories.QueryBuilderInterface;
import io.kestra.plugin.core.dashboard.data.IData; 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.NotBlank;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern; 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) @Pattern(regexp = JAVA_IDENTIFIER_REGEX)
private String type; private String type;
@Valid
private Map<String, C> columns; private Map<String, C> columns;
@Setter @Setter
@Valid
@Nullable
private List<AbstractFilter<F>> where; private List<AbstractFilter<F>> where;
private List<OrderBy> orderBy; 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.ChartOption;
import io.kestra.core.models.dashboards.DataFilter; import io.kestra.core.models.dashboards.DataFilter;
import io.kestra.core.validations.DataChartValidation; import io.kestra.core.validations.DataChartValidation;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import lombok.Getter; import lombok.Getter;
@@ -20,6 +21,7 @@ import lombok.experimental.SuperBuilder;
@DataChartValidation @DataChartValidation
public abstract class DataChart<P extends ChartOption, D extends DataFilter<?, ?>> extends Chart<P> implements io.kestra.core.models.Plugin { public abstract class DataChart<P extends ChartOption, D extends DataFilter<?, ?>> extends Chart<P> implements io.kestra.core.models.Plugin {
@NotNull @NotNull
@Valid
private D data; private D data;
public Integer minNumberOfAggregations() { public Integer minNumberOfAggregations() {

View File

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

View File

@@ -1,15 +1,16 @@
package io.kestra.core.models.executions; package io.kestra.core.models.executions;
import io.micronaut.core.annotation.Introspected;
import lombok.Builder;
import lombok.Value;
import io.kestra.core.models.tasks.Output; import io.kestra.core.models.tasks.Output;
import io.kestra.core.models.triggers.AbstractTrigger; import io.kestra.core.models.triggers.AbstractTrigger;
import io.micronaut.core.annotation.Introspected;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Builder;
import lombok.Value;
import java.net.URI; import java.net.URI;
import java.util.Collections; import java.util.Collections;
import java.util.Map; import java.util.Map;
import jakarta.validation.constraints.NotNull;
@Value @Value
@Builder @Builder
@@ -21,6 +22,7 @@ public class ExecutionTrigger {
@NotNull @NotNull
String type; String type;
@Schema(type = "object", additionalProperties = Schema.AdditionalPropertiesValue.TRUE)
Map<String, Object> variables; Map<String, Object> variables;
URI logFile; URI logFile;

View File

@@ -35,7 +35,6 @@ import static io.kestra.core.utils.Rethrow.throwFunction;
@JsonDeserialize(using = Property.PropertyDeserializer.class) @JsonDeserialize(using = Property.PropertyDeserializer.class)
@JsonSerialize(using = Property.PropertySerializer.class) @JsonSerialize(using = Property.PropertySerializer.class)
@Builder @Builder
@NoArgsConstructor
@AllArgsConstructor(access = AccessLevel.PACKAGE) @AllArgsConstructor(access = AccessLevel.PACKAGE)
@Schema( @Schema(
oneOf = { oneOf = {
@@ -51,6 +50,7 @@ public class Property<T> {
.copy() .copy()
.configure(SerializationFeature.WRITE_DURATIONS_AS_TIMESTAMPS, false); .configure(SerializationFeature.WRITE_DURATIONS_AS_TIMESTAMPS, false);
private final boolean skipCache;
private String expression; private String expression;
private T value; private T value;
@@ -60,13 +60,23 @@ public class Property<T> {
@Deprecated @Deprecated
// Note: when not used, this constructor would not be deleted but made private so it can only be used by ofExpression(String) and the deserializer // Note: when not used, this constructor would not be deleted but made private so it can only be used by ofExpression(String) and the deserializer
public Property(String expression) { public Property(String expression) {
this.expression = expression; this(expression, false);
} }
private Property(String expression, boolean skipCache) {
this.expression = expression;
this.skipCache = skipCache;
}
/**
* @deprecated use {@link #ofValue(Object)} instead.
*/
@VisibleForTesting @VisibleForTesting
@Deprecated
public Property(Map<?, ?> map) { public Property(Map<?, ?> map) {
try { try {
expression = MAPPER.writeValueAsString(map); expression = MAPPER.writeValueAsString(map);
this.skipCache = false;
} catch (JsonProcessingException e) { } catch (JsonProcessingException e) {
throw new IllegalArgumentException(e); throw new IllegalArgumentException(e);
} }
@@ -79,9 +89,6 @@ public class Property<T> {
/** /**
* Returns a new {@link Property} with no cached rendered value, * Returns a new {@link Property} with no cached rendered value,
* so that the next render will evaluate its original Pebble expression. * so that the next render will evaluate its original Pebble expression.
* <p>
* The returned property will still cache its rendered result.
* To re-evaluate on a subsequent render, call {@code skipCache()} again.
* *
* @return a new {@link Property} without a pre-rendered value * @return a new {@link Property} without a pre-rendered value
*/ */
@@ -133,6 +140,7 @@ public class Property<T> {
/** /**
* Build a new Property object with a Pebble expression.<br> * Build a new Property object with a Pebble expression.<br>
* This property object will not cache its rendered value.
* <p> * <p>
* Use {@link #ofValue(Object)} to build a property with a value instead. * Use {@link #ofValue(Object)} to build a property with a value instead.
*/ */
@@ -142,11 +150,11 @@ public class Property<T> {
throw new IllegalArgumentException("'expression' must be a valid Pebble expression"); throw new IllegalArgumentException("'expression' must be a valid Pebble expression");
} }
return new Property<>(expression); return new Property<>(expression, true);
} }
/** /**
* Render a property then convert it to its target type.<br> * Render a property, then convert it to its target type.<br>
* <p> * <p>
* This method is designed to be used only by the {@link io.kestra.core.runners.RunContextProperty}. * This method is designed to be used only by the {@link io.kestra.core.runners.RunContextProperty}.
* *
@@ -164,7 +172,7 @@ public class Property<T> {
* @see io.kestra.core.runners.RunContextProperty#as(Class, Map) * @see io.kestra.core.runners.RunContextProperty#as(Class, Map)
*/ */
public static <T> T as(Property<T> property, PropertyContext context, Class<T> clazz, Map<String, Object> variables) throws IllegalVariableEvaluationException { public static <T> T as(Property<T> property, PropertyContext context, Class<T> clazz, Map<String, Object> variables) throws IllegalVariableEvaluationException {
if (property.value == null) { if (property.skipCache || property.value == null) {
String rendered = context.render(property.expression, variables); String rendered = context.render(property.expression, variables);
property.value = MAPPER.convertValue(rendered, clazz); property.value = MAPPER.convertValue(rendered, clazz);
} }
@@ -192,7 +200,7 @@ public class Property<T> {
*/ */
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
public static <T, I> T asList(Property<T> property, PropertyContext context, Class<I> itemClazz, Map<String, Object> variables) throws IllegalVariableEvaluationException { public static <T, I> T asList(Property<T> property, PropertyContext context, Class<I> itemClazz, Map<String, Object> variables) throws IllegalVariableEvaluationException {
if (property.value == null) { if (property.skipCache || property.value == null) {
JavaType type = MAPPER.getTypeFactory().constructCollectionLikeType(List.class, itemClazz); JavaType type = MAPPER.getTypeFactory().constructCollectionLikeType(List.class, itemClazz);
try { try {
String trimmedExpression = property.expression.trim(); String trimmedExpression = property.expression.trim();
@@ -244,7 +252,7 @@ public class Property<T> {
*/ */
@SuppressWarnings({"rawtypes", "unchecked"}) @SuppressWarnings({"rawtypes", "unchecked"})
public static <T, K, V> T asMap(Property<T> property, RunContext runContext, Class<K> keyClass, Class<V> valueClass, Map<String, Object> variables) throws IllegalVariableEvaluationException { public static <T, K, V> T asMap(Property<T> property, RunContext runContext, Class<K> keyClass, Class<V> valueClass, Map<String, Object> variables) throws IllegalVariableEvaluationException {
if (property.value == null) { if (property.skipCache || property.value == null) {
JavaType targetMapType = MAPPER.getTypeFactory().constructMapType(Map.class, keyClass, valueClass); JavaType targetMapType = MAPPER.getTypeFactory().constructMapType(Map.class, keyClass, valueClass);
try { try {

View File

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

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 // 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); Map<String, String> trigger = (Map<String, String>) context.getVariable(TRIGGER);
if (!isFileUriValid(trigger.get(NAMESPACE), trigger.get("flowId"), trigger.get("executionId"), path)) { return 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 false; return false;
} }

View File

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

View File

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

View File

@@ -20,8 +20,6 @@ import java.io.BufferedOutputStream;
import java.io.File; import java.io.File;
import java.io.FileOutputStream; import java.io.FileOutputStream;
import java.net.URI; import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.atomic.AtomicReference; 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> { public class Download extends AbstractHttp implements RunnableTask<Download.Output> {
@Schema(title = "Should the task fail when downloading an empty file.") @Schema(title = "Should the task fail when downloading an empty file.")
@Builder.Default @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 { public Output run(RunContext runContext) throws Exception {
Logger logger = runContext.logger(); Logger logger = runContext.logger();
@@ -111,20 +117,22 @@ public class Download extends AbstractHttp implements RunnableTask<Download.Outp
} }
} }
String filename = null; String rFilename = runContext.render(this.saveAs).as(String.class).orElse(null);
if (response.getHeaders().firstValue("Content-Disposition").isPresent()) { if (rFilename == null) {
String contentDisposition = response.getHeaders().firstValue("Content-Disposition").orElseThrow(); if (response.getHeaders().firstValue("Content-Disposition").isPresent()) {
filename = filenameFromHeader(runContext, contentDisposition); String contentDisposition = response.getHeaders().firstValue("Content-Disposition").orElseThrow();
} rFilename = filenameFromHeader(runContext, contentDisposition);
if (filename != null) { if (rFilename != null) {
filename = URLEncoder.encode(filename, StandardCharsets.UTF_8); rFilename = rFilename.replace(' ', '+');
}
}
} }
logger.debug("File '{}' downloaded with size '{}'", from, size); logger.debug("File '{}' downloaded with size '{}'", from, size);
return Output.builder() return Output.builder()
.code(response.getStatus().getCode()) .code(response.getStatus().getCode())
.uri(runContext.storage().putFile(tempFile, filename)) .uri(runContext.storage().putFile(tempFile, rFilename))
.headers(response.getHeaders().map()) .headers(response.getHeaders().map())
.length(size.get()) .length(size.get())
.build(); .build();

View File

@@ -267,6 +267,18 @@ public abstract class AbstractRunnerTest {
multipleConditionTriggerCaseTest.flowTriggerMultiplePreconditions(); 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 @Test
@LoadFlows({"flows/valids/each-null.yaml"}) @LoadFlows({"flows/valids/each-null.yaml"})
void eachWithNull() throws Exception { void eachWithNull() throws Exception {

View File

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

View File

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

View File

@@ -212,4 +212,44 @@ public class MultipleConditionTriggerCaseTest {
e -> e.getState().getCurrent().equals(Type.SUCCESS), e -> e.getState().getCurrent().equals(Type.SUCCESS),
MAIN_TENANT, "io.kestra.tests.trigger.multiple.preconditions", "flow-trigger-multiple-preconditions-flow-listen", Duration.ofSeconds(1))); 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

@@ -101,6 +101,19 @@ class RunContextPropertyTest {
var runContextProperty = new RunContextProperty<>(Property.<String>builder().expression("{{ variable }}").build(), runContext); var runContextProperty = new RunContextProperty<>(Property.<String>builder().expression("{{ variable }}").build(), runContext);
assertThat(runContextProperty.as(String.class, Map.of("variable", "value1"))).isEqualTo(Optional.of("value1")); assertThat(runContextProperty.as(String.class, Map.of("variable", "value1"))).isEqualTo(Optional.of("value1"));
assertThat(runContextProperty.skipCache().as(String.class, Map.of("variable", "value2"))).isEqualTo(Optional.of("value2")); var skippedCache = runContextProperty.skipCache();
assertThat(skippedCache.as(String.class, Map.of("variable", "value2"))).isEqualTo(Optional.of("value2"));
// assure skipCache is preserved across calls
assertThat(skippedCache.as(String.class, Map.of("variable", "value3"))).isEqualTo(Optional.of("value3"));
}
@Test
void asShouldNotReturnCachedRenderedPropertyWithOfExpression() throws IllegalVariableEvaluationException {
var runContext = runContextFactory.of();
var runContextProperty = new RunContextProperty<String>(Property.ofExpression("{{ variable }}"), runContext);
assertThat(runContextProperty.as(String.class, Map.of("variable", "value1"))).isEqualTo(Optional.of("value1"));
assertThat(runContextProperty.as(String.class, Map.of("variable", "value2"))).isEqualTo(Optional.of("value2"));
} }
} }

View File

@@ -112,33 +112,6 @@ public class FileSizeFunctionTest {
assertThat(size).isEqualTo(FILE_SIZE); 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 @Test
void returnsCorrectSize_givenUri_andCurrentExecution() throws IOException, IllegalVariableEvaluationException { void returnsCorrectSize_givenUri_andCurrentExecution() throws IOException, IllegalVariableEvaluationException {
String executionId = IdUtils.create(); String executionId = IdUtils.create();

View File

@@ -259,6 +259,27 @@ class ReadFileFunctionTest {
assertThat(variableRenderer.render("{{ read(nsfile) }}", variables)).isEqualTo("Hello World"); 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 { private URI createFile() throws IOException {
File tempFile = File.createTempFile("file", ".txt"); File tempFile = File.createTempFile("file", ".txt");
Files.write(tempFile.toPath(), "Hello World".getBytes()); 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.repositories.FlowRepositoryInterface;
import io.kestra.core.runners.ConcurrencyLimit; import io.kestra.core.runners.ConcurrencyLimit;
import io.kestra.core.runners.RunnerUtils; import io.kestra.core.runners.RunnerUtils;
import io.kestra.core.utils.TestsUtils;
import jakarta.inject.Inject; import jakarta.inject.Inject;
import jakarta.inject.Named; import jakarta.inject.Named;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance; import org.junit.jupiter.api.TestInstance;
import reactor.core.publisher.Flux;
import java.time.Duration; import java.time.Duration;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException; import java.util.concurrent.TimeoutException;
import static io.kestra.core.utils.Rethrow.throwRunnable; import static io.kestra.core.utils.Rethrow.throwRunnable;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertTrue;
@KestraTest(startRunner = true) @KestraTest(startRunner = true)
@TestInstance(TestInstance.Lifecycle.PER_CLASS) @TestInstance(TestInstance.Lifecycle.PER_CLASS)
@@ -54,14 +58,29 @@ class ConcurrencyLimitServiceTest {
@Test @Test
@LoadFlows("flows/valids/flow-concurrency-queue.yml") @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 // 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"); Execution result = runUntilQueued(TESTS_FLOW_NS, "flow-concurrency-queue");
assertThat(result.getState().isQueued()).isTrue(); 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); Execution unqueued = concurrencyLimitService.unqueue(result, State.Type.RUNNING);
assertThat(unqueued.getState().isRunning()).isTrue(); assertThat(unqueued.getState().isRunning()).isTrue();
executionQueue.emit(unqueued);
assertTrue(terminated.await(10, TimeUnit.SECONDS));
receive.blockLast();
} }
@Test @Test
@@ -73,7 +92,6 @@ class ConcurrencyLimitServiceTest {
assertThat(limit.get().getTenantId()).isEqualTo(execution.getTenantId()); assertThat(limit.get().getTenantId()).isEqualTo(execution.getTenantId());
assertThat(limit.get().getNamespace()).isEqualTo(execution.getNamespace()); assertThat(limit.get().getNamespace()).isEqualTo(execution.getNamespace());
assertThat(limit.get().getFlowId()).isEqualTo(execution.getFlowId()); assertThat(limit.get().getFlowId()).isEqualTo(execution.getFlowId());
assertThat(limit.get().getRunning()).isEqualTo(0);
} }
@Test @Test

View File

@@ -156,6 +156,26 @@ class DownloadTest {
assertThat(output.getUri().toString()).endsWith("filename.jpg"); 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 @Test
void contentDispositionWithPath() throws Exception { void contentDispositionWithPath() throws Exception {
EmbeddedServer embeddedServer = applicationContext.getBean(EmbeddedServer.class); 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); .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 // prevent recursive flow triggers
.filter(flow -> flowService.removeUnwanted(flow, execution)) !flowService.removeUnwanted(flow, execution) ||
// filter out Test Executions // filter out Test Executions
.filter(flow -> execution.getKind() == null) execution.getKind() != null ||
// ensure flow & triggers are enabled // ensure flow & triggers are enabled
.filter(flow -> !flow.isDisabled() && !(flow instanceof FlowWithException)) flow.isDisabled() || flow instanceof FlowWithException ||
.filter(flow -> flow.getTriggers() != null && !flow.getTriggers().isEmpty()) flow.getTriggers() == null || flow.getTriggers().isEmpty()) {
.flatMap(flow -> flowTriggers(flow).map(trigger -> new FlowWithFlowTrigger(flow, trigger))) return Collections.emptyList();
}
return flowTriggers(flow).map(trigger -> new FlowWithFlowTrigger(flow, trigger))
// filter on the execution state the flow listen to // filter on the execution state the flow listen to
.filter(flowWithFlowTrigger -> flowWithFlowTrigger.getTrigger().getStates().contains(execution.getState().getCurrent())) .filter(flowWithFlowTrigger -> flowWithFlowTrigger.getTrigger().getStates().contains(execution.getState().getCurrent()))
// validate flow triggers conditions excluding multiple conditions // validate flow triggers conditions excluding multiple conditions
@@ -74,96 +205,6 @@ public class FlowTriggerService {
execution execution
) )
)).toList(); )).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) { private Stream<MultipleCondition> flowTriggerMultipleConditions(FlowWithFlowTrigger flowWithFlowTrigger) {

View File

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

View File

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

View File

@@ -1,8 +1,10 @@
package io.kestra.repository.mysql; package io.kestra.repository.mysql;
import io.kestra.core.models.triggers.Trigger; import io.kestra.core.models.triggers.Trigger;
import io.kestra.core.runners.ScheduleContextInterface;
import io.kestra.core.utils.DateUtils; import io.kestra.core.utils.DateUtils;
import io.kestra.jdbc.repository.AbstractJdbcTriggerRepository; import io.kestra.jdbc.repository.AbstractJdbcTriggerRepository;
import io.kestra.jdbc.runner.JdbcSchedulerContext;
import io.kestra.jdbc.services.JdbcFilterService; import io.kestra.jdbc.services.JdbcFilterService;
import jakarta.inject.Inject; import jakarta.inject.Inject;
import jakarta.inject.Named; import jakarta.inject.Named;
@@ -11,6 +13,10 @@ import org.jooq.Condition;
import org.jooq.Field; import org.jooq.Field;
import org.jooq.impl.DSL; 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.Date;
import java.util.List; import java.util.List;
@@ -45,4 +51,11 @@ public class MysqlTriggerRepository extends AbstractJdbcTriggerRepository {
throw new IllegalArgumentException("Unsupported GroupType: " + groupType); 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 reactor.core.publisher.FluxSink;
import java.time.ZonedDateTime; import java.time.ZonedDateTime;
import java.time.temporal.Temporal;
import java.util.*; import java.util.*;
import java.util.function.Function; import java.util.function.Function;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -151,7 +152,7 @@ public abstract class AbstractJdbcTriggerRepository extends AbstractJdbcReposito
.select(field("value")) .select(field("value"))
.from(this.jdbcRepository.getTable()) .from(this.jdbcRepository.getTable())
.where( .where(
(field("next_execution_date").lessThan(now.toOffsetDateTime()) (field("next_execution_date").lessThan(toNextExecutionTime(now))
// we check for null for backwards compatibility // we check for null for backwards compatibility
.or(field("next_execution_date").isNull())) .or(field("next_execution_date").isNull()))
.and(field("execution_id").isNull()) .and(field("execution_id").isNull())
@@ -169,7 +170,7 @@ public abstract class AbstractJdbcTriggerRepository extends AbstractJdbcReposito
.select(field("value")) .select(field("value"))
.from(this.jdbcRepository.getTable()) .from(this.jdbcRepository.getTable())
.where( .where(
(field("next_execution_date").lessThan(now.toOffsetDateTime()) (field("next_execution_date").lessThan(toNextExecutionTime(now))
// we check for null for backwards compatibility // we check for null for backwards compatibility
.or(field("next_execution_date").isNull())) .or(field("next_execution_date").isNull()))
.and(field("execution_id").isNotNull()) .and(field("execution_id").isNotNull())
@@ -179,6 +180,10 @@ public abstract class AbstractJdbcTriggerRepository extends AbstractJdbcReposito
.map(r -> this.jdbcRepository.deserialize(r.get("value", String.class)))); .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) { public Trigger save(Trigger trigger, ScheduleContextInterface scheduleContextInterface) {
JdbcSchedulerContext jdbcSchedulerContext = (JdbcSchedulerContext) 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. * 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. * It locked the raw and is wrapped in a transaction, so the consumer should use the provided dslContext for any database access.
* <p> * <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) { public ExecutionRunning countThenProcess(FlowInterface flow, BiFunction<DSLContext, ConcurrencyLimit, Pair<ExecutionRunning, ConcurrencyLimit>> consumer) {
return this.jdbcRepository 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) { public List<ConcurrencyLimit> find(String tenantId) {
return this.jdbcRepository return this.jdbcRepository
@@ -132,8 +132,7 @@ public class AbstractJdbcConcurrencyLimitStorage extends AbstractJdbcRepository
.and(field("namespace").eq(flow.getNamespace())) .and(field("namespace").eq(flow.getNamespace()))
.and(field("flow_id").eq(flow.getId())); .and(field("flow_id").eq(flow.getId()));
return Optional.ofNullable(select.forUpdate().fetchOne()) return this.jdbcRepository.fetchOne(select.forUpdate());
.map(record -> this.jdbcRepository.map(record));
} }
private void update(DSLContext dslContext, ConcurrencyLimit concurrencyLimit) { private void update(DSLContext dslContext, ConcurrencyLimit concurrencyLimit) {

View File

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

View File

@@ -424,7 +424,7 @@ public class JdbcExecutor implements ExecutorInterface {
MultipleConditionEvent multipleConditionEvent = either.getLeft(); MultipleConditionEvent multipleConditionEvent = either.getLeft();
flowTriggerService.computeExecutionsFromFlowTriggers(multipleConditionEvent.execution(), List.of(multipleConditionEvent.flow()), Optional.of(multipleConditionStorage)) flowTriggerService.computeExecutionsFromFlowTriggerPreconditions(multipleConditionEvent.execution(), multipleConditionEvent.flow(), multipleConditionStorage)
.forEach(exec -> { .forEach(exec -> {
try { try {
executionQueue.emit(exec); executionQueue.emit(exec);
@@ -1230,8 +1230,10 @@ public class JdbcExecutor implements ExecutorInterface {
private void processFlowTriggers(Execution execution) throws QueueException { private void processFlowTriggers(Execution execution) throws QueueException {
// directly process simple conditions // directly process simple conditions
flowTriggerService.withFlowTriggersOnly(allFlows.stream()) flowTriggerService.withFlowTriggersOnly(allFlows.stream())
.filter(f ->ListUtils.emptyOnNull(f.getTrigger().getConditions()).stream().noneMatch(c -> c instanceof MultipleCondition) && f.getTrigger().getPreconditions() == null) .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()) .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))); .forEach(throwConsumer(exec -> executionQueue.emit(exec)));
// send multiple conditions to the multiple condition queue for later processing // 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.models.triggers.Trigger;
import io.kestra.core.repositories.TriggerRepositoryInterface; import io.kestra.core.repositories.TriggerRepositoryInterface;
import io.kestra.core.runners.ScheduleContextInterface; import io.kestra.core.runners.ScheduleContextInterface;
import io.kestra.core.runners.Scheduler;
import io.kestra.core.runners.SchedulerTriggerStateInterface; import io.kestra.core.runners.SchedulerTriggerStateInterface;
import io.kestra.core.services.FlowListenersInterface; import io.kestra.core.services.FlowListenersInterface;
import io.kestra.core.services.FlowService; import io.kestra.core.services.FlowService;
@@ -56,6 +57,9 @@ public class JdbcScheduler extends AbstractScheduler {
.forEach(abstractTrigger -> triggerRepository.delete(Trigger.of(flow, abstractTrigger))); .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 @Override

View File

@@ -115,6 +115,10 @@ public abstract class JdbcServiceLivenessCoordinatorTest {
if (either.getLeft().getTaskRun().getState().getCurrent() == Type.RUNNING) { if (either.getLeft().getTaskRun().getState().getCurrent() == Type.RUNNING) {
runningLatch.countDown(); runningLatch.countDown();
} }
if (either.getLeft().getTaskRun().getState().getCurrent() == Type.FAILED) {
fail("Worker task result should not be in FAILED state as it should be resubmitted");
}
}); });
workerJobQueue.emit(workerTask(Duration.ofSeconds(5))); workerJobQueue.emit(workerTask(Duration.ofSeconds(5)));

View File

@@ -25,7 +25,7 @@ import java.util.concurrent.atomic.AtomicBoolean;
@Slf4j @Slf4j
@Singleton @Singleton
public class TestRunner implements Runnable, AutoCloseable { 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 schedulerEnabled = true;
@Setter private boolean workerEnabled = true; @Setter private boolean workerEnabled = true;

View File

@@ -26,6 +26,26 @@
document.getElementsByTagName("html")[0].classList.add(localStorage.getItem("theme")); document.getElementsByTagName("html")[0].classList.add(localStorage.getItem("theme"));
} }
</script> </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> </head>
<body> <body>
<noscript> <noscript>

12
ui/package-lock.json generated
View File

@@ -24,7 +24,6 @@
"cronstrue": "^3.9.0", "cronstrue": "^3.9.0",
"cytoscape": "^3.33.0", "cytoscape": "^3.33.0",
"dagre": "^0.8.5", "dagre": "^0.8.5",
"el-table-infinite-scroll": "^3.0.7",
"element-plus": "2.11.5", "element-plus": "2.11.5",
"humanize-duration": "^3.33.1", "humanize-duration": "^3.33.1",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
@@ -9941,17 +9940,6 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/el-table-infinite-scroll": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/el-table-infinite-scroll/-/el-table-infinite-scroll-3.0.7.tgz",
"integrity": "sha512-at7f8GjNzvkf16i5kCBb1MOq6wI65k+TuaSt5wgiOLAKvdTr36+wAvnOnPYVIPhEpGeM8mRgLZQr2b5YV0lQaw==",
"license": "MIT",
"dependencies": {
"core-js": "^3.x",
"element-plus": "^2.x",
"vue": "^3.x"
}
},
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.207", "version": "1.5.207",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.207.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.207.tgz",

View File

@@ -38,7 +38,6 @@
"cronstrue": "^3.9.0", "cronstrue": "^3.9.0",
"cytoscape": "^3.33.0", "cytoscape": "^3.33.0",
"dagre": "^0.8.5", "dagre": "^0.8.5",
"el-table-infinite-scroll": "^3.0.7",
"element-plus": "2.11.5", "element-plus": "2.11.5",
"humanize-duration": "^3.33.1", "humanize-duration": "^3.33.1",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",

View File

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

View File

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

View File

@@ -197,7 +197,6 @@
import {trackTabOpen, trackTabClose} from "../utils/tabTracking"; import {trackTabOpen, trackTabClose} from "../utils/tabTracking";
import {Panel, Tab, TabLive} from "../utils/multiPanelTypes"; import {Panel, Tab, TabLive} from "../utils/multiPanelTypes";
import {usePanelDefaultSize} from "../composables/usePanelDefaultSize";
const {t} = useI18n(); const {t} = useI18n();
const {showKeyShortcuts} = useKeyShortcuts(); 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") { function newPanelDrop(_e: DragEvent, direction: "left" | "right") {
if (!movedTabInfo.value) return; if (!movedTabInfo.value) return;

View File

@@ -22,6 +22,8 @@
columns: optionalColumns, columns: optionalColumns,
storageKey: storageKey storageKey: storageKey
}" }"
:defaultScope="false"
:defaultTimeRange="false"
/> />
</template> </template>
<template #table> <template #table>
@@ -298,6 +300,7 @@
<script setup lang="ts"> <script setup lang="ts">
import _merge from "lodash/merge"; import _merge from "lodash/merge";
import {ref, computed, watch} from "vue"; import {ref, computed, watch} from "vue";
import moment from "moment";
import {useI18n} from "vue-i18n"; import {useI18n} from "vue-i18n";
import {useRoute} from "vue-router"; import {useRoute} from "vue-router";
import {ElMessage} from "element-plus"; import {ElMessage} from "element-plus";
@@ -333,11 +336,9 @@
import TopNavBar from "../layout/TopNavBar.vue"; import TopNavBar from "../layout/TopNavBar.vue";
import BulkSelect from "../layout/BulkSelect.vue"; import BulkSelect from "../layout/BulkSelect.vue";
import LogsWrapper from "../logs/LogsWrapper.vue"; import LogsWrapper from "../logs/LogsWrapper.vue";
//@ts-expect-error No declaration file
import SelectTable from "../layout/SelectTable.vue"; import SelectTable from "../layout/SelectTable.vue";
import TriggerAvatar from "../flows/TriggerAvatar.vue"; import TriggerAvatar from "../flows/TriggerAvatar.vue";
import KSFilter from "../filter/components/KSFilter.vue"; import KSFilter from "../filter/components/KSFilter.vue";
import useRestoreUrl from "../../composables/useRestoreUrl";
import MarkdownTooltip from "../layout/MarkdownTooltip.vue"; import MarkdownTooltip from "../layout/MarkdownTooltip.vue";
import useRouteContext from "../../composables/useRouteContext"; import useRouteContext from "../../composables/useRouteContext";
@@ -436,8 +437,6 @@
.filter(Boolean) as ColumnConfig[] .filter(Boolean) as ColumnConfig[]
); );
const {saveRestoreUrl} = useRestoreUrl();
const loadData = (callback?: () => void) => { const loadData = (callback?: () => void) => {
const query = loadQuery({ const query = loadQuery({
size: parseInt(String(route.query?.size ?? "25")), size: parseInt(String(route.query?.size ?? "25")),
@@ -463,8 +462,7 @@
const {ready, onSort, onPageChanged, queryWithFilter, load} = useDataTableActions({ const {ready, onSort, onPageChanged, queryWithFilter, load} = useDataTableActions({
dataTableRef: dataTable, dataTableRef: dataTable,
loadData, loadData
saveRestoreUrl
}); });
const { const {
@@ -696,7 +694,16 @@
}; };
const loadQuery = (base: any) => { 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); return _merge(base, queryFilter);
}; };

View File

@@ -18,7 +18,7 @@
</template> </template>
<script setup lang="ts"> <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 {stringify, parse} from "@kestra-io/ui-libs/flow-yaml-utils";
import type {Dashboard, Chart} from "./composables/useDashboards"; import type {Dashboard, Chart} from "./composables/useDashboards";
@@ -89,9 +89,16 @@
} }
if (!props.isFlow && !props.isNamespace) { 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({ router.replace({
params: {...route.params, dashboard: id}, 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(() => { onBeforeMount(() => {
const ID = getDashboard(route, "id"); 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)); if (props.isFlow) {
else if (props.isNamespace && ID === "default") load("default", YAML_NAMESPACE); 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> </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) { async function updateChartPreview(event: any) {
const chart = YAML_UTILS.getChartAtPosition(event.model.getValue(), event.position); const chart = YAML_UTILS.getChartAtPosition(event.model.getValue(), event.position);
if (chart) { if (chart) {
const result = await loadChart(chart); const result = await dashboardStore.loadChart(chart);
dashboardStore.selectedChart = typeof result.data === "object" dashboardStore.selectedChart = typeof result.data === "object"
? { ? {
...result.data, ...result.data,

View File

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

View File

@@ -37,6 +37,7 @@
FIELDNAME_INJECTION_KEY, FIELDNAME_INJECTION_KEY,
FULL_SCHEMA_INJECTION_KEY, FULL_SCHEMA_INJECTION_KEY,
FULL_SOURCE_INJECTION_KEY, FULL_SOURCE_INJECTION_KEY,
ON_TASK_EDITOR_CLICK_INJECTION_KEY,
PARENT_PATH_INJECTION_KEY, PARENT_PATH_INJECTION_KEY,
POSITION_INJECTION_KEY, POSITION_INJECTION_KEY,
REF_PATH_INJECTION_KEY, REF_PATH_INJECTION_KEY,
@@ -111,6 +112,15 @@
provide(BLOCK_SCHEMA_PATH_INJECTION_KEY, computed(() => props.blockSchemaPath ?? dashboardStore.schema.$ref ?? "")); provide(BLOCK_SCHEMA_PATH_INJECTION_KEY, computed(() => props.blockSchemaPath ?? dashboardStore.schema.$ref ?? ""));
provide(FULL_SOURCE_INJECTION_KEY, computed(() => dashboardStore.sourceCode ?? "")); provide(FULL_SOURCE_INJECTION_KEY, computed(() => dashboardStore.sourceCode ?? ""));
provide(POSITION_INJECTION_KEY, props.position ?? "after"); 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(); const pluginsStore = usePluginsStore();

View File

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

View File

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

View File

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

View File

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

View File

@@ -23,9 +23,15 @@
</template> </template>
<script setup lang="ts"> <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 collapsed = ref(false);
const route = useRoute();
const scrollKey = computed(() => `docs:${route.fullPath}`);
useScrollMemory(scrollKey, undefined, true);
</script> </script>

View File

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

View File

@@ -51,6 +51,7 @@
refresh: {shown: true, callback: refresh} refresh: {shown: true, callback: refresh}
}" }"
@update-properties="updateDisplayColumns" @update-properties="updateDisplayColumns"
:defaultScope="defaultScopeFilter"
/> />
</template> </template>
@@ -70,7 +71,7 @@
@selection-change="handleSelectionChange" @selection-change="handleSelectionChange"
:selectable="!hidden?.includes('selection') && canCheck" :selectable="!hidden?.includes('selection') && canCheck"
:no-data-text="$t('no_results.executions')" :no-data-text="$t('no_results.executions')"
:rowKey="(row: any) => `${row.namespace}-${row.id}`" :rowKey="(row: any) => row.id"
> >
<template #select-actions> <template #select-actions>
<BulkSelect <BulkSelect
@@ -144,10 +145,7 @@
<el-form> <el-form>
<ElFormItem :label="$t('execution labels')"> <ElFormItem :label="$t('execution labels')">
<LabelInput <LabelInput v-model:labels="executionLabels" />
:key="executionLabels.map((l) => l.key).join('-')"
v-model:labels="executionLabels"
/>
</ElFormItem> </ElFormItem>
</el-form> </el-form>
</el-dialog> </el-dialog>
@@ -384,7 +382,7 @@
import _merge from "lodash/merge"; import _merge from "lodash/merge";
import {useI18n} from "vue-i18n"; import {useI18n} from "vue-i18n";
import {useRoute, useRouter} from "vue-router"; 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 * as YAML_UTILS from "@kestra-io/ui-libs/flow-yaml-utils";
import {ElMessageBox, ElSwitch, ElFormItem, ElAlert, ElCheckbox} from "element-plus"; import {ElMessageBox, ElSwitch, ElFormItem, ElAlert, ElCheckbox} from "element-plus";
@@ -411,7 +409,6 @@
import DateAgo from "../layout/DateAgo.vue"; import DateAgo from "../layout/DateAgo.vue";
import DataTable from "../layout/DataTable.vue"; import DataTable from "../layout/DataTable.vue";
import BulkSelect from "../layout/BulkSelect.vue"; import BulkSelect from "../layout/BulkSelect.vue";
//@ts-expect-error no declaration file
import SelectTable from "../layout/SelectTable.vue"; import SelectTable from "../layout/SelectTable.vue";
import KSFilter from "../filter/components/KSFilter.vue"; import KSFilter from "../filter/components/KSFilter.vue";
import Sections from "../dashboard/sections/Sections.vue"; import Sections from "../dashboard/sections/Sections.vue";
@@ -424,14 +421,12 @@
import {filterValidLabels} from "./utils"; import {filterValidLabels} from "./utils";
import {useToast} from "../../utils/toast"; import {useToast} from "../../utils/toast";
import {storageKeys} from "../../utils/constants"; import {storageKeys} from "../../utils/constants";
import {defaultNamespace} from "../../composables/useNamespaces";
import {humanizeDuration, invisibleSpace} from "../../utils/filters"; import {humanizeDuration, invisibleSpace} from "../../utils/filters";
import Utils from "../../utils/utils"; import Utils from "../../utils/utils";
import action from "../../models/action"; import action from "../../models/action";
import permission from "../../models/permission"; import permission from "../../models/permission";
import useRestoreUrl from "../../composables/useRestoreUrl";
import useRouteContext from "../../composables/useRouteContext"; import useRouteContext from "../../composables/useRouteContext";
import {useTableColumns} from "../../composables/useTableColumns"; import {useTableColumns} from "../../composables/useTableColumns";
import {useDataTableActions} from "../../composables/useDataTableActions"; import {useDataTableActions} from "../../composables/useDataTableActions";
@@ -463,6 +458,7 @@
hidden?: string[] | null; hidden?: string[] | null;
flowId?: string | undefined; flowId?: string | undefined;
namespace?: string | undefined; namespace?: string | undefined;
defaultScopeFilter?: boolean;
}>(), { }>(), {
embed: false, embed: false,
filter: true, filter: true,
@@ -475,6 +471,7 @@
hidden: null, hidden: null,
flowId: undefined, flowId: undefined,
namespace: undefined, namespace: undefined,
defaultScopeFilter: undefined
}); });
const emit = defineEmits<{ const emit = defineEmits<{
@@ -496,7 +493,6 @@
const selectedStatus = ref(undefined); const selectedStatus = ref(undefined);
const lastRefreshDate = ref(new Date()); const lastRefreshDate = ref(new Date());
const unqueueDialogVisible = ref(false); const unqueueDialogVisible = ref(false);
const isDefaultNamespaceAllow = ref(true);
const changeStatusDialogVisible = ref(false); const changeStatusDialogVisible = ref(false);
const actionOptions = ref<Record<string, any>>({}); const actionOptions = ref<Record<string, any>>({});
const dblClickRouteName = ref("executions/update"); const dblClickRouteName = ref("executions/update");
@@ -614,11 +610,6 @@
const routeInfo = computed(() => ({title: t("executions")})); const routeInfo = computed(() => ({title: t("executions")}));
useRouteContext(routeInfo, props.embed); useRouteContext(routeInfo, props.embed);
const {saveRestoreUrl} = useRestoreUrl({
restoreUrl: true,
isDefaultNamespaceAllow: isDefaultNamespaceAllow.value
});
const dataTableRef = ref(null); const dataTableRef = ref(null);
const selectTableRef = useTemplateRef<typeof SelectTable>("selectTable"); const selectTableRef = useTemplateRef<typeof SelectTable>("selectTable");
@@ -634,8 +625,7 @@
dblClickRouteName: dblClickRouteName.value, dblClickRouteName: dblClickRouteName.value,
embed: props.embed, embed: props.embed,
dataTableRef, dataTableRef,
loadData: loadData, loadData: loadData
saveRestoreUrl
}); });
const { const {
@@ -1043,31 +1033,6 @@
emit("state-count", {runningCount, totalCount}); 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");
}
});
watch(isOpenLabelsModal, (opening) => { watch(isOpenLabelsModal, (opening) => {
if (opening) { if (opening) {
executionLabels.value = []; executionLabels.value = [];

View File

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

View File

@@ -150,7 +150,7 @@ export function useExecutionRoot() {
follow(); follow();
window.addEventListener("popstate", 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; previousExecutionId.value = route.params.id as string;
}); });

View File

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

View File

@@ -531,8 +531,9 @@
} }
.content-container { .content-container {
height: calc(100vh - 0px); height: calc(100vh - 0px);
overflow-y: auto !important; overflow-y: scroll;
overflow-x: hidden; overflow-x: hidden;
scrollbar-gutter: stable;
word-wrap: break-word; word-wrap: break-word;
word-break: break-word; word-break: break-word;
position: relative; position: relative;
@@ -541,19 +542,16 @@
:deep(.el-collapse) { :deep(.el-collapse) {
.el-collapse-item__wrap { .el-collapse-item__wrap {
overflow-y: auto !important;
max-height: none !important; max-height: none !important;
} }
.el-collapse-item__content { .el-collapse-item__content {
overflow-y: auto !important;
word-wrap: break-word; word-wrap: break-word;
word-break: break-word; word-break: break-word;
} }
} }
:deep(.var-value) { :deep(.var-value) {
overflow-y: auto !important;
word-wrap: break-word; word-wrap: break-word;
word-break: break-word; word-break: break-word;
} }

View File

@@ -45,6 +45,8 @@
searchInputFullWidth?: boolean; searchInputFullWidth?: boolean;
legacyQuery?: boolean; legacyQuery?: boolean;
readOnly?: boolean; readOnly?: boolean;
defaultScope?: boolean;
defaultTimeRange?: boolean;
}>(), { }>(), {
buttons: () => ({}), buttons: () => ({}),
tableOptions: () => ({}), tableOptions: () => ({}),
@@ -53,7 +55,9 @@
showSearchInput: true, showSearchInput: true,
searchInputFullWidth: false, searchInputFullWidth: false,
legacyQuery: false, legacyQuery: false,
readOnly: false readOnly: false,
defaultScope: undefined,
defaultTimeRange: undefined,
}); });
const emits = defineEmits<{ const emits = defineEmits<{
@@ -75,7 +79,9 @@
} = useFilters( } = useFilters(
props.configuration, props.configuration,
props.showSearchInput, props.showSearchInput,
props.legacyQuery props.legacyQuery,
props.defaultScope,
props.defaultTimeRange,
); );
const {savedFilters, saveFilter, updateSavedFilter, deleteSavedFilter} = useSavedFilters( const {savedFilters, saveFilter, updateSavedFilter, deleteSavedFilter} = useSavedFilters(
@@ -166,6 +172,7 @@
watch(appliedFilters, (newFilters) => { watch(appliedFilters, (newFilters) => {
emits("filter", newFilters); emits("filter", newFilters);
}, {deep: true}); }, {deep: true});
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@@ -0,0 +1,84 @@
import {nextTick, 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,
{
namespace,
includeTimeRange,
includeScope,
legacyQuery,
}: DefaultFilterOptions = {}): { query: LocationQuery, change: boolean } {
if(currentQuery && Object.keys(currentQuery).length > 0) {
return {
query: currentQuery,
change: false,
}
}
const query = {...currentQuery};
if (namespace === undefined && defaultNamespace() && !hasFilterKey(query, NAMESPACE_FILTER_PREFIX)) {
query[legacyQuery ? "namespace" : `${NAMESPACE_FILTER_PREFIX}[PREFIX]`] = defaultNamespace();
}
if (includeScope && !hasFilterKey(query, SCOPE_FILTER_PREFIX)) {
query[legacyQuery ? "scope" : `${SCOPE_FILTER_PREFIX}[EQUALS]`] = "USER";
}
const TIME_FILTER_KEYS = /startDate|endDate|timeRange/;
if (includeTimeRange && !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;
}
return {query, change: true};
}
export function useDefaultFilter(
defaultOptions?: DefaultFilterOptions,
) {
const route = useRoute();
const router = useRouter();
onMounted(async () => {
// wait for router to be ready
await nextTick()
// wait for the useRestoreUrl to apply its changes
await nextTick()
// finally add default filter if necessary
const {query, change} = applyDefaultFilters(route.query, defaultOptions)
if(change) {
router.replace({...route, query})
}
});
function resetDefaultFilter(){
router.replace({
...route,
query: applyDefaultFilters({}, defaultOptions).query
});
}
return {
resetDefaultFilter
}
}

View File

@@ -17,8 +17,16 @@ import {
KV_COMPARATORS KV_COMPARATORS
} from "../utils/filterTypes"; } from "../utils/filterTypes";
import {usePreAppliedFilters} from "./usePreAppliedFilters"; import {usePreAppliedFilters} from "./usePreAppliedFilters";
import {useDefaultFilter} from "./useDefaultFilter";
export function useFilters(configuration: FilterConfiguration, showSearchInput = true, legacyQuery = false) {
export function useFilters(
configuration: FilterConfiguration,
showSearchInput = true,
legacyQuery = false,
defaultScope?: boolean,
defaultTimeRange?: boolean
) {
const router = useRouter(); const router = useRouter();
const route = useRoute(); const route = useRoute();
@@ -28,8 +36,7 @@ export function useFilters(configuration: FilterConfiguration, showSearchInput =
const { const {
markAsPreApplied, markAsPreApplied,
hasPreApplied, hasPreApplied,
getPreApplied, getPreApplied
getAllPreApplied
} = usePreAppliedFilters(); } = usePreAppliedFilters();
const appendQueryParam = (query: Record<string, any>, key: string, value: string) => { const appendQueryParam = (query: Record<string, any>, key: string, value: string) => {
@@ -367,24 +374,24 @@ export function useFilters(configuration: FilterConfiguration, showSearchInput =
updateRoute(); updateRoute();
}; };
/** const {resetDefaultFilter} = useDefaultFilter({
* Resets all filters to their pre-applied state and clears the search query legacyQuery,
*/ includeScope: defaultScope ?? configuration.keys?.some((k) => k.key === "scope"),
includeTimeRange: defaultTimeRange ?? configuration.keys?.some((k) => k.key === "timeRange"),
});
const resetToPreApplied = () => { const resetToPreApplied = () => {
appliedFilters.value = getAllPreApplied();
searchQuery.value = ""; searchQuery.value = "";
updateRoute(); resetDefaultFilter();
}; };
watch(searchQuery, () => {
updateRoute();
});
return { return {
appliedFilters: computed(() => appliedFilters.value), appliedFilters: computed(() => appliedFilters.value),
searchQuery: computed({ searchQuery,
get: () => searchQuery.value,
set: value => {
searchQuery.value = value;
updateRoute();
}
}),
addFilter, addFilter,
removeFilter, removeFilter,
updateFilter, updateFilter,

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.last24hours"), value: "PT24H"},
{label: t("datepicker.last48hours"), value: "PT48H"}, {label: t("datepicker.last48hours"), value: "PT48H"},
{label: t("datepicker.last7days"), value: "PT168H"}, {label: t("datepicker.last7days"), value: "PT168H"},
{label: t("datepicker.last30days"), value: "PT720H"}, {label: t("datepicker.last30days"), value: "P30D"},
{label: t("datepicker.last365days"), value: "PT8760H"}, {label: t("datepicker.last365days"), value: "PT8760H"},
]; ];

View File

@@ -2,13 +2,15 @@ import {computed, ComputedRef} from "vue";
import {FilterConfiguration} from "../utils/filterTypes"; import {FilterConfiguration} from "../utils/filterTypes";
import {useI18n} from "vue-i18n"; import {useI18n} from "vue-i18n";
export const useBlueprintFilter = (): ComputedRef<FilterConfiguration> => computed(() => { export const useBlueprintFilter = (): ComputedRef<FilterConfiguration> => {
const {t} = useI18n(); const {t} = useI18n();
return { return computed(() => {
title: t("filter.titles.blueprint_filters"), return {
searchPlaceholder: t("filter.search_placeholders.search_blueprints"), title: t("filter.titles.blueprint_filters"),
keys: [ 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 {useValues} from "../composables/useValues";
import {useI18n} from "vue-i18n"; import {useI18n} from "vue-i18n";
export const useDashboardFilter = (): ComputedRef<FilterConfiguration> => computed(() => { export const useDashboardFilter = (): ComputedRef<FilterConfiguration> => {
const {t} = useI18n(); const {t} = useI18n();
return { return computed(() => {
title: t("filter.titles.dashboard_filters"), return {
searchPlaceholder: t("filter.search_placeholders.search_dashboards"), title: t("filter.titles.dashboard_filters"),
keys: [ searchPlaceholder: t("filter.search_placeholders.search_dashboards"),
{ keys: [
key: "namespace", {
label: t("filter.namespace.label"), key: "namespace",
description: t("filter.namespace.description"), label: t("filter.namespace.label"),
comparators: [ description: t("filter.namespace.description"),
Comparators.IN, comparators: [
Comparators.NOT_IN, Comparators.IN,
Comparators.CONTAINS, Comparators.NOT_IN,
Comparators.PREFIX, Comparators.CONTAINS,
], Comparators.PREFIX,
valueType: "multi-select", ],
valueProvider: async () => { valueType: "multi-select",
const user = useAuthStore().user; valueProvider: async () => {
if (user && user.hasAnyActionOnAnyNamespace(permission.NAMESPACE, action.READ)) { const user = useAuthStore().user;
const namespacesStore = useNamespacesStore(); if (user && user.hasAnyActionOnAnyNamespace(permission.NAMESPACE, action.READ)) {
const namespaces = (await namespacesStore.loadAutocomplete()) as string[]; const namespacesStore = useNamespacesStore();
return [...new Set(namespaces const namespaces = (await namespacesStore.loadAutocomplete()) as string[];
.flatMap(namespace => { return [...new Set(namespaces
return namespace.split(".").reduce((current: string[], part: string) => { .flatMap(namespace => {
const previousCombination = current?.[current.length - 1]; return namespace.split(".").reduce((current: string[], part: string) => {
return [...current, `${(previousCombination ? previousCombination + "." : "")}${part}`]; const previousCombination = current?.[current.length - 1];
}, []); return [...current, `${(previousCombination ? previousCombination + "." : "")}${part}`];
}))].map(namespace => ({ }, []);
label: namespace, }))].map(namespace => ({
value: 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: "state",
{ label: t("filter.state.label"),
key: "timeRange", description: t("filter.state.description"),
label: t("filter.timeRange_dashboard.label"), comparators: [Comparators.IN, Comparators.NOT_IN],
description: t("filter.timeRange_dashboard.description"), valueType: "multi-select",
comparators: [Comparators.EQUALS], valueProvider: async () => {
valueType: "select", const {VALUES} = useValues("executions");
valueProvider: async () => { return VALUES.EXECUTION_STATES;
const {VALUES} = useValues("dashboard"); },
return VALUES.RELATIVE_DATE; 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(); const {t} = useI18n();
return { return computed(() => {
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 {
// return flowIds.map((flowId: string) => ({label: flowId, value: flowId})); title: t("filter.titles.namespace_dashboard_filters"),
// }, searchPlaceholder: t("filter.search_placeholders.search_dashboards"),
searchable: true keys: [
}, {
{ key: "flowId",
key: "timeRange", label: t("filter.flowId.label"),
label: t("filter.timeRange_dashboard.label"), description: t("filter.flowId.description"),
description: t("filter.timeRange_dashboard.description"), comparators: [
comparators: [Comparators.EQUALS], Comparators.EQUALS,
valueType: "select", Comparators.NOT_EQUALS,
valueProvider: async () => { Comparators.CONTAINS,
const {VALUES} = useValues("dashboard"); Comparators.STARTS_WITH,
return VALUES.RELATIVE_DATE; 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(); const {t} = useI18n();
return { return computed(() => {
title: t("filter.titles.flow_dashboard_filters"),
searchPlaceholder: t("filter.search_placeholders.search_dashboards"), return {
keys: [ title: t("filter.titles.flow_dashboard_filters"),
{ searchPlaceholder: t("filter.search_placeholders.search_dashboards"),
key: "timeRange", keys: [
label: t("filter.timeRange_dashboard.label"), {
description: t("filter.timeRange_dashboard.description"), key: "timeRange",
comparators: [Comparators.EQUALS], label: t("filter.timeRange_dashboard.label"),
valueType: "select", description: t("filter.timeRange_dashboard.description"),
valueProvider: async () => { comparators: [Comparators.EQUALS],
const {VALUES} = useValues("dashboard"); valueType: "select",
return VALUES.RELATIVE_DATE; 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 {useAuthStore} from "override/stores/auth";
import {useValues} from "../composables/useValues"; import {useValues} from "../composables/useValues";
import {useI18n} from "vue-i18n"; 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 {t} = useI18n();
const route = useRoute();
return { return computed(() => {
title: t("filter.titles.execution_filters"), return {
searchPlaceholder: t("filter.search_placeholders.search_executions"), title: t("filter.titles.execution_filters"),
keys: [ searchPlaceholder: t("filter.search_placeholders.search_executions"),
{ keys: [
key: "namespace", ...(route.name !== "namespaces/update" ? [
label: t("filter.namespace.label"), {
description: t("filter.namespace.description"), key: "namespace",
comparators: [ label: t("filter.namespace.label"),
Comparators.IN, description: t("filter.namespace.description"),
Comparators.NOT_IN, comparators: [
Comparators.CONTAINS, Comparators.IN,
Comparators.PREFIX, Comparators.NOT_IN,
], Comparators.CONTAINS,
valueType: "multi-select", Comparators.PREFIX,
valueProvider: async () => { ],
const user = useAuthStore().user; valueType: "multi-select" as const,
if (user && user.hasAnyActionOnAnyNamespace(permission.NAMESPACE, action.READ)) { valueProvider: async () => {
const namespacesStore = useNamespacesStore(); const user = useAuthStore().user;
const namespaces = (await namespacesStore.loadAutocomplete()) as string[]; if (user && user.hasAnyActionOnAnyNamespace(permission.NAMESPACE, action.READ)) {
return [...new Set(namespaces const namespacesStore = useNamespacesStore();
.flatMap(namespace => { const namespaces = (await namespacesStore.loadAutocomplete()) as string[];
return namespace.split(".").reduce((current: string[], part: string) => { return [...new Set(namespaces
const previousCombination = current?.[current.length - 1]; .flatMap(namespace => {
return [...current, `${(previousCombination ? previousCombination + "." : "")}${part}`]; return namespace.split(".").reduce((current: string[], part: string) => {
}, []); const previousCombination = current?.[current.length - 1];
}))].map(namespace => ({ return [...current, `${(previousCombination ? previousCombination + "." : "")}${part}`];
label: namespace, }, []);
value: namespace }))].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: "state",
{ label: t("filter.state.label"),
key: "flowId", description: t("filter.state.description"),
label: t("filter.flowId.label"), comparators: [Comparators.IN, Comparators.NOT_IN],
description: t("filter.flowId.description"), valueType: "multi-select",
comparators: [ valueProvider: async () => {
Comparators.EQUALS, const {VALUES} = useValues("executions");
Comparators.NOT_EQUALS, return VALUES.EXECUTION_STATES;
Comparators.CONTAINS, },
Comparators.STARTS_WITH, showComparatorSelection: true,
Comparators.ENDS_WITH, searchable: true
],
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;
}, },
showComparatorSelection: true, {
searchable: true key: "scope",
}, label: t("filter.scope.label"),
{ description: t("filter.scope.description"),
key: "scope", comparators: [Comparators.EQUALS, Comparators.NOT_EQUALS],
label: t("filter.scope.label"), valueType: "radio",
description: t("filter.scope.description"), valueProvider: async () => {
comparators: [Comparators.EQUALS, Comparators.NOT_EQUALS], const {VALUES} = useValues("executions");
valueType: "radio", return VALUES.SCOPES;
valueProvider: async () => { },
const {VALUES} = useValues("executions"); showComparatorSelection: false
return VALUES.SCOPES;
}, },
showComparatorSelection: false {
}, key: "childFilter",
{ label: t("filter.childFilter.label"),
key: "childFilter", description: t("filter.childFilter.description"),
label: t("filter.childFilter.label"), comparators: [Comparators.EQUALS],
description: t("filter.childFilter.description"), valueType: "radio",
comparators: [Comparators.EQUALS], valueProvider: async () => {
valueType: "radio", const {VALUES} = useValues("executions");
valueProvider: async () => { return VALUES.CHILDS;
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 {useValues} from "../composables/useValues";
import {useI18n} from "vue-i18n"; import {useI18n} from "vue-i18n";
export const useFlowExecutionFilter = (): ComputedRef<FilterConfiguration> => computed(() => { export const useFlowExecutionFilter = (): ComputedRef<FilterConfiguration> => {
const {t} = useI18n(); const {t} = useI18n();
return { return computed(() => {
title: t("filter.titles.flow_execution_filters"), return {
searchPlaceholder: t("filter.search_placeholders.search_executions"), title: t("filter.titles.flow_execution_filters"),
keys: [ searchPlaceholder: t("filter.search_placeholders.search_executions"),
{ keys: [
key: "state", {
label: t("filter.state.label"), key: "state",
description: t("filter.state.description"), label: t("filter.state.label"),
comparators: [Comparators.IN, Comparators.NOT_IN], description: t("filter.state.description"),
valueType: "multi-select", comparators: [Comparators.IN, Comparators.NOT_IN],
valueProvider: async () => { valueType: "multi-select",
const {VALUES} = useValues("executions"); valueProvider: async () => {
return VALUES.EXECUTION_STATES; 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;
}, },
showComparatorSelection: false {
}, key: "scope",
{ label: t("filter.scope.label"),
key: "childFilter", description: t("filter.scope.description"),
label: t("filter.childFilter.label"), comparators: [Comparators.EQUALS, Comparators.NOT_EQUALS],
description: t("filter.childFilter.description"), valueType: "radio",
comparators: [Comparators.EQUALS], valueProvider: async () => {
valueType: "radio", const {VALUES} = useValues("executions");
valueProvider: async () => { return VALUES.SCOPES;
const {VALUES} = useValues("executions"); },
return VALUES.CHILDS; 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 {useAuthStore} from "override/stores/auth";
import {useValues} from "../composables/useValues"; import {useValues} from "../composables/useValues";
import {useI18n} from "vue-i18n"; import {useI18n} from "vue-i18n";
import {useRoute} from "vue-router";
export const useFlowFilter = (): ComputedRef<FilterConfiguration> => computed(() => { export const useFlowFilter = (): ComputedRef<FilterConfiguration> => {
const {t} = useI18n(); const {t} = useI18n();
const route = useRoute();
return { return computed(() => ({
title: t("filter.titles.flow_filters"), title: t("filter.titles.flow_filters"),
searchPlaceholder: t("filter.search_placeholders.search_flows"), searchPlaceholder: t("filter.search_placeholders.search_flows"),
keys: [ keys: [
{ ...(route.name !== "namespaces/update" ? [
key: "namespace", {
label: t("filter.namespace.label"), key: "namespace",
description: t("filter.namespace.description"), label: t("filter.namespace.label"),
comparators: [ description: t("filter.namespace.description"),
Comparators.IN, comparators: [
Comparators.NOT_IN, Comparators.IN,
Comparators.CONTAINS, Comparators.NOT_IN,
Comparators.PREFIX, Comparators.CONTAINS,
], Comparators.PREFIX,
valueType: "multi-select", ],
valueProvider: async () => { valueType: "multi-select" as const,
const user = useAuthStore().user; valueProvider: async () => {
if (user && user.hasAnyActionOnAnyNamespace(permission.NAMESPACE, action.READ)) { const user = useAuthStore().user;
const namespacesStore = useNamespacesStore(); if (user && user.hasAnyActionOnAnyNamespace(permission.NAMESPACE, action.READ)) {
const namespaces = (await namespacesStore.loadAutocomplete()) as string[]; const namespacesStore = useNamespacesStore();
return [...new Set(namespaces const namespaces = (await namespacesStore.loadAutocomplete()) as string[];
.flatMap(namespace => { return [...new Set(namespaces
return namespace.split(".").reduce((current: string[], part: string) => { .flatMap(namespace => {
const previousCombination = current?.[current.length - 1]; return namespace.split(".").reduce((current: string[], part: string) => {
return [...current, `${(previousCombination ? previousCombination + "." : "")}${part}`]; const previousCombination = current?.[current.length - 1];
}, []); return [...current, `${(previousCombination ? previousCombination + "." : "")}${part}`];
}))].map(namespace => ({ }, []);
label: namespace, }))].map(namespace => ({
value: namespace label: namespace,
})); value: namespace
} }));
return []; }
return [];
},
searchable: true
}, },
searchable: true ] : []) as any,
},
{ {
key: "scope", key: "scope",
label: t("filter.scope_flow.label"), label: t("filter.scope_flow.label"),
@@ -65,5 +69,5 @@ export const useFlowFilter = (): ComputedRef<FilterConfiguration> => computed(()
valueType: "text", valueType: "text",
}, },
] ]
}; }));
}); };

View File

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

View File

@@ -3,24 +3,26 @@ import {FilterConfiguration, Comparators} from "../utils/filterTypes";
import {useValues} from "../composables/useValues"; import {useValues} from "../composables/useValues";
import {useI18n} from "vue-i18n"; import {useI18n} from "vue-i18n";
export const useLogExecutionsFilter = (): ComputedRef<FilterConfiguration> => computed(() => { export const useLogExecutionsFilter = (): ComputedRef<FilterConfiguration> => {
const {t} = useI18n(); const {t} = useI18n();
return { return computed(() => {
title: t("filter.titles.log_filters"), return {
searchPlaceholder: t("filter.search_placeholders.search_logs"), title: t("filter.titles.log_filters"),
keys: [ searchPlaceholder: t("filter.search_placeholders.search_logs"),
{ keys: [
key: "level", {
label: t("filter.level.label"), key: "level",
description: t("filter.level.description"), label: t("filter.level.label"),
comparators: [Comparators.EQUALS, Comparators.NOT_EQUALS], description: t("filter.level.description"),
valueType: "select", comparators: [Comparators.EQUALS, Comparators.NOT_EQUALS],
valueProvider: async () => { valueType: "select",
const {VALUES} = useValues("logs"); valueProvider: async () => {
return VALUES.LEVELS; 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 {useAuthStore} from "override/stores/auth";
import {useValues} from "../composables/useValues"; import {useValues} from "../composables/useValues";
import {useI18n} from "vue-i18n"; import {useI18n} from "vue-i18n";
import {useRoute} from "vue-router";
export const useLogFilter = (): ComputedRef<FilterConfiguration> => computed(() => { export const useLogFilter = (): ComputedRef<FilterConfiguration> => {
const {t} = useI18n(); const {t} = useI18n();
const route = useRoute();
return { return computed(() => {
title: t("filter.titles.log_filters"), return {
searchPlaceholder: t("filter.search_placeholders.search_logs"), title: t("filter.titles.log_filters"),
keys: [ searchPlaceholder: t("filter.search_placeholders.search_logs"),
{ keys: [
key: "namespace", ...(route.name !== "namespaces/update" && route.name !== "flows/update" ? [
label: t("filter.namespace.label"), {
description: t("filter.namespace.description"), key: "namespace",
comparators: [ label: t("filter.namespace.label"),
Comparators.IN, description: t("filter.namespace.description"),
Comparators.NOT_IN, comparators: [
Comparators.CONTAINS, Comparators.IN,
Comparators.PREFIX, Comparators.NOT_IN,
], Comparators.CONTAINS,
valueType: "multi-select", Comparators.PREFIX,
valueProvider: async () => { ],
const user = useAuthStore().user; valueType: "multi-select" as const,
if (user && user.hasAnyActionOnAnyNamespace(permission.NAMESPACE, action.READ)) { valueProvider: async () => {
const namespacesStore = useNamespacesStore(); const user = useAuthStore().user;
const namespaces = (await namespacesStore.loadAutocomplete()) as string[]; if (user && user.hasAnyActionOnAnyNamespace(permission.NAMESPACE, action.READ)) {
return [...new Set(namespaces const namespacesStore = useNamespacesStore();
.flatMap(namespace => { const namespaces = (await namespacesStore.loadAutocomplete()) as string[];
return namespace.split(".").reduce((current: string[], part: string) => { return [...new Set(namespaces
const previousCombination = current?.[current.length - 1]; .flatMap(namespace => {
return [...current, `${(previousCombination ? previousCombination + "." : "")}${part}`]; return namespace.split(".").reduce((current: string[], part: string) => {
}, []); const previousCombination = current?.[current.length - 1];
}))].map(namespace => ({ return [...current, `${(previousCombination ? previousCombination + "." : "")}${part}`];
label: namespace, }, []);
value: namespace }))].map(namespace => ({
})); label: namespace,
} value: namespace
return []; }));
}, }
searchable: true return [];
}, },
{ searchable: true
key: "level", },
label: t("filter.level.label"), ] : []) as any,
description: t("filter.level.description"), {
comparators: [Comparators.EQUALS, Comparators.NOT_EQUALS], key: "level",
valueType: "select", label: t("filter.level.label"),
valueProvider: async () => { description: t("filter.level.description"),
const {VALUES} = useValues("logs"); comparators: [Comparators.EQUALS, Comparators.NOT_EQUALS],
return VALUES.LEVELS; valueType: "select",
}, valueProvider: async () => {
showComparatorSelection: true const {VALUES} = useValues("logs");
}, return VALUES.LEVELS;
{ },
key: "timeRange", showComparatorSelection: true
label: t("filter.timeRange_log.label"), },
description: t("filter.timeRange_log.description"), {
comparators: [Comparators.EQUALS], key: "timeRange",
valueType: "select", label: t("filter.timeRange_log.label"),
valueProvider: async () => { description: t("filter.timeRange_log.description"),
const {VALUES} = useValues("logs"); comparators: [Comparators.EQUALS],
return VALUES.RELATIVE_DATE; 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], key: "scope",
valueType: "radio", label: t("filter.scope_log.label"),
valueProvider: async () => { description: t("filter.scope_log.description"),
const {VALUES} = useValues("logs"); comparators: [Comparators.EQUALS, Comparators.NOT_EQUALS],
return VALUES.SCOPES; valueType: "radio",
}, valueProvider: async () => {
showComparatorSelection: false const {VALUES} = useValues("logs");
}, return VALUES.SCOPES;
{ },
key: "triggerId", showComparatorSelection: false
label: t("filter.triggerId.label"), },
description: t("filter.triggerId.description"), {
comparators: [ key: "triggerId",
// Comparators.IN, label: t("filter.triggerId.label"),
// Comparators.NOT_IN, description: t("filter.triggerId.description"),
Comparators.EQUALS, comparators: [
Comparators.NOT_EQUALS, // Comparators.IN,
Comparators.CONTAINS, // Comparators.NOT_IN,
Comparators.STARTS_WITH, Comparators.EQUALS,
Comparators.ENDS_WITH Comparators.NOT_EQUALS,
], Comparators.CONTAINS,
valueType: "text", Comparators.STARTS_WITH,
}, Comparators.ENDS_WITH
{ ],
key: "flowId", valueType: "text",
label: t("filter.flowId.label"), },
description: t("filter.flowId.description"), ...(route.name !== "flows/update" ? [{
comparators: [ key: "flowId",
Comparators.EQUALS, label: t("filter.flowId.label"),
Comparators.NOT_EQUALS, description: t("filter.flowId.description"),
Comparators.CONTAINS, comparators: [
Comparators.STARTS_WITH, Comparators.EQUALS,
Comparators.ENDS_WITH, Comparators.NOT_EQUALS,
], Comparators.CONTAINS,
valueType: "text", 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 {useI18n} from "vue-i18n";
import {useExecutionsStore} from "../../../stores/executions"; import {useExecutionsStore} from "../../../stores/executions";
export const useMetricFilter = (): ComputedRef<FilterConfiguration> => computed(() => { export const useMetricFilter = (): ComputedRef<FilterConfiguration> => {
const {t} = useI18n(); const {t} = useI18n();
return { return computed(() => {
title: t("filter.titles.metric_filters"), return {
searchPlaceholder: t("filter.search_placeholders.search_metrics"), title: t("filter.titles.metric_filters"),
keys: [ searchPlaceholder: t("filter.search_placeholders.search_metrics"),
{ keys: [
key: "metric", {
label: t("filter.metric.label"), key: "metric",
description: t("filter.metric.description"), label: t("filter.metric.label"),
comparators: [Comparators.EQUALS], description: t("filter.metric.description"),
valueType: "select", comparators: [Comparators.EQUALS],
valueProvider: async () => { valueType: "select",
const executionsStore = useExecutionsStore(); valueProvider: async () => {
const taskRuns = executionsStore.execution?.taskRunList ?? []; const executionsStore = useExecutionsStore();
return taskRuns.map(taskRun => ({ const taskRuns = executionsStore.execution?.taskRunList ?? [];
label: taskRun.taskId + (taskRun.value ? ` - ${taskRun.value}` : ""), return taskRuns.map(taskRun => ({
value: taskRun.id label: taskRun.taskId + (taskRun.value ? ` - ${taskRun.value}` : ""),
})); value: taskRun.id
}, }));
searchable: true },
} searchable: true
] }
}; ]
}); };
});
};
export const useFlowMetricFilter = (): ComputedRef<FilterConfiguration> => computed(() => { export const useFlowMetricFilter = (): ComputedRef<FilterConfiguration> => {
const {t} = useI18n(); const {t} = useI18n();
return { return computed(() => {
title: t("filter.titles.flow_metric_filters"), return {
searchPlaceholder: t("filter.search_placeholders.search_metrics"), title: t("filter.titles.flow_metric_filters"),
keys: [ searchPlaceholder: t("filter.search_placeholders.search_metrics"),
{ keys: [
key: "task", {
label: t("filter.task.label"), key: "task",
description: t("filter.task.description"), label: t("filter.task.label"),
comparators: [ description: t("filter.task.description"),
Comparators.EQUALS, comparators: [
], Comparators.EQUALS,
valueType: "select", ],
valueProvider: async () => { valueType: "select",
return (useFlowStore().tasksWithMetrics as string[]).map((value) => ({ valueProvider: async () => {
label: value, return (useFlowStore().tasksWithMetrics as string[]).map((value) => ({
value label: value,
})); value
}));
},
searchable: true
}, },
searchable: true {
}, key: "metric",
{ label: t("filter.metric.label"),
key: "metric", description: t("filter.metric.description"),
label: t("filter.metric.label"), comparators: [
description: t("filter.metric.description"), Comparators.EQUALS
comparators: [ ],
Comparators.EQUALS valueType: "select",
], valueProvider: async () => {
valueType: "select", return (useFlowStore().metrics as string[]).map((value) => ({
valueProvider: async () => { label: value,
return (useFlowStore().metrics as string[]).map((value) => ({ value
label: value, }));
value },
})); searchable: true
}, },
searchable: true {
}, key: "aggregation",
{ label: t("filter.aggregation.label"),
key: "aggregation", description: t("filter.aggregation.description"),
label: t("filter.aggregation.label"), comparators: [Comparators.EQUALS],
description: t("filter.aggregation.description"), valueType: "select",
comparators: [Comparators.EQUALS], valueProvider: async () => {
valueType: "select", const {VALUES} = useValues("metrics");
valueProvider: async () => { return [...VALUES.AGGREGATIONS, {label: "Count", value: "COUNT"}];
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 {FilterConfiguration} from "../../../components/filter/utils/filterTypes";
import {useI18n} from "vue-i18n"; import {useI18n} from "vue-i18n";
export const useNamespacesFilter = (): ComputedRef<FilterConfiguration> => computed(() => { export const useNamespacesFilter = (): ComputedRef<FilterConfiguration> => {
const {t} = useI18n(); const {t} = useI18n();
return { return computed(() => {
title: t("filter.titles.namespaces_filters"), return {
searchPlaceholder: t("filter.search_placeholders.search_namespaces"), title: t("filter.titles.namespace_filters"),
keys: [], 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 {FilterConfiguration} from "../../../components/filter/utils/filterTypes";
import {useI18n} from "vue-i18n"; import {useI18n} from "vue-i18n";
export const usePluginFilter = (): ComputedRef<FilterConfiguration> => computed(() => { export const usePluginFilter = (): ComputedRef<FilterConfiguration> => {
const {t} = useI18n(); const {t} = useI18n();
return { return computed(() => {
title: t("filter.titles.plugin_filters"), return {
searchPlaceholder: t("filter.search_placeholders.search_plugins", {count: 900}), title: t("filter.titles.plugin_filters"),
keys: [], 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 {useNamespacesStore} from "override/stores/namespaces";
import {useAuthStore} from "override/stores/auth"; import {useAuthStore} from "override/stores/auth";
import {useI18n} from "vue-i18n"; 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 {t} = useI18n();
const route = useRoute();
return { return computed(() => {
title: t("filter.titles.secret_filters"), return {
searchPlaceholder: t("filter.search_placeholders.search_secrets"), title: t("filter.titles.secret_filters"),
keys: [ searchPlaceholder: t("filter.search_placeholders.search_secrets"),
{ keys: [
key: "namespace", ...(route.name !== "namespaces/update" ? [
label: t("filter.namespace.label"), {
description: t("filter.namespace.description"), key: "namespace",
comparators: [ label: t("filter.namespace.label"),
Comparators.IN, description: t("filter.namespace.description"),
Comparators.NOT_IN, comparators: [
Comparators.CONTAINS, Comparators.IN,
Comparators.PREFIX, Comparators.NOT_IN,
], Comparators.CONTAINS,
valueType: "multi-select", Comparators.PREFIX,
valueProvider: async () => { ],
const user = useAuthStore().user; valueType: "multi-select",
if (user && user.hasAnyActionOnAnyNamespace(permission.NAMESPACE, action.READ)) { valueProvider: async () => {
const namespacesStore = useNamespacesStore(); const user = useAuthStore().user;
const namespaces = (await namespacesStore.loadAutocomplete()) as string[]; if (user && user.hasAnyActionOnAnyNamespace(permission.NAMESPACE, action.READ)) {
return [...new Set(namespaces const namespacesStore = useNamespacesStore();
.flatMap(namespace => { const namespaces = (await namespacesStore.loadAutocomplete()) as string[];
return namespace.split(".").reduce((current: string[], part: string) => { return [...new Set(namespaces
const previousCombination = current?.[current.length - 1]; .flatMap(namespace => {
return [...current, `${(previousCombination ? previousCombination + "." : "")}${part}`]; return namespace.split(".").reduce((current: string[], part: string) => {
}, []); const previousCombination = current?.[current.length - 1];
}))].map(namespace => ({ return [...current, `${(previousCombination ? previousCombination + "." : "")}${part}`];
label: namespace, }, []);
value: namespace }))].map(namespace => ({
})); label: namespace,
} value: namespace
return []; }));
}, }
searchable: true return [];
}, },
], searchable: true
}; },
}); ] : []) as any,
],
};
});
};

View File

@@ -6,113 +6,118 @@ import {useNamespacesStore} from "override/stores/namespaces";
import {useAuthStore} from "override/stores/auth"; import {useAuthStore} from "override/stores/auth";
import {useValues} from "../composables/useValues"; import {useValues} from "../composables/useValues";
import {useI18n} from "vue-i18n"; 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 {t} = useI18n();
const route = useRoute();
return { return computed(() => {
title: t("filter.titles.trigger_filters"), return {
searchPlaceholder: t("filter.search_placeholders.search_triggers"), title: t("filter.titles.trigger_filters"),
keys: [ searchPlaceholder: t("filter.search_placeholders.search_triggers"),
{ keys: [
key: "namespace", ...(route.name !== "namespaces/update" ? [
label: t("filter.namespace.label"), {
description: t("filter.namespace.description"), key: "namespace",
comparators: [ label: t("filter.namespace.label"),
Comparators.IN, description: t("filter.namespace.description"),
Comparators.NOT_IN, comparators: [
Comparators.CONTAINS, Comparators.IN,
Comparators.PREFIX, Comparators.NOT_IN,
], Comparators.CONTAINS,
valueType: "multi-select", Comparators.PREFIX,
valueProvider: async () => { ],
const user = useAuthStore().user; valueType: "multi-select" as const,
if (user && user.hasAnyActionOnAnyNamespace(permission.NAMESPACE, action.READ)) { valueProvider: async () => {
const namespacesStore = useNamespacesStore(); const user = useAuthStore().user;
const namespaces = (await namespacesStore.loadAutocomplete()) as string[]; if (user && user.hasAnyActionOnAnyNamespace(permission.NAMESPACE, action.READ)) {
return [...new Set(namespaces const namespacesStore = useNamespacesStore();
.flatMap(namespace => { const namespaces = (await namespacesStore.loadAutocomplete()) as string[];
return namespace.split(".").reduce((current: string[], part: string) => { return [...new Set(namespaces
const previousCombination = current?.[current.length - 1]; .flatMap(namespace => {
return [...current, `${(previousCombination ? previousCombination + "." : "")}${part}`]; return namespace.split(".").reduce((current: string[], part: string) => {
}, []); const previousCombination = current?.[current.length - 1];
}))].map(namespace => ({ return [...current, `${(previousCombination ? previousCombination + "." : "")}${part}`];
label: namespace, }, []);
value: namespace }))].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: "scope",
{ label: t("filter.scope_trigger.label"),
key: "flowId", description: t("filter.scope_trigger.description"),
label: t("filter.flowId.label"), comparators: [Comparators.EQUALS, Comparators.NOT_EQUALS],
description: t("filter.flowId.description"), valueType: "radio",
comparators: [ valueProvider: async () => {
Comparators.EQUALS, const {VALUES} = useValues("triggers");
Comparators.NOT_EQUALS, return VALUES.SCOPES;
Comparators.CONTAINS, },
Comparators.STARTS_WITH, showComparatorSelection: false
Comparators.ENDS_WITH, },
], {
valueType: "text", key: "triggerId",
}, label: t("filter.triggerId_trigger.label"),
{ description: t("filter.triggerId_trigger.description"),
key: "timeRange", comparators: [
label: t("filter.timeRange_trigger.label"), Comparators.IN,
description: t("filter.timeRange_trigger.description"), Comparators.NOT_IN,
comparators: [Comparators.EQUALS], Comparators.EQUALS,
valueType: "select", Comparators.NOT_EQUALS,
valueProvider: async () => { Comparators.CONTAINS,
const {VALUES} = useValues("triggers"); Comparators.STARTS_WITH,
return VALUES.RELATIVE_DATE; 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 operation
}; };
}) })
.filter(Boolean); .filter(v => v !== null);
type Filter = Pick<AppliedFilter, "key" | "comparator" | "value">; type Filter = Pick<AppliedFilter, "key" | "comparator" | "value">;

View File

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

View File

@@ -8,6 +8,8 @@
refresh: {shown: true, callback: load} refresh: {shown: true, callback: load}
}" }"
legacyQuery legacyQuery
:defaultScope="false"
:defaultTimeRange="false"
/> />
<div v-bind="$attrs" v-loading="isLoading"> <div v-bind="$attrs" v-loading="isLoading">

View File

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

View File

@@ -16,6 +16,8 @@
@update-properties="updateDisplayColumns" @update-properties="updateDisplayColumns"
legacyQuery legacyQuery
readOnly readOnly
:defaultScope="false"
:defaultTimeRange="false"
/> />
<el-table <el-table
@@ -472,7 +474,7 @@
backfill: cleanBackfill.value backfill: cleanBackfill.value
}) })
.then((newTrigger: any) => { .then((newTrigger: any) => {
(window as any).$toast().saved(newTrigger.id); toast.saved(newTrigger.triggerId);
triggers.value = triggers.value.map((t: any) => { triggers.value = triggers.value.map((t: any) => {
if (t.id === newTrigger.id) { if (t.id === newTrigger.id) {
return newTrigger return newTrigger
@@ -493,7 +495,7 @@
const pauseBackfill = (trigger: any) => { const pauseBackfill = (trigger: any) => {
triggerStore.pauseBackfill(trigger) triggerStore.pauseBackfill(trigger)
.then((newTrigger: any) => { .then((newTrigger: any) => {
toast.saved(newTrigger.id); toast.saved(newTrigger.triggerId);
triggers.value = triggers.value.map((t: any) => { triggers.value = triggers.value.map((t: any) => {
if (t.id === newTrigger.id) { if (t.id === newTrigger.id) {
return newTrigger return newTrigger
@@ -506,7 +508,7 @@
const unpauseBackfill = (trigger: any) => { const unpauseBackfill = (trigger: any) => {
triggerStore.unpauseBackfill(trigger) triggerStore.unpauseBackfill(trigger)
.then((newTrigger: any) => { .then((newTrigger: any) => {
toast.saved(newTrigger.id); toast.saved(newTrigger.triggerId);
triggers.value = triggers.value.map((t: any) => { triggers.value = triggers.value.map((t: any) => {
if (t.id === newTrigger.id) { if (t.id === newTrigger.id) {
return newTrigger return newTrigger
@@ -519,7 +521,7 @@
const deleteBackfill = (trigger: any) => { const deleteBackfill = (trigger: any) => {
triggerStore.deleteBackfill(trigger) triggerStore.deleteBackfill(trigger)
.then((newTrigger: any) => { .then((newTrigger: any) => {
toast.saved(newTrigger.id); toast.saved(newTrigger.triggerId);
triggers.value = triggers.value.map((t: any) => { triggers.value = triggers.value.map((t: any) => {
if (t.id === newTrigger.id) { if (t.id === newTrigger.id) {
return newTrigger return newTrigger
@@ -532,7 +534,7 @@
const setDisabled = (trigger: any, value: boolean) => { const setDisabled = (trigger: any, value: boolean) => {
triggerStore.update({...trigger, disabled: !value}) triggerStore.update({...trigger, disabled: !value})
.then((newTrigger: any) => { .then((newTrigger: any) => {
toast.saved(newTrigger.id); toast.saved(newTrigger.triggerId);
triggers.value = triggers.value.map((t: any) => { triggers.value = triggers.value.map((t: any) => {
if (t.id === newTrigger.id) { if (t.id === newTrigger.id) {
return newTrigger return newTrigger
@@ -548,7 +550,7 @@
flowId: trigger.flowId, flowId: trigger.flowId,
triggerId: trigger.triggerId triggerId: trigger.triggerId
}).then((newTrigger: any) => { }).then((newTrigger: any) => {
toast.saved(newTrigger.id); toast.saved(newTrigger.triggerId);
triggers.value = triggers.value.map((t: any) => { triggers.value = triggers.value.map((t: any) => {
if (t.id === newTrigger.id) { if (t.id === newTrigger.id) {
return newTrigger return newTrigger
@@ -564,7 +566,7 @@
flowId: trigger.flowId, flowId: trigger.flowId,
triggerId: trigger.triggerId triggerId: trigger.triggerId
}).then((newTrigger: any) => { }).then((newTrigger: any) => {
toast.saved(newTrigger.id); toast.saved(newTrigger.triggerId);
triggers.value = triggers.value.map((t: any) => { triggers.value = triggers.value.map((t: any) => {
if (t.id === newTrigger.id) { if (t.id === newTrigger.id) {
return newTrigger return newTrigger

View File

@@ -53,6 +53,7 @@
refresh: {shown: true, callback: refresh} refresh: {shown: true, callback: refresh}
}" }"
@update-properties="updateDisplayColumns" @update-properties="updateDisplayColumns"
:defaultScope="!route.name?.toString().startsWith('namespaces/')"
/> />
</template> </template>
@@ -204,6 +205,7 @@
<template #default="scope"> <template #default="scope">
<TimeSeries <TimeSeries
:chart="mappedChart(scope.row.id, scope.row.namespace)" :chart="mappedChart(scope.row.id, scope.row.namespace)"
:filters="chartFilters()"
showDefault showDefault
short short
/> />
@@ -248,8 +250,8 @@
<script setup lang="ts"> <script setup lang="ts">
import {ref, computed, onMounted, useTemplateRef} from "vue"; import {ref, computed, useTemplateRef} from "vue";
import {useRoute, useRouter} from "vue-router"; import {useRoute} from "vue-router";
import {useI18n} from "vue-i18n"; import {useI18n} from "vue-i18n";
import _merge from "lodash/merge"; import _merge from "lodash/merge";
import * as FILTERS from "../../utils/filters"; import * as FILTERS from "../../utils/filters";
@@ -272,7 +274,6 @@
import TriggerAvatar from "./TriggerAvatar.vue"; import TriggerAvatar from "./TriggerAvatar.vue";
import DataTable from "../layout/DataTable.vue"; import DataTable from "../layout/DataTable.vue";
import BulkSelect from "../layout/BulkSelect.vue"; import BulkSelect from "../layout/BulkSelect.vue";
//@ts-expect-error no declaration file
import SelectTable from "../layout/SelectTable.vue"; import SelectTable from "../layout/SelectTable.vue";
import KSFilter from "../filter/components/KSFilter.vue"; import KSFilter from "../filter/components/KSFilter.vue";
import MarkdownTooltip from "../layout/MarkdownTooltip.vue"; import MarkdownTooltip from "../layout/MarkdownTooltip.vue";
@@ -283,17 +284,16 @@
import permission from "../../models/permission"; import permission from "../../models/permission";
import {useToast} from "../../utils/toast"; import {useToast} from "../../utils/toast";
import {defaultNamespace} from "../../composables/useNamespaces";
import {useFlowStore} from "../../stores/flow"; import {useFlowStore} from "../../stores/flow";
import {useAuthStore} from "override/stores/auth"; import {useAuthStore} from "override/stores/auth";
import {useMiscStore} from "override/stores/misc";
import {useExecutionsStore} from "../../stores/executions"; import {useExecutionsStore} from "../../stores/executions";
import {useTableColumns} from "../../composables/useTableColumns"; import {useTableColumns} from "../../composables/useTableColumns";
import {DataTableRef, useDataTableActions} from "../../composables/useDataTableActions"; import {DataTableRef, useDataTableActions} from "../../composables/useDataTableActions";
import {useSelectTableActions} from "../../composables/useSelectTableActions"; import {useSelectTableActions} from "../../composables/useSelectTableActions";
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
topbar?: boolean; topbar?: boolean;
namespace?: string; namespace?: string;
@@ -307,9 +307,9 @@
const flowStore = useFlowStore(); const flowStore = useFlowStore();
const authStore = useAuthStore(); const authStore = useAuthStore();
const executionsStore = useExecutionsStore(); const executionsStore = useExecutionsStore();
const miscStore = useMiscStore();
const route = useRoute(); const route = useRoute();
const router = useRouter();
const {t} = useI18n(); const {t} = useI18n();
const toast = useToast() const toast = useToast()
@@ -622,24 +622,14 @@
return MAPPED_CHARTS; return MAPPED_CHARTS;
} }
onMounted(() => { function chartFilters() {
const query = {...route.query}; const DEFAULT_DURATION = miscStore.configs?.chartDefaultDuration ?? "P30D";
const queryKeys = Object.keys(query); return [{
let queryHasChanged = false; field: "timeRange",
value: DEFAULT_DURATION,
if (props.namespace === undefined && defaultNamespace() && !queryKeys.some(key => key.startsWith("filters[namespace]"))) { operation: "EQUALS"
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});
});
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">

View File

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

View File

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

View File

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

View File

@@ -19,6 +19,7 @@
:creating="isCreating" :creating="isCreating"
:path="path" :path="path"
:diffOverviewBar="false" :diffOverviewBar="false"
:scrollKey="editorScrollKey"
@update:model-value="editorUpdate" @update:model-value="editorUpdate"
@cursor="updatePluginDocumentation" @cursor="updatePluginDocumentation"
@save="flow ? saveFlowYaml(): saveFileContent()" @save="flow ? saveFlowYaml(): saveFileContent()"
@@ -224,6 +225,19 @@
const namespacesStore = useNamespacesStore(); const namespacesStore = useNamespacesStore();
const miscStore = useMiscStore(); 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() { function loadPluginsHash() {
miscStore.loadConfigs().then(config => { miscStore.loadConfigs().then(config => {
hash.value = config.pluginsHash; hash.value = config.pluginsHash;

View File

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

View File

@@ -304,6 +304,7 @@
const suggestWidgetResizeObserver = ref<MutationObserver>() const suggestWidgetResizeObserver = ref<MutationObserver>()
const suggestWidgetObserver = ref<MutationObserver>() const suggestWidgetObserver = ref<MutationObserver>()
const suggestWidget = ref<HTMLElement>() const suggestWidget = ref<HTMLElement>()
const resizeObserver = ref<ResizeObserver>()
defineExpose({ defineExpose({
focus, focus,
@@ -871,6 +872,20 @@
setTimeout(() => monaco.editor.remeasureFonts(), 1) setTimeout(() => monaco.editor.remeasureFonts(), 1)
emit("editorDidMount", editorResolved.value); 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(); highlightLine();
} }
@@ -928,6 +943,8 @@
function destroy() { function destroy() {
disposeObservers(); disposeObservers();
disposeCompletions.value?.(); disposeCompletions.value?.();
resizeObserver.value?.disconnect();
resizeObserver.value = undefined;
if (localDiffEditor.value !== undefined) { if (localDiffEditor.value !== undefined) {
localDiffEditor.value?.dispose(); localDiffEditor.value?.dispose();
localDiffEditor.value?.getModel()?.modified?.dispose(); localDiffEditor.value?.getModel()?.modified?.dispose();

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="position-relative"> <div ref="container" class="position-relative">
<div v-if="hasSelection && data.length" class="bulk-select-header"> <div v-if="hasSelection && data.length" class="bulk-select-header">
<slot name="select-actions" /> <slot name="select-actions" />
</div> </div>
@@ -9,12 +9,8 @@
v-bind="$attrs" v-bind="$attrs"
:data :data
:rowKey :rowKey
:emptyText="data.length === 0 && infiniteScrollLoad === undefined ? noDataText : ''" :emptyText="data.length === 0 ? noDataText : ''"
@selection-change="selectionChanged" @selection-change="selectionChanged"
v-el-table-infinite-scroll="infiniteScrollLoadWithDisableHandling"
:infiniteScrollDisabled="infiniteScrollLoad === undefined ? true : infiniteScrollDisabled"
:infiniteScrollDelay="0"
:height="data.length === 0 && infiniteScrollLoad === undefined ? '100px' : tableHeight"
> >
<el-table-column type="selection" v-if="selectable && showSelection" reserveSelection /> <el-table-column type="selection" v-if="selectable && showSelection" reserveSelection />
<slot name="default" /> <slot name="default" />
@@ -22,155 +18,107 @@
</div> </div>
</template> </template>
<script> <script setup lang="ts">
import elTableInfiniteScroll from "el-table-infinite-scroll"; import {ref, onMounted, onUnmounted, onUpdated, watch} from "vue";
export default { const props = withDefaults(defineProps<{
data() { showSelection?: boolean;
return { selectable?: boolean;
hasSelection: false, expandable?: boolean;
infiniteScrollDisabled: false, data?: any[];
tableHeight: this.infiniteScrollLoad === undefined ? "auto" : "100%" noDataText?: string;
} rowKey?: string | ((row: any) => string | number);
}, }>(), {
expose: ["resetInfiniteScroll", "setSelection", "waitTableRender", "toggleRowExpansion"], showSelection: true,
computed: { selectable: true,
scrollWrapper() { expandable: false,
if (this.data) { data: () => [],
return this.$refs.table?.$el?.querySelector(".el-scrollbar__wrap"); noDataText: undefined,
} rowKey: "id"
});
return undefined; const emit = defineEmits<{
}, "selection-change": [selection: any[]];
tableView() { }>();
if (this.data) {
return this.scrollWrapper?.querySelector(".el-scrollbar__view");
}
return undefined; const table = ref<any>(null);
}, const hasSelection = ref(false);
stillHaveDataToFetch() { const container = ref<HTMLElement>();
return this.infiniteScrollDisabled === false;
},
},
directives: {
elTableInfiniteScroll
},
methods: {
async resetInfiniteScroll() {
this.infiniteScrollDisabled = false;
this.tableHeight = await this.computeTableHeight();
},
async toggleRowExpansion(row, expand){
this.$refs.table.toggleRowExpansion(row, expand)
// this.$refs.table.clearSelection()
},
async waitTableRender() {
if (this.tableView === undefined) {
return Promise.resolve();
}
if (this.tableView.querySelectorAll(".el-table__body > tbody > *")?.length === this.data?.length) { const toggleRowExpansion = (row: any, expand?: boolean) => {
return Promise.resolve(); table.value?.toggleRowExpansion(row, expand);
} };
return new Promise(resolve => { const selectionChanged = (selection: any[]) => {
const observer = new MutationObserver(([{target}]) => { hasSelection.value = selection.length > 0;
if (target.childElementCount === this.data?.length) { emit("selection-change", selection);
observer.disconnect(); };
resolve();
}
});
observer.observe(this.tableView.querySelector(".el-table__body > tbody"), {childList: true}); const clearSelection = () => {
}); table.value?.clearSelection();
}, hasSelection.value = false;
selectionChanged(selection) { };
this.hasSelection = selection.length > 0;
this.$emit("selection-change", selection);
},
setSelection(selection) {
this.$refs.table.clearSelection();
if (Array.isArray(selection)) {
const isFunction = typeof this.rowKey === "function";
selection.forEach(sel => {
const row = this.data.find(r => isFunction
? this.rowKey(r) === this.rowKey(sel)
: r[this.rowKey] === sel[this.rowKey]);
if (row) this.$refs.table.toggleRowSelection(row, true);
});
}
this.selectionChanged(selection);
},
computeHeaderSize() {
const tableElement = this.$refs.table?.$el;
if(!tableElement) return; const setSelection = (selection: any[]) => {
table.value?.clearSelection();
if (Array.isArray(selection)) {
const isFunction = typeof props.rowKey === "function";
selection.forEach(sel => {
const row = props.data.find(r => isFunction
? props.rowKey(r) === props.rowKey(sel)
: r[props.rowKey] === sel[props.rowKey]);
if (row) table.value?.toggleRowSelection(row, true);
});
}
selectionChanged(selection);
};
this.$el.style.setProperty("--table-header-width", `${tableElement.clientWidth}px`); const computeHeaderSize = () => {
this.$el.style.setProperty("--table-header-height", `${tableElement.querySelector("thead").clientHeight}px`); const tableElement = table.value?.$el;
}, if (!tableElement || !container.value) return;
async computeTableHeight() { container.value.style.setProperty("--table-header-width", `${tableElement.clientWidth}px`);
await this.waitTableRender(); container.value.style.setProperty("--table-header-height", `${tableElement.querySelector("thead").clientHeight}px`);
};
if (this.infiniteScrollLoad === undefined || this.scrollWrapper === undefined) { onMounted(() => {
return "auto"; window.addEventListener("resize", computeHeaderSize);
} });
if (!this.stillHaveDataToFetch && this.data.length === 0) { onUnmounted(() => {
return "calc(var(--table-header-height) + 60px)"; window.removeEventListener("resize", computeHeaderSize);
} });
return this.stillHaveDataToFetch || this.tableView === undefined ? "100%" : `min(${this.tableView.scrollHeight}px, 100%)`; onUpdated(() => {
}, computeHeaderSize();
async infiniteScrollLoadWithDisableHandling() { });
let load = await this.infiniteScrollLoad?.();
while (load !== undefined && load.length === 0) {
load = await this.infiniteScrollLoad?.();
}
this.infiniteScrollDisabled = load === undefined; watch(() => props.data, () => {
if (props.data.length === 0) {
return load; hasSelection.value = false;
} table.value?.clearSelection();
}, } else {
props: { const currentSelection = table.value?.getSelectionRows() ?? [];
showSelection: {type: Boolean, default: true}, const validSelection = currentSelection.filter((sel: any) => {
selectable: {type: Boolean, default: true}, const isFunction = typeof props.rowKey === "function";
expandable: {type: Boolean, default: false}, return props.data.some(r => isFunction
data: {type: Array, default: () => []}, ? props.rowKey(r) === props.rowKey(sel)
noDataText: {type: String, default: undefined}, : r[props.rowKey] === sel[props.rowKey]);
infiniteScrollLoad: {type: Function, default: undefined}, });
rowKey: {type: [String, Function], default: "id"} if (validSelection.length !== currentSelection.length) {
}, table.value?.clearSelection();
emits: [ hasSelection.value = false;
"selection-change" } else if (table.value) {
], selectionChanged(currentSelection);
async mounted() {
window.addEventListener("resize", this.computeHeaderSize);
},
unmounted() {
window.removeEventListener("resize", this.computeHeaderSize);
},
updated() {
this.computeHeaderSize();
},
watch: {
data: {
async handler() {
this.tableHeight = await this.computeTableHeight();
},
immediate: true
},
async stillHaveDataToFetch(newVal, oldVal) {
if (oldVal !== newVal) {
this.tableHeight = await this.computeTableHeight();
}
} }
} }
} }, {immediate: true});
</script>
defineExpose({
setSelection,
clearSelection,
toggleRowExpansion
});
</script>
<style scoped lang="scss"> <style scoped lang="scss">
.bulk-select-header { .bulk-select-header {
z-index: 1; z-index: 1;
@@ -186,4 +134,12 @@
z-index: 0; z-index: 0;
} }
} }
@media (max-width: 500px) {
:deep(.el-table__empty-text) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
</style> </style>

View File

@@ -2,7 +2,7 @@
<TopNavBar v-if="!embed" :title="routeInfo.title" /> <TopNavBar v-if="!embed" :title="routeInfo.title" />
<section v-bind="$attrs" :class="{'container': !embed}" class="log-panel"> <section v-bind="$attrs" :class="{'container': !embed}" class="log-panel">
<div class="log-content"> <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"> <template #navbar v-if="!embed || showFilters">
<KSFilter <KSFilter
:configuration="logFilter" :configuration="logFilter"
@@ -11,8 +11,9 @@
refresh: {shown: true, callback: refresh}, refresh: {shown: true, callback: refresh},
columns: {shown: false} columns: {shown: false}
}" }"
:defaultScope="false"
/> />
</template> </template>xx
<template v-if="showStatChart()" #top> <template v-if="showStatChart()" #top>
<Sections ref="dashboard" :charts :dashboard="{id: 'default', charts: []}" showDefault /> <Sections ref="dashboard" :charts :dashboard="{id: 'default', charts: []}" showDefault />
@@ -20,7 +21,7 @@
<template #table> <template #table>
<div v-loading="isLoading"> <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 <LogLine
v-for="(log, i) in logsStore.logs" v-for="(log, i) in logsStore.logs"
:key="`${log.taskRunId}-${i}`" :key="`${log.taskRunId}-${i}`"
@@ -42,6 +43,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {ref, computed, onMounted, watch, useTemplateRef} 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 {useLogFilter} from "../filter/configurations";
import KSFilter from "../filter/components/KSFilter.vue"; import KSFilter from "../filter/components/KSFilter.vue";
import Sections from "../dashboard/sections/Sections.vue"; import Sections from "../dashboard/sections/Sections.vue";
@@ -49,193 +55,151 @@
import TopNavBar from "../../components/layout/TopNavBar.vue"; import TopNavBar from "../../components/layout/TopNavBar.vue";
import LogLine from "../logs/LogLine.vue"; import LogLine from "../logs/LogLine.vue";
import NoData from "../layout/NoData.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 {storageKeys} from "../../utils/constants";
import {decodeSearchParams} from "../filter/utils/helpers"; import {decodeSearchParams} from "../filter/utils/helpers";
import * as YAML_UTILS from "@kestra-io/ui-libs/flow-yaml-utils"; import * as YAML_UTILS from "@kestra-io/ui-libs/flow-yaml-utils";
import YAML_CHART from "../dashboard/assets/logs_timeseries_chart.yaml?raw"; import YAML_CHART from "../dashboard/assets/logs_timeseries_chart.yaml?raw";
import {useLogsStore} from "../../stores/logs"; import {useLogsStore} from "../../stores/logs";
import {defaultNamespace} from "../../composables/useNamespaces"; import {useDataTableActions} from "../../composables/useDataTableActions";
import {defineComponent} from "vue"; import useRouteContext from "../../composables/useRouteContext";
export default defineComponent({ const props = withDefaults(defineProps<{
mixins: [RouteContext, RestoreUrl, DataTableActions], logLevel?: string;
props: { embed?: boolean;
logLevel: { showFilters?: boolean;
type: String, filters?: Record<string, any>;
default: undefined reloadLogs?: number;
}, }>(), {
embed: { embed: false,
type: Boolean, showFilters: false,
default: false filters: undefined,
}, logLevel: undefined,
showFilters: { reloadLogs: undefined
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);
}
// the default is PT30D const route = useRoute();
return this.$moment().subtract(7, "days").toISOString(true); const {t} = useI18n();
}, const logsStore = useLogsStore();
namespace() { const logFilter = useLogFilter();
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 queryKeys = Object.keys(query); const routeInfo = computed(() => ({
if (defaultNamespace() && !queryKeys.some(key => key.startsWith("filters[namespace]"))) { title: t("logs"),
query["filters[namespace][PREFIX]"] = defaultNamespace(); }));
queryHasChanged = true; useRouteContext(routeInfo, props.embed);
}
if (queryHasChanged) { const isLoading = ref(false);
next({ const lastRefreshDate = ref(new Date());
...to, const showChart = ref(localStorage.getItem(storageKeys.SHOW_LOGS_CHART) !== "false");
query, const dashboardRef = useTemplateRef("dashboard");
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();
if (this.isFlowEdit) { const isFlowEdit = computed(() => route.name === "flows/update");
queryFilter["filters[namespace][EQUALS]"] = this.namespace; const isNamespaceEdit = computed(() => route.name === "namespaces/update");
queryFilter["filters[flowId][EQUALS]"] = this.flowId; const selectedLogLevel = computed(() => {
} else if (this.isNamespaceEdit) { const decodedParams = decodeSearchParams(route.query);
queryFilter["filters[namespace][EQUALS]"] = this.namespace; 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"]) { // the default is PT30D
queryFilter["startDate"] = this.startDate; return moment().subtract(7, "days").toISOString(true);
queryFilter["endDate"] = this.endDate; });
} 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) if (isFlowEdit.value) {
}, queryFilter["filters[namespace][EQUALS]"] = namespace.value;
load() { queryFilter["filters[flowId][EQUALS]"] = flowId.value;
this.isLoading = true } else if (isNamespaceEdit.value) {
queryFilter["filters[namespace][EQUALS]"] = namespace.value;
}
const data = { if (!queryFilter["startDate"] || !queryFilter["endDate"]) {
page: this.filters ? this.internalPageNumber : this.$route.query.page || this.internalPageNumber, queryFilter["startDate"] = startDate.value;
size: this.filters ? this.internalPageSize : this.$route.query.size || this.internalPageSize, queryFilter["endDate"] = endDate.value;
...this.filters }
};
this.logsStore.findLogs(this.loadQuery({
...data,
minLevel: this.filters ? null : this.selectedLogLevel,
sort: "timestamp:desc"
}))
.finally(() => {
this.isLoading = false
this.saveRestoreUrl();
});
}, delete queryFilter["level"];
},
watch: { return _merge(base, queryFilter);
reloadLogs(newValue) { };
if(newValue) this.refresh();
}, 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> </script>

View File

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

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