mirror of
https://github.com/kestra-io/kestra.git
synced 2025-12-25 11:12:12 -05:00
Compare commits
49 Commits
fix/remove
...
v1.1.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbbc0824ff | ||
|
|
842b8d604b | ||
|
|
bd5ac06c5b | ||
|
|
335fe1e88c | ||
|
|
5c52ab300a | ||
|
|
756069f1a6 | ||
|
|
faba958f08 | ||
|
|
a772a61d62 | ||
|
|
f2cb79cb98 | ||
|
|
9ea0b1cebb | ||
|
|
867dc20d47 | ||
|
|
c669759afb | ||
|
|
7e3cd8a2cb | ||
|
|
f203c5f43a | ||
|
|
f4e90cc540 | ||
|
|
ce0fd58c94 | ||
|
|
f1b950941c | ||
|
|
559f3f2634 | ||
|
|
9bc65b84f1 | ||
|
|
223b137381 | ||
|
|
80d1df6eeb | ||
|
|
a87e7f3b8d | ||
|
|
710862ef33 | ||
|
|
d74f535ea1 | ||
|
|
1673f24356 | ||
|
|
2ad90625b8 | ||
|
|
e77b80a1a8 | ||
|
|
6223b1f672 | ||
|
|
23329f4d48 | ||
|
|
ed60cb6670 | ||
|
|
f6306883b4 | ||
|
|
89433dc04c | ||
|
|
4837408c59 | ||
|
|
5a8c36caa5 | ||
|
|
a2335abc0c | ||
|
|
310a7bbbe9 | ||
|
|
162feaf38c | ||
|
|
94050be49c | ||
|
|
848a5ac9d7 | ||
|
|
9ac7a9ce9a | ||
|
|
c42838f3e1 | ||
|
|
c499d62b63 | ||
|
|
8fbc62e12c | ||
|
|
ae143f29f4 | ||
|
|
e4a11fc9ce | ||
|
|
ebacfc70b9 | ||
|
|
5bf67180a3 | ||
|
|
1e670b5e7e | ||
|
|
0dacad5ee1 |
@@ -2,6 +2,7 @@ package io.kestra.cli.commands.migrations.metadata;
|
||||
|
||||
import io.kestra.cli.AbstractCommand;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.inject.Provider;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import picocli.CommandLine;
|
||||
|
||||
@@ -12,13 +13,13 @@ import picocli.CommandLine;
|
||||
@Slf4j
|
||||
public class KvMetadataMigrationCommand extends AbstractCommand {
|
||||
@Inject
|
||||
private MetadataMigrationService metadataMigrationService;
|
||||
private Provider<MetadataMigrationService> metadataMigrationServiceProvider;
|
||||
|
||||
@Override
|
||||
public Integer call() throws Exception {
|
||||
super.call();
|
||||
try {
|
||||
metadataMigrationService.kvMigration();
|
||||
metadataMigrationServiceProvider.get().kvMigration();
|
||||
} catch (Exception e) {
|
||||
System.err.println("❌ KV Metadata migration failed: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
|
||||
@@ -2,6 +2,7 @@ package io.kestra.cli.commands.migrations.metadata;
|
||||
|
||||
import io.kestra.cli.AbstractCommand;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.inject.Provider;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import picocli.CommandLine;
|
||||
|
||||
@@ -12,13 +13,13 @@ import picocli.CommandLine;
|
||||
@Slf4j
|
||||
public class SecretsMetadataMigrationCommand extends AbstractCommand {
|
||||
@Inject
|
||||
private MetadataMigrationService metadataMigrationService;
|
||||
private Provider<MetadataMigrationService> metadataMigrationServiceProvider;
|
||||
|
||||
@Override
|
||||
public Integer call() throws Exception {
|
||||
super.call();
|
||||
try {
|
||||
metadataMigrationService.secretMigration();
|
||||
metadataMigrationServiceProvider.get().secretMigration();
|
||||
} catch (Exception e) {
|
||||
System.err.println("❌ Secrets Metadata migration failed: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package io.kestra.cli.commands.servers;
|
||||
|
||||
import com.google.common.collect.ImmutableMap;
|
||||
import io.kestra.cli.services.TenantIdSelectorService;
|
||||
import io.kestra.core.models.ServerType;
|
||||
import io.kestra.core.repositories.LocalFlowRepositoryLoader;
|
||||
import io.kestra.core.runners.ExecutorInterface;
|
||||
import io.kestra.core.services.SkipExecutionService;
|
||||
import io.kestra.core.services.StartExecutorService;
|
||||
@@ -10,6 +12,8 @@ import io.micronaut.context.ApplicationContext;
|
||||
import jakarta.inject.Inject;
|
||||
import picocli.CommandLine;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -19,6 +23,9 @@ import java.util.Map;
|
||||
description = "Start the Kestra executor"
|
||||
)
|
||||
public class ExecutorCommand extends AbstractServerCommand {
|
||||
@CommandLine.Spec
|
||||
CommandLine.Model.CommandSpec spec;
|
||||
|
||||
@Inject
|
||||
private ApplicationContext applicationContext;
|
||||
|
||||
@@ -28,22 +35,28 @@ public class ExecutorCommand extends AbstractServerCommand {
|
||||
@Inject
|
||||
private StartExecutorService startExecutorService;
|
||||
|
||||
@CommandLine.Option(names = {"--skip-executions"}, split=",", description = "The list of execution identifiers to skip, separated by a coma; for troubleshooting purpose only")
|
||||
@CommandLine.Option(names = {"-f", "--flow-path"}, description = "Tenant identifier required to load flows from the specified path")
|
||||
private File flowPath;
|
||||
|
||||
@CommandLine.Option(names = "--tenant", description = "Tenant identifier, Required to load flows from path")
|
||||
private String tenantId;
|
||||
|
||||
@CommandLine.Option(names = {"--skip-executions"}, split=",", description = "List of execution IDs to skip, separated by commas; for troubleshooting only")
|
||||
private List<String> skipExecutions = Collections.emptyList();
|
||||
|
||||
@CommandLine.Option(names = {"--skip-flows"}, split=",", description = "The list of flow identifiers (tenant|namespace|flowId) to skip, separated by a coma; for troubleshooting purpose only")
|
||||
@CommandLine.Option(names = {"--skip-flows"}, split=",", description = "List of flow identifiers (tenant|namespace|flowId) to skip, separated by a coma; for troubleshooting only")
|
||||
private List<String> skipFlows = Collections.emptyList();
|
||||
|
||||
@CommandLine.Option(names = {"--skip-namespaces"}, split=",", description = "The list of namespace identifiers (tenant|namespace) to skip, separated by a coma; for troubleshooting purpose only")
|
||||
@CommandLine.Option(names = {"--skip-namespaces"}, split=",", description = "List of namespace identifiers (tenant|namespace) to skip, separated by a coma; for troubleshooting only")
|
||||
private List<String> skipNamespaces = Collections.emptyList();
|
||||
|
||||
@CommandLine.Option(names = {"--skip-tenants"}, split=",", description = "The list of tenants to skip, separated by a coma; for troubleshooting purpose only")
|
||||
@CommandLine.Option(names = {"--skip-tenants"}, split=",", description = "List of tenants to skip, separated by a coma; for troubleshooting only")
|
||||
private List<String> skipTenants = Collections.emptyList();
|
||||
|
||||
@CommandLine.Option(names = {"--start-executors"}, split=",", description = "The list of Kafka Stream executors to start, separated by a command. Use it only with the Kafka queue, for debugging purpose.")
|
||||
@CommandLine.Option(names = {"--start-executors"}, split=",", description = "List of Kafka Stream executors to start, separated by a command. Use it only with the Kafka queue; for debugging only")
|
||||
private List<String> startExecutors = Collections.emptyList();
|
||||
|
||||
@CommandLine.Option(names = {"--not-start-executors"}, split=",", description = "The list of Kafka Stream executors to not start, separated by a command. Use it only with the Kafka queue, for debugging purpose.")
|
||||
@CommandLine.Option(names = {"--not-start-executors"}, split=",", description = "Lst of Kafka Stream executors to not start, separated by a command. Use it only with the Kafka queue; for debugging only")
|
||||
private List<String> notStartExecutors = Collections.emptyList();
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
@@ -64,6 +77,16 @@ public class ExecutorCommand extends AbstractServerCommand {
|
||||
|
||||
super.call();
|
||||
|
||||
if (flowPath != null) {
|
||||
try {
|
||||
LocalFlowRepositoryLoader localFlowRepositoryLoader = applicationContext.getBean(LocalFlowRepositoryLoader.class);
|
||||
TenantIdSelectorService tenantIdSelectorService = applicationContext.getBean(TenantIdSelectorService.class);
|
||||
localFlowRepositoryLoader.load(tenantIdSelectorService.getTenantId(this.tenantId), this.flowPath);
|
||||
} catch (IOException e) {
|
||||
throw new CommandLine.ParameterException(this.spec.commandLine(), "Invalid flow path", e);
|
||||
}
|
||||
}
|
||||
|
||||
ExecutorInterface executorService = applicationContext.getBean(ExecutorInterface.class);
|
||||
executorService.run();
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ public class IndexerCommand extends AbstractServerCommand {
|
||||
@Inject
|
||||
private SkipExecutionService skipExecutionService;
|
||||
|
||||
@CommandLine.Option(names = {"--skip-indexer-records"}, split=",", description = "a list of indexer record keys, separated by a coma; for troubleshooting purpose only")
|
||||
@CommandLine.Option(names = {"--skip-indexer-records"}, split=",", description = "a list of indexer record keys, separated by a coma; for troubleshooting only")
|
||||
private List<String> skipIndexerRecords = Collections.emptyList();
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
|
||||
@@ -42,7 +42,7 @@ public class StandAloneCommand extends AbstractServerCommand {
|
||||
@Nullable
|
||||
private FileChangedEventListener fileWatcher;
|
||||
|
||||
@CommandLine.Option(names = {"-f", "--flow-path"}, description = "the flow path containing flow to inject at startup (when running with a memory flow repository)")
|
||||
@CommandLine.Option(names = {"-f", "--flow-path"}, description = "Tenant identifier required to load flows from the specified path")
|
||||
private File flowPath;
|
||||
|
||||
@CommandLine.Option(names = "--tenant", description = "Tenant identifier, Required to load flows from path with the enterprise edition")
|
||||
@@ -51,19 +51,19 @@ public class StandAloneCommand extends AbstractServerCommand {
|
||||
@CommandLine.Option(names = {"--worker-thread"}, description = "the number of worker threads, defaults to eight times the number of available processors. Set it to 0 to avoid starting a worker.")
|
||||
private int workerThread = defaultWorkerThread();
|
||||
|
||||
@CommandLine.Option(names = {"--skip-executions"}, split=",", description = "a list of execution identifiers to skip, separated by a coma; for troubleshooting purpose only")
|
||||
@CommandLine.Option(names = {"--skip-executions"}, split=",", description = "a list of execution identifiers to skip, separated by a coma; for troubleshooting only")
|
||||
private List<String> skipExecutions = Collections.emptyList();
|
||||
|
||||
@CommandLine.Option(names = {"--skip-flows"}, split=",", description = "a list of flow identifiers (namespace.flowId) to skip, separated by a coma; for troubleshooting purpose only")
|
||||
@CommandLine.Option(names = {"--skip-flows"}, split=",", description = "a list of flow identifiers (namespace.flowId) to skip, separated by a coma; for troubleshooting only")
|
||||
private List<String> skipFlows = Collections.emptyList();
|
||||
|
||||
@CommandLine.Option(names = {"--skip-namespaces"}, split=",", description = "a list of namespace identifiers (tenant|namespace) to skip, separated by a coma; for troubleshooting purpose only")
|
||||
@CommandLine.Option(names = {"--skip-namespaces"}, split=",", description = "a list of namespace identifiers (tenant|namespace) to skip, separated by a coma; for troubleshooting only")
|
||||
private List<String> skipNamespaces = Collections.emptyList();
|
||||
|
||||
@CommandLine.Option(names = {"--skip-tenants"}, split=",", description = "a list of tenants to skip, separated by a coma; for troubleshooting purpose only")
|
||||
@CommandLine.Option(names = {"--skip-tenants"}, split=",", description = "a list of tenants to skip, separated by a coma; for troubleshooting only")
|
||||
private List<String> skipTenants = Collections.emptyList();
|
||||
|
||||
@CommandLine.Option(names = {"--skip-indexer-records"}, split=",", description = "a list of indexer record keys, separated by a coma; for troubleshooting purpose only")
|
||||
@CommandLine.Option(names = {"--skip-indexer-records"}, split=",", description = "a list of indexer record keys, separated by a coma; for troubleshooting only")
|
||||
private List<String> skipIndexerRecords = Collections.emptyList();
|
||||
|
||||
@CommandLine.Option(names = {"--no-tutorials"}, description = "Flag to disable auto-loading of tutorial flows.")
|
||||
|
||||
@@ -40,7 +40,7 @@ public class WebServerCommand extends AbstractServerCommand {
|
||||
@Option(names = {"--no-indexer"}, description = "Flag to disable starting an embedded indexer.")
|
||||
private boolean indexerDisabled = false;
|
||||
|
||||
@CommandLine.Option(names = {"--skip-indexer-records"}, split=",", description = "a list of indexer record keys, separated by a coma; for troubleshooting purpose only")
|
||||
@CommandLine.Option(names = {"--skip-indexer-records"}, split=",", description = "a list of indexer record keys, separated by a coma; for troubleshooting only")
|
||||
private List<String> skipIndexerRecords = Collections.emptyList();
|
||||
|
||||
@Override
|
||||
|
||||
@@ -30,15 +30,15 @@ micronaut:
|
||||
read-idle-timeout: 60m
|
||||
write-idle-timeout: 60m
|
||||
idle-timeout: 60m
|
||||
netty:
|
||||
max-zstd-encode-size: 67108864 # increased to 64MB from the default of 32MB
|
||||
max-chunk-size: 10MB
|
||||
max-header-size: 32768 # increased from the default of 8k
|
||||
responses:
|
||||
file:
|
||||
cache-seconds: 86400
|
||||
cache-control:
|
||||
public: true
|
||||
netty:
|
||||
max-zstd-encode-size: 67108864 # increased to 64MB from the default of 32MB
|
||||
max-chunk-size: 10MB
|
||||
max-header-size: 32768 # increased from the default of 8k
|
||||
|
||||
# Access log configuration, see https://docs.micronaut.io/latest/guide/index.html#accessLogger
|
||||
access-logger:
|
||||
|
||||
@@ -68,7 +68,8 @@ class NoConfigCommandTest {
|
||||
|
||||
|
||||
assertThat(exitCode).isNotZero();
|
||||
assertThat(out.toString()).isEmpty();
|
||||
// check that the only log is an access log: this has the advantage to also check that access log is working!
|
||||
assertThat(out.toString()).contains("POST /api/v1/main/flows HTTP/1.1 | status: 500");
|
||||
assertThat(err.toString()).contains("No bean of type [io.kestra.core.repositories.FlowRepositoryInterface] exists");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import io.kestra.core.models.annotations.Plugin;
|
||||
import io.kestra.core.models.dashboards.filters.AbstractFilter;
|
||||
import io.kestra.core.repositories.QueryBuilderInterface;
|
||||
import io.kestra.plugin.core.dashboard.data.IData;
|
||||
import jakarta.annotation.Nullable;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
@@ -33,9 +35,12 @@ public abstract class DataFilter<F extends Enum<F>, C extends ColumnDescriptor<F
|
||||
@Pattern(regexp = JAVA_IDENTIFIER_REGEX)
|
||||
private String type;
|
||||
|
||||
@Valid
|
||||
private Map<String, C> columns;
|
||||
|
||||
@Setter
|
||||
@Valid
|
||||
@Nullable
|
||||
private List<AbstractFilter<F>> where;
|
||||
|
||||
private List<OrderBy> orderBy;
|
||||
|
||||
@@ -5,6 +5,7 @@ import io.kestra.core.models.annotations.Plugin;
|
||||
import io.kestra.core.models.dashboards.ChartOption;
|
||||
import io.kestra.core.models.dashboards.DataFilter;
|
||||
import io.kestra.core.validations.DataChartValidation;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.Getter;
|
||||
@@ -20,6 +21,7 @@ import lombok.experimental.SuperBuilder;
|
||||
@DataChartValidation
|
||||
public abstract class DataChart<P extends ChartOption, D extends DataFilter<?, ?>> extends Chart<P> implements io.kestra.core.models.Plugin {
|
||||
@NotNull
|
||||
@Valid
|
||||
private D data;
|
||||
|
||||
public Integer minNumberOfAggregations() {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package io.kestra.core.models.dashboards.filters;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.annotation.JsonSubTypes;
|
||||
import com.fasterxml.jackson.annotation.JsonTypeInfo;
|
||||
import io.micronaut.core.annotation.Introspected;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.experimental.SuperBuilder;
|
||||
@@ -32,6 +35,9 @@ import lombok.experimental.SuperBuilder;
|
||||
@SuperBuilder
|
||||
@Introspected
|
||||
public abstract class AbstractFilter<F extends Enum<F>> {
|
||||
@NotNull
|
||||
@JsonProperty(value = "field", required = true)
|
||||
@Valid
|
||||
private F field;
|
||||
private String labelKey;
|
||||
|
||||
|
||||
@@ -82,8 +82,7 @@ public abstract class FilesService {
|
||||
}
|
||||
|
||||
private static String resolveUniqueNameForFile(final Path path) {
|
||||
String filename = path.getFileName().toString();
|
||||
String encodedFilename = java.net.URLEncoder.encode(filename, java.nio.charset.StandardCharsets.UTF_8);
|
||||
return IdUtils.from(path.toString()) + "-" + encodedFilename;
|
||||
String filename = path.getFileName().toString().replace(' ', '+');
|
||||
return IdUtils.from(path.toString()) + "-" + filename;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,10 +151,7 @@ abstract class AbstractFileFunction implements Function {
|
||||
// if there is a trigger of type execution, we also allow accessing a file from the parent execution
|
||||
Map<String, String> trigger = (Map<String, String>) context.getVariable(TRIGGER);
|
||||
|
||||
if (!isFileUriValid(trigger.get(NAMESPACE), trigger.get("flowId"), trigger.get("executionId"), path)) {
|
||||
throw new IllegalArgumentException("Unable to read the file '" + path + "' as it didn't belong to the parent execution");
|
||||
}
|
||||
return true;
|
||||
return isFileUriValid(trigger.get(NAMESPACE), trigger.get("flowId"), trigger.get("executionId"), path);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -383,6 +383,7 @@ public class ExecutionService {
|
||||
if (!isFlowable || s.equals(taskRunId)) {
|
||||
TaskRun newTaskRun;
|
||||
|
||||
State.Type targetState = newState;
|
||||
if (task instanceof Pause pauseTask) {
|
||||
State.Type terminalState = newState == State.Type.RUNNING ? State.Type.SUCCESS : newState;
|
||||
Pause.Resumed _resumed = resumed != null ? resumed : Pause.Resumed.now(terminalState);
|
||||
@@ -392,23 +393,23 @@ public class ExecutionService {
|
||||
// if it's a Pause task with no subtask, we terminate the task
|
||||
if (ListUtils.isEmpty(pauseTask.getTasks()) && ListUtils.isEmpty(pauseTask.getErrors()) && ListUtils.isEmpty(pauseTask.getFinally())) {
|
||||
if (newState == State.Type.RUNNING) {
|
||||
newTaskRun = newTaskRun.withState(State.Type.SUCCESS);
|
||||
targetState = State.Type.SUCCESS;
|
||||
} else if (newState == State.Type.KILLING) {
|
||||
newTaskRun = newTaskRun.withState(State.Type.KILLED);
|
||||
} else {
|
||||
newTaskRun = newTaskRun.withState(newState);
|
||||
targetState = State.Type.KILLED;
|
||||
}
|
||||
} else {
|
||||
// we should set the state to RUNNING so that subtasks are executed
|
||||
newTaskRun = newTaskRun.withState(State.Type.RUNNING);
|
||||
targetState = State.Type.RUNNING;
|
||||
}
|
||||
newTaskRun = newTaskRun.withState(targetState);
|
||||
} else {
|
||||
newTaskRun = originalTaskRun.withState(newState);
|
||||
newTaskRun = originalTaskRun.withState(targetState);
|
||||
}
|
||||
|
||||
|
||||
if (originalTaskRun.getAttempts() != null && !originalTaskRun.getAttempts().isEmpty()) {
|
||||
ArrayList<TaskRunAttempt> attempts = new ArrayList<>(originalTaskRun.getAttempts());
|
||||
attempts.set(attempts.size() - 1, attempts.getLast().withState(newState));
|
||||
attempts.set(attempts.size() - 1, attempts.getLast().withState(targetState));
|
||||
newTaskRun = newTaskRun.withAttempts(attempts);
|
||||
}
|
||||
|
||||
|
||||
@@ -33,11 +33,13 @@ public class ExecutionsDataFilterValidator implements ConstraintValidator<Execut
|
||||
}
|
||||
});
|
||||
|
||||
executionsDataFilter.getWhere().forEach(filter -> {
|
||||
if (filter.getField() == Executions.Fields.LABELS && filter.getLabelKey() == null) {
|
||||
violations.add("Label filters must have a `labelKey`.");
|
||||
}
|
||||
});
|
||||
if (executionsDataFilter.getWhere() != null) {
|
||||
executionsDataFilter.getWhere().forEach(filter -> {
|
||||
if (filter.getField() == Executions.Fields.LABELS && filter.getLabelKey() == null) {
|
||||
violations.add("Label filters must have a `labelKey`.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!violations.isEmpty()) {
|
||||
context.disableDefaultConstraintViolation();
|
||||
|
||||
@@ -20,8 +20,6 @@ import java.io.BufferedOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
@@ -60,7 +58,15 @@ import static io.kestra.core.utils.Rethrow.throwConsumer;
|
||||
public class Download extends AbstractHttp implements RunnableTask<Download.Output> {
|
||||
@Schema(title = "Should the task fail when downloading an empty file.")
|
||||
@Builder.Default
|
||||
private final Property<Boolean> failOnEmptyResponse = Property.ofValue(true);
|
||||
private Property<Boolean> failOnEmptyResponse = Property.ofValue(true);
|
||||
|
||||
@Schema(
|
||||
title = "Name of the file inside the output.",
|
||||
description = """
|
||||
If not provided, the filename will be extracted from the `Content-Disposition` header.
|
||||
If no `Content-Disposition` header, a name would be generated."""
|
||||
)
|
||||
private Property<String> saveAs;
|
||||
|
||||
public Output run(RunContext runContext) throws Exception {
|
||||
Logger logger = runContext.logger();
|
||||
@@ -111,20 +117,22 @@ public class Download extends AbstractHttp implements RunnableTask<Download.Outp
|
||||
}
|
||||
}
|
||||
|
||||
String filename = null;
|
||||
if (response.getHeaders().firstValue("Content-Disposition").isPresent()) {
|
||||
String contentDisposition = response.getHeaders().firstValue("Content-Disposition").orElseThrow();
|
||||
filename = filenameFromHeader(runContext, contentDisposition);
|
||||
}
|
||||
if (filename != null) {
|
||||
filename = URLEncoder.encode(filename, StandardCharsets.UTF_8);
|
||||
String rFilename = runContext.render(this.saveAs).as(String.class).orElse(null);
|
||||
if (rFilename == null) {
|
||||
if (response.getHeaders().firstValue("Content-Disposition").isPresent()) {
|
||||
String contentDisposition = response.getHeaders().firstValue("Content-Disposition").orElseThrow();
|
||||
rFilename = filenameFromHeader(runContext, contentDisposition);
|
||||
if (rFilename != null) {
|
||||
rFilename = rFilename.replace(' ', '+');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug("File '{}' downloaded with size '{}'", from, size);
|
||||
|
||||
return Output.builder()
|
||||
.code(response.getStatus().getCode())
|
||||
.uri(runContext.storage().putFile(tempFile, filename))
|
||||
.uri(runContext.storage().putFile(tempFile, rFilename))
|
||||
.headers(response.getHeaders().map())
|
||||
.length(size.get())
|
||||
.build();
|
||||
|
||||
@@ -267,6 +267,12 @@ public abstract class AbstractRunnerTest {
|
||||
multipleConditionTriggerCaseTest.flowTriggerMultiplePreconditions();
|
||||
}
|
||||
|
||||
@Test
|
||||
@LoadFlows({"flows/valids/flow-trigger-multiple-conditions-flow-a.yaml", "flows/valids/flow-trigger-multiple-conditions-flow-listen.yaml"})
|
||||
void flowTriggerMultipleConditions() throws Exception {
|
||||
multipleConditionTriggerCaseTest.flowTriggerMultipleConditions();
|
||||
}
|
||||
|
||||
@Test
|
||||
@LoadFlows({"flows/valids/each-null.yaml"})
|
||||
void eachWithNull() throws Exception {
|
||||
|
||||
@@ -445,6 +445,7 @@ class ExecutionServiceTest {
|
||||
|
||||
assertThat(killed.getState().getCurrent()).isEqualTo(State.Type.CANCELLED);
|
||||
assertThat(killed.findTaskRunsByTaskId("pause").getFirst().getState().getCurrent()).isEqualTo(State.Type.KILLED);
|
||||
assertThat(killed.findTaskRunsByTaskId("pause").getFirst().getAttempts().getFirst().getState().getCurrent()).isEqualTo(State.Type.KILLED);
|
||||
assertThat(killed.getState().getHistories()).hasSize(5);
|
||||
}
|
||||
|
||||
|
||||
@@ -106,28 +106,28 @@ class FilesServiceTest {
|
||||
var runContext = runContextFactory.of();
|
||||
|
||||
Path fileWithSpace = tempDir.resolve("with space.txt");
|
||||
Path fileWithUnicode = tempDir.resolve("สวัสดี.txt");
|
||||
Path fileWithUnicode = tempDir.resolve("สวัสดี&.txt");
|
||||
|
||||
Files.writeString(fileWithSpace, "content");
|
||||
Files.writeString(fileWithUnicode, "content");
|
||||
|
||||
Path targetFileWithSpace = runContext.workingDir().path().resolve("with space.txt");
|
||||
Path targetFileWithUnicode = runContext.workingDir().path().resolve("สวัสดี.txt");
|
||||
Path targetFileWithUnicode = runContext.workingDir().path().resolve("สวัสดี&.txt");
|
||||
|
||||
Files.copy(fileWithSpace, targetFileWithSpace);
|
||||
Files.copy(fileWithUnicode, targetFileWithUnicode);
|
||||
|
||||
Map<String, URI> outputFiles = FilesService.outputFiles(
|
||||
runContext,
|
||||
List.of("with space.txt", "สวัสดี.txt")
|
||||
List.of("with space.txt", "สวัสดี&.txt")
|
||||
);
|
||||
|
||||
assertThat(outputFiles).hasSize(2);
|
||||
assertThat(outputFiles).containsKey("with space.txt");
|
||||
assertThat(outputFiles).containsKey("สวัสดี.txt");
|
||||
assertThat(outputFiles).containsKey("สวัสดี&.txt");
|
||||
|
||||
assertThat(runContext.storage().getFile(outputFiles.get("with space.txt"))).isNotNull();
|
||||
assertThat(runContext.storage().getFile(outputFiles.get("สวัสดี.txt"))).isNotNull();
|
||||
assertThat(runContext.storage().getFile(outputFiles.get("สวัสดี&.txt"))).isNotNull();
|
||||
}
|
||||
|
||||
private URI createFile() throws IOException {
|
||||
|
||||
@@ -212,4 +212,24 @@ public class MultipleConditionTriggerCaseTest {
|
||||
e -> e.getState().getCurrent().equals(Type.SUCCESS),
|
||||
MAIN_TENANT, "io.kestra.tests.trigger.multiple.preconditions", "flow-trigger-multiple-preconditions-flow-listen", Duration.ofSeconds(1)));
|
||||
}
|
||||
|
||||
public void flowTriggerMultipleConditions() throws TimeoutException, QueueException {
|
||||
Execution execution = runnerUtils.runOne(MAIN_TENANT, "io.kestra.tests.trigger.multiple.conditions",
|
||||
"flow-trigger-multiple-conditions-flow-a");
|
||||
assertThat(execution.getTaskRunList().size()).isEqualTo(1);
|
||||
assertThat(execution.getState().getCurrent()).isEqualTo(State.Type.SUCCESS);
|
||||
|
||||
// trigger is done
|
||||
Execution triggerExecution = runnerUtils.awaitFlowExecution(
|
||||
e -> e.getState().getCurrent().equals(Type.SUCCESS),
|
||||
MAIN_TENANT, "io.kestra.tests.trigger.multiple.conditions", "flow-trigger-multiple-conditions-flow-listen");
|
||||
executionRepository.delete(triggerExecution);
|
||||
assertThat(triggerExecution.getTaskRunList().size()).isEqualTo(1);
|
||||
assertThat(triggerExecution.getState().getCurrent()).isEqualTo(State.Type.SUCCESS);
|
||||
|
||||
// we assert that we didn't have any other flow triggered
|
||||
assertThrows(RuntimeException.class, () -> runnerUtils.awaitFlowExecution(
|
||||
e -> e.getState().getCurrent().equals(Type.SUCCESS),
|
||||
MAIN_TENANT, "io.kestra.tests.trigger.multiple.conditions", "flow-trigger-multiple-conditions-flow-listen", Duration.ofSeconds(1)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,33 +112,6 @@ public class FileSizeFunctionTest {
|
||||
assertThat(size).isEqualTo(FILE_SIZE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldThrowIllegalArgumentException_givenTrigger_andParentExecution_andMissingNamespace() throws IOException {
|
||||
String executionId = IdUtils.create();
|
||||
URI internalStorageURI = getInternalStorageURI(executionId);
|
||||
URI internalStorageFile = getInternalStorageFile(internalStorageURI);
|
||||
|
||||
Map<String, Object> variables = Map.of(
|
||||
"flow", Map.of(
|
||||
"id", "subflow",
|
||||
"namespace", NAMESPACE,
|
||||
"tenantId", MAIN_TENANT),
|
||||
"execution", Map.of("id", IdUtils.create()),
|
||||
"trigger", Map.of(
|
||||
"flowId", FLOW,
|
||||
"executionId", executionId,
|
||||
"tenantId", MAIN_TENANT
|
||||
)
|
||||
);
|
||||
|
||||
Exception ex = assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> variableRenderer.render("{{ fileSize('" + internalStorageFile + "') }}", variables)
|
||||
);
|
||||
|
||||
assertTrue(ex.getMessage().startsWith("Unable to read the file"), "Exception message doesn't match expected one");
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsCorrectSize_givenUri_andCurrentExecution() throws IOException, IllegalVariableEvaluationException {
|
||||
String executionId = IdUtils.create();
|
||||
|
||||
@@ -259,6 +259,27 @@ class ReadFileFunctionTest {
|
||||
assertThat(variableRenderer.render("{{ read(nsfile) }}", variables)).isEqualTo("Hello World");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReadChildFileEvenIfTrigger() throws IOException, IllegalVariableEvaluationException {
|
||||
String namespace = "my.namespace";
|
||||
String flowId = "flow";
|
||||
String executionId = IdUtils.create();
|
||||
URI internalStorageURI = URI.create("/" + namespace.replace(".", "/") + "/" + flowId + "/executions/" + executionId + "/tasks/task/" + IdUtils.create() + "/123456.ion");
|
||||
URI internalStorageFile = storageInterface.put(MAIN_TENANT, namespace, internalStorageURI, new ByteArrayInputStream("Hello from a task output".getBytes()));
|
||||
|
||||
Map<String, Object> variables = Map.of(
|
||||
"flow", Map.of(
|
||||
"id", "flow",
|
||||
"namespace", "notme",
|
||||
"tenantId", MAIN_TENANT),
|
||||
"execution", Map.of("id", "notme"),
|
||||
"trigger", Map.of("namespace", "notme", "flowId", "parent", "executionId", "parent")
|
||||
);
|
||||
|
||||
String render = variableRenderer.render("{{ read('" + internalStorageFile + "') }}", variables);
|
||||
assertThat(render).isEqualTo("Hello from a task output");
|
||||
}
|
||||
|
||||
private URI createFile() throws IOException {
|
||||
File tempFile = File.createTempFile("file", ".txt");
|
||||
Files.write(tempFile.toPath(), "Hello World".getBytes());
|
||||
|
||||
@@ -12,20 +12,24 @@ import io.kestra.core.queues.QueueInterface;
|
||||
import io.kestra.core.repositories.FlowRepositoryInterface;
|
||||
import io.kestra.core.runners.ConcurrencyLimit;
|
||||
import io.kestra.core.runners.RunnerUtils;
|
||||
import io.kestra.core.utils.TestsUtils;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.inject.Named;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.TestInstance;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
import static io.kestra.core.utils.Rethrow.throwRunnable;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
@KestraTest(startRunner = true)
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
@@ -54,14 +58,29 @@ class ConcurrencyLimitServiceTest {
|
||||
|
||||
@Test
|
||||
@LoadFlows("flows/valids/flow-concurrency-queue.yml")
|
||||
void unqueueExecution() throws QueueException, TimeoutException {
|
||||
void unqueueExecution() throws QueueException, TimeoutException, InterruptedException {
|
||||
// run a first flow so the second is queued
|
||||
runnerUtils.runOneUntilRunning(TENANT_ID, TESTS_FLOW_NS, "flow-concurrency-queue");
|
||||
Execution first = runnerUtils.runOneUntilRunning(TENANT_ID, TESTS_FLOW_NS, "flow-concurrency-queue");
|
||||
Execution result = runUntilQueued(TESTS_FLOW_NS, "flow-concurrency-queue");
|
||||
assertThat(result.getState().isQueued()).isTrue();
|
||||
|
||||
// await for the execution to be terminated
|
||||
CountDownLatch terminated = new CountDownLatch(2);
|
||||
Flux<Execution> receive = TestsUtils.receive(executionQueue, (either) -> {
|
||||
if (either.getLeft().getId().equals(first.getId()) && either.getLeft().getState().isTerminated()) {
|
||||
terminated.countDown();
|
||||
}
|
||||
if (either.getLeft().getId().equals(result.getId()) && either.getLeft().getState().isTerminated()) {
|
||||
terminated.countDown();
|
||||
}
|
||||
});
|
||||
|
||||
Execution unqueued = concurrencyLimitService.unqueue(result, State.Type.RUNNING);
|
||||
assertThat(unqueued.getState().isRunning()).isTrue();
|
||||
executionQueue.emit(unqueued);
|
||||
|
||||
assertTrue(terminated.await(10, TimeUnit.SECONDS));
|
||||
receive.blockLast();
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -73,7 +92,6 @@ class ConcurrencyLimitServiceTest {
|
||||
assertThat(limit.get().getTenantId()).isEqualTo(execution.getTenantId());
|
||||
assertThat(limit.get().getNamespace()).isEqualTo(execution.getNamespace());
|
||||
assertThat(limit.get().getFlowId()).isEqualTo(execution.getFlowId());
|
||||
assertThat(limit.get().getRunning()).isEqualTo(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -156,6 +156,26 @@ class DownloadTest {
|
||||
assertThat(output.getUri().toString()).endsWith("filename.jpg");
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
void fileNameShouldOverrideContentDisposition() throws Exception {
|
||||
EmbeddedServer embeddedServer = applicationContext.getBean(EmbeddedServer.class);
|
||||
embeddedServer.start();
|
||||
|
||||
Download task = Download.builder()
|
||||
.id(DownloadTest.class.getSimpleName())
|
||||
.type(DownloadTest.class.getName())
|
||||
.uri(Property.ofValue(embeddedServer.getURI() + "/content-disposition"))
|
||||
.saveAs(Property.ofValue("hardcoded-filename.jpg"))
|
||||
.build();
|
||||
|
||||
RunContext runContext = TestsUtils.mockRunContext(this.runContextFactory, task, ImmutableMap.of());
|
||||
|
||||
Download.Output output = task.run(runContext);
|
||||
|
||||
assertThat(output.getUri().toString()).endsWith("hardcoded-filename.jpg");
|
||||
}
|
||||
|
||||
@Test
|
||||
void contentDispositionWithPath() throws Exception {
|
||||
EmbeddedServer embeddedServer = applicationContext.getBean(EmbeddedServer.class);
|
||||
|
||||
@@ -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}}"
|
||||
@@ -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"
|
||||
@@ -1,4 +1,4 @@
|
||||
version=1.1.0-SNAPSHOT
|
||||
version=1.1.3
|
||||
|
||||
org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
org.gradle.parallel=true
|
||||
|
||||
@@ -12,7 +12,6 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
public abstract class AbstractJdbcExecutionQueuedStorage extends AbstractJdbcRepository {
|
||||
protected io.kestra.jdbc.AbstractJdbcRepository<ExecutionQueued> jdbcRepository;
|
||||
@@ -70,18 +69,12 @@ public abstract class AbstractJdbcExecutionQueuedStorage extends AbstractJdbcRep
|
||||
this.jdbcRepository
|
||||
.getDslContextWrapper()
|
||||
.transaction(configuration -> {
|
||||
var select = DSL
|
||||
.using(configuration)
|
||||
.select(AbstractJdbcRepository.field("value"))
|
||||
.from(this.jdbcRepository.getTable())
|
||||
.where(buildTenantCondition(execution.getTenantId()))
|
||||
.and(field("key").eq(IdUtils.fromParts(execution.getTenantId(), execution.getNamespace(), execution.getFlowId(), execution.getId())))
|
||||
.forUpdate();
|
||||
|
||||
Optional<ExecutionQueued> maybeExecution = this.jdbcRepository.fetchOne(select);
|
||||
if (maybeExecution.isPresent()) {
|
||||
this.jdbcRepository.delete(maybeExecution.get());
|
||||
}
|
||||
DSL
|
||||
.using(configuration)
|
||||
.deleteFrom(this.jdbcRepository.getTable())
|
||||
.where(buildTenantCondition(execution.getTenantId()))
|
||||
.and(field("key").eq(IdUtils.fromParts(execution.getTenantId(), execution.getNamespace(), execution.getFlowId(), execution.getId())))
|
||||
.execute();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1230,8 +1230,10 @@ public class JdbcExecutor implements ExecutorInterface {
|
||||
private void processFlowTriggers(Execution execution) throws QueueException {
|
||||
// directly process simple conditions
|
||||
flowTriggerService.withFlowTriggersOnly(allFlows.stream())
|
||||
.filter(f ->ListUtils.emptyOnNull(f.getTrigger().getConditions()).stream().noneMatch(c -> c instanceof MultipleCondition) && f.getTrigger().getPreconditions() == null)
|
||||
.flatMap(f -> flowTriggerService.computeExecutionsFromFlowTriggers(execution, List.of(f.getFlow()), Optional.empty()).stream())
|
||||
.filter(f -> ListUtils.emptyOnNull(f.getTrigger().getConditions()).stream().noneMatch(c -> c instanceof MultipleCondition) && f.getTrigger().getPreconditions() == null)
|
||||
.map(f -> f.getFlow())
|
||||
.distinct() // as computeExecutionsFromFlowTriggers is based on flow, we must map FlowWithFlowTrigger to a flow and distinct to avoid multiple execution for the same flow
|
||||
.flatMap(f -> flowTriggerService.computeExecutionsFromFlowTriggers(execution, List.of(f), Optional.empty()).stream())
|
||||
.forEach(throwConsumer(exec -> executionQueue.emit(exec)));
|
||||
|
||||
// send multiple conditions to the multiple condition queue for later processing
|
||||
|
||||
@@ -4,6 +4,7 @@ import io.kestra.core.models.flows.FlowWithSource;
|
||||
import io.kestra.core.models.triggers.Trigger;
|
||||
import io.kestra.core.repositories.TriggerRepositoryInterface;
|
||||
import io.kestra.core.runners.ScheduleContextInterface;
|
||||
import io.kestra.core.runners.Scheduler;
|
||||
import io.kestra.core.runners.SchedulerTriggerStateInterface;
|
||||
import io.kestra.core.services.FlowListenersInterface;
|
||||
import io.kestra.core.services.FlowService;
|
||||
@@ -56,6 +57,9 @@ public class JdbcScheduler extends AbstractScheduler {
|
||||
.forEach(abstractTrigger -> triggerRepository.delete(Trigger.of(flow, abstractTrigger)));
|
||||
}
|
||||
});
|
||||
|
||||
// No-op consumption of the trigger queue, so the events are purged from the queue
|
||||
this.triggerQueue.receive(Scheduler.class, trigger -> { });
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -25,7 +25,7 @@ import java.util.concurrent.atomic.AtomicBoolean;
|
||||
@Slf4j
|
||||
@Singleton
|
||||
public class TestRunner implements Runnable, AutoCloseable {
|
||||
@Setter private int workerThread = Math.max(3, Runtime.getRuntime().availableProcessors());
|
||||
@Setter private int workerThread = Math.max(3, Runtime.getRuntime().availableProcessors()) * 16;
|
||||
@Setter private boolean schedulerEnabled = true;
|
||||
@Setter private boolean workerEnabled = true;
|
||||
|
||||
|
||||
@@ -35,16 +35,18 @@
|
||||
<WeatherSunny v-else />
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="panelWrapper" :class="{panelTabResizing: resizing}" :style="{width: activeTab?.length ? `${panelWidth}px` : 0}">
|
||||
<div class="panelWrapper" ref="panelWrapper" :class="{panelTabResizing: resizing}" :style="{width: activeTab?.length ? `${panelWidth}px` : 0}">
|
||||
<div :style="{overflow: 'hidden'}">
|
||||
<button v-if="activeTab.length" class="closeButton" @click="setActiveTab('')">
|
||||
<Close />
|
||||
</button>
|
||||
<ContextDocs v-if="activeTab === 'docs'" />
|
||||
<ContextNews v-else-if="activeTab === 'news'" />
|
||||
<template v-else>
|
||||
{{ activeTab }}
|
||||
</template>
|
||||
<KeepAlive>
|
||||
<ContextDocs v-if="activeTab === 'docs'" />
|
||||
<ContextNews v-else-if="activeTab === 'news'" />
|
||||
<template v-else>
|
||||
{{ activeTab }}
|
||||
</template>
|
||||
</KeepAlive>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -96,6 +98,7 @@
|
||||
});
|
||||
|
||||
const panelWidth = ref(640)
|
||||
const panelWrapper = ref<HTMLDivElement | null>(null)
|
||||
|
||||
const {startResizing, resizing} = useResizablePanel(activeTab)
|
||||
|
||||
|
||||
@@ -4,14 +4,22 @@
|
||||
<slot name="back-button" />
|
||||
<h2>{{ title }}</h2>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="content" ref="contentRef">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref} from "vue";
|
||||
|
||||
defineProps<{title:string}>();
|
||||
|
||||
const contentRef = ref<HTMLDivElement | null>(null);
|
||||
|
||||
defineExpose({
|
||||
contentRef
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -197,7 +197,6 @@
|
||||
|
||||
import {trackTabOpen, trackTabClose} from "../utils/tabTracking";
|
||||
import {Panel, Tab, TabLive} from "../utils/multiPanelTypes";
|
||||
import {usePanelDefaultSize} from "../composables/usePanelDefaultSize";
|
||||
|
||||
const {t} = useI18n();
|
||||
const {showKeyShortcuts} = useKeyShortcuts();
|
||||
@@ -449,7 +448,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
const defaultSize = usePanelDefaultSize(panels);
|
||||
const defaultSize = computed(() => panels.value.length === 0 ? 1 : (panels.value.reduce((acc, panel) => acc + panel.size, 0) / panels.value.length));
|
||||
|
||||
function newPanelDrop(_e: DragEvent, direction: "left" | "right") {
|
||||
if (!movedTabInfo.value) return;
|
||||
|
||||
@@ -298,6 +298,7 @@
|
||||
<script setup lang="ts">
|
||||
import _merge from "lodash/merge";
|
||||
import {ref, computed, watch} from "vue";
|
||||
import moment from "moment";
|
||||
import {useI18n} from "vue-i18n";
|
||||
import {useRoute} from "vue-router";
|
||||
import {ElMessage} from "element-plus";
|
||||
@@ -337,7 +338,6 @@
|
||||
import SelectTable from "../layout/SelectTable.vue";
|
||||
import TriggerAvatar from "../flows/TriggerAvatar.vue";
|
||||
import KSFilter from "../filter/components/KSFilter.vue";
|
||||
import useRestoreUrl from "../../composables/useRestoreUrl";
|
||||
import MarkdownTooltip from "../layout/MarkdownTooltip.vue";
|
||||
import useRouteContext from "../../composables/useRouteContext";
|
||||
|
||||
@@ -436,8 +436,6 @@
|
||||
.filter(Boolean) as ColumnConfig[]
|
||||
);
|
||||
|
||||
const {saveRestoreUrl} = useRestoreUrl();
|
||||
|
||||
const loadData = (callback?: () => void) => {
|
||||
const query = loadQuery({
|
||||
size: parseInt(String(route.query?.size ?? "25")),
|
||||
@@ -463,8 +461,7 @@
|
||||
|
||||
const {ready, onSort, onPageChanged, queryWithFilter, load} = useDataTableActions({
|
||||
dataTableRef: dataTable,
|
||||
loadData,
|
||||
saveRestoreUrl
|
||||
loadData
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -696,7 +693,16 @@
|
||||
};
|
||||
|
||||
const loadQuery = (base: any) => {
|
||||
let queryFilter = queryWithFilter();
|
||||
const queryFilter = queryWithFilter();
|
||||
|
||||
const timeRange = queryFilter["filters[timeRange][EQUALS]"];
|
||||
if (timeRange) {
|
||||
const end = new Date();
|
||||
const start = new Date(end.getTime() - moment.duration(timeRange).asMilliseconds());
|
||||
queryFilter["filters[startDate][GREATER_THAN_OR_EQUAL_TO]"] = start.toISOString();
|
||||
queryFilter["filters[endDate][LESS_THAN_OR_EQUAL_TO]"] = end.toISOString();
|
||||
delete queryFilter["filters[timeRange][EQUALS]"];
|
||||
}
|
||||
|
||||
return _merge(base, queryFilter);
|
||||
};
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, onBeforeMount, ref, useTemplateRef} from "vue";
|
||||
import {computed, onBeforeMount, ref, useTemplateRef, watch} from "vue";
|
||||
import {stringify, parse} from "@kestra-io/ui-libs/flow-yaml-utils";
|
||||
|
||||
import type {Dashboard, Chart} from "./composables/useDashboards";
|
||||
@@ -89,9 +89,16 @@
|
||||
}
|
||||
|
||||
if (!props.isFlow && !props.isNamespace) {
|
||||
// Preserve timeRange filter when switching dashboards
|
||||
const preservedQuery = Object.fromEntries(
|
||||
Object.entries(route.query).filter(([key]) =>
|
||||
key.includes("timeRange")
|
||||
)
|
||||
);
|
||||
|
||||
router.replace({
|
||||
params: {...route.params, dashboard: id},
|
||||
query: route.params.dashboard !== id ? {} : {...route.query},
|
||||
query: route.params.dashboard !== id ? preservedQuery : {...route.query},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -102,8 +109,22 @@
|
||||
onBeforeMount(() => {
|
||||
const ID = getDashboard(route, "id");
|
||||
|
||||
if (props.isFlow && ID === "default") load("default", processFlowYaml(YAML_FLOW, route.params.namespace as string, route.params.id as string));
|
||||
else if (props.isNamespace && ID === "default") load("default", YAML_NAMESPACE);
|
||||
if (props.isFlow) {
|
||||
load(ID, processFlowYaml(YAML_FLOW, route.params.namespace as string, route.params.id as string));
|
||||
} else if (props.isNamespace) {
|
||||
load(ID, YAML_NAMESPACE);
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => getDashboard(route, "id"), (newId, oldId) => {
|
||||
if (newId !== oldId) {
|
||||
const defaultYAML = props.isFlow
|
||||
? processFlowYaml(YAML_FLOW, route.params.namespace as string, route.params.id as string)
|
||||
: props.isNamespace
|
||||
? YAML_NAMESPACE
|
||||
: YAML_MAIN;
|
||||
load(newId, defaultYAML);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -28,33 +28,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function loadChart(chart: any) {
|
||||
const yamlChart = YAML_UTILS.stringify(chart);
|
||||
const result: { error: string | null; data: null | {
|
||||
id?: string;
|
||||
name?: string;
|
||||
type?: string;
|
||||
chartOptions?: Record<string, any>;
|
||||
dataFilters?: any[];
|
||||
charts?: any[];
|
||||
}; raw: any } = {
|
||||
error: null,
|
||||
data: null,
|
||||
raw: {}
|
||||
};
|
||||
const errors = await dashboardStore.validateChart(yamlChart);
|
||||
if (errors.constraints) {
|
||||
result.error = errors.constraints;
|
||||
} else {
|
||||
result.data = {...chart, content: yamlChart, raw: chart};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function updateChartPreview(event: any) {
|
||||
const chart = YAML_UTILS.getChartAtPosition(event.model.getValue(), event.position);
|
||||
if (chart) {
|
||||
const result = await loadChart(chart);
|
||||
const result = await dashboardStore.loadChart(chart);
|
||||
dashboardStore.selectedChart = typeof result.data === "object"
|
||||
? {
|
||||
...result.data,
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
<template>
|
||||
<div class="button-top">
|
||||
<ValidationError class="mx-3" tooltipPlacement="bottom-start" :errors="errors" />
|
||||
<ValidationError
|
||||
class="mx-3"
|
||||
tooltipPlacement="bottom-start"
|
||||
:errors="dashboardStore.errors"
|
||||
:warnings="dashboardStore.warnings"
|
||||
/>
|
||||
|
||||
<el-button
|
||||
:icon="ContentSave"
|
||||
@@ -17,6 +22,7 @@
|
||||
import {useI18n} from "vue-i18n";
|
||||
import ContentSave from "vue-material-design-icons/ContentSave.vue";
|
||||
import ValidationError from "../../flows/ValidationError.vue";
|
||||
import {useDashboardStore} from "../../../stores/dashboard";
|
||||
|
||||
const {t} = useI18n();
|
||||
|
||||
@@ -24,15 +30,11 @@
|
||||
(e: "save"): void;
|
||||
}>();
|
||||
|
||||
const props = defineProps<{
|
||||
warnings?: string[];
|
||||
errors?: string[];
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
const dashboardStore = useDashboardStore();
|
||||
|
||||
const saveButtonType = computed(() => {
|
||||
if (props.errors) return "danger";
|
||||
return props.warnings ? "warning" : "primary";
|
||||
if (dashboardStore.errors) return "danger";
|
||||
return dashboardStore.warnings ? "warning" : "primary";
|
||||
});
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
FIELDNAME_INJECTION_KEY,
|
||||
FULL_SCHEMA_INJECTION_KEY,
|
||||
FULL_SOURCE_INJECTION_KEY,
|
||||
ON_TASK_EDITOR_CLICK_INJECTION_KEY,
|
||||
PARENT_PATH_INJECTION_KEY,
|
||||
POSITION_INJECTION_KEY,
|
||||
REF_PATH_INJECTION_KEY,
|
||||
@@ -111,6 +112,15 @@
|
||||
provide(BLOCK_SCHEMA_PATH_INJECTION_KEY, computed(() => props.blockSchemaPath ?? dashboardStore.schema.$ref ?? ""));
|
||||
provide(FULL_SOURCE_INJECTION_KEY, computed(() => dashboardStore.sourceCode ?? ""));
|
||||
provide(POSITION_INJECTION_KEY, props.position ?? "after");
|
||||
provide(ON_TASK_EDITOR_CLICK_INJECTION_KEY, (elt) => {
|
||||
const type = elt?.type;
|
||||
dashboardStore.loadChart(elt);
|
||||
if(type){
|
||||
pluginsStore.updateDocumentation({type});
|
||||
}else{
|
||||
pluginsStore.updateDocumentation();
|
||||
}
|
||||
})
|
||||
|
||||
const pluginsStore = usePluginsStore();
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div class="w-100 p-4">
|
||||
<Sections
|
||||
:key="dashboardStore.sourceCode"
|
||||
:dashboard="{id: 'default', charts: []}"
|
||||
:charts="charts.map(chart => chart.data).filter(chart => chart !== null)"
|
||||
showDefault
|
||||
@@ -9,11 +10,12 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {onMounted, ref} from "vue";
|
||||
import {ref, watch} from "vue";
|
||||
import Sections from "../sections/Sections.vue";
|
||||
import {Chart} from "../composables/useDashboards";
|
||||
import {useDashboardStore} from "../../../stores/dashboard";
|
||||
import * as YAML_UTILS from "@kestra-io/ui-libs/flow-yaml-utils";
|
||||
import throttle from "lodash/throttle";
|
||||
|
||||
interface Result {
|
||||
error: string[] | null;
|
||||
@@ -23,21 +25,27 @@
|
||||
|
||||
const charts = ref<Result[]>([])
|
||||
|
||||
onMounted(async () => {
|
||||
validateAndLoadAllCharts();
|
||||
});
|
||||
|
||||
const dashboardStore = useDashboardStore();
|
||||
|
||||
function validateAndLoadAllCharts() {
|
||||
charts.value = [];
|
||||
const validateAndLoadAllChartsThrottled = throttle(validateAndLoadAllCharts, 500);
|
||||
|
||||
async function validateAndLoadAllCharts() {
|
||||
const allCharts = YAML_UTILS.getAllCharts(dashboardStore.sourceCode) ?? [];
|
||||
allCharts.forEach(async (chart: any) => {
|
||||
const loadedChart = await loadChart(chart);
|
||||
charts.value.push(loadedChart);
|
||||
});
|
||||
charts.value = await Promise.all(allCharts.map(async (chart: any) => {
|
||||
return loadChart(chart);
|
||||
}));
|
||||
}
|
||||
|
||||
watch(
|
||||
() => dashboardStore.sourceCode,
|
||||
() => {
|
||||
validateAndLoadAllChartsThrottled();
|
||||
}
|
||||
, {immediate: true}
|
||||
);
|
||||
|
||||
|
||||
|
||||
async function loadChart(chart: any) {
|
||||
const yamlChart = YAML_UTILS.stringify(chart);
|
||||
const result: Result = {
|
||||
|
||||
@@ -96,14 +96,19 @@
|
||||
return [DEFAULT, ...dashboards.value].filter((d) => !search.value || d.title.toLowerCase().includes(search.value.toLowerCase()));
|
||||
});
|
||||
|
||||
const ID = getDashboard(route, "id") as string;
|
||||
|
||||
const selected = ref(null);
|
||||
const STORAGE_KEY = getDashboard(route, "key");
|
||||
|
||||
const selected = ref<string | null>(null);
|
||||
const select = (dashboard: any) => {
|
||||
selected.value = dashboard?.title;
|
||||
|
||||
if (dashboard?.id) localStorage.setItem(ID, dashboard.id)
|
||||
else localStorage.removeItem(ID);
|
||||
if (STORAGE_KEY) {
|
||||
if (dashboard?.id) {
|
||||
localStorage.setItem(STORAGE_KEY, dashboard.id);
|
||||
} else {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
emits("dashboard", dashboard.id);
|
||||
};
|
||||
@@ -121,7 +126,7 @@
|
||||
});
|
||||
};
|
||||
|
||||
const fetchLast = () => localStorage.getItem(ID);
|
||||
const getStoredDashboard = () => STORAGE_KEY ? localStorage.getItem(STORAGE_KEY) : null;
|
||||
const fetchDashboards = () => {
|
||||
dashboardStore
|
||||
.list({})
|
||||
@@ -129,13 +134,17 @@
|
||||
dashboards.value = response.results;
|
||||
|
||||
const creation = Boolean(route.query.created);
|
||||
const lastSelected = creation ? (route.params?.dashboard ?? fetchLast()) : (fetchLast() ?? route.params?.dashboard);
|
||||
const lastSelected = creation
|
||||
? (route.params?.dashboard ?? getStoredDashboard())
|
||||
: (getStoredDashboard() ?? route.params?.dashboard);
|
||||
|
||||
if (lastSelected) {
|
||||
const dashboard = dashboards.value.find((d) => d.id === lastSelected);
|
||||
|
||||
if (dashboard) select(dashboard);
|
||||
else {
|
||||
if (dashboard) {
|
||||
selected.value = dashboard.title;
|
||||
emits("dashboard", dashboard.id);
|
||||
} else {
|
||||
selected.value = null;
|
||||
emits("dashboard", "default");
|
||||
}
|
||||
@@ -145,15 +154,19 @@
|
||||
|
||||
onBeforeMount(() => fetchDashboards());
|
||||
|
||||
const tenant = ref(route.params.tenant);
|
||||
watch(route, (r) => {
|
||||
if (tenant.value !== r.params.tenant) {
|
||||
fetchDashboards();
|
||||
tenant.value = r.params.tenant;
|
||||
}
|
||||
},
|
||||
{deep: true},
|
||||
);
|
||||
const tenant = ref();
|
||||
watch(() => route.params.tenant, (t) => {
|
||||
if (tenant.value !== t) {
|
||||
fetchDashboards();
|
||||
tenant.value = t;
|
||||
}
|
||||
}, {immediate: true});
|
||||
|
||||
watch(() => route.params?.dashboard, (val) => {
|
||||
if(route.name === "home" && STORAGE_KEY) {
|
||||
localStorage.setItem(STORAGE_KEY, val as string);
|
||||
}
|
||||
}, {immediate: true});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -161,14 +174,6 @@
|
||||
span{
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
:deep(svg){
|
||||
color: var(--ks-content-tertiary);
|
||||
font-size: 1.10rem;
|
||||
position: absolute;
|
||||
bottom: -0.10rem;
|
||||
right: 0.08rem;
|
||||
}
|
||||
}
|
||||
.dropdown {
|
||||
width: 300px;
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<template>
|
||||
<section id="charts" :class="{padding}">
|
||||
<el-row :gutter="16">
|
||||
<el-col
|
||||
<div class="dashboard-sections-container">
|
||||
<section id="charts" :class="{padding}">
|
||||
<div
|
||||
v-for="chart in props.charts"
|
||||
:key="`chart__${chart.id}`"
|
||||
:xs="24"
|
||||
:sm="(chart.chartOptions?.width || 6) * 4"
|
||||
:md="(chart.chartOptions?.width || 6) * 2"
|
||||
class="dashboard-block"
|
||||
:class="{
|
||||
[`dash-width-${chart.chartOptions?.width || 6}`]: true
|
||||
}"
|
||||
>
|
||||
<div class="d-flex flex-column">
|
||||
<div class="d-flex justify-content-between">
|
||||
@@ -64,9 +65,9 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -133,14 +134,28 @@
|
||||
<style scoped lang="scss">
|
||||
@import "@kestra-io/ui-libs/src/scss/variables";
|
||||
|
||||
.dashboard-sections-container{
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
$smallMobile: 375px;
|
||||
$tablet: 768px;
|
||||
|
||||
section#charts {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
@container (min-width: #{$smallMobile}) {
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
}
|
||||
@container (min-width: #{$tablet}) {
|
||||
grid-template-columns: repeat(12, 1fr);
|
||||
}
|
||||
&.padding {
|
||||
padding: 0 2rem 1rem;
|
||||
}
|
||||
|
||||
& .el-row .el-col {
|
||||
margin-bottom: 1rem;
|
||||
|
||||
.dashboard-block {
|
||||
& > div {
|
||||
height: 100%;
|
||||
padding: 1.5rem;
|
||||
@@ -159,5 +174,24 @@ section#charts {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.dash-width-3, .dash-width-6, .dash-width-9, .dash-width-12 {
|
||||
grid-column: span 3;
|
||||
}
|
||||
|
||||
@container (min-width: #{$smallMobile}) {
|
||||
.dash-width-6, .dash-width-9, .dash-width-12 {
|
||||
grid-column: span 6;
|
||||
}
|
||||
}
|
||||
|
||||
@container (min-width: #{$tablet}) {
|
||||
.dash-width-9 {
|
||||
grid-column: span 9;
|
||||
}
|
||||
.dash-width-12 {
|
||||
grid-column: span 12;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<ContextInfoContent :title="routeInfo.title">
|
||||
<ContextInfoContent :title="routeInfo.title" ref="contextInfoRef">
|
||||
<template v-if="isOnline" #back-button>
|
||||
<button
|
||||
class="back-button"
|
||||
@@ -26,7 +26,7 @@
|
||||
<OpenInNew class="blank" />
|
||||
</router-link>
|
||||
</template>
|
||||
<div ref="docWrapper" class="docs-controls">
|
||||
<div class="docs-controls">
|
||||
<template v-if="isOnline">
|
||||
<ContextDocsSearch />
|
||||
<DocsMenu />
|
||||
@@ -42,7 +42,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, watch, computed, getCurrentInstance, onUnmounted, onMounted, nextTick} from "vue";
|
||||
import {ref, watch, computed, getCurrentInstance, onUnmounted, onMounted} from "vue";
|
||||
import {useDocStore} from "../../stores/doc";
|
||||
import {useI18n} from "vue-i18n";
|
||||
import OpenInNew from "vue-material-design-icons/OpenInNew.vue";
|
||||
@@ -55,7 +55,9 @@
|
||||
import ContextInfoContent from "../ContextInfoContent.vue";
|
||||
import ContextChildTableOfContents from "./ContextChildTableOfContents.vue";
|
||||
|
||||
|
||||
import {useNetwork} from "@vueuse/core"
|
||||
import {useScrollMemory} from "../../composables/useScrollMemory"
|
||||
const {isOnline} = useNetwork()
|
||||
|
||||
import Markdown from "../../components/layout/Markdown.vue";
|
||||
@@ -64,19 +66,18 @@
|
||||
const docStore = useDocStore();
|
||||
const {t} = useI18n({useScope: "global"});
|
||||
|
||||
const docWrapper = ref<HTMLDivElement | null>(null);
|
||||
const contextInfoRef = ref<InstanceType<typeof ContextInfoContent> | null>(null);
|
||||
const docHistory = ref<string[]>([]);
|
||||
const currentHistoryIndex = ref(-1);
|
||||
const ast = ref<any>(undefined);
|
||||
|
||||
const pageMetadata = computed(() => docStore.pageMetadata);
|
||||
const docPath = computed(() => docStore.docPath);
|
||||
|
||||
const routeInfo = computed(() => ({
|
||||
title: pageMetadata.value?.title ?? t("docs"),
|
||||
}));
|
||||
const canGoBack = computed(() => docHistory.value.length > 1 && currentHistoryIndex.value > 0);
|
||||
|
||||
|
||||
const addToHistory = (path: string) => {
|
||||
// Always store the path, even empty ones
|
||||
const pathToAdd = path || "";
|
||||
@@ -179,8 +180,10 @@
|
||||
|
||||
addToHistory(val);
|
||||
refreshPage(val);
|
||||
nextTick(() => docWrapper.value?.scrollTo(0, 0));
|
||||
}, {immediate: true});
|
||||
|
||||
const scrollableElement = computed(() => contextInfoRef.value?.contentRef ?? null)
|
||||
useScrollMemory(ref("context-panel-docs"), scrollableElement as any)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -241,4 +244,4 @@
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -23,9 +23,15 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref} from "vue"
|
||||
import {ref, computed} from "vue"
|
||||
import {useRoute} from "vue-router";
|
||||
import {useScrollMemory} from "../../composables/useScrollMemory";
|
||||
|
||||
const collapsed = ref(false);
|
||||
const route = useRoute();
|
||||
const scrollKey = computed(() => `docs:${route.fullPath}`);
|
||||
|
||||
useScrollMemory(scrollKey, undefined, true);
|
||||
|
||||
</script>
|
||||
|
||||
@@ -224,4 +230,4 @@
|
||||
padding-bottom: 1px !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
import {useExecutionsStore} from "../../stores/executions";
|
||||
import {useAuthStore} from "override/stores/auth";
|
||||
|
||||
const props = defineProps<{
|
||||
const props = withDefaults(defineProps<{
|
||||
component: string;
|
||||
execution: {
|
||||
id: string;
|
||||
@@ -95,7 +95,10 @@
|
||||
};
|
||||
};
|
||||
tooltipPosition: string;
|
||||
}>();
|
||||
}>(), {
|
||||
component: "el-button",
|
||||
tooltipPosition: "bottom"
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
follow: [];
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
</template>
|
||||
|
||||
<template v-if="showStatChart()" #top>
|
||||
<Sections ref="dashboardComponent" :dashboard="{id: 'default', charts: []}" :charts showDefault />
|
||||
<Sections ref="dashboardComponent" :dashboard="{id: 'default', charts: []}" :charts showDefault class="mb-4" />
|
||||
</template>
|
||||
|
||||
<template #table>
|
||||
@@ -70,7 +70,7 @@
|
||||
@selection-change="handleSelectionChange"
|
||||
:selectable="!hidden?.includes('selection') && canCheck"
|
||||
:no-data-text="$t('no_results.executions')"
|
||||
:rowKey="(row: any) => `${row.namespace}-${row.id}`"
|
||||
:rowKey="(row: any) => row.id"
|
||||
>
|
||||
<template #select-actions>
|
||||
<BulkSelect
|
||||
@@ -144,10 +144,7 @@
|
||||
|
||||
<el-form>
|
||||
<ElFormItem :label="$t('execution labels')">
|
||||
<LabelInput
|
||||
:key="executionLabels.map((l) => l.key).join('-')"
|
||||
v-model:labels="executionLabels"
|
||||
/>
|
||||
<LabelInput v-model:labels="executionLabels" />
|
||||
</ElFormItem>
|
||||
</el-form>
|
||||
</el-dialog>
|
||||
@@ -384,7 +381,7 @@
|
||||
import _merge from "lodash/merge";
|
||||
import {useI18n} from "vue-i18n";
|
||||
import {useRoute, useRouter} from "vue-router";
|
||||
import {ref, computed, onMounted, watch, h, useTemplateRef} from "vue";
|
||||
import {ref, computed, watch, h, useTemplateRef} from "vue";
|
||||
import * as YAML_UTILS from "@kestra-io/ui-libs/flow-yaml-utils";
|
||||
import {ElMessageBox, ElSwitch, ElFormItem, ElAlert, ElCheckbox} from "element-plus";
|
||||
|
||||
@@ -424,18 +421,17 @@
|
||||
import {filterValidLabels} from "./utils";
|
||||
import {useToast} from "../../utils/toast";
|
||||
import {storageKeys} from "../../utils/constants";
|
||||
import {defaultNamespace} from "../../composables/useNamespaces";
|
||||
import {humanizeDuration, invisibleSpace} from "../../utils/filters";
|
||||
import Utils from "../../utils/utils";
|
||||
|
||||
import action from "../../models/action";
|
||||
import permission from "../../models/permission";
|
||||
|
||||
import useRestoreUrl from "../../composables/useRestoreUrl";
|
||||
import useRouteContext from "../../composables/useRouteContext";
|
||||
import {useTableColumns} from "../../composables/useTableColumns";
|
||||
import {useDataTableActions} from "../../composables/useDataTableActions";
|
||||
import {useSelectTableActions} from "../../composables/useSelectTableActions";
|
||||
import {useApplyDefaultFilter} from "../filter/composables/useDefaultFilter";
|
||||
|
||||
import {useFlowStore} from "../../stores/flow";
|
||||
import {useAuthStore} from "override/stores/auth";
|
||||
@@ -496,7 +492,6 @@
|
||||
const selectedStatus = ref(undefined);
|
||||
const lastRefreshDate = ref(new Date());
|
||||
const unqueueDialogVisible = ref(false);
|
||||
const isDefaultNamespaceAllow = ref(true);
|
||||
const changeStatusDialogVisible = ref(false);
|
||||
const actionOptions = ref<Record<string, any>>({});
|
||||
const dblClickRouteName = ref("executions/update");
|
||||
@@ -614,11 +609,6 @@
|
||||
const routeInfo = computed(() => ({title: t("executions")}));
|
||||
useRouteContext(routeInfo, props.embed);
|
||||
|
||||
const {saveRestoreUrl} = useRestoreUrl({
|
||||
restoreUrl: true,
|
||||
isDefaultNamespaceAllow: isDefaultNamespaceAllow.value
|
||||
});
|
||||
|
||||
const dataTableRef = ref(null);
|
||||
const selectTableRef = useTemplateRef<typeof SelectTable>("selectTable");
|
||||
|
||||
@@ -634,8 +624,7 @@
|
||||
dblClickRouteName: dblClickRouteName.value,
|
||||
embed: props.embed,
|
||||
dataTableRef,
|
||||
loadData: loadData,
|
||||
saveRestoreUrl
|
||||
loadData: loadData
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -1043,29 +1032,10 @@
|
||||
emit("state-count", {runningCount, totalCount});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
const query = {...route.query};
|
||||
let queryHasChanged = false;
|
||||
|
||||
const queryKeys = Object.keys(query);
|
||||
if (props.namespace === undefined && defaultNamespace() && !queryKeys.some(key => key.startsWith("filters[namespace]"))) {
|
||||
query["filters[namespace][PREFIX]"] = defaultNamespace();
|
||||
queryHasChanged = true;
|
||||
}
|
||||
|
||||
if (!queryKeys.some(key => key.startsWith("filters[scope]"))) {
|
||||
query["filters[scope][EQUALS]"] = "USER";
|
||||
queryHasChanged = true;
|
||||
}
|
||||
|
||||
if (queryHasChanged) {
|
||||
router.replace({query});
|
||||
}
|
||||
|
||||
if (route.name === "flows/update") {
|
||||
optionalColumns.value = optionalColumns.value.
|
||||
filter(col => col.prop !== "namespace" && col.prop !== "flowId");
|
||||
}
|
||||
useApplyDefaultFilter({
|
||||
namespace: props.namespace,
|
||||
includeTimeRange: true,
|
||||
includeScope: true
|
||||
});
|
||||
|
||||
watch(isOpenLabelsModal, (opening) => {
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
v-if="!isExecutionStarted"
|
||||
:execution="execution"
|
||||
/>
|
||||
<el-card id="gantt" shadow="never" v-else-if="execution && executionsStore.flow">
|
||||
<template #header>
|
||||
<el-card id="gantt" shadow="never" :class="{'no-border': !hasValidDate}" v-else-if="execution && executionsStore.flow">
|
||||
<template #header v-if="hasValidDate">
|
||||
<div class="d-flex">
|
||||
<Duration class="th text-end" :histories="execution.state.histories" />
|
||||
<span class="text-end" v-for="(date, i) in dates" :key="i">
|
||||
@@ -234,6 +234,9 @@
|
||||
isExecutionStarted() {
|
||||
return this.execution?.state?.current && !["CREATED", "QUEUED"].includes(this.execution.state.current);
|
||||
},
|
||||
hasValidDate() {
|
||||
return isFinite(this.delta());
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
forwardEvent(type, event) {
|
||||
@@ -443,6 +446,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
.no-border {
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
// To Separate through Line
|
||||
:deep(.vue-recycle-scroller__item-view) {
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<script lang="ts" setup>
|
||||
import {ref, computed, watch, PropType} from "vue";
|
||||
import DateSelect from "./DateSelect.vue";
|
||||
import {useI18n} from "vue-i18n";
|
||||
|
||||
interface TimePreset {
|
||||
value?: string;
|
||||
@@ -64,9 +65,11 @@
|
||||
timeFilterPresets.value.map(preset => preset.value)
|
||||
);
|
||||
|
||||
const {t} = useI18n();
|
||||
|
||||
const customAwarePlaceholder = computed<string | undefined>(() => {
|
||||
if (props.placeholder) return props.placeholder;
|
||||
return props.allowCustom ? "datepicker.custom" : undefined;
|
||||
return props.allowCustom ? t("datepicker.custom") : undefined;
|
||||
});
|
||||
|
||||
const onTimeRangeSelect = (range: string | undefined) => {
|
||||
@@ -92,4 +95,4 @@
|
||||
},
|
||||
{immediate: true}
|
||||
);
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -531,8 +531,9 @@
|
||||
}
|
||||
.content-container {
|
||||
height: calc(100vh - 0px);
|
||||
overflow-y: auto !important;
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
scrollbar-gutter: stable;
|
||||
word-wrap: break-word;
|
||||
word-break: break-word;
|
||||
position: relative;
|
||||
@@ -541,19 +542,16 @@
|
||||
|
||||
:deep(.el-collapse) {
|
||||
.el-collapse-item__wrap {
|
||||
overflow-y: auto !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
|
||||
.el-collapse-item__content {
|
||||
overflow-y: auto !important;
|
||||
word-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.var-value) {
|
||||
overflow-y: auto !important;
|
||||
word-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
70
ui/src/components/filter/composables/useDefaultFilter.ts
Normal file
70
ui/src/components/filter/composables/useDefaultFilter.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import {onMounted} from "vue";
|
||||
import {LocationQuery, useRoute, useRouter} from "vue-router";
|
||||
import {useMiscStore} from "override/stores/misc";
|
||||
import {defaultNamespace} from "../../../composables/useNamespaces";
|
||||
|
||||
interface DefaultFilterOptions {
|
||||
namespace?: string;
|
||||
includeTimeRange?: boolean;
|
||||
includeScope?: boolean;
|
||||
legacyQuery?: boolean;
|
||||
}
|
||||
|
||||
const NAMESPACE_FILTER_PREFIX = "filters[namespace]";
|
||||
const SCOPE_FILTER_PREFIX = "filters[scope]";
|
||||
const TIME_RANGE_FILTER_PREFIX = "filters[timeRange]";
|
||||
|
||||
const hasFilterKey = (query: LocationQuery, prefix: string): boolean =>
|
||||
Object.keys(query).some(key => key.startsWith(prefix));
|
||||
|
||||
export function applyDefaultFilters(
|
||||
currentQuery: LocationQuery,
|
||||
options: DefaultFilterOptions & {
|
||||
configuration?: any;
|
||||
route?: any
|
||||
} = {}): { query: LocationQuery; hasChanges: boolean } {
|
||||
|
||||
const {configuration, route, namespace, includeTimeRange, includeScope, legacyQuery = false} = options;
|
||||
|
||||
const hasTimeRange = configuration && route
|
||||
? configuration.keys?.some((k: any) => k.key === "timeRange") ?? false
|
||||
: includeTimeRange ?? false;
|
||||
const hasScope = configuration && route
|
||||
? route?.name !== "logs/list" && (configuration.keys?.some((k: any) => k.key === "scope") ?? false)
|
||||
: includeScope ?? false;
|
||||
|
||||
const query = {...currentQuery};
|
||||
let hasChanges = false;
|
||||
|
||||
if (namespace === undefined && defaultNamespace() && !hasFilterKey(query, NAMESPACE_FILTER_PREFIX)) {
|
||||
query[legacyQuery ? "namespace" : `${NAMESPACE_FILTER_PREFIX}[PREFIX]`] = defaultNamespace();
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
if (hasScope && !hasFilterKey(query, SCOPE_FILTER_PREFIX)) {
|
||||
query[legacyQuery ? "scope" : `${SCOPE_FILTER_PREFIX}[EQUALS]`] = "USER";
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
const TIME_FILTER_KEYS = /startDate|endDate|timeRange/;
|
||||
|
||||
if (hasTimeRange && !Object.keys(query).some(key => TIME_FILTER_KEYS.test(key))) {
|
||||
const defaultDuration = useMiscStore().configs?.chartDefaultDuration ?? "P30D";
|
||||
query[legacyQuery ? "timeRange" : `${TIME_RANGE_FILTER_PREFIX}[EQUALS]`] = defaultDuration;
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
return {query, hasChanges};
|
||||
}
|
||||
|
||||
export function useApplyDefaultFilter(options?: DefaultFilterOptions) {
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
onMounted(() => {
|
||||
const {query, hasChanges} = applyDefaultFilters(route.query, options);
|
||||
if (hasChanges) {
|
||||
router.replace({query});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
KV_COMPARATORS
|
||||
} from "../utils/filterTypes";
|
||||
import {usePreAppliedFilters} from "./usePreAppliedFilters";
|
||||
import {applyDefaultFilters} from "./useDefaultFilter";
|
||||
|
||||
export function useFilters(configuration: FilterConfiguration, showSearchInput = true, legacyQuery = false) {
|
||||
const router = useRouter();
|
||||
@@ -28,8 +29,7 @@ export function useFilters(configuration: FilterConfiguration, showSearchInput =
|
||||
const {
|
||||
markAsPreApplied,
|
||||
hasPreApplied,
|
||||
getPreApplied,
|
||||
getAllPreApplied
|
||||
getPreApplied
|
||||
} = usePreAppliedFilters();
|
||||
|
||||
const appendQueryParam = (query: Record<string, any>, key: string, value: string) => {
|
||||
@@ -367,13 +367,10 @@ export function useFilters(configuration: FilterConfiguration, showSearchInput =
|
||||
updateRoute();
|
||||
};
|
||||
|
||||
/**
|
||||
* Resets all filters to their pre-applied state and clears the search query
|
||||
*/
|
||||
const resetToPreApplied = () => {
|
||||
appliedFilters.value = getAllPreApplied();
|
||||
const defaultQuery = applyDefaultFilters({}, {configuration, route, legacyQuery}).query;
|
||||
searchQuery.value = "";
|
||||
updateRoute();
|
||||
router.push({query: defaultQuery});
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -43,7 +43,7 @@ export function useValues(label: string | undefined, t?: ReturnType<typeof useI1
|
||||
{label: t("datepicker.last24hours"), value: "PT24H"},
|
||||
{label: t("datepicker.last48hours"), value: "PT48H"},
|
||||
{label: t("datepicker.last7days"), value: "PT168H"},
|
||||
{label: t("datepicker.last30days"), value: "PT720H"},
|
||||
{label: t("datepicker.last30days"), value: "P30D"},
|
||||
{label: t("datepicker.last365days"), value: "PT8760H"},
|
||||
];
|
||||
|
||||
|
||||
@@ -6,46 +6,50 @@ import {useNamespacesStore} from "override/stores/namespaces";
|
||||
import {useAuthStore} from "override/stores/auth";
|
||||
import {useValues} from "../composables/useValues";
|
||||
import {useI18n} from "vue-i18n";
|
||||
import {useRoute} from "vue-router";
|
||||
|
||||
export const useExecutionFilter = (): ComputedRef<FilterConfiguration> => computed(() => {
|
||||
const {t} = useI18n();
|
||||
const route = useRoute();
|
||||
|
||||
return {
|
||||
title: t("filter.titles.execution_filters"),
|
||||
searchPlaceholder: t("filter.search_placeholders.search_executions"),
|
||||
keys: [
|
||||
{
|
||||
key: "namespace",
|
||||
label: t("filter.namespace.label"),
|
||||
description: t("filter.namespace.description"),
|
||||
comparators: [
|
||||
Comparators.IN,
|
||||
Comparators.NOT_IN,
|
||||
Comparators.CONTAINS,
|
||||
Comparators.PREFIX,
|
||||
],
|
||||
valueType: "multi-select",
|
||||
valueProvider: async () => {
|
||||
const user = useAuthStore().user;
|
||||
if (user && user.hasAnyActionOnAnyNamespace(permission.NAMESPACE, action.READ)) {
|
||||
const namespacesStore = useNamespacesStore();
|
||||
const namespaces = (await namespacesStore.loadAutocomplete()) as string[];
|
||||
return [...new Set(namespaces
|
||||
.flatMap(namespace => {
|
||||
return namespace.split(".").reduce((current: string[], part: string) => {
|
||||
const previousCombination = current?.[current.length - 1];
|
||||
return [...current, `${(previousCombination ? previousCombination + "." : "")}${part}`];
|
||||
}, []);
|
||||
}))].map(namespace => ({
|
||||
label: namespace,
|
||||
value: namespace
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
...(route.name !== "namespaces/update" ? [
|
||||
{
|
||||
key: "namespace",
|
||||
label: t("filter.namespace.label"),
|
||||
description: t("filter.namespace.description"),
|
||||
comparators: [
|
||||
Comparators.IN,
|
||||
Comparators.NOT_IN,
|
||||
Comparators.CONTAINS,
|
||||
Comparators.PREFIX,
|
||||
],
|
||||
valueType: "multi-select" as const,
|
||||
valueProvider: async () => {
|
||||
const user = useAuthStore().user;
|
||||
if (user && user.hasAnyActionOnAnyNamespace(permission.NAMESPACE, action.READ)) {
|
||||
const namespacesStore = useNamespacesStore();
|
||||
const namespaces = (await namespacesStore.loadAutocomplete()) as string[];
|
||||
return [...new Set(namespaces
|
||||
.flatMap(namespace => {
|
||||
return namespace.split(".").reduce((current: string[], part: string) => {
|
||||
const previousCombination = current?.[current.length - 1];
|
||||
return [...current, `${(previousCombination ? previousCombination + "." : "")}${part}`];
|
||||
}, []);
|
||||
}))].map(namespace => ({
|
||||
label: namespace,
|
||||
value: namespace
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
},
|
||||
searchable: true
|
||||
},
|
||||
searchable: true
|
||||
},
|
||||
{
|
||||
] : []) as any,
|
||||
...(route.name !== "flows/update" ? [{
|
||||
key: "flowId",
|
||||
label: t("filter.flowId.label"),
|
||||
description: t("filter.flowId.description"),
|
||||
@@ -57,7 +61,7 @@ export const useExecutionFilter = (): ComputedRef<FilterConfiguration> => comput
|
||||
Comparators.ENDS_WITH,
|
||||
],
|
||||
valueType: "text",
|
||||
},
|
||||
}] : []) as any,
|
||||
{
|
||||
key: "kind",
|
||||
label: t("filter.kind.label"),
|
||||
|
||||
@@ -6,45 +6,49 @@ import {useNamespacesStore} from "override/stores/namespaces";
|
||||
import {useAuthStore} from "override/stores/auth";
|
||||
import {useValues} from "../composables/useValues";
|
||||
import {useI18n} from "vue-i18n";
|
||||
import {useRoute} from "vue-router";
|
||||
|
||||
export const useFlowFilter = (): ComputedRef<FilterConfiguration> => computed(() => {
|
||||
const {t} = useI18n();
|
||||
const route = useRoute();
|
||||
|
||||
return {
|
||||
title: t("filter.titles.flow_filters"),
|
||||
searchPlaceholder: t("filter.search_placeholders.search_flows"),
|
||||
keys: [
|
||||
{
|
||||
key: "namespace",
|
||||
label: t("filter.namespace.label"),
|
||||
description: t("filter.namespace.description"),
|
||||
comparators: [
|
||||
Comparators.IN,
|
||||
Comparators.NOT_IN,
|
||||
Comparators.CONTAINS,
|
||||
Comparators.PREFIX,
|
||||
],
|
||||
valueType: "multi-select",
|
||||
valueProvider: async () => {
|
||||
const user = useAuthStore().user;
|
||||
if (user && user.hasAnyActionOnAnyNamespace(permission.NAMESPACE, action.READ)) {
|
||||
const namespacesStore = useNamespacesStore();
|
||||
const namespaces = (await namespacesStore.loadAutocomplete()) as string[];
|
||||
return [...new Set(namespaces
|
||||
.flatMap(namespace => {
|
||||
return namespace.split(".").reduce((current: string[], part: string) => {
|
||||
const previousCombination = current?.[current.length - 1];
|
||||
return [...current, `${(previousCombination ? previousCombination + "." : "")}${part}`];
|
||||
}, []);
|
||||
}))].map(namespace => ({
|
||||
label: namespace,
|
||||
value: namespace
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
...(route.name !== "namespaces/update" ? [
|
||||
{
|
||||
key: "namespace",
|
||||
label: t("filter.namespace.label"),
|
||||
description: t("filter.namespace.description"),
|
||||
comparators: [
|
||||
Comparators.IN,
|
||||
Comparators.NOT_IN,
|
||||
Comparators.CONTAINS,
|
||||
Comparators.PREFIX,
|
||||
],
|
||||
valueType: "multi-select" as const,
|
||||
valueProvider: async () => {
|
||||
const user = useAuthStore().user;
|
||||
if (user && user.hasAnyActionOnAnyNamespace(permission.NAMESPACE, action.READ)) {
|
||||
const namespacesStore = useNamespacesStore();
|
||||
const namespaces = (await namespacesStore.loadAutocomplete()) as string[];
|
||||
return [...new Set(namespaces
|
||||
.flatMap(namespace => {
|
||||
return namespace.split(".").reduce((current: string[], part: string) => {
|
||||
const previousCombination = current?.[current.length - 1];
|
||||
return [...current, `${(previousCombination ? previousCombination + "." : "")}${part}`];
|
||||
}, []);
|
||||
}))].map(namespace => ({
|
||||
label: namespace,
|
||||
value: namespace
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
},
|
||||
searchable: true
|
||||
},
|
||||
searchable: true
|
||||
},
|
||||
] : []) as any,
|
||||
{
|
||||
key: "scope",
|
||||
label: t("filter.scope_flow.label"),
|
||||
|
||||
@@ -3,47 +3,51 @@ import {Comparators, FilterConfiguration} from "../utils/filterTypes";
|
||||
import {useI18n} from "vue-i18n";
|
||||
import {useNamespacesStore} from "override/stores/namespaces";
|
||||
import {useAuthStore} from "override/stores/auth";
|
||||
import {useRoute} from "vue-router";
|
||||
import permission from "../../../models/permission";
|
||||
import action from "../../../models/action";
|
||||
|
||||
export const useKvFilter = (): ComputedRef<FilterConfiguration> => computed(() => {
|
||||
const {t} = useI18n();
|
||||
const route = useRoute();
|
||||
|
||||
return {
|
||||
title: t("filter.titles.kv_filters"),
|
||||
searchPlaceholder: t("filter.search_placeholders.search_kv"),
|
||||
keys: [
|
||||
{
|
||||
key: "namespace",
|
||||
label: t("filter.namespace.label"),
|
||||
description: t("filter.namespace.description"),
|
||||
comparators: [
|
||||
Comparators.IN,
|
||||
Comparators.NOT_IN,
|
||||
Comparators.CONTAINS,
|
||||
Comparators.PREFIX,
|
||||
],
|
||||
valueType: "multi-select",
|
||||
valueProvider: async () => {
|
||||
const user = useAuthStore().user;
|
||||
if (user && user.hasAnyActionOnAnyNamespace(permission.NAMESPACE, action.READ)) {
|
||||
const namespacesStore = useNamespacesStore();
|
||||
const namespaces = (await namespacesStore.loadAutocomplete()) as string[];
|
||||
return [...new Set(namespaces
|
||||
.flatMap(namespace => {
|
||||
return namespace.split(".").reduce((current: string[], part: string) => {
|
||||
const previousCombination = current?.[current.length - 1];
|
||||
return [...current, `${(previousCombination ? previousCombination + "." : "")}${part}`];
|
||||
}, []);
|
||||
}))].map(namespace => ({
|
||||
label: namespace,
|
||||
value: namespace
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
},
|
||||
searchable: true
|
||||
}
|
||||
...(route.name !== "namespaces/update" ? [
|
||||
{
|
||||
key: "namespace",
|
||||
label: t("filter.namespace.label"),
|
||||
description: t("filter.namespace.description"),
|
||||
comparators: [
|
||||
Comparators.IN,
|
||||
Comparators.NOT_IN,
|
||||
Comparators.CONTAINS,
|
||||
Comparators.PREFIX,
|
||||
],
|
||||
valueType: "multi-select" as const,
|
||||
valueProvider: async () => {
|
||||
const user = useAuthStore().user;
|
||||
if (user && user.hasAnyActionOnAnyNamespace(permission.NAMESPACE, action.READ)) {
|
||||
const namespacesStore = useNamespacesStore();
|
||||
const namespaces = (await namespacesStore.loadAutocomplete()) as string[];
|
||||
return [...new Set(namespaces
|
||||
.flatMap(namespace => {
|
||||
return namespace.split(".").reduce((current: string[], part: string) => {
|
||||
const previousCombination = current?.[current.length - 1];
|
||||
return [...current, `${(previousCombination ? previousCombination + "." : "")}${part}`];
|
||||
}, []);
|
||||
}))].map(namespace => ({
|
||||
label: namespace,
|
||||
value: namespace
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
},
|
||||
searchable: true
|
||||
}
|
||||
] : []) as any,
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
@@ -6,45 +6,49 @@ import {useNamespacesStore} from "override/stores/namespaces";
|
||||
import {useAuthStore} from "override/stores/auth";
|
||||
import {useValues} from "../composables/useValues";
|
||||
import {useI18n} from "vue-i18n";
|
||||
import {useRoute} from "vue-router";
|
||||
|
||||
export const useLogFilter = (): ComputedRef<FilterConfiguration> => computed(() => {
|
||||
const {t} = useI18n();
|
||||
const route = useRoute();
|
||||
|
||||
return {
|
||||
title: t("filter.titles.log_filters"),
|
||||
searchPlaceholder: t("filter.search_placeholders.search_logs"),
|
||||
keys: [
|
||||
{
|
||||
key: "namespace",
|
||||
label: t("filter.namespace.label"),
|
||||
description: t("filter.namespace.description"),
|
||||
comparators: [
|
||||
Comparators.IN,
|
||||
Comparators.NOT_IN,
|
||||
Comparators.CONTAINS,
|
||||
Comparators.PREFIX,
|
||||
],
|
||||
valueType: "multi-select",
|
||||
valueProvider: async () => {
|
||||
const user = useAuthStore().user;
|
||||
if (user && user.hasAnyActionOnAnyNamespace(permission.NAMESPACE, action.READ)) {
|
||||
const namespacesStore = useNamespacesStore();
|
||||
const namespaces = (await namespacesStore.loadAutocomplete()) as string[];
|
||||
return [...new Set(namespaces
|
||||
.flatMap(namespace => {
|
||||
return namespace.split(".").reduce((current: string[], part: string) => {
|
||||
const previousCombination = current?.[current.length - 1];
|
||||
return [...current, `${(previousCombination ? previousCombination + "." : "")}${part}`];
|
||||
}, []);
|
||||
}))].map(namespace => ({
|
||||
label: namespace,
|
||||
value: namespace
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
...(route.name !== "namespaces/update" && route.name !== "flows/update" ? [
|
||||
{
|
||||
key: "namespace",
|
||||
label: t("filter.namespace.label"),
|
||||
description: t("filter.namespace.description"),
|
||||
comparators: [
|
||||
Comparators.IN,
|
||||
Comparators.NOT_IN,
|
||||
Comparators.CONTAINS,
|
||||
Comparators.PREFIX,
|
||||
],
|
||||
valueType: "multi-select" as const,
|
||||
valueProvider: async () => {
|
||||
const user = useAuthStore().user;
|
||||
if (user && user.hasAnyActionOnAnyNamespace(permission.NAMESPACE, action.READ)) {
|
||||
const namespacesStore = useNamespacesStore();
|
||||
const namespaces = (await namespacesStore.loadAutocomplete()) as string[];
|
||||
return [...new Set(namespaces
|
||||
.flatMap(namespace => {
|
||||
return namespace.split(".").reduce((current: string[], part: string) => {
|
||||
const previousCombination = current?.[current.length - 1];
|
||||
return [...current, `${(previousCombination ? previousCombination + "." : "")}${part}`];
|
||||
}, []);
|
||||
}))].map(namespace => ({
|
||||
label: namespace,
|
||||
value: namespace
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
},
|
||||
searchable: true
|
||||
},
|
||||
searchable: true
|
||||
},
|
||||
] : []) as any,
|
||||
{
|
||||
key: "level",
|
||||
label: t("filter.level.label"),
|
||||
@@ -95,7 +99,7 @@ export const useLogFilter = (): ComputedRef<FilterConfiguration> => computed(()
|
||||
],
|
||||
valueType: "text",
|
||||
},
|
||||
{
|
||||
...(route.name !== "flows/update" ? [{
|
||||
key: "flowId",
|
||||
label: t("filter.flowId.label"),
|
||||
description: t("filter.flowId.description"),
|
||||
@@ -107,7 +111,7 @@ export const useLogFilter = (): ComputedRef<FilterConfiguration> => computed(()
|
||||
Comparators.ENDS_WITH,
|
||||
],
|
||||
valueType: "text",
|
||||
},
|
||||
}] : []) as any,
|
||||
]
|
||||
};
|
||||
});
|
||||
@@ -6,7 +6,7 @@ export const useNamespacesFilter = (): ComputedRef<FilterConfiguration> => compu
|
||||
const {t} = useI18n();
|
||||
|
||||
return {
|
||||
title: t("filter.titles.namespaces_filters"),
|
||||
title: t("filter.titles.namespace_filters"),
|
||||
searchPlaceholder: t("filter.search_placeholders.search_namespaces"),
|
||||
keys: [],
|
||||
};
|
||||
|
||||
@@ -5,45 +5,49 @@ import action from "../../../models/action";
|
||||
import {useNamespacesStore} from "override/stores/namespaces";
|
||||
import {useAuthStore} from "override/stores/auth";
|
||||
import {useI18n} from "vue-i18n";
|
||||
import {useRoute} from "vue-router";
|
||||
|
||||
export const useSecretsFilter = (): ComputedRef<FilterConfiguration> => computed(() => {
|
||||
const {t} = useI18n();
|
||||
const route = useRoute();
|
||||
|
||||
return {
|
||||
title: t("filter.titles.secret_filters"),
|
||||
searchPlaceholder: t("filter.search_placeholders.search_secrets"),
|
||||
keys: [
|
||||
{
|
||||
key: "namespace",
|
||||
label: t("filter.namespace.label"),
|
||||
description: t("filter.namespace.description"),
|
||||
comparators: [
|
||||
Comparators.IN,
|
||||
Comparators.NOT_IN,
|
||||
Comparators.CONTAINS,
|
||||
Comparators.PREFIX,
|
||||
],
|
||||
valueType: "multi-select",
|
||||
valueProvider: async () => {
|
||||
const user = useAuthStore().user;
|
||||
if (user && user.hasAnyActionOnAnyNamespace(permission.NAMESPACE, action.READ)) {
|
||||
const namespacesStore = useNamespacesStore();
|
||||
const namespaces = (await namespacesStore.loadAutocomplete()) as string[];
|
||||
return [...new Set(namespaces
|
||||
.flatMap(namespace => {
|
||||
return namespace.split(".").reduce((current: string[], part: string) => {
|
||||
const previousCombination = current?.[current.length - 1];
|
||||
return [...current, `${(previousCombination ? previousCombination + "." : "")}${part}`];
|
||||
}, []);
|
||||
}))].map(namespace => ({
|
||||
label: namespace,
|
||||
value: namespace
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
...(route.name !== "namespaces/update" ? [
|
||||
{
|
||||
key: "namespace",
|
||||
label: t("filter.namespace.label"),
|
||||
description: t("filter.namespace.description"),
|
||||
comparators: [
|
||||
Comparators.IN,
|
||||
Comparators.NOT_IN,
|
||||
Comparators.CONTAINS,
|
||||
Comparators.PREFIX,
|
||||
],
|
||||
valueType: "multi-select" as const,
|
||||
valueProvider: async () => {
|
||||
const user = useAuthStore().user;
|
||||
if (user && user.hasAnyActionOnAnyNamespace(permission.NAMESPACE, action.READ)) {
|
||||
const namespacesStore = useNamespacesStore();
|
||||
const namespaces = (await namespacesStore.loadAutocomplete()) as string[];
|
||||
return [...new Set(namespaces
|
||||
.flatMap(namespace => {
|
||||
return namespace.split(".").reduce((current: string[], part: string) => {
|
||||
const previousCombination = current?.[current.length - 1];
|
||||
return [...current, `${(previousCombination ? previousCombination + "." : "")}${part}`];
|
||||
}, []);
|
||||
}))].map(namespace => ({
|
||||
label: namespace,
|
||||
value: namespace
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
},
|
||||
searchable: true
|
||||
},
|
||||
searchable: true
|
||||
},
|
||||
] : []) as any,
|
||||
],
|
||||
};
|
||||
});
|
||||
@@ -6,46 +6,50 @@ import {useNamespacesStore} from "override/stores/namespaces";
|
||||
import {useAuthStore} from "override/stores/auth";
|
||||
import {useValues} from "../composables/useValues";
|
||||
import {useI18n} from "vue-i18n";
|
||||
import {useRoute} from "vue-router";
|
||||
|
||||
export const useTriggerFilter = (): ComputedRef<FilterConfiguration> => computed(() => {
|
||||
const {t} = useI18n();
|
||||
const route = useRoute();
|
||||
|
||||
return {
|
||||
title: t("filter.titles.trigger_filters"),
|
||||
searchPlaceholder: t("filter.search_placeholders.search_triggers"),
|
||||
keys: [
|
||||
{
|
||||
key: "namespace",
|
||||
label: t("filter.namespace.label"),
|
||||
description: t("filter.namespace.description"),
|
||||
comparators: [
|
||||
Comparators.IN,
|
||||
Comparators.NOT_IN,
|
||||
Comparators.CONTAINS,
|
||||
Comparators.PREFIX,
|
||||
],
|
||||
valueType: "multi-select",
|
||||
valueProvider: async () => {
|
||||
const user = useAuthStore().user;
|
||||
if (user && user.hasAnyActionOnAnyNamespace(permission.NAMESPACE, action.READ)) {
|
||||
const namespacesStore = useNamespacesStore();
|
||||
const namespaces = (await namespacesStore.loadAutocomplete()) as string[];
|
||||
return [...new Set(namespaces
|
||||
.flatMap(namespace => {
|
||||
return namespace.split(".").reduce((current: string[], part: string) => {
|
||||
const previousCombination = current?.[current.length - 1];
|
||||
return [...current, `${(previousCombination ? previousCombination + "." : "")}${part}`];
|
||||
}, []);
|
||||
}))].map(namespace => ({
|
||||
label: namespace,
|
||||
value: namespace
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
...(route.name !== "namespaces/update" ? [
|
||||
{
|
||||
key: "namespace",
|
||||
label: t("filter.namespace.label"),
|
||||
description: t("filter.namespace.description"),
|
||||
comparators: [
|
||||
Comparators.IN,
|
||||
Comparators.NOT_IN,
|
||||
Comparators.CONTAINS,
|
||||
Comparators.PREFIX,
|
||||
],
|
||||
valueType: "multi-select" as const,
|
||||
valueProvider: async () => {
|
||||
const user = useAuthStore().user;
|
||||
if (user && user.hasAnyActionOnAnyNamespace(permission.NAMESPACE, action.READ)) {
|
||||
const namespacesStore = useNamespacesStore();
|
||||
const namespaces = (await namespacesStore.loadAutocomplete()) as string[];
|
||||
return [...new Set(namespaces
|
||||
.flatMap(namespace => {
|
||||
return namespace.split(".").reduce((current: string[], part: string) => {
|
||||
const previousCombination = current?.[current.length - 1];
|
||||
return [...current, `${(previousCombination ? previousCombination + "." : "")}${part}`];
|
||||
}, []);
|
||||
}))].map(namespace => ({
|
||||
label: namespace,
|
||||
value: namespace
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
},
|
||||
searchable: true
|
||||
},
|
||||
searchable: true
|
||||
},
|
||||
{
|
||||
] : []) as any,
|
||||
...(route.name !== "flows/update" ? [{
|
||||
key: "flowId",
|
||||
label: t("filter.flowId.label"),
|
||||
description: t("filter.flowId.description"),
|
||||
@@ -57,7 +61,7 @@ export const useTriggerFilter = (): ComputedRef<FilterConfiguration> => computed
|
||||
Comparators.ENDS_WITH,
|
||||
],
|
||||
valueType: "text",
|
||||
},
|
||||
}] : []) as any,
|
||||
{
|
||||
key: "timeRange",
|
||||
label: t("filter.timeRange_trigger.label"),
|
||||
|
||||
@@ -39,7 +39,7 @@ export const decodeSearchParams = (query: LocationQuery) =>
|
||||
operation
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
.filter(v => v !== null);
|
||||
|
||||
type Filter = Pick<AppliedFilter, "key" | "comparator" | "value">;
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
:namespace="flowStore.flow?.namespace"
|
||||
:flowId="flowStore.flow?.id"
|
||||
:topbar="false"
|
||||
:restoreUrl="false"
|
||||
filter
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
import FlowRootTopBar from "./FlowRootTopBar.vue";
|
||||
import FlowConcurrency from "./FlowConcurrency.vue";
|
||||
import DemoAuditLogs from "../demo/AuditLogs.vue";
|
||||
import {useAuthStore} from "override/stores/auth"
|
||||
import {useAuthStore} from "override/stores/auth";
|
||||
import {useMiscStore} from "override/stores/misc";
|
||||
|
||||
export default {
|
||||
@@ -59,13 +59,12 @@
|
||||
"$route.params.tab": {
|
||||
immediate: true,
|
||||
handler: function (newTab) {
|
||||
if (newTab === "overview") {
|
||||
if (newTab === "overview" || newTab === "executions") {
|
||||
const dateTimeKeys = ["startDate", "endDate", "timeRange"];
|
||||
|
||||
if (!Object.keys(this.$route.query).some((key) => dateTimeKeys.some((dateTimeKey) => key.includes(dateTimeKey)))) {
|
||||
const miscStore = useMiscStore();
|
||||
const defaultDuration = miscStore.configs?.chartDefaultDuration || "P30D";
|
||||
const newQuery = {...this.$route.query, "filters[timeRange][EQUALS]": defaultDuration};
|
||||
const DEFAULT_DURATION = this.miscStore.configs?.chartDefaultDuration ?? "P30D";
|
||||
const newQuery = {...this.$route.query, "filters[timeRange][EQUALS]": DEFAULT_DURATION};
|
||||
this.$router.replace({name: this.$route.name, params: this.$route.params, query: newQuery});
|
||||
}
|
||||
}
|
||||
@@ -314,7 +313,7 @@
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapStores(useCoreStore, useFlowStore, useAuthStore),
|
||||
...mapStores(useCoreStore, useFlowStore, useAuthStore, useMiscStore),
|
||||
routeInfo() {
|
||||
return {
|
||||
title: this.$route.params.id,
|
||||
|
||||
@@ -472,7 +472,7 @@
|
||||
backfill: cleanBackfill.value
|
||||
})
|
||||
.then((newTrigger: any) => {
|
||||
(window as any).$toast().saved(newTrigger.id);
|
||||
toast.saved(newTrigger.triggerId);
|
||||
triggers.value = triggers.value.map((t: any) => {
|
||||
if (t.id === newTrigger.id) {
|
||||
return newTrigger
|
||||
@@ -493,7 +493,7 @@
|
||||
const pauseBackfill = (trigger: any) => {
|
||||
triggerStore.pauseBackfill(trigger)
|
||||
.then((newTrigger: any) => {
|
||||
toast.saved(newTrigger.id);
|
||||
toast.saved(newTrigger.triggerId);
|
||||
triggers.value = triggers.value.map((t: any) => {
|
||||
if (t.id === newTrigger.id) {
|
||||
return newTrigger
|
||||
@@ -506,7 +506,7 @@
|
||||
const unpauseBackfill = (trigger: any) => {
|
||||
triggerStore.unpauseBackfill(trigger)
|
||||
.then((newTrigger: any) => {
|
||||
toast.saved(newTrigger.id);
|
||||
toast.saved(newTrigger.triggerId);
|
||||
triggers.value = triggers.value.map((t: any) => {
|
||||
if (t.id === newTrigger.id) {
|
||||
return newTrigger
|
||||
@@ -519,7 +519,7 @@
|
||||
const deleteBackfill = (trigger: any) => {
|
||||
triggerStore.deleteBackfill(trigger)
|
||||
.then((newTrigger: any) => {
|
||||
toast.saved(newTrigger.id);
|
||||
toast.saved(newTrigger.triggerId);
|
||||
triggers.value = triggers.value.map((t: any) => {
|
||||
if (t.id === newTrigger.id) {
|
||||
return newTrigger
|
||||
@@ -532,7 +532,7 @@
|
||||
const setDisabled = (trigger: any, value: boolean) => {
|
||||
triggerStore.update({...trigger, disabled: !value})
|
||||
.then((newTrigger: any) => {
|
||||
toast.saved(newTrigger.id);
|
||||
toast.saved(newTrigger.triggerId);
|
||||
triggers.value = triggers.value.map((t: any) => {
|
||||
if (t.id === newTrigger.id) {
|
||||
return newTrigger
|
||||
@@ -548,7 +548,7 @@
|
||||
flowId: trigger.flowId,
|
||||
triggerId: trigger.triggerId
|
||||
}).then((newTrigger: any) => {
|
||||
toast.saved(newTrigger.id);
|
||||
toast.saved(newTrigger.triggerId);
|
||||
triggers.value = triggers.value.map((t: any) => {
|
||||
if (t.id === newTrigger.id) {
|
||||
return newTrigger
|
||||
@@ -564,7 +564,7 @@
|
||||
flowId: trigger.flowId,
|
||||
triggerId: trigger.triggerId
|
||||
}).then((newTrigger: any) => {
|
||||
toast.saved(newTrigger.id);
|
||||
toast.saved(newTrigger.triggerId);
|
||||
triggers.value = triggers.value.map((t: any) => {
|
||||
if (t.id === newTrigger.id) {
|
||||
return newTrigger
|
||||
|
||||
@@ -204,6 +204,7 @@
|
||||
<template #default="scope">
|
||||
<TimeSeries
|
||||
:chart="mappedChart(scope.row.id, scope.row.namespace)"
|
||||
:filters="chartFilters()"
|
||||
showDefault
|
||||
short
|
||||
/>
|
||||
@@ -248,8 +249,8 @@
|
||||
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, computed, onMounted, useTemplateRef} from "vue";
|
||||
import {useRoute, useRouter} from "vue-router";
|
||||
import {ref, computed, useTemplateRef} from "vue";
|
||||
import {useRoute} from "vue-router";
|
||||
import {useI18n} from "vue-i18n";
|
||||
import _merge from "lodash/merge";
|
||||
import * as FILTERS from "../../utils/filters";
|
||||
@@ -283,16 +284,16 @@
|
||||
import permission from "../../models/permission";
|
||||
|
||||
import {useToast} from "../../utils/toast";
|
||||
import {defaultNamespace} from "../../composables/useNamespaces";
|
||||
|
||||
import {useFlowStore} from "../../stores/flow";
|
||||
import {useAuthStore} from "override/stores/auth";
|
||||
import {useMiscStore} from "override/stores/misc";
|
||||
import {useExecutionsStore} from "../../stores/executions";
|
||||
|
||||
import {useTableColumns} from "../../composables/useTableColumns";
|
||||
import {DataTableRef, useDataTableActions} from "../../composables/useDataTableActions";
|
||||
import {useSelectTableActions} from "../../composables/useSelectTableActions";
|
||||
|
||||
import {useApplyDefaultFilter} from "../filter/composables/useDefaultFilter";
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
topbar?: boolean;
|
||||
@@ -307,9 +308,9 @@
|
||||
const flowStore = useFlowStore();
|
||||
const authStore = useAuthStore();
|
||||
const executionsStore = useExecutionsStore();
|
||||
const miscStore = useMiscStore();
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const {t} = useI18n();
|
||||
const toast = useToast()
|
||||
@@ -494,6 +495,11 @@
|
||||
updateVisibleColumns(newColumns);
|
||||
}
|
||||
|
||||
useApplyDefaultFilter({
|
||||
namespace: props.namespace,
|
||||
includeScope: true
|
||||
});
|
||||
|
||||
function exportFlows() {
|
||||
toast.confirm(
|
||||
t("flow export", {flowCount: queryBulkAction.value ? flowStore.total : selection.value.length}),
|
||||
@@ -622,24 +628,14 @@
|
||||
return MAPPED_CHARTS;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const query = {...route.query};
|
||||
const queryKeys = Object.keys(query);
|
||||
let queryHasChanged = false;
|
||||
|
||||
if (props.namespace === undefined && defaultNamespace() && !queryKeys.some(key => key.startsWith("filters[namespace]"))) {
|
||||
query["filters[namespace][PREFIX]"] = defaultNamespace();
|
||||
queryHasChanged = true;
|
||||
}
|
||||
|
||||
if (!queryKeys.some(key => key.startsWith("filters[scope]"))) {
|
||||
query["filters[scope][EQUALS]"] = "USER";
|
||||
queryHasChanged = true;
|
||||
}
|
||||
|
||||
if (queryHasChanged) router.replace({query});
|
||||
});
|
||||
|
||||
function chartFilters() {
|
||||
const DEFAULT_DURATION = miscStore.configs?.chartDefaultDuration ?? "P30D";
|
||||
return [{
|
||||
field: "timeRange",
|
||||
value: DEFAULT_DURATION,
|
||||
operation: "EQUALS"
|
||||
}];
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -56,7 +56,6 @@
|
||||
import DataTable from "../layout/DataTable.vue";
|
||||
import SearchField from "../layout/SearchField.vue";
|
||||
import NamespaceSelect from "../namespaces/components/NamespaceSelect.vue";
|
||||
import useRestoreUrl from "../../composables/useRestoreUrl";
|
||||
import useRouteContext from "../../composables/useRouteContext";
|
||||
import {useDataTableActions} from "../../composables/useDataTableActions";
|
||||
|
||||
@@ -77,11 +76,9 @@
|
||||
}));
|
||||
|
||||
useRouteContext(routeInfo);
|
||||
const {saveRestoreUrl} = useRestoreUrl({restoreUrl: true, isDefaultNamespaceAllow: true});
|
||||
|
||||
const {onPageChanged, onDataTableValue, queryWithFilter, ready} = useDataTableActions({
|
||||
loadData,
|
||||
saveRestoreUrl
|
||||
loadData
|
||||
});
|
||||
|
||||
const namespace = computed({
|
||||
|
||||
@@ -83,6 +83,7 @@
|
||||
/* eslint-disable vue/enforce-style-attribute */
|
||||
import {computed, onMounted, ref, shallowRef, watch} from "vue";
|
||||
import {useI18n} from "vue-i18n";
|
||||
import {useThrottleFn} from "@vueuse/core";
|
||||
import UnfoldLessHorizontal from "vue-material-design-icons/UnfoldLessHorizontal.vue";
|
||||
import UnfoldMoreHorizontal from "vue-material-design-icons/UnfoldMoreHorizontal.vue";
|
||||
import Help from "vue-material-design-icons/Help.vue";
|
||||
@@ -94,6 +95,7 @@
|
||||
import {TabFocus} from "monaco-editor/esm/vs/editor/browser/config/tabFocus";
|
||||
import MonacoEditor from "./MonacoEditor.vue";
|
||||
import type * as monaco from "monaco-editor/esm/vs/editor/editor.api";
|
||||
import {useScrollMemory} from "../../composables/useScrollMemory";
|
||||
|
||||
const {t} = useI18n()
|
||||
|
||||
@@ -123,6 +125,7 @@
|
||||
shouldFocus: {type: Boolean, default: true},
|
||||
showScroll: {type: Boolean, default: false},
|
||||
diffOverviewBar: {type: Boolean, default: true},
|
||||
scrollKey: {type: String, default: undefined},
|
||||
})
|
||||
|
||||
defineOptions({
|
||||
@@ -312,6 +315,29 @@
|
||||
return
|
||||
}
|
||||
|
||||
const codeEditor = editor as monaco.editor.IStandaloneCodeEditor;
|
||||
const scrollMemory = props.scrollKey ? useScrollMemory(ref(props.scrollKey)) : null;
|
||||
|
||||
if (props.scrollKey && scrollMemory) {
|
||||
const savedState = scrollMemory.loadData<monaco.editor.ICodeEditorViewState>("viewState");
|
||||
if (savedState) {
|
||||
codeEditor.restoreViewState(savedState);
|
||||
codeEditor.revealLineInCenterIfOutsideViewport?.(codeEditor.getPosition()?.lineNumber ?? 1);
|
||||
}
|
||||
|
||||
const top = scrollMemory.loadData<number>("scrollTop", 0);
|
||||
if (typeof top === "number") {
|
||||
codeEditor.setScrollTop(top);
|
||||
}
|
||||
|
||||
const throttledSave = useThrottleFn(() => {
|
||||
scrollMemory.saveData(codeEditor.saveViewState(), "viewState");
|
||||
scrollMemory.saveData(codeEditor.getScrollTop(), "scrollTop");
|
||||
}, 100);
|
||||
|
||||
codeEditor.onDidScrollChange?.(throttledSave);
|
||||
}
|
||||
|
||||
if (!isDiff.value) {
|
||||
editor.onDidBlurEditorWidget?.(() => {
|
||||
emit("focusout", isCodeEditor(editor)
|
||||
@@ -468,6 +494,10 @@
|
||||
position: position,
|
||||
model: model,
|
||||
});
|
||||
// Save view state when cursor changes
|
||||
if (scrollMemory) {
|
||||
scrollMemory.saveData(codeEditor.saveViewState(), "viewState");
|
||||
}
|
||||
}, 100) as unknown as number;
|
||||
highlightPebble();
|
||||
});
|
||||
|
||||
@@ -59,12 +59,12 @@
|
||||
const {t} = useI18n();
|
||||
|
||||
const exportYaml = () => {
|
||||
const src = flowStore.flowYaml
|
||||
if(!src) {
|
||||
return;
|
||||
}
|
||||
const blob = new Blob([src], {type: "text/yaml"});
|
||||
localUtils.downloadUrl(window.URL.createObjectURL(blob), "flow.yaml");
|
||||
if(!flowStore.flow || !flowStore.flowYaml) return;
|
||||
|
||||
const {id, namespace} = flowStore.flow;
|
||||
const blob = new Blob([flowStore.flowYaml], {type: "text/yaml"});
|
||||
|
||||
localUtils.downloadUrl(window.URL.createObjectURL(blob), `${namespace}.${id}.yaml`);
|
||||
};
|
||||
|
||||
const flowStore = useFlowStore();
|
||||
@@ -109,24 +109,31 @@
|
||||
const onSaveAll = inject(FILES_SAVE_ALL_INJECTION_KEY);
|
||||
|
||||
async function save(){
|
||||
// Save the isCreating before saving.
|
||||
// saveAll can change its value.
|
||||
const isCreating = flowStore.isCreating
|
||||
await flowStore.saveAll()
|
||||
try {
|
||||
// Save the isCreating before saving.
|
||||
// saveAll can change its value.
|
||||
const isCreating = flowStore.isCreating
|
||||
await flowStore.saveAll()
|
||||
|
||||
if(isCreating){
|
||||
await router.push({
|
||||
name: "flows/update",
|
||||
params: {
|
||||
id: flowStore.flow?.id,
|
||||
namespace: flowStore.flow?.namespace,
|
||||
tab: "edit",
|
||||
tenant: routeParams.value.tenant,
|
||||
},
|
||||
});
|
||||
if(isCreating){
|
||||
await router.push({
|
||||
name: "flows/update",
|
||||
params: {
|
||||
id: flowStore.flow?.id,
|
||||
namespace: flowStore.flow?.namespace,
|
||||
tab: "edit",
|
||||
tenant: routeParams.value.tenant,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onSaveAll?.();
|
||||
} catch (error: any) {
|
||||
if (error?.status === 401) {
|
||||
toast.error("401 Unauthorized", undefined, {duration: 2000});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
onSaveAll?.();
|
||||
}
|
||||
|
||||
const deleteFlow = () => {
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
:creating="isCreating"
|
||||
:path="path"
|
||||
:diffOverviewBar="false"
|
||||
:scrollKey="editorScrollKey"
|
||||
@update:model-value="editorUpdate"
|
||||
@cursor="updatePluginDocumentation"
|
||||
@save="flow ? saveFlowYaml(): saveFileContent()"
|
||||
@@ -224,6 +225,19 @@
|
||||
const namespacesStore = useNamespacesStore();
|
||||
const miscStore = useMiscStore();
|
||||
|
||||
const editorScrollKey = computed(() => {
|
||||
if (props.flow) {
|
||||
const ns = flowStore.flow?.namespace ?? "";
|
||||
const id = flowStore.flow?.id ?? "";
|
||||
return `flow:${ns}/${id}:code`;
|
||||
}
|
||||
const ns = namespace.value;
|
||||
if (ns && props.path) {
|
||||
return `file:${ns}:${props.path}`;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
function loadPluginsHash() {
|
||||
miscStore.loadConfigs().then(config => {
|
||||
hash.value = config.pluginsHash;
|
||||
|
||||
@@ -463,7 +463,7 @@
|
||||
for (const item of itemsArr) {
|
||||
const fullPath = `${parentPath}${item.fileName}`;
|
||||
result.push({path: fullPath, fileName: item.fileName, id: item.id});
|
||||
if (isDirectory(item) && item.children.length > 0) {
|
||||
if (isDirectory(item) && item.children?.length > 0) {
|
||||
result.push(...flattenTree(item.children, `${fullPath}/`));
|
||||
}
|
||||
}
|
||||
@@ -688,21 +688,22 @@
|
||||
|
||||
async function removeItems() {
|
||||
if(confirmation.value.nodes === undefined) return;
|
||||
for (const node of confirmation.value.nodes) {
|
||||
await Promise.all(confirmation.value.nodes.map(async (node, i) => {
|
||||
const path = filesStore.getPath(node.id) ?? "";
|
||||
try {
|
||||
await namespacesStore.deleteFileDirectory({
|
||||
namespace: props.currentNS ?? route.params.namespace as string,
|
||||
path: filesStore.getPath(node) ?? "",
|
||||
path,
|
||||
});
|
||||
tree.value.remove(node.id);
|
||||
closeTab?.({
|
||||
path: filesStore.getPath(node) ?? "",
|
||||
path,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete file: ${node.fileName}`, error);
|
||||
toast.error(`Failed to delete file: ${node.fileName}`);
|
||||
}
|
||||
}
|
||||
}));
|
||||
confirmation.value = {visible: false, nodes: []};
|
||||
toast.success("Selected files deleted successfully.");
|
||||
}
|
||||
|
||||
@@ -235,7 +235,7 @@
|
||||
import {useI18n} from "vue-i18n";
|
||||
import {useRoute} from "vue-router";
|
||||
import _groupBy from "lodash/groupBy";
|
||||
import {computed, ref, useTemplateRef, watch} from "vue";
|
||||
import {computed, nextTick, ref, useTemplateRef, watch} from "vue";
|
||||
|
||||
import Check from "vue-material-design-icons/Check.vue";
|
||||
import Delete from "vue-material-design-icons/Delete.vue";
|
||||
@@ -272,7 +272,6 @@
|
||||
import DataTable from "../layout/DataTable.vue";
|
||||
import _merge from "lodash/merge";
|
||||
import {type DataTableRef, useDataTableActions} from "../../composables/useDataTableActions.ts";
|
||||
|
||||
const dataTable = useTemplateRef<DataTableRef>("dataTable");
|
||||
|
||||
const loadData = async (callback?: () => void) => {
|
||||
@@ -491,6 +490,8 @@
|
||||
kv.value.key = entry.key;
|
||||
const {type, value} = await namespacesStore.kv({namespace: entry.namespace, key: entry.key});
|
||||
kv.value.type = type;
|
||||
// Force the type reset before setting the value
|
||||
await nextTick();
|
||||
if (type === "JSON") {
|
||||
kv.value.value = JSON.stringify(value);
|
||||
} else if (type === "BOOLEAN") {
|
||||
@@ -504,7 +505,7 @@
|
||||
}
|
||||
|
||||
function removeKv(namespace: string, key: string) {
|
||||
toast.confirm("delete confirm", async () => {
|
||||
toast.confirm(t("delete confirm"), async () => {
|
||||
return namespacesStore
|
||||
.deleteKv({namespace, key: key})
|
||||
.then(() => {
|
||||
@@ -543,14 +544,16 @@
|
||||
const type = kv.value.type;
|
||||
let value: any = kv.value.value;
|
||||
|
||||
if (type === "STRING" || type === "DURATION") {
|
||||
if (type === "STRING") {
|
||||
value = JSON.stringify(value);
|
||||
} else if (["DURATION", "JSON"].includes(type)) {
|
||||
value = value || "";
|
||||
} else if (type === "DATETIME") {
|
||||
value = new Date(value!).toISOString();
|
||||
} else if (type === "DATE") {
|
||||
value = new Date(value!).toISOString().split("T")[0];
|
||||
} else if (["NUMBER", "BOOLEAN", "JSON"].includes(type)) {
|
||||
value = JSON.stringify(value);
|
||||
} else {
|
||||
value = String(value);
|
||||
}
|
||||
|
||||
const contentType = "text/plain";
|
||||
@@ -605,10 +608,9 @@
|
||||
|
||||
const formRef = ref();
|
||||
|
||||
watch(() => kv.value.type, () => {
|
||||
if (formRef.value) {
|
||||
(formRef.value as any).clearValidate("value");
|
||||
}
|
||||
watch(() => kv.value.type, (newType) => {
|
||||
formRef.value?.clearValidate("value");
|
||||
if (newType === "BOOLEAN") kv.value.value = false;
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
if (props.labels.length === 0) {
|
||||
addItem();
|
||||
} else {
|
||||
locals.value = [...props.labels];
|
||||
locals.value = props.labels;
|
||||
if (locals.value.length === 0) {
|
||||
addItem();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<ContextInfoContent :title="t('feeds.title')">
|
||||
<ContextInfoContent ref="contextInfoRef" :title="t('feeds.title')">
|
||||
<div
|
||||
class="post"
|
||||
:class="{
|
||||
@@ -46,9 +46,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, onMounted, reactive} from "vue";
|
||||
import {computed, onMounted, reactive, ref} from "vue";
|
||||
import {useI18n} from "vue-i18n";
|
||||
import {useStorage} from "@vueuse/core"
|
||||
import {useScrollMemory} from "../../composables/useScrollMemory"
|
||||
|
||||
import OpenInNew from "vue-material-design-icons/OpenInNew.vue";
|
||||
import MenuDown from "vue-material-design-icons/MenuDown.vue";
|
||||
@@ -62,6 +63,7 @@
|
||||
const apiStore = useApiStore();
|
||||
const {t} = useI18n({useScope: "global"});
|
||||
|
||||
const contextInfoRef = ref<InstanceType<typeof ContextInfoContent> | null>(null);
|
||||
const feeds = computed(() => apiStore.feeds);
|
||||
|
||||
const expanded = reactive<Record<string, boolean>>({});
|
||||
@@ -70,6 +72,9 @@
|
||||
onMounted(() => {
|
||||
lastNewsReadDate.value = feeds.value[0].publicationDate;
|
||||
});
|
||||
|
||||
const scrollableElement = computed(() => contextInfoRef.value?.contentRef || null)
|
||||
useScrollMemory(ref("context-panel-news"), scrollableElement as any)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<TopNavBar v-if="!embed" :title="routeInfo.title" />
|
||||
<section v-bind="$attrs" :class="{'container': !embed}" class="log-panel">
|
||||
<div class="log-content">
|
||||
<DataTable @page-changed="onPageChanged" ref="dataTable" :total="logsStore.total" :size="pageSize" :page="pageNumber" :embed="embed">
|
||||
<DataTable @page-changed="onPageChanged" ref="dataTable" :total="logsStore.total" :size="internalPageSize" :page="internalPageNumber" :embed="embed">
|
||||
<template #navbar v-if="!embed || showFilters">
|
||||
<KSFilter
|
||||
:configuration="logFilter"
|
||||
@@ -15,12 +15,12 @@
|
||||
</template>
|
||||
|
||||
<template v-if="showStatChart()" #top>
|
||||
<Sections ref="dashboard" :charts :dashboard="{id: 'default', charts: []}" showDefault />
|
||||
<Sections ref="dashboardRef" :charts :dashboard="{id: 'default', charts: []}" showDefault class="mb-4" />
|
||||
</template>
|
||||
|
||||
<template #table>
|
||||
<div v-loading="isLoading">
|
||||
<div v-if="logsStore.logs !== undefined && logsStore.logs.length > 0" class="logs-wrapper">
|
||||
<div v-if="logsStore.logs !== undefined && logsStore.logs?.length > 0" class="logs-wrapper">
|
||||
<LogLine
|
||||
v-for="(log, i) in logsStore.logs"
|
||||
:key="`${log.taskRunId}-${i}`"
|
||||
@@ -42,6 +42,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, computed, onMounted, watch} from "vue";
|
||||
import {useRoute} from "vue-router";
|
||||
import {useI18n} from "vue-i18n";
|
||||
import _merge from "lodash/merge";
|
||||
import moment from "moment";
|
||||
import {useLogFilter} from "../filter/configurations";
|
||||
import KSFilter from "../filter/components/KSFilter.vue";
|
||||
import Sections from "../dashboard/sections/Sections.vue";
|
||||
@@ -49,193 +54,151 @@
|
||||
import TopNavBar from "../../components/layout/TopNavBar.vue";
|
||||
import LogLine from "../logs/LogLine.vue";
|
||||
import NoData from "../layout/NoData.vue";
|
||||
|
||||
const logFilter = useLogFilter();
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import {mapStores} from "pinia";
|
||||
import RouteContext from "../../mixins/routeContext";
|
||||
import RestoreUrl from "../../mixins/restoreUrl";
|
||||
import DataTableActions from "../../mixins/dataTableActions";
|
||||
import _merge from "lodash/merge";
|
||||
import {storageKeys} from "../../utils/constants";
|
||||
import {decodeSearchParams} from "../filter/utils/helpers";
|
||||
import * as YAML_UTILS from "@kestra-io/ui-libs/flow-yaml-utils";
|
||||
import YAML_CHART from "../dashboard/assets/logs_timeseries_chart.yaml?raw";
|
||||
import {useLogsStore} from "../../stores/logs";
|
||||
import {defaultNamespace} from "../../composables/useNamespaces";
|
||||
import {defineComponent} from "vue";
|
||||
import {useDataTableActions} from "../../composables/useDataTableActions";
|
||||
import useRouteContext from "../../composables/useRouteContext";
|
||||
|
||||
export default defineComponent({
|
||||
mixins: [RouteContext, RestoreUrl, DataTableActions],
|
||||
props: {
|
||||
logLevel: {
|
||||
type: String,
|
||||
default: undefined
|
||||
},
|
||||
embed: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showFilters: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
filters: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
reloadLogs: {
|
||||
type: Number,
|
||||
default: undefined
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isDefaultNamespaceAllow: true,
|
||||
task: undefined,
|
||||
isLoading: false,
|
||||
lastRefreshDate: new Date(),
|
||||
canAutoRefresh: false,
|
||||
showChart: localStorage.getItem(storageKeys.SHOW_LOGS_CHART) !== "false",
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
storageKeys() {
|
||||
return storageKeys
|
||||
},
|
||||
...mapStores(useLogsStore),
|
||||
routeInfo() {
|
||||
return {
|
||||
title: this.$t("logs"),
|
||||
};
|
||||
},
|
||||
isFlowEdit() {
|
||||
return this.$route.name === "flows/update"
|
||||
},
|
||||
isNamespaceEdit() {
|
||||
return this.$route.name === "namespaces/update"
|
||||
},
|
||||
selectedLogLevel() {
|
||||
const decodedParams = decodeSearchParams(this.$route.query);
|
||||
const levelFilters = decodedParams.filter(item => item?.field === "level");
|
||||
const decoded = levelFilters.length > 0 ? levelFilters[0]?.value : "INFO";
|
||||
return this.logLevel || decoded || localStorage.getItem("defaultLogLevel") || "INFO";
|
||||
},
|
||||
endDate() {
|
||||
if (this.$route.query.endDate) {
|
||||
return this.$route.query.endDate;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
startDate() {
|
||||
// we mention the last refresh date here to trick
|
||||
// VueJs fine grained reactivity system and invalidate
|
||||
// computed property startDate
|
||||
if (this.$route.query.startDate && this.lastRefreshDate) {
|
||||
return this.$route.query.startDate;
|
||||
}
|
||||
if (this.$route.query.timeRange) {
|
||||
return this.$moment().subtract(this.$moment.duration(this.$route.query.timeRange).as("milliseconds")).toISOString(true);
|
||||
}
|
||||
const props = withDefaults(defineProps<{
|
||||
logLevel?: string;
|
||||
embed?: boolean;
|
||||
showFilters?: boolean;
|
||||
filters?: Record<string, any>;
|
||||
reloadLogs?: number;
|
||||
}>(), {
|
||||
embed: false,
|
||||
showFilters: false,
|
||||
filters: undefined,
|
||||
logLevel: undefined,
|
||||
reloadLogs: undefined
|
||||
});
|
||||
|
||||
// the default is PT30D
|
||||
return this.$moment().subtract(7, "days").toISOString(true);
|
||||
},
|
||||
namespace() {
|
||||
return this.$route.params.namespace ?? this.$route.params.id;
|
||||
},
|
||||
flowId() {
|
||||
return this.$route.params.id;
|
||||
},
|
||||
charts() {
|
||||
return [
|
||||
{...YAML_UTILS.parse(YAML_CHART), content: YAML_CHART}
|
||||
];
|
||||
}
|
||||
},
|
||||
beforeRouteEnter(to: any, _: any, next: (route?: any) => void) {
|
||||
const query = {...to.query};
|
||||
let queryHasChanged = false;
|
||||
const route = useRoute();
|
||||
const {t} = useI18n();
|
||||
const logsStore = useLogsStore();
|
||||
const logFilter = useLogFilter();
|
||||
|
||||
const queryKeys = Object.keys(query);
|
||||
if (defaultNamespace() && !queryKeys.some(key => key.startsWith("filters[namespace]"))) {
|
||||
query["filters[namespace][PREFIX]"] = defaultNamespace();
|
||||
queryHasChanged = true;
|
||||
}
|
||||
const routeInfo = computed(() => ({
|
||||
title: t("logs"),
|
||||
}));
|
||||
useRouteContext(routeInfo, props.embed);
|
||||
|
||||
if (queryHasChanged) {
|
||||
next({
|
||||
...to,
|
||||
query,
|
||||
replace: true
|
||||
});
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
showStatChart() {
|
||||
return this.showChart;
|
||||
},
|
||||
onShowChartChange(value: boolean) {
|
||||
this.showChart = value;
|
||||
localStorage.setItem(storageKeys.SHOW_LOGS_CHART, value.toString());
|
||||
if (this.showStatChart()) {
|
||||
this.load();
|
||||
}
|
||||
},
|
||||
refresh() {
|
||||
this.lastRefreshDate = new Date();
|
||||
if (this.$refs.dashboard) {
|
||||
this.$refs.dashboard.refreshCharts();
|
||||
}
|
||||
this.load();
|
||||
},
|
||||
loadQuery(base: any) {
|
||||
let queryFilter = this.filters ?? this.queryWithFilter();
|
||||
const isLoading = ref(false);
|
||||
const lastRefreshDate = ref(new Date());
|
||||
const showChart = ref(localStorage.getItem(storageKeys.SHOW_LOGS_CHART) !== "false");
|
||||
const dashboardRef = ref();
|
||||
|
||||
if (this.isFlowEdit) {
|
||||
queryFilter["filters[namespace][EQUALS]"] = this.namespace;
|
||||
queryFilter["filters[flowId][EQUALS]"] = this.flowId;
|
||||
} else if (this.isNamespaceEdit) {
|
||||
queryFilter["filters[namespace][EQUALS]"] = this.namespace;
|
||||
}
|
||||
const isFlowEdit = computed(() => route.name === "flows/update");
|
||||
const isNamespaceEdit = computed(() => route.name === "namespaces/update");
|
||||
const selectedLogLevel = computed(() => {
|
||||
const decodedParams = decodeSearchParams(route.query);
|
||||
const levelFilters = decodedParams.filter(item => item?.field === "level");
|
||||
const decoded = levelFilters.length > 0 ? levelFilters[0]?.value : "INFO";
|
||||
return props.logLevel || decoded || localStorage.getItem("defaultLogLevel") || "INFO";
|
||||
});
|
||||
const endDate = computed(() => {
|
||||
if (route.query.endDate) {
|
||||
return route.query.endDate;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
const startDate = computed(() => {
|
||||
// we mention the last refresh date here to trick
|
||||
// VueJs fine grained reactivity system and invalidate
|
||||
// computed property startDate
|
||||
if (route.query.startDate && lastRefreshDate.value) {
|
||||
return route.query.startDate;
|
||||
}
|
||||
if (route.query.timeRange) {
|
||||
return moment().subtract(moment.duration(route.query.timeRange as string).as("milliseconds")).toISOString(true);
|
||||
}
|
||||
|
||||
if (!queryFilter["startDate"] || !queryFilter["endDate"]) {
|
||||
queryFilter["startDate"] = this.startDate;
|
||||
queryFilter["endDate"] = this.endDate;
|
||||
}
|
||||
// the default is PT30D
|
||||
return moment().subtract(7, "days").toISOString(true);
|
||||
});
|
||||
const flowId = computed(() => route.params.id);
|
||||
const namespace = computed(() => route.params.namespace ?? route.params.id);
|
||||
const charts = computed(() => [
|
||||
{...YAML_UTILS.parse(YAML_CHART), content: YAML_CHART}
|
||||
]);
|
||||
|
||||
delete queryFilter["level"];
|
||||
const loadQuery = (base: any) => {
|
||||
let queryFilter = props.filters ?? queryWithFilter();
|
||||
|
||||
return _merge(base, queryFilter)
|
||||
},
|
||||
load() {
|
||||
this.isLoading = true
|
||||
if (isFlowEdit.value) {
|
||||
queryFilter["filters[namespace][EQUALS]"] = namespace.value;
|
||||
queryFilter["filters[flowId][EQUALS]"] = flowId.value;
|
||||
} else if (isNamespaceEdit.value) {
|
||||
queryFilter["filters[namespace][EQUALS]"] = namespace.value;
|
||||
}
|
||||
|
||||
const data = {
|
||||
page: this.filters ? this.internalPageNumber : this.$route.query.page || this.internalPageNumber,
|
||||
size: this.filters ? this.internalPageSize : this.$route.query.size || this.internalPageSize,
|
||||
...this.filters
|
||||
};
|
||||
this.logsStore.findLogs(this.loadQuery({
|
||||
...data,
|
||||
minLevel: this.filters ? null : this.selectedLogLevel,
|
||||
sort: "timestamp:desc"
|
||||
}))
|
||||
.finally(() => {
|
||||
this.isLoading = false
|
||||
this.saveRestoreUrl();
|
||||
});
|
||||
if (!queryFilter["startDate"] || !queryFilter["endDate"]) {
|
||||
queryFilter["startDate"] = startDate.value;
|
||||
queryFilter["endDate"] = endDate.value;
|
||||
}
|
||||
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
reloadLogs(newValue) {
|
||||
if(newValue) this.refresh();
|
||||
},
|
||||
delete queryFilter["level"];
|
||||
|
||||
return _merge(base, queryFilter);
|
||||
};
|
||||
|
||||
const loadData = (callback?: () => void) => {
|
||||
isLoading.value = true;
|
||||
|
||||
const data = {
|
||||
page: props.filters ? internalPageNumber.value : route.query.page || internalPageNumber.value,
|
||||
size: props.filters ? internalPageSize.value : route.query.size || internalPageSize.value,
|
||||
...props.filters
|
||||
};
|
||||
|
||||
logsStore.findLogs(loadQuery({
|
||||
...data,
|
||||
minLevel: props.filters ? null : selectedLogLevel.value,
|
||||
sort: "timestamp:desc"
|
||||
}))
|
||||
.finally(() => {
|
||||
isLoading.value = false;
|
||||
if (callback) callback();
|
||||
});
|
||||
};
|
||||
|
||||
const {onPageChanged, queryWithFilter, internalPageNumber, internalPageSize} = useDataTableActions({
|
||||
loadData
|
||||
});
|
||||
|
||||
const showStatChart = () => showChart.value;
|
||||
|
||||
const onShowChartChange = (value: boolean) => {
|
||||
showChart.value = value;
|
||||
localStorage.setItem(storageKeys.SHOW_LOGS_CHART, value.toString());
|
||||
if (showStatChart()) {
|
||||
loadData();
|
||||
}
|
||||
};
|
||||
|
||||
const refresh = () => {
|
||||
lastRefreshDate.value = new Date();
|
||||
if (dashboardRef.value) {
|
||||
dashboardRef.value.refreshCharts();
|
||||
}
|
||||
loadData();
|
||||
};
|
||||
|
||||
watch(() => route.query, () => {
|
||||
loadData();
|
||||
}, {deep: true});
|
||||
|
||||
watch(() => props.reloadLogs, (newValue) => {
|
||||
if (newValue) refresh();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
// Load data on mount if not embedded
|
||||
if (!props.embed) {
|
||||
loadData();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
|
||||
const namespace = computed(() => route.params?.id) as Ref<string>;
|
||||
|
||||
const miscStore = useMiscStore();
|
||||
const namespacesStore = useNamespacesStore();
|
||||
|
||||
watch(namespace, (newID) => {
|
||||
@@ -40,13 +41,12 @@
|
||||
});
|
||||
|
||||
watch(() => route.params.tab, (newTab) => {
|
||||
if (newTab === "overview") {
|
||||
if (newTab === "overview" || newTab === "executions") {
|
||||
const dateTimeKeys = ["startDate", "endDate", "timeRange"];
|
||||
|
||||
if (!Object.keys(route.query).some((key) => dateTimeKeys.some((dateTimeKey) => key.includes(dateTimeKey)))) {
|
||||
const miscStore = useMiscStore();
|
||||
const defaultDuration = miscStore.configs?.chartDefaultDuration || "P30D";
|
||||
const newQuery = {...route.query, "filters[timeRange][EQUALS]": defaultDuration};
|
||||
const DEFAULT_DURATION = miscStore.configs?.chartDefaultDuration ?? "P30D";
|
||||
const newQuery = {...route.query, "filters[timeRange][EQUALS]": DEFAULT_DURATION};
|
||||
router.replace({name: route.name, params: route.params, query: newQuery});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="no-code">
|
||||
<div class="no-code" ref="scrollContainer">
|
||||
<div class="p-4">
|
||||
<Task
|
||||
v-if="creatingTask || editingTask"
|
||||
@@ -64,6 +64,7 @@
|
||||
import {usePluginsStore} from "../../stores/plugins";
|
||||
import {useKeyboardSave} from "./utils/useKeyboardSave";
|
||||
import {deepEqual} from "../../utils/utils";
|
||||
import {useScrollMemory} from "../../composables/useScrollMemory";
|
||||
|
||||
|
||||
const props = defineProps<NoCodeProps>();
|
||||
@@ -195,6 +196,28 @@
|
||||
emit("editTask", parentPath, blockSchemaPath, refPath)
|
||||
})
|
||||
|
||||
// Scroll position persistence for No-code editor
|
||||
const scrollContainer = ref<HTMLDivElement | null>(null);
|
||||
|
||||
const flowIdentity = computed(() => {
|
||||
const namespace = flowStore.flow?.namespace ?? "";
|
||||
const flowId = flowStore.flow?.id ?? "";
|
||||
return `${namespace}/${flowId}`;
|
||||
});
|
||||
|
||||
const scrollKey = computed(() => {
|
||||
const base = `nocode:${flowIdentity.value}`;
|
||||
// home screen
|
||||
if (!props.creatingTask && !props.editingTask) return `${base}:home`;
|
||||
// task-specific
|
||||
const action = props.creatingTask ? "create" : "edit";
|
||||
const parentPath = props.parentPath ?? "";
|
||||
const refPath = props.refPath ?? "";
|
||||
const fieldName = props.fieldName ?? "";
|
||||
return `${base}:task:${action}:parentPath:${parentPath}:refPath:${refPath}:fieldName:${fieldName}`;
|
||||
});
|
||||
|
||||
useScrollMemory(scrollKey, scrollContainer);
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div v-if="playgroundStore.enabled && isTask && taskObject?.id" class="flow-playground">
|
||||
<PlaygroundRunTaskButton :taskId="taskObject?.id" />
|
||||
<div v-if="playgroundStore.enabled && isTask && taskModel?.id" class="flow-playground">
|
||||
<PlaygroundRunTaskButton :taskId="taskModel?.id" />
|
||||
</div>
|
||||
<el-form v-if="isTaskDefinitionBasedOnType" labelPosition="top">
|
||||
<el-form-item>
|
||||
@@ -17,12 +17,12 @@
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div @click="isPlugin && pluginsStore.updateDocumentation(taskObject as Parameters<typeof pluginsStore.updateDocumentation>[0])">
|
||||
<div @click="() => onTaskEditorClick(taskModel)">
|
||||
<TaskObject
|
||||
v-loading="isLoading"
|
||||
v-if="(selectedTaskType || !isTaskDefinitionBasedOnType) && schema"
|
||||
name="root"
|
||||
:modelValue="taskObject"
|
||||
:modelValue="taskModel"
|
||||
@update:model-value="onTaskInput"
|
||||
:schema
|
||||
:properties
|
||||
@@ -43,6 +43,7 @@
|
||||
FULL_SCHEMA_INJECTION_KEY,
|
||||
SCHEMA_DEFINITIONS_INJECTION_KEY,
|
||||
DATA_TYPES_MAP_INJECTION_KEY,
|
||||
ON_TASK_EDITOR_CLICK_INJECTION_KEY,
|
||||
} from "../injectionKeys";
|
||||
import {removeNullAndUndefined} from "../utils/cleanUp";
|
||||
import {removeRefPrefix, usePluginsStore} from "../../../stores/plugins";
|
||||
@@ -63,9 +64,9 @@
|
||||
const pluginsStore = usePluginsStore();
|
||||
const playgroundStore = usePlaygroundStore();
|
||||
|
||||
type PartialCodeElement = Partial<NoCodeElement>;
|
||||
type PartialNoCodeElement = Partial<NoCodeElement>;
|
||||
|
||||
const taskObject = ref<PartialCodeElement | undefined>({});
|
||||
const taskModel = ref<PartialNoCodeElement | undefined>({});
|
||||
const selectedTaskType = ref<string>();
|
||||
const isLoading = ref(false);
|
||||
|
||||
@@ -108,7 +109,7 @@
|
||||
|
||||
watch(modelValue, (v) => {
|
||||
if (!v) {
|
||||
taskObject.value = {};
|
||||
taskModel.value = {};
|
||||
selectedTaskType.value = undefined;
|
||||
} else {
|
||||
setup()
|
||||
@@ -150,20 +151,20 @@
|
||||
});
|
||||
|
||||
function setup() {
|
||||
const parsed = YAML_UTILS.parse<PartialCodeElement>(modelValue.value);
|
||||
const parsed = YAML_UTILS.parse<PartialNoCodeElement>(modelValue.value);
|
||||
if(isPluginDefaults.value){
|
||||
const {forced, type, values} = parsed as any;
|
||||
taskObject.value = {...values, forced, type};
|
||||
taskModel.value = {...values, forced, type};
|
||||
}else{
|
||||
taskObject.value = parsed;
|
||||
taskModel.value = parsed;
|
||||
}
|
||||
selectedTaskType.value = taskObject.value?.type;
|
||||
selectedTaskType.value = taskModel.value?.type;
|
||||
}
|
||||
|
||||
// when tab is opened, load the documentation
|
||||
onActivated(() => {
|
||||
if(selectedTaskType.value && parentPath !== "inputs"){
|
||||
pluginsStore.updateDocumentation(taskObject.value as Parameters<typeof pluginsStore.updateDocumentation>[0]);
|
||||
pluginsStore.updateDocumentation(taskModel.value as Parameters<typeof pluginsStore.updateDocumentation>[0]);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -218,7 +219,7 @@
|
||||
const resolvedType = computed<string>(() => {
|
||||
if(resolvedTypes.value.length > 1 && selectedTaskType.value){
|
||||
// find the resolvedType that match the current dataType
|
||||
const dataType = taskObject.value?.data?.type;
|
||||
const dataType = taskModel.value?.data?.type;
|
||||
if(dataType){
|
||||
for(const typeLocal of resolvedTypes.value){
|
||||
const schema = definitions.value?.[typeLocal];
|
||||
@@ -330,13 +331,13 @@
|
||||
watch([selectedTaskType, fullSchema], ([task]) => {
|
||||
if (task) {
|
||||
if(isPlugin.value){
|
||||
pluginsStore.updateDocumentation(taskObject.value as Parameters<typeof pluginsStore.updateDocumentation>[0]);
|
||||
pluginsStore.updateDocumentation(taskModel.value as Parameters<typeof pluginsStore.updateDocumentation>[0]);
|
||||
}
|
||||
}
|
||||
}, {immediate: true});
|
||||
|
||||
function onTaskInput(val: PartialCodeElement | undefined) {
|
||||
taskObject.value = val;
|
||||
function onTaskInput(val: PartialNoCodeElement | undefined) {
|
||||
taskModel.value = val;
|
||||
if(fieldName){
|
||||
val = {
|
||||
[fieldName]: val,
|
||||
@@ -362,12 +363,21 @@
|
||||
}
|
||||
|
||||
function onTaskTypeSelect() {
|
||||
const value: PartialCodeElement = {
|
||||
const value: PartialNoCodeElement = {
|
||||
type: selectedTaskType.value ?? ""
|
||||
};
|
||||
|
||||
onTaskInput(value);
|
||||
}
|
||||
|
||||
const onTaskEditorClick = inject(ON_TASK_EDITOR_CLICK_INJECTION_KEY, (elt?: PartialNoCodeElement) => {
|
||||
const type = elt?.type;
|
||||
if(isPlugin.value && type){
|
||||
pluginsStore.updateDocumentation({type});
|
||||
}else{
|
||||
pluginsStore.updateDocumentation();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type {ComputedRef, InjectionKey, Ref} from "vue"
|
||||
import {TopologyClickParams} from "./utils/types"
|
||||
import {NoCodeElement, TopologyClickParams} from "./utils/types"
|
||||
import {Panel} from "../../utils/multiPanelTypes"
|
||||
|
||||
export const BLOCK_SCHEMA_PATH_INJECTION_KEY = Symbol("block-schema-path-injection-key") as InjectionKey<ComputedRef<string>>
|
||||
@@ -90,4 +90,6 @@ export const FULL_SCHEMA_INJECTION_KEY = Symbol("full-schema-injection-key") as
|
||||
|
||||
export const SCHEMA_DEFINITIONS_INJECTION_KEY = Symbol("schema-definitions-injection-key") as InjectionKey<ComputedRef<Record<string, any>>>
|
||||
|
||||
export const DATA_TYPES_MAP_INJECTION_KEY = Symbol("data-types-injection-key") as InjectionKey<ComputedRef<Record<string, string[] | undefined>>>
|
||||
export const DATA_TYPES_MAP_INJECTION_KEY = Symbol("data-types-injection-key") as InjectionKey<ComputedRef<Record<string, string[] | undefined>>>
|
||||
|
||||
export const ON_TASK_EDITOR_CLICK_INJECTION_KEY = Symbol("on-task-editor-click-injection-key") as InjectionKey<(elt?: Partial<NoCodeElement>) => void>;
|
||||
@@ -62,7 +62,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, computed, onBeforeMount} from "vue";
|
||||
import {ref, computed, onBeforeMount, watch} from "vue";
|
||||
import {useRoute, useRouter} from "vue-router";
|
||||
import {isEntryAPluginElementPredicate, TaskIcon} from "@kestra-io/ui-libs";
|
||||
import DottedLayout from "../layout/DottedLayout.vue";
|
||||
@@ -71,6 +71,7 @@
|
||||
import headerImage from "../../assets/icons/plugin.svg";
|
||||
import headerImageDark from "../../assets/icons/plugin-dark.svg";
|
||||
import {usePluginsStore} from "../../stores/plugins";
|
||||
import useRestoreUrl from "../../composables/useRestoreUrl";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -85,13 +86,23 @@
|
||||
embed: false
|
||||
});
|
||||
|
||||
const {saveRestoreUrl} = useRestoreUrl();
|
||||
|
||||
const icons = ref<Record<string, any>>({});
|
||||
const searchText = ref("");
|
||||
|
||||
const handleSearch = (query: string) => {
|
||||
searchText.value = query;
|
||||
const newQuery: Record<string, any> = {...route.query};
|
||||
if (query !== undefined && query !== null && String(query).trim() !== "") {
|
||||
newQuery.q = query;
|
||||
} else {
|
||||
// remove an empty `q=` in the URL on plugins/view
|
||||
delete newQuery.q;
|
||||
}
|
||||
|
||||
router.push({
|
||||
query: {...route.query, q: query || undefined}
|
||||
query: newQuery
|
||||
});
|
||||
};
|
||||
|
||||
@@ -144,7 +155,13 @@
|
||||
if (!cls) {
|
||||
return;
|
||||
}
|
||||
router.push({name: "plugins/view", params: {cls: cls}})
|
||||
router.push({
|
||||
name: "plugins/view",
|
||||
params: {
|
||||
...route.params,
|
||||
cls: cls
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
const isVisible = (plugin: any) => {
|
||||
@@ -171,6 +188,11 @@
|
||||
loadPluginIcons();
|
||||
searchText.value = String(route.query?.q ?? "");
|
||||
});
|
||||
|
||||
watch(() => route.query.q, (newQ) => {
|
||||
searchText.value = String(newQ ?? "");
|
||||
saveRestoreUrl();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
</el-breadcrumb>
|
||||
</div>
|
||||
|
||||
<div v-if="currentView === 'list'" class="list">
|
||||
<div v-if="currentView === 'list'" class="list" ref="listRef">
|
||||
<div
|
||||
v-for="plugin in sortedPlugins"
|
||||
:key="`${plugin.group}-${plugin.title}`"
|
||||
@@ -48,7 +48,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="currentView === 'group'" class="group-view">
|
||||
<div v-else-if="currentView === 'group'" class="group-view" ref="groupRef">
|
||||
<PluginUnified
|
||||
:group="currentGroup"
|
||||
:subgroup="currentSubgroup"
|
||||
@@ -57,7 +57,7 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="currentView === 'documentation'" :class="['doc-view', {'no-padding': !currentDocumentationPlugin}]">
|
||||
<div v-else-if="currentView === 'documentation'" :class="['doc-view', {'no-padding': !currentDocumentationPlugin}]" ref="docRef">
|
||||
<PluginDocumentation
|
||||
:plugin="currentDocumentationPlugin"
|
||||
/>
|
||||
@@ -72,6 +72,7 @@
|
||||
import PluginUnified from "./PluginUnified.vue";
|
||||
import PluginDocumentation from "./PluginDocumentation.vue";
|
||||
import {usePluginsStore} from "../../stores/plugins";
|
||||
import {useScrollMemory} from "../../composables/useScrollMemory";
|
||||
import {capitalize, formatPluginTitle} from "../../utils/global";
|
||||
|
||||
interface Props {
|
||||
@@ -94,6 +95,18 @@
|
||||
const navigationStack = ref<NavigationItem[]>([]);
|
||||
const currentDocumentationPlugin = ref<any>(null);
|
||||
const currentView = ref<"list" | "group" | "documentation">("documentation");
|
||||
const listRef = ref<HTMLDivElement | null>(null);
|
||||
const groupRef = ref<HTMLDivElement | null>(null);
|
||||
const docRef = ref<HTMLDivElement | null>(null);
|
||||
const scrollKeyBase = "plugins:documentation";
|
||||
|
||||
const listScrollKey = computed(() => `${scrollKeyBase}:list`);
|
||||
const groupScrollKey = computed(() => `${scrollKeyBase}:group`);
|
||||
const docScrollKey = computed(() => `${scrollKeyBase}:documentation`);
|
||||
|
||||
useScrollMemory(listScrollKey, listRef);
|
||||
useScrollMemory(groupScrollKey, groupRef);
|
||||
useScrollMemory(docScrollKey, docRef);
|
||||
|
||||
const getSimpleType = (item: string) => item.split(".").pop() || item;
|
||||
|
||||
@@ -275,7 +288,9 @@
|
||||
}
|
||||
}, {immediate: true, deep: true});
|
||||
|
||||
onMounted(loadPluginIcons);
|
||||
onMounted(async () => {
|
||||
await loadPluginIcons();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -148,7 +148,7 @@
|
||||
>
|
||||
<NamespaceSelect
|
||||
v-model="secret.namespace"
|
||||
:readonly="secret.update"
|
||||
:readOnly="secret.update"
|
||||
:includeSystemNamespace="true"
|
||||
all
|
||||
/>
|
||||
|
||||
@@ -3,6 +3,7 @@ import {useRoute, useRouter} from "vue-router";
|
||||
import _merge from "lodash/merge";
|
||||
import _cloneDeep from "lodash/cloneDeep";
|
||||
import _isEqual from "lodash/isEqual";
|
||||
import useRestoreUrl from "./useRestoreUrl";
|
||||
|
||||
interface SortItem {
|
||||
prop?: string;
|
||||
@@ -26,7 +27,6 @@ interface DataTableActionsOptions {
|
||||
embed?: boolean;
|
||||
dataTableRef?: Ref<DataTableRef | null>;
|
||||
loadData?: (callback?: () => void) => void;
|
||||
saveRestoreUrl?: () => void;
|
||||
}
|
||||
|
||||
export function useDataTableActions(options: DataTableActionsOptions = {}) {
|
||||
@@ -35,7 +35,6 @@ export function useDataTableActions(options: DataTableActionsOptions = {}) {
|
||||
|
||||
const sort = ref("");
|
||||
const dblClickRouteName = ref(options.dblClickRouteName);
|
||||
const loadInit = ref(true);
|
||||
const ready = ref(false);
|
||||
const internalPageSize = ref(25);
|
||||
const internalPageNumber = ref(1);
|
||||
@@ -47,6 +46,8 @@ export function useDataTableActions(options: DataTableActionsOptions = {}) {
|
||||
const embed = computed(() => options.embed);
|
||||
const dataTableRef = computed(() => options.dataTableRef?.value);
|
||||
|
||||
const {loadInit, saveRestoreUrl} = useRestoreUrl({restoreUrl: true});
|
||||
|
||||
const sortString = (sortItem: SortItem, sortKeyMapper: (k: string) => string): string | undefined => {
|
||||
if (sortItem && sortItem.prop && sortItem.order) {
|
||||
return `${sortKeyMapper(sortItem.prop)}:${sortItem.order === "descending" ? "desc" : "asc"}`;
|
||||
@@ -149,9 +150,7 @@ export function useDataTableActions(options: DataTableActionsOptions = {}) {
|
||||
ready.value = true;
|
||||
loadInit.value = true;
|
||||
|
||||
if (options.saveRestoreUrl) {
|
||||
options.saveRestoreUrl();
|
||||
}
|
||||
saveRestoreUrl();
|
||||
|
||||
if (dataTableRef.value) {
|
||||
dataTableRef.value.isLoading = false;
|
||||
|
||||
@@ -47,6 +47,11 @@ export default function useRestoreUrl(options: UseRestoreUrlOptions = {}) {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Merges saved URL query parameters from sessionStorage with current route.
|
||||
* Only adds missing parameters to avoid overwriting user changes.
|
||||
* Updates route only when changes are made.
|
||||
*/
|
||||
const goToRestoreUrl = () => {
|
||||
if (!restoreUrl) {
|
||||
return;
|
||||
@@ -84,9 +89,12 @@ export default function useRestoreUrl(options: UseRestoreUrlOptions = {}) {
|
||||
}
|
||||
};
|
||||
|
||||
// Automatically call goToRestoreUrl on mount if needed (equivalent to created() hook)
|
||||
/**
|
||||
* Automatically restores saved URL state from sessionStorage on mount.
|
||||
* Only triggers when restoreUrl is enabled and saved state exists.
|
||||
*/
|
||||
onMounted(() => {
|
||||
if (Object.keys(route.query).length === 0 && restoreUrl) {
|
||||
if (restoreUrl && localStorageValue.value) {
|
||||
loadInit.value = false;
|
||||
goToRestoreUrl();
|
||||
}
|
||||
|
||||
49
ui/src/composables/useScrollMemory.ts
Normal file
49
ui/src/composables/useScrollMemory.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import {watch, nextTick, ref, Ref, onActivated} from "vue"
|
||||
import {useScroll, useThrottleFn, useWindowScroll} from "@vueuse/core"
|
||||
import {storageKeys} from "../utils/constants"
|
||||
|
||||
export function useScrollMemory(keyRef: Ref<string>, elementRef?: Ref<HTMLElement | null>, useWindow = false): {
|
||||
saveData: (value: any, suffix?: string) => void;
|
||||
loadData: <T = any>(suffix?: string, defaultValue?: T) => T | undefined;
|
||||
} {
|
||||
const getStorageKey = (suffix = "") => `${storageKeys.SCROLL_MEMORY_PREFIX}-${keyRef.value}${suffix}`
|
||||
|
||||
const saveToStorage = (value: any, suffix = "") => {
|
||||
sessionStorage?.setItem(getStorageKey(suffix), JSON.stringify(value))
|
||||
}
|
||||
|
||||
const loadFromStorage = <T = any>(suffix = "", defaultValue?: T): T | undefined => {
|
||||
const saved = sessionStorage?.getItem(getStorageKey(suffix))
|
||||
return saved ? JSON.parse(saved) : defaultValue
|
||||
}
|
||||
|
||||
const saveScroll = (value: number) => saveToStorage(value)
|
||||
const loadScroll = (): number => loadFromStorage("", 0) || 0
|
||||
|
||||
const restoreScroll = () => {
|
||||
const scrollTop = loadScroll()
|
||||
const applyScroll = useWindow
|
||||
? () => window.scrollTo({top: scrollTop, behavior: "smooth"})
|
||||
: () => { if (elementRef?.value) elementRef.value.scrollTo({top: scrollTop, behavior: "smooth"}) }
|
||||
setTimeout(applyScroll, 10)
|
||||
}
|
||||
|
||||
const throttledSave = useThrottleFn((top: number) => saveScroll(top), 100)
|
||||
|
||||
if (useWindow) {
|
||||
const {y} = useWindowScroll({throttle: 16, onScroll: () => throttledSave(y.value)})
|
||||
watch(keyRef, () => nextTick(restoreScroll), {immediate: true})
|
||||
onActivated(() => nextTick(restoreScroll))
|
||||
} else {
|
||||
useScroll(elementRef || ref(null), {
|
||||
throttle: 16,
|
||||
onScroll: () => { if (elementRef?.value) throttledSave(elementRef.value.scrollTop) }
|
||||
})
|
||||
watch([keyRef, () => elementRef?.value], ([newKey, newElement]) => {
|
||||
if (newElement && newKey) nextTick(restoreScroll)
|
||||
}, {immediate: true})
|
||||
onActivated(() => nextTick(restoreScroll))
|
||||
}
|
||||
|
||||
return {saveData: saveToStorage, loadData: loadFromStorage}
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
<template>
|
||||
<Dashboards
|
||||
v-if="tab === 'overview' && ALLOWED_CREATION_ROUTES.includes(String(route.name))"
|
||||
@dashboard="onSelectDashboard"
|
||||
/>
|
||||
<Action
|
||||
v-if="deleted"
|
||||
type="default"
|
||||
@@ -34,12 +38,20 @@
|
||||
import Action from "../../../components/namespaces/components/buttons/Action.vue";
|
||||
// @ts-expect-error does not have types
|
||||
import TriggerFlow from "../../../components/flows/TriggerFlow.vue";
|
||||
import Dashboards from "../../../components/dashboard/components/selector/Selector.vue";
|
||||
import {ALLOWED_CREATION_ROUTES} from "../../../components/dashboard/composables/useDashboards";
|
||||
import permission from "../../../models/permission";
|
||||
import action from "../../../models/action";
|
||||
import {useAuthStore} from "override/stores/auth";
|
||||
|
||||
const {t} = useI18n();
|
||||
|
||||
const onSelectDashboard = (value: any) => {
|
||||
router.replace({
|
||||
params: {...route.params, dashboard: value}
|
||||
});
|
||||
};
|
||||
|
||||
const coreStore = useCoreStore();
|
||||
const flowStore = useFlowStore();
|
||||
const router = useRouter();
|
||||
|
||||
@@ -107,7 +107,6 @@
|
||||
import {useDocStore} from "../../../../stores/doc";
|
||||
import {canCreate} from "override/composables/blueprintsPermissions";
|
||||
import {useDataTableActions} from "../../../../composables/useDataTableActions";
|
||||
import useRestoreUrl from "../../../../composables/useRestoreUrl";
|
||||
import {useBlueprintFilter} from "../../../../components/filter/configurations";
|
||||
|
||||
const blueprintFilter = useBlueprintFilter();
|
||||
@@ -128,8 +127,6 @@
|
||||
|
||||
const {onPageChanged, onDataLoaded, load, ready, internalPageNumber, internalPageSize} = useDataTableActions({loadData});
|
||||
|
||||
useRestoreUrl();
|
||||
|
||||
const emit = defineEmits(["goToDetail", "loaded"]);
|
||||
|
||||
const route = useRoute();
|
||||
@@ -273,15 +270,13 @@
|
||||
docStore.docId = `blueprints.${props.blueprintType}`;
|
||||
});
|
||||
|
||||
watch(route,
|
||||
(newValue, oldValue) => {
|
||||
if (oldValue.name === newValue.name) {
|
||||
selectedTags.value = initSelectedTags();
|
||||
searchText.value = route.query.q || "";
|
||||
load(onDataLoaded);
|
||||
}
|
||||
}
|
||||
);
|
||||
watch(route, (newRoute, oldRoute) => {
|
||||
if (newRoute.name === oldRoute.name) {
|
||||
selectedTags.value = initSelectedTags();
|
||||
searchText.value = newRoute.query.q || "";
|
||||
load(onDataLoaded);
|
||||
}
|
||||
});
|
||||
|
||||
watch(searchText, () => {
|
||||
load(onDataLoaded);
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
<template>
|
||||
<Dashboards
|
||||
v-if="tab === 'overview' && ALLOWED_CREATION_ROUTES.includes(String(route.name))"
|
||||
@dashboard="onSelectDashboard"
|
||||
/>
|
||||
|
||||
<Action
|
||||
v-if="tab === 'flows'"
|
||||
:label="t('create_flow')"
|
||||
@@ -21,17 +26,25 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, Ref} from "vue";
|
||||
import {useRoute} from "vue-router";
|
||||
import {useRoute, useRouter} from "vue-router";
|
||||
import {useI18n} from "vue-i18n";
|
||||
import {useNamespacesStore} from "override/stores/namespaces";
|
||||
import Action from "../../../components/namespaces/components/buttons/Action.vue";
|
||||
|
||||
import Dashboards from "../../../components/dashboard/components/selector/Selector.vue";
|
||||
import {ALLOWED_CREATION_ROUTES} from "../../../components/dashboard/composables/useDashboards";
|
||||
import FamilyTree from "vue-material-design-icons/FamilyTree.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const {t} = useI18n({useScope: "global"});
|
||||
const namespacesStore = useNamespacesStore();
|
||||
|
||||
const onSelectDashboard = (value: any) => {
|
||||
router.replace({
|
||||
params: {...route.params, dashboard: value}
|
||||
});
|
||||
};
|
||||
|
||||
const tab = computed(() => route.params?.tab);
|
||||
const namespace = computed(() => route.params?.id) as Ref<string>;
|
||||
</script>
|
||||
|
||||
@@ -89,11 +89,14 @@
|
||||
import permission from "../../../models/permission";
|
||||
import action from "../../../models/action";
|
||||
|
||||
import useRestoreUrl from "../../../composables/useRestoreUrl";
|
||||
|
||||
import DotsSquare from "vue-material-design-icons/DotsSquare.vue";
|
||||
import TextSearch from "vue-material-design-icons/TextSearch.vue";
|
||||
import {useAuthStore} from "override/stores/auth";
|
||||
|
||||
const namespacesFilter = useNamespacesFilter();
|
||||
const {saveRestoreUrl} = useRestoreUrl({restoreUrl: true});
|
||||
|
||||
interface Node {
|
||||
id: string;
|
||||
@@ -127,8 +130,12 @@
|
||||
|
||||
onMounted(() => loadData());
|
||||
watch(
|
||||
() => route.query,
|
||||
() => loadData(),
|
||||
() => route.query.q,
|
||||
() => {
|
||||
loadData();
|
||||
saveRestoreUrl();
|
||||
},
|
||||
{immediate: true}
|
||||
);
|
||||
|
||||
const miscStore = useMiscStore();
|
||||
|
||||
@@ -7,21 +7,23 @@ import DemoAuditLogs from "../components/demo/AuditLogs.vue"
|
||||
import DemoInstance from "../components/demo/Instance.vue"
|
||||
import DemoApps from "../components/demo/Apps.vue"
|
||||
import DemoTests from "../components/demo/Tests.vue"
|
||||
import {useMiscStore} from "override/stores/misc";
|
||||
import {applyDefaultFilters} from "../components/filter/composables/useDefaultFilter";
|
||||
|
||||
function maybeAddTimeRangeFilter(to) {
|
||||
const dateTimeKeys = ["startDate", "endDate", "timeRange"];
|
||||
|
||||
// Default to the configured duration if no time range is set
|
||||
if (!Object.keys(to.query).some((key) => dateTimeKeys.some((dateTimeKey) => key.includes(dateTimeKey)))) {
|
||||
const miscStore = useMiscStore();
|
||||
const defaultDuration = miscStore.configs?.chartDefaultDuration || "P30D"; // Fallback to 30 days
|
||||
to.query["filters[timeRange][EQUALS]"] = defaultDuration;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
export function applyBeforeEnterFilter(options) {
|
||||
return (to, _from, next) => {
|
||||
const {query, hasChanges} = applyDefaultFilters(to.query, options);
|
||||
|
||||
if (hasChanges) {
|
||||
next({
|
||||
name: to.name,
|
||||
params: to.params,
|
||||
query,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
export default [
|
||||
@@ -35,15 +37,6 @@ export default [
|
||||
path: "/:tenant?/dashboards/:dashboard?",
|
||||
component: () => import("../components/dashboard/Dashboard.vue"),
|
||||
beforeEnter: (to, from, next) => {
|
||||
if (maybeAddTimeRangeFilter(to)) {
|
||||
next({
|
||||
name: to.name,
|
||||
params: to.params,
|
||||
query: to.query,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!to.params.dashboard) {
|
||||
next({
|
||||
name: "home",
|
||||
@@ -53,16 +46,21 @@ export default [
|
||||
},
|
||||
query: to.query,
|
||||
});
|
||||
} else {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
applyBeforeEnterFilter({includeTimeRange: true, includeScope: false})(to, from, next);
|
||||
},
|
||||
},
|
||||
{name: "dashboards/create", path: "/:tenant?/dashboards/new", component: () => import("../components/dashboard/components/Create.vue")},
|
||||
{name: "dashboards/update", path: "/:tenant?/dashboards/:dashboard/edit", component: () => import("override/components/dashboard/Edit.vue")},
|
||||
|
||||
//Flows
|
||||
{name: "flows/list", path: "/:tenant?/flows", component: () => import("../components/flows/Flows.vue")},
|
||||
{
|
||||
name: "flows/list",
|
||||
path: "/:tenant?/flows",
|
||||
component: () => import("../components/flows/Flows.vue"),
|
||||
beforeEnter: applyBeforeEnterFilter({includeTimeRange: false, includeScope: true}),
|
||||
},
|
||||
{name: "flows/search", path: "/:tenant?/flows/search", component: () => import("../components/flows/FlowsSearch.vue")},
|
||||
{name: "flows/create", path: "/:tenant?/flows/new", component: () => import("../components/flows/FlowCreate.vue")},
|
||||
{name: "flows/update", path: "/:tenant?/flows/edit/:namespace/:id/:tab?", component: () => import("../components/flows/FlowRoot.vue")},
|
||||
@@ -72,18 +70,7 @@ export default [
|
||||
name: "executions/list",
|
||||
path: "/:tenant?/executions",
|
||||
component: () => import("../components/executions/Executions.vue"),
|
||||
beforeEnter: (to, from, next) => {
|
||||
if (maybeAddTimeRangeFilter(to)) {
|
||||
next({
|
||||
name: to.name,
|
||||
params: to.params,
|
||||
query: to.query,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
beforeEnter: applyBeforeEnterFilter({includeTimeRange: true, includeScope: true}),
|
||||
},
|
||||
{name: "executions/update", path: "/:tenant?/executions/:namespace/:flowId/:id/:tab?", component: () => import("../components/executions/ExecutionRoot.vue")},
|
||||
|
||||
@@ -111,18 +98,7 @@ export default [
|
||||
name: "logs/list",
|
||||
path: "/:tenant?/logs",
|
||||
component: () => import("../components/logs/LogsWrapper.vue"),
|
||||
beforeEnter: (to, from, next) => {
|
||||
if (maybeAddTimeRangeFilter(to)) {
|
||||
next({
|
||||
name: to.name,
|
||||
params: to.params,
|
||||
query: to.query,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
beforeEnter: applyBeforeEnterFilter({includeTimeRange: true, includeScope: false}),
|
||||
},
|
||||
|
||||
//Namespaces
|
||||
|
||||
@@ -42,7 +42,7 @@ export const useBlueprintsStore = defineStore("blueprints", () => {
|
||||
const PARAMS = {params: options.params, ...VALIDATE};
|
||||
|
||||
const COMMUNITY = `${API_URL}/blueprints/kinds/${options.kind}/versions/${version}${edition === "OSS" ? "?ee=false" : ""}`;
|
||||
const CUSTOM = `${apiUrl()}/blueprints/${options.type}${options.kind}`;
|
||||
const CUSTOM = `${apiUrl()}/blueprints/${options.type}`;
|
||||
|
||||
const response = await axios.get(options.type === "community" ? COMMUNITY : CUSTOM, PARAMS);
|
||||
|
||||
@@ -52,7 +52,7 @@ export const useBlueprintsStore = defineStore("blueprints", () => {
|
||||
|
||||
const getBlueprint = async (options: Options) => {
|
||||
const COMMUNITY = `${API_URL}/blueprints/kinds/${options.kind}/${options.id}/versions/${version}`;
|
||||
const CUSTOM = `${apiUrl()}/blueprints/${options.type}${options.kind}/${options.id}`;
|
||||
const CUSTOM = `${apiUrl()}/blueprints/${options.type}/${options.id}`;
|
||||
|
||||
const response = await axios.get(options.type == "community" ? COMMUNITY : CUSTOM);
|
||||
|
||||
@@ -66,7 +66,7 @@ export const useBlueprintsStore = defineStore("blueprints", () => {
|
||||
|
||||
const getBlueprintSource = async (options: Options) => {
|
||||
const COMMUNITY = `${API_URL}/blueprints/kinds/${options.kind}/${options.id}/versions/${version}/source`;
|
||||
const CUSTOM = `${apiUrl()}/blueprints/${options.type}${options.kind}/${options.id}/source`;
|
||||
const CUSTOM = `${apiUrl()}/blueprints/${options.type}/${options.id}/source`;
|
||||
|
||||
const response = await axios.get(options.type == "community" ? COMMUNITY : CUSTOM);
|
||||
|
||||
@@ -76,7 +76,7 @@ export const useBlueprintsStore = defineStore("blueprints", () => {
|
||||
|
||||
const getBlueprintGraph = async (options: Options) => {
|
||||
const COMMUNITY = `${API_URL}/blueprints/kinds/${options.kind}/${options.id}/versions/${version}/graph`;
|
||||
const CUSTOM = `${apiUrl()}/blueprints/${options.type}${options.kind}/${options.id}/graph`;
|
||||
const CUSTOM = `${apiUrl()}/blueprints/${options.type}/${options.id}/graph`;
|
||||
|
||||
const response = await axios.get(options.type == "community" ? COMMUNITY : CUSTOM);
|
||||
|
||||
@@ -88,7 +88,7 @@ export const useBlueprintsStore = defineStore("blueprints", () => {
|
||||
const PARAMS = {params: options.params, ...VALIDATE};
|
||||
|
||||
const COMMUNITY = `${API_URL}/blueprints/kinds/${options.kind}/versions/${version}/tags`;
|
||||
const CUSTOM = `${apiUrl()}/blueprints/${options.type}${options.kind}/tags`;
|
||||
const CUSTOM = `${apiUrl()}/blueprints/${options.type}/tags`;
|
||||
|
||||
const response = await axios.get(options.type == "community" ? COMMUNITY : CUSTOM, PARAMS);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {computed, ref} from "vue";
|
||||
import {computed, nextTick, ref, watch} from "vue";
|
||||
import {defineStore} from "pinia";
|
||||
|
||||
import type {AxiosRequestConfig, AxiosResponse} from "axios";
|
||||
@@ -21,146 +21,230 @@ import type {Dashboard, Chart, Request, Parameters} from "../components/dashboar
|
||||
import {useAxios} from "../utils/axios";
|
||||
import {removeRefPrefix, usePluginsStore} from "./plugins";
|
||||
import * as YAML_UTILS from "@kestra-io/ui-libs/flow-yaml-utils";
|
||||
import _throttle from "lodash/throttle";
|
||||
import {useCoreStore} from "./core";
|
||||
import {useI18n} from "vue-i18n";
|
||||
|
||||
|
||||
|
||||
export const useDashboardStore = defineStore("dashboard", () => {
|
||||
const selectedChart = ref<Chart>();
|
||||
const dashboard = ref<Dashboard>();
|
||||
const chartErrors = ref<string[]>([]);
|
||||
const isCreating = ref<boolean>(false);
|
||||
const selectedChart = ref<Chart>();
|
||||
const dashboard = ref<Dashboard>();
|
||||
const chartErrors = ref<string[]>([]);
|
||||
const isCreating = ref<boolean>(false);
|
||||
|
||||
const sourceCode = ref("")
|
||||
const parsedSource = computed<{ id?: string, [key:string]: any } | undefined>((previous) => {
|
||||
try {
|
||||
return YAML_UTILS.parse(sourceCode.value);
|
||||
} catch {
|
||||
return previous;
|
||||
}
|
||||
})
|
||||
|
||||
const axios = useAxios();
|
||||
|
||||
async function list(options: Record<string, any>) {
|
||||
const {sort, ...params} = options;
|
||||
const response = await axios.get(`${apiUrl()}/dashboards?size=100${sort ? `&sort=${sort}` : ""}`, {params});
|
||||
|
||||
return response.data;
|
||||
const sourceCode = ref("")
|
||||
const parsedSource = computed<{ id?: string, [key:string]: any } | undefined>((previous) => {
|
||||
try {
|
||||
return YAML_UTILS.parse(sourceCode.value);
|
||||
} catch {
|
||||
return previous;
|
||||
}
|
||||
})
|
||||
|
||||
async function load(id: Dashboard["id"]) {
|
||||
const response = await axios.get(`${apiUrl()}/dashboards/${id}`, {validateStatus});
|
||||
let dashboardLoaded: Dashboard;
|
||||
const axios = useAxios();
|
||||
|
||||
if (response.status === 200) dashboardLoaded = response.data;
|
||||
else dashboardLoaded = {title: "Default", id, charts: [], sourceCode: ""};
|
||||
async function list(options: Record<string, any>) {
|
||||
const {sort, ...params} = options;
|
||||
const response = await axios.get(`${apiUrl()}/dashboards?size=100${sort ? `&sort=${sort}` : ""}`, {params});
|
||||
|
||||
dashboard.value = dashboardLoaded;
|
||||
sourceCode.value = dashboardLoaded.sourceCode ?? ""
|
||||
return response.data;
|
||||
}
|
||||
|
||||
return dashboardLoaded;
|
||||
async function load(id: Dashboard["id"]) {
|
||||
const response = await axios.get(`${apiUrl()}/dashboards/${id}`, {validateStatus});
|
||||
let dashboardLoaded: Dashboard;
|
||||
|
||||
if (response.status === 200) dashboardLoaded = response.data;
|
||||
else dashboardLoaded = {title: "Default", id, charts: [], sourceCode: ""};
|
||||
|
||||
dashboard.value = dashboardLoaded;
|
||||
sourceCode.value = dashboardLoaded.sourceCode ?? ""
|
||||
|
||||
return dashboardLoaded;
|
||||
}
|
||||
|
||||
async function create(source: Dashboard["sourceCode"]) {
|
||||
const response = await axios.post(`${apiUrl()}/dashboards`, source, header);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function update({id, source}: {id: Dashboard["id"]; source: Dashboard["sourceCode"];}) {
|
||||
const response = await axios.put(`${apiUrl()}/dashboards/${id}`, source, header);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function deleteDashboard(id: Dashboard["id"]) {
|
||||
const response = await axios.delete(`${apiUrl()}/dashboards/${id}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function validateDashboard(source: Dashboard["sourceCode"]) {
|
||||
const response = await axios.post(`${apiUrl()}/dashboards/validate`, source, header);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function generate(id: Dashboard["id"], chartId: Chart["id"], parameters: Parameters) {
|
||||
const response = await axios.post(`${apiUrl()}/dashboards/${id}/charts/${chartId}`, parameters, {validateStatus});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function validateChart(source: string) {
|
||||
const response = await axios.post(`${apiUrl()}/dashboards/validate/chart`, source, header);
|
||||
chartErrors.value = response.data;
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function chartPreview(request: Request) {
|
||||
const response = await axios.post(`${apiUrl()}/dashboards/charts/preview`, request);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function exportDashboard(dashboard: Dashboard, chart: Chart, parameters: Parameters) {
|
||||
const isDefault = dashboard.id === "default";
|
||||
|
||||
const path = isDefault ? "/charts/export/to-csv" : `/${dashboard.id}/charts/${chart.id}/export/to-csv`;
|
||||
const payload = isDefault ? {chart: chart.content, globalFilter: parameters} : parameters;
|
||||
|
||||
const filename = `chart__${chart.id}`;
|
||||
|
||||
return axios
|
||||
.post(`${apiUrl()}/dashboards${path}`, payload, response)
|
||||
.then((res) => downloadHandler(res, filename));
|
||||
}
|
||||
|
||||
const pluginsStore = usePluginsStore();
|
||||
|
||||
const InitialSchema = {}
|
||||
|
||||
const schema = computed<{
|
||||
definitions: any,
|
||||
$ref: string,
|
||||
}>(() => {
|
||||
return pluginsStore.schemaType?.dashboard ?? InitialSchema;
|
||||
})
|
||||
|
||||
const definitions = computed<Record<string, any>>(() => {
|
||||
return schema.value.definitions ?? {};
|
||||
});
|
||||
|
||||
function recursivelyLoopUpSchemaRef(a: any, definitions: Record<string, any>): any {
|
||||
if (a.$ref) {
|
||||
const ref = removeRefPrefix(a.$ref);
|
||||
return recursivelyLoopUpSchemaRef(definitions[ref], definitions);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
async function create(source: Dashboard["sourceCode"]) {
|
||||
const response = await axios.post(`${apiUrl()}/dashboards`, source, header);
|
||||
return response.data;
|
||||
const rootSchema = computed<Record<string, any> | undefined>(() => {
|
||||
return recursivelyLoopUpSchemaRef(schema.value, definitions.value);
|
||||
});
|
||||
|
||||
const rootProperties = computed<Record<string, any> | undefined>(() => {
|
||||
return rootSchema.value?.properties;
|
||||
});
|
||||
|
||||
async function loadChart(chart: any) {
|
||||
const yamlChart = YAML_UTILS.stringify(chart);
|
||||
if(selectedChart.value?.content === yamlChart){
|
||||
return {
|
||||
error: chartErrors.value.length > 0 ? chartErrors.value[0] : null,
|
||||
data: selectedChart.value ? {...selectedChart.value, raw: chart} : null,
|
||||
raw: chart
|
||||
};
|
||||
}
|
||||
|
||||
async function update({id, source}: {id: Dashboard["id"]; source: Dashboard["sourceCode"];}) {
|
||||
const response = await axios.put(`${apiUrl()}/dashboards/${id}`, source, header);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function deleteDashboard(id: Dashboard["id"]) {
|
||||
const response = await axios.delete(`${apiUrl()}/dashboards/${id}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function validateDashboard(source: Dashboard["sourceCode"]) {
|
||||
const response = await axios.post(`${apiUrl()}/dashboards/validate`, source, header);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function generate(id: Dashboard["id"], chartId: Chart["id"], parameters: Parameters) {
|
||||
const response = await axios.post(`${apiUrl()}/dashboards/${id}/charts/${chartId}`, parameters, {validateStatus});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function validateChart(source: string) {
|
||||
const response = await axios.post(`${apiUrl()}/dashboards/validate/chart`, source, header);
|
||||
chartErrors.value = response.data;
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function chartPreview(request: Request) {
|
||||
const response = await axios.post(`${apiUrl()}/dashboards/charts/preview`, request);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function exportDashboard(dashboard: Dashboard, chart: Chart, parameters: Parameters) {
|
||||
const isDefault = dashboard.id === "default";
|
||||
|
||||
const path = isDefault ? "/charts/export/to-csv" : `/${dashboard.id}/charts/${chart.id}/export/to-csv`;
|
||||
const payload = isDefault ? {chart: chart.content, globalFilter: parameters} : parameters;
|
||||
|
||||
const filename = `chart__${chart.id}`;
|
||||
|
||||
return axios
|
||||
.post(`${apiUrl()}/dashboards${path}`, payload, response)
|
||||
.then((res) => downloadHandler(res, filename));
|
||||
}
|
||||
|
||||
const pluginsStore = usePluginsStore();
|
||||
|
||||
const InitialSchema = {}
|
||||
|
||||
const schema = computed<{
|
||||
definitions: any,
|
||||
$ref: string,
|
||||
}>(() => {
|
||||
return pluginsStore.schemaType?.dashboard ?? InitialSchema;
|
||||
})
|
||||
|
||||
const definitions = computed<Record<string, any>>(() => {
|
||||
return schema.value.definitions ?? {};
|
||||
});
|
||||
|
||||
function recursivelyLoopUpSchemaRef(a: any, definitions: Record<string, any>): any {
|
||||
if (a.$ref) {
|
||||
const ref = removeRefPrefix(a.$ref);
|
||||
return recursivelyLoopUpSchemaRef(definitions[ref], definitions);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
const rootSchema = computed<Record<string, any> | undefined>(() => {
|
||||
return recursivelyLoopUpSchemaRef(schema.value, definitions.value);
|
||||
});
|
||||
|
||||
const rootProperties = computed<Record<string, any> | undefined>(() => {
|
||||
return rootSchema.value?.properties;
|
||||
});
|
||||
|
||||
return {
|
||||
dashboard,
|
||||
chartErrors,
|
||||
isCreating,
|
||||
selectedChart,
|
||||
list,
|
||||
load,
|
||||
create,
|
||||
update,
|
||||
delete: deleteDashboard,
|
||||
validateDashboard,
|
||||
generate,
|
||||
validateChart,
|
||||
chartPreview,
|
||||
export: exportDashboard,
|
||||
|
||||
schema,
|
||||
definitions,
|
||||
rootSchema,
|
||||
rootProperties,
|
||||
sourceCode,
|
||||
parsedSource,
|
||||
const result: { error: string | null; data: null | {
|
||||
id?: string;
|
||||
name?: string;
|
||||
type?: string;
|
||||
chartOptions?: Record<string, any>;
|
||||
dataFilters?: any[];
|
||||
charts?: any[];
|
||||
}; raw: any } = {
|
||||
error: null,
|
||||
data: null,
|
||||
raw: {}
|
||||
};
|
||||
const errors = await validateChart(yamlChart);
|
||||
|
||||
if (errors.constraints) {
|
||||
result.error = errors.constraints;
|
||||
} else {
|
||||
result.data = {...chart, content: yamlChart, raw: chart};
|
||||
}
|
||||
|
||||
selectedChart.value = typeof result.data === "object"
|
||||
? {
|
||||
...result.data,
|
||||
chartOptions: {
|
||||
...result.data?.chartOptions,
|
||||
width: 12
|
||||
}
|
||||
} as any
|
||||
: undefined;
|
||||
chartErrors.value = [result.error].filter(e => e !== null);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const errors = ref<string[] | undefined>();
|
||||
const warnings = ref<string[] | undefined>();
|
||||
const coreStore = useCoreStore();
|
||||
|
||||
const {t} = useI18n()
|
||||
|
||||
watch(sourceCode, _throttle(async () => {
|
||||
const errorsResult = await validateDashboard(sourceCode.value);
|
||||
|
||||
const dbId = dashboard.value?.id;
|
||||
if (errorsResult.constraints) {
|
||||
errors.value = [errorsResult.constraints];
|
||||
} else {
|
||||
errors.value = undefined;
|
||||
}
|
||||
|
||||
if (dbId !== undefined && YAML_UTILS.parse(sourceCode.value).id !== dbId) {
|
||||
coreStore.message = {
|
||||
variant: "error",
|
||||
title: t("readonly property"),
|
||||
message: t("dashboards.edition.id readonly"),
|
||||
};
|
||||
|
||||
await nextTick();
|
||||
if(sourceCode.value && dbId){
|
||||
sourceCode.value = YAML_UTILS.replaceBlockWithPath({
|
||||
source: sourceCode.value,
|
||||
path: "id",
|
||||
newContent: dbId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, 300, {trailing: true, leading: false}));
|
||||
|
||||
return {
|
||||
dashboard,
|
||||
chartErrors,
|
||||
isCreating,
|
||||
selectedChart,
|
||||
list,
|
||||
load,
|
||||
create,
|
||||
update,
|
||||
delete: deleteDashboard,
|
||||
validateDashboard,
|
||||
generate,
|
||||
validateChart,
|
||||
chartPreview,
|
||||
export: exportDashboard,
|
||||
loadChart,
|
||||
errors,
|
||||
warnings,
|
||||
|
||||
schema,
|
||||
definitions,
|
||||
rootSchema,
|
||||
rootProperties,
|
||||
sourceCode,
|
||||
parsedSource,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -220,10 +220,10 @@ export const useFileExplorerStore = defineStore("fileExplorer", () => {
|
||||
for (const item of array) {
|
||||
const folderPath = `${basePath}${item.fileName}`;
|
||||
if (folderPath === parentPath && isDirectory(item)) {
|
||||
item.children = sorted([...item.children, NEW]);
|
||||
item.children = sorted([...(item.children ?? []), NEW]);
|
||||
return true;
|
||||
}
|
||||
if (isDirectory(item) && pushItemToFolder(`${folderPath}/`, item.children, pathParts.slice(1))) {
|
||||
if (isDirectory(item) && pushItemToFolder(`${folderPath}/`, item.children ?? [], pathParts.slice(1))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -249,6 +249,7 @@ export const useFileExplorerStore = defineStore("fileExplorer", () => {
|
||||
function getPath(uid: string ) {
|
||||
// first, use the node unique id to find it in all the subtrees of the fileTree
|
||||
const findPath = (array: TreeNode[], currentPath = ""): string | undefined => {
|
||||
if (!Array.isArray(array)) return undefined;
|
||||
for (const item of array) {
|
||||
const newPath = currentPath ? `${currentPath}/${item.fileName}` : item.fileName;
|
||||
if (item.id === uid) {
|
||||
|
||||
@@ -23,6 +23,8 @@ const textYamlHeader = {
|
||||
}
|
||||
}
|
||||
|
||||
const VALIDATE = {validateStatus: (status: number) => status === 200 || status === 401};
|
||||
|
||||
interface Trigger {
|
||||
id: string;
|
||||
type: string;
|
||||
@@ -401,10 +403,13 @@ export const useFlowStore = defineStore("flow", () => {
|
||||
}
|
||||
function saveFlow(options: { flow: string }) {
|
||||
const flowData = YAML_UTILS.parse(options.flow)
|
||||
return axios.put(`${apiUrl()}/flows/${flowData.namespace}/${flowData.id}`, options.flow, textYamlHeader)
|
||||
return axios.put(`${apiUrl()}/flows/${flowData.namespace}/${flowData.id}`, options.flow, {
|
||||
...textYamlHeader,
|
||||
...VALIDATE
|
||||
})
|
||||
.then(response => {
|
||||
if (response.status >= 300) {
|
||||
return Promise.reject(new Error("Server error on flow save"))
|
||||
return Promise.reject(response)
|
||||
} else {
|
||||
flow.value = response.data;
|
||||
|
||||
@@ -427,7 +432,13 @@ export const useFlowStore = defineStore("flow", () => {
|
||||
}
|
||||
|
||||
function createFlow(options: { flow: string }) {
|
||||
return axios.post(`${apiUrl()}/flows`, options.flow, textYamlHeader).then(response => {
|
||||
return axios.post(`${apiUrl()}/flows`, options.flow, {
|
||||
...textYamlHeader,
|
||||
...VALIDATE
|
||||
}).then(response => {
|
||||
if (response.status >= 300) {
|
||||
return Promise.reject(response)
|
||||
}
|
||||
|
||||
const creationPanels = localStorage.getItem(`el-fl-creation-${creationId.value}`) ?? YAML_UTILS.stringify([]);
|
||||
localStorage.setItem(`el-fl-${flow.value!.namespace}-${flow.value!.id}`, creationPanels);
|
||||
|
||||
@@ -107,25 +107,11 @@
|
||||
|
||||
:deep(.alert-info) {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 16px 16px 0 16px;
|
||||
padding: .5rem !important;
|
||||
background-color: var(--ks-background-info);
|
||||
border: 1px solid var(--ks-border-info);
|
||||
border-left-width: 5px;
|
||||
border-radius: 8px;
|
||||
|
||||
&::before {
|
||||
content: '!';
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
margin-top: 4px;
|
||||
border-radius: 50%;
|
||||
background: var(--ks-content-info);
|
||||
border: 1px solid var(--ks-border-info);
|
||||
color: var(--ks-content-inverse);
|
||||
font: 600 13px/20px sans-serif;
|
||||
text-align: center;
|
||||
}
|
||||
border-left-width: 0.25rem;
|
||||
border-radius: 0.5rem;
|
||||
|
||||
p { color: var(--ks-content-info); }
|
||||
}
|
||||
@@ -135,7 +121,7 @@
|
||||
color: var(--ks-content-info);
|
||||
border: 1px solid var(--ks-border-info);
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
white-space: nowrap; // Prevent button text from wrapping
|
||||
white-space: nowrap;
|
||||
|
||||
.material-design-icon {
|
||||
position: absolute;
|
||||
|
||||
@@ -40,6 +40,7 @@ export const storageKeys = {
|
||||
TIMEZONE_STORAGE_KEY: "timezone",
|
||||
SAVED_FILTERS_PREFIX: "saved_filters",
|
||||
FILTER_ORDER_PREFIX: "filter-order",
|
||||
SCROLL_MEMORY_PREFIX: "scroll",
|
||||
}
|
||||
|
||||
export const executeFlowBehaviours = {
|
||||
|
||||
28
ui/tests/storybook/components/labels/LabelInput.stories.tsx
Normal file
28
ui/tests/storybook/components/labels/LabelInput.stories.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import LabelInput from "../../../../src/components/labels/LabelInput.vue";
|
||||
import {ref} from "vue";
|
||||
import {Meta, StoryFn} from "@storybook/vue3";
|
||||
|
||||
export default {
|
||||
title: "Components/Labels/LabelInput",
|
||||
component: LabelInput,
|
||||
} as Meta<typeof LabelInput>;
|
||||
|
||||
const Template: StoryFn<typeof LabelInput> = (args) => ({
|
||||
setup() {
|
||||
const model = ref(args.labels);
|
||||
return () => <LabelInput {...args} labels={model.value} onUpdate:labels={(labs) => model.value = labs}/>;
|
||||
}
|
||||
});
|
||||
|
||||
export const Default = Template.bind({});
|
||||
Default.args = {
|
||||
labels: [],
|
||||
};
|
||||
|
||||
export const WithValue = Template.bind({});
|
||||
WithValue.args = {
|
||||
labels: [{
|
||||
key: "example-label",
|
||||
value: "example-value",
|
||||
}],
|
||||
};
|
||||
@@ -531,7 +531,7 @@ class FlowControllerTest {
|
||||
List<String> namespaces = client.toBlocking().retrieve(
|
||||
HttpRequest.GET("/api/v1/main/flows/distinct-namespaces"), Argument.listOf(String.class));
|
||||
|
||||
assertThat(namespaces.size()).isEqualTo(11);
|
||||
assertThat(namespaces.size()).isEqualTo(12);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -202,24 +202,24 @@ class KVControllerTest {
|
||||
|
||||
static Stream<Arguments> kvSetKeyValueArgs() {
|
||||
return Stream.of(
|
||||
Arguments.of("{\"hello\":\"world\"}", Map.class),
|
||||
Arguments.of("[\"hello\",\"world\"]", List.class),
|
||||
Arguments.of("\"hello\"", String.class),
|
||||
Arguments.of("1", Integer.class),
|
||||
Arguments.of("1.0", BigDecimal.class),
|
||||
Arguments.of("true", Boolean.class),
|
||||
Arguments.of("false", Boolean.class),
|
||||
Arguments.of("2021-09-01", LocalDate.class),
|
||||
Arguments.of("2021-09-01T01:02:03Z", Instant.class),
|
||||
Arguments.of("\"PT5S\"", Duration.class)
|
||||
Arguments.of(MediaType.TEXT_PLAIN, "{\"hello\":\"world\"}", Map.class),
|
||||
Arguments.of(MediaType.TEXT_PLAIN, "[\"hello\",\"world\"]", List.class),
|
||||
Arguments.of(MediaType.TEXT_PLAIN, "\"hello\"", String.class),
|
||||
Arguments.of(MediaType.TEXT_PLAIN, "1", Integer.class),
|
||||
Arguments.of(MediaType.TEXT_PLAIN, "1.0", BigDecimal.class),
|
||||
Arguments.of(MediaType.TEXT_PLAIN, "true", Boolean.class),
|
||||
Arguments.of(MediaType.TEXT_PLAIN, "false", Boolean.class),
|
||||
Arguments.of(MediaType.TEXT_PLAIN, "2021-09-01", LocalDate.class),
|
||||
Arguments.of(MediaType.TEXT_PLAIN, "2021-09-01T01:02:03Z", Instant.class),
|
||||
Arguments.of(MediaType.TEXT_PLAIN, "\"PT5S\"", Duration.class)
|
||||
);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("kvSetKeyValueArgs")
|
||||
void setKeyValue(String value, Class<?> expectedClass) throws IOException, ResourceExpiredException {
|
||||
void setKeyValue(MediaType mediaType, String value, Class<?> expectedClass) throws IOException, ResourceExpiredException {
|
||||
String myDescription = "myDescription";
|
||||
client.toBlocking().exchange(HttpRequest.PUT("/api/v1/main/namespaces/" + NAMESPACE + "/kv/my-key", value).header("ttl", "PT5M").header("description", myDescription));
|
||||
client.toBlocking().exchange(HttpRequest.PUT("/api/v1/main/namespaces/" + NAMESPACE + "/kv/my-key", value).contentType(mediaType).header("ttl", "PT5M").header("description", myDescription));
|
||||
|
||||
KVStore kvStore = kvStore();
|
||||
Class<?> valueClazz = kvStore.getValue("my-key").get().value().getClass();
|
||||
@@ -294,7 +294,7 @@ class KVControllerTest {
|
||||
assertThat(httpClientResponseException.getStatus().getCode()).isEqualTo(HttpStatus.UNPROCESSABLE_ENTITY.getCode());
|
||||
assertThat(httpClientResponseException.getMessage()).isEqualTo(expectedErrorMessage);
|
||||
|
||||
httpClientResponseException = Assertions.assertThrows(HttpClientResponseException.class, () -> client.toBlocking().exchange(HttpRequest.PUT("/api/v1/main/namespaces/" + NAMESPACE + "/kv/bad$key", "\"content\"")));
|
||||
httpClientResponseException = Assertions.assertThrows(HttpClientResponseException.class, () -> client.toBlocking().exchange(HttpRequest.PUT("/api/v1/main/namespaces/" + NAMESPACE + "/kv/bad$key", "\"content\"").contentType(MediaType.TEXT_PLAIN)));
|
||||
assertThat(httpClientResponseException.getStatus().getCode()).isEqualTo(HttpStatus.UNPROCESSABLE_ENTITY.getCode());
|
||||
assertThat(httpClientResponseException.getMessage()).isEqualTo(expectedErrorMessage);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user