mirror of
https://github.com/kestra-io/kestra.git
synced 2025-12-26 05:00:31 -05:00
Compare commits
19 Commits
executor_v
...
fix/tenant
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12fd7f81c0 | ||
|
|
3cd340f972 | ||
|
|
721dc61aa4 | ||
|
|
d12a33e9ba | ||
|
|
56caaa2a91 | ||
|
|
52dda7621c | ||
|
|
88ab8e2a71 | ||
|
|
545ed57000 | ||
|
|
31de6660fa | ||
|
|
db1ef67a69 | ||
|
|
b7fbb3af66 | ||
|
|
50f412a11e | ||
|
|
5bced31e1b | ||
|
|
76d349d57e | ||
|
|
63df8e3e46 | ||
|
|
4e4e082b79 | ||
|
|
7b67f9a0f5 | ||
|
|
b59098e61f | ||
|
|
43f02e7e33 |
@@ -7,6 +7,8 @@ import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
|
||||
import static io.kestra.core.utils.RegexPatterns.JAVA_IDENTIFIER_REGEX;
|
||||
|
||||
/**
|
||||
* Top-level marker interface for Kestra's plugin of type App.
|
||||
*/
|
||||
@@ -18,6 +20,6 @@ public interface AppBlockInterface extends io.kestra.core.models.Plugin {
|
||||
)
|
||||
@NotNull
|
||||
@NotBlank
|
||||
@Pattern(regexp = "^[A-Za-z_$][A-Za-z0-9_$]*(\\.[A-Za-z_$][A-Za-z0-9_$]*)*$")
|
||||
@Pattern(regexp = JAVA_IDENTIFIER_REGEX)
|
||||
String getType();
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
|
||||
import static io.kestra.core.utils.RegexPatterns.JAVA_IDENTIFIER_REGEX;
|
||||
|
||||
/**
|
||||
* Top-level marker interface for Kestra's plugin of type App.
|
||||
*/
|
||||
@@ -18,6 +20,6 @@ public interface AppPluginInterface extends io.kestra.core.models.Plugin {
|
||||
)
|
||||
@NotNull
|
||||
@NotBlank
|
||||
@Pattern(regexp = "^[A-Za-z_$][A-Za-z0-9_$]*(\\.[A-Za-z_$][A-Za-z0-9_$]*)*$")
|
||||
@Pattern(regexp = JAVA_IDENTIFIER_REGEX)
|
||||
String getType();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package io.kestra.core.exceptions;
|
||||
|
||||
public class InvalidTriggerConfigurationException extends KestraRuntimeException {
|
||||
public InvalidTriggerConfigurationException() {
|
||||
super();
|
||||
}
|
||||
|
||||
public InvalidTriggerConfigurationException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public InvalidTriggerConfigurationException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import lombok.experimental.SuperBuilder;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
|
||||
import static io.kestra.core.utils.RegexPatterns.JAVA_IDENTIFIER_REGEX;
|
||||
|
||||
@io.kestra.core.models.annotations.Plugin
|
||||
@SuperBuilder
|
||||
@Getter
|
||||
@@ -20,6 +22,6 @@ import jakarta.validation.constraints.Pattern;
|
||||
@JsonInclude(JsonInclude.Include.NON_DEFAULT)
|
||||
public abstract class Condition implements Plugin, Rethrow.PredicateChecked<ConditionContext, InternalException> {
|
||||
@NotNull
|
||||
@Pattern(regexp = "^[A-Za-z_$][A-Za-z0-9_$]*(\\.[A-Za-z_$][A-Za-z0-9_$]*)*$")
|
||||
@Pattern(regexp = JAVA_IDENTIFIER_REGEX)
|
||||
protected String type;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import static io.kestra.core.utils.RegexPatterns.JAVA_IDENTIFIER_REGEX;
|
||||
|
||||
@SuperBuilder(toBuilder = true)
|
||||
@Getter
|
||||
@NoArgsConstructor
|
||||
@@ -28,7 +30,7 @@ import java.util.Set;
|
||||
public abstract class DataFilter<F extends Enum<F>, C extends ColumnDescriptor<F>> implements io.kestra.core.models.Plugin, IData<F> {
|
||||
@NotNull
|
||||
@NotBlank
|
||||
@Pattern(regexp = "^[A-Za-z_$][A-Za-z0-9_$]*(\\.[A-Za-z_$][A-Za-z0-9_$]*)*$")
|
||||
@Pattern(regexp = JAVA_IDENTIFIER_REGEX)
|
||||
private String type;
|
||||
|
||||
private Map<String, C> columns;
|
||||
|
||||
@@ -19,6 +19,8 @@ import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static io.kestra.core.utils.RegexPatterns.JAVA_IDENTIFIER_REGEX;
|
||||
|
||||
@SuperBuilder(toBuilder = true)
|
||||
@Getter
|
||||
@NoArgsConstructor
|
||||
@@ -27,7 +29,7 @@ import java.util.Set;
|
||||
public abstract class DataFilterKPI<F extends Enum<F>, C extends ColumnDescriptor<F>> implements io.kestra.core.models.Plugin, IData<F> {
|
||||
@NotNull
|
||||
@NotBlank
|
||||
@Pattern(regexp = "^[A-Za-z_$][A-Za-z0-9_$]*(\\.[A-Za-z_$][A-Za-z0-9_$]*)*$")
|
||||
@Pattern(regexp = JAVA_IDENTIFIER_REGEX)
|
||||
private String type;
|
||||
|
||||
private C columns;
|
||||
|
||||
@@ -12,6 +12,8 @@ import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.experimental.SuperBuilder;
|
||||
|
||||
import static io.kestra.core.utils.RegexPatterns.JAVA_IDENTIFIER_REGEX;
|
||||
|
||||
@SuperBuilder(toBuilder = true)
|
||||
@Getter
|
||||
@NoArgsConstructor
|
||||
@@ -26,7 +28,7 @@ public abstract class Chart<P extends ChartOption> implements io.kestra.core.mod
|
||||
|
||||
@NotNull
|
||||
@NotBlank
|
||||
@Pattern(regexp = "^[A-Za-z_$][A-Za-z0-9_$]*(\\.[A-Za-z_$][A-Za-z0-9_$]*)*$")
|
||||
@Pattern(regexp = JAVA_IDENTIFIER_REGEX)
|
||||
protected String type;
|
||||
|
||||
@Valid
|
||||
|
||||
@@ -52,6 +52,7 @@ public class TaskRun implements TenantInterface {
|
||||
|
||||
@With
|
||||
@JsonInclude(JsonInclude.Include.ALWAYS)
|
||||
@Nullable
|
||||
Variables outputs;
|
||||
|
||||
@NotNull
|
||||
@@ -64,7 +65,6 @@ public class TaskRun implements TenantInterface {
|
||||
Boolean dynamic;
|
||||
|
||||
// Set it to true to force execution even if the execution is killed
|
||||
@Nullable
|
||||
@With
|
||||
Boolean forceExecution;
|
||||
|
||||
@@ -217,7 +217,7 @@ public class TaskRun implements TenantInterface {
|
||||
public boolean isSame(TaskRun taskRun) {
|
||||
return this.getId().equals(taskRun.getId()) &&
|
||||
((this.getValue() == null && taskRun.getValue() == null) || (this.getValue() != null && this.getValue().equals(taskRun.getValue()))) &&
|
||||
((this.getIteration() == null && taskRun.getIteration() == null) || (this.getIteration() != null && this.getIteration().equals(taskRun.getIteration()))) ;
|
||||
((this.getIteration() == null && taskRun.getIteration() == null) || (this.getIteration() != null && this.getIteration().equals(taskRun.getIteration())));
|
||||
}
|
||||
|
||||
public String toString(boolean pretty) {
|
||||
@@ -249,7 +249,7 @@ public class TaskRun implements TenantInterface {
|
||||
* This method is used when the retry is apply on a task
|
||||
* but the retry type is NEW_EXECUTION
|
||||
*
|
||||
* @param retry Contains the retry configuration
|
||||
* @param retry Contains the retry configuration
|
||||
* @param execution Contains the attempt number and original creation date
|
||||
* @return The next retry date, null if maxAttempt || maxDuration is reached
|
||||
*/
|
||||
@@ -270,6 +270,7 @@ public class TaskRun implements TenantInterface {
|
||||
|
||||
/**
|
||||
* This method is used when the Retry definition comes from the flow
|
||||
*
|
||||
* @param retry The retry configuration
|
||||
* @return The next retry date, null if maxAttempt || maxDuration is reached
|
||||
*/
|
||||
|
||||
@@ -77,14 +77,6 @@ public abstract class AbstractFlow implements FlowInterface {
|
||||
Map<String, Object> variables;
|
||||
|
||||
|
||||
@Schema(
|
||||
oneOf = {
|
||||
String.class, // Corresponds to 'type: string' in OpenAPI
|
||||
Map.class // Corresponds to 'type: object' in OpenAPI
|
||||
}
|
||||
)
|
||||
interface StringOrMapValue {}
|
||||
|
||||
@Valid
|
||||
private WorkerGroup workerGroup;
|
||||
|
||||
|
||||
@@ -24,10 +24,6 @@ public class PluginDefault {
|
||||
|
||||
@Schema(
|
||||
type = "object",
|
||||
oneOf = {
|
||||
Map.class,
|
||||
String.class
|
||||
},
|
||||
additionalProperties = Schema.AdditionalPropertiesValue.FALSE
|
||||
)
|
||||
private final Map<String, Object> values;
|
||||
|
||||
@@ -8,6 +8,8 @@ import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
|
||||
import static io.kestra.core.utils.RegexPatterns.JAVA_IDENTIFIER_REGEX;
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_DEFAULT)
|
||||
public interface TaskInterface extends Plugin, PluginVersioning {
|
||||
@NotNull
|
||||
@@ -17,7 +19,7 @@ public interface TaskInterface extends Plugin, PluginVersioning {
|
||||
|
||||
@NotNull
|
||||
@NotBlank
|
||||
@Pattern(regexp = "^[A-Za-z_$][A-Za-z0-9_$]*(\\.[A-Za-z_$][A-Za-z0-9_$]*)*$")
|
||||
@Pattern(regexp = JAVA_IDENTIFIER_REGEX)
|
||||
@Schema(title = "The class name of this task.")
|
||||
String getType();
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import lombok.NoArgsConstructor;
|
||||
import lombok.experimental.SuperBuilder;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
import static io.kestra.core.utils.RegexPatterns.JAVA_IDENTIFIER_REGEX;
|
||||
|
||||
@Plugin
|
||||
@SuperBuilder(toBuilder = true)
|
||||
@Getter
|
||||
@@ -22,7 +24,7 @@ public abstract class LogExporter<T extends Output> implements io.kestra.core.m
|
||||
protected String id;
|
||||
|
||||
@NotBlank
|
||||
@Pattern(regexp = "^[A-Za-z_$][A-Za-z0-9_$]*(\\.[A-Za-z_$][A-Za-z0-9_$]*)*$")
|
||||
@Pattern(regexp = JAVA_IDENTIFIER_REGEX)
|
||||
protected String type;
|
||||
|
||||
public abstract T sendLogs(RunContext runContext, Flux<LogRecord> logRecords) throws Exception;
|
||||
|
||||
@@ -25,6 +25,7 @@ import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static io.kestra.core.utils.WindowsUtils.windowsToUnixPath;
|
||||
import static io.kestra.core.utils.RegexPatterns.JAVA_IDENTIFIER_REGEX;
|
||||
|
||||
/**
|
||||
* Base class for all task runners.
|
||||
@@ -36,7 +37,7 @@ import static io.kestra.core.utils.WindowsUtils.windowsToUnixPath;
|
||||
@JsonInclude(JsonInclude.Include.NON_DEFAULT)
|
||||
public abstract class TaskRunner<T extends TaskRunnerDetailResult> implements Plugin, PluginVersioning, WorkerJobLifecycle {
|
||||
@NotBlank
|
||||
@Pattern(regexp = "^[A-Za-z_$][A-Za-z0-9_$]*(\\.[A-Za-z_$][A-Za-z0-9_$]*)*$")
|
||||
@Pattern(regexp = JAVA_IDENTIFIER_REGEX)
|
||||
protected String type;
|
||||
|
||||
@PluginProperty(hidden = true, group = PluginProperty.CORE_GROUP)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package io.kestra.core.models.triggers;
|
||||
|
||||
import io.kestra.core.exceptions.InvalidTriggerConfigurationException;
|
||||
import io.kestra.core.models.annotations.PluginProperty;
|
||||
import io.kestra.core.models.conditions.ConditionContext;
|
||||
import io.kestra.core.models.executions.Execution;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.time.DateTimeException;
|
||||
import java.time.Duration;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.Optional;
|
||||
@@ -29,15 +31,29 @@ public interface PollingTriggerInterface extends WorkerTriggerInterface {
|
||||
* Compute the next evaluation date of the trigger based on the existing trigger context: by default, it uses the current date and the interval.
|
||||
* Schedulable triggers must override this method.
|
||||
*/
|
||||
default ZonedDateTime nextEvaluationDate(ConditionContext conditionContext, Optional<? extends TriggerContext> last) throws Exception {
|
||||
return ZonedDateTime.now().plus(this.getInterval());
|
||||
default ZonedDateTime nextEvaluationDate(ConditionContext conditionContext, Optional<? extends TriggerContext> last) throws InvalidTriggerConfigurationException {
|
||||
return computeNextEvaluationDate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the next evaluation date of the trigger: by default, it uses the current date and the interval.
|
||||
* Schedulable triggers must override this method as it's used to init them when there is no evaluation date.
|
||||
*/
|
||||
default ZonedDateTime nextEvaluationDate() {
|
||||
return ZonedDateTime.now().plus(this.getInterval());
|
||||
default ZonedDateTime nextEvaluationDate() throws InvalidTriggerConfigurationException {
|
||||
return computeNextEvaluationDate();
|
||||
}
|
||||
|
||||
/**
|
||||
* computes the next evaluation date using the configured interval.
|
||||
* Throw InvalidTriggerConfigurationException, if the interval causes date overflow.
|
||||
*/
|
||||
private ZonedDateTime computeNextEvaluationDate() throws InvalidTriggerConfigurationException {
|
||||
Duration interval = this.getInterval();
|
||||
|
||||
try {
|
||||
return ZonedDateTime.now().plus(interval);
|
||||
} catch (DateTimeException | ArithmeticException e) {
|
||||
throw new InvalidTriggerConfigurationException("Trigger interval too large", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package io.kestra.core.models.triggers;
|
||||
|
||||
import io.kestra.core.exceptions.InvalidTriggerConfigurationException;
|
||||
import io.kestra.core.models.HasUID;
|
||||
import io.kestra.core.models.conditions.ConditionContext;
|
||||
import io.kestra.core.models.executions.Execution;
|
||||
@@ -167,9 +168,14 @@ public class Trigger extends TriggerContext implements HasUID {
|
||||
// Used to update trigger in flowListeners
|
||||
public static Trigger of(FlowInterface flow, AbstractTrigger abstractTrigger, ConditionContext conditionContext, Optional<Trigger> lastTrigger) throws Exception {
|
||||
ZonedDateTime nextDate = null;
|
||||
boolean disabled = lastTrigger.map(TriggerContext::getDisabled).orElse(Boolean.FALSE);
|
||||
|
||||
if (abstractTrigger instanceof PollingTriggerInterface pollingTriggerInterface) {
|
||||
nextDate = pollingTriggerInterface.nextEvaluationDate(conditionContext, Optional.empty());
|
||||
try {
|
||||
nextDate = pollingTriggerInterface.nextEvaluationDate(conditionContext, Optional.empty());
|
||||
} catch (InvalidTriggerConfigurationException e) {
|
||||
disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
return Trigger.builder()
|
||||
@@ -180,7 +186,7 @@ public class Trigger extends TriggerContext implements HasUID {
|
||||
.date(ZonedDateTime.now().truncatedTo(ChronoUnit.SECONDS))
|
||||
.nextExecutionDate(nextDate)
|
||||
.stopAfter(abstractTrigger.getStopAfter())
|
||||
.disabled(lastTrigger.map(TriggerContext::getDisabled).orElse(Boolean.FALSE))
|
||||
.disabled(disabled)
|
||||
.backfill(null)
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
|
||||
import static io.kestra.core.utils.RegexPatterns.JAVA_IDENTIFIER_REGEX;
|
||||
|
||||
public interface TriggerInterface extends Plugin, PluginVersioning {
|
||||
@NotNull
|
||||
@@ -17,7 +18,7 @@ public interface TriggerInterface extends Plugin, PluginVersioning {
|
||||
|
||||
@NotNull
|
||||
@NotBlank
|
||||
@Pattern(regexp = "^[A-Za-z_$][A-Za-z0-9_$]*(\\.[A-Za-z_$][A-Za-z0-9_$]*)*$")
|
||||
@Pattern(regexp = JAVA_IDENTIFIER_REGEX)
|
||||
@Schema(title = "The class name for this current trigger.")
|
||||
String getType();
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.experimental.SuperBuilder;
|
||||
|
||||
import static io.kestra.core.utils.RegexPatterns.JAVA_IDENTIFIER_REGEX;
|
||||
|
||||
@io.kestra.core.models.annotations.Plugin
|
||||
@SuperBuilder(toBuilder = true)
|
||||
@Getter
|
||||
@@ -15,6 +17,6 @@ import lombok.experimental.SuperBuilder;
|
||||
public abstract class AdditionalPlugin implements Plugin {
|
||||
@NotNull
|
||||
@NotBlank
|
||||
@Pattern(regexp = "^[A-Za-z_$][A-Za-z0-9_$]*(\\.[A-Za-z_$][A-Za-z0-9_$]*)*$")
|
||||
@Pattern(regexp = JAVA_IDENTIFIER_REGEX)
|
||||
protected String type;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package io.kestra.core.utils;
|
||||
|
||||
public class RegexPatterns {
|
||||
public static final String JAVA_IDENTIFIER_REGEX = "^[A-Za-z_$][A-Za-z0-9_$]*(\\.[A-Za-z_$][A-Za-z0-9_$]*)*$";
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import lombok.Getter;
|
||||
|
||||
import static io.kestra.core.utils.RegexPatterns.JAVA_IDENTIFIER_REGEX;
|
||||
|
||||
@Getter
|
||||
@JsonTypeInfo(
|
||||
use = JsonTypeInfo.Id.NAME,
|
||||
@@ -20,6 +22,6 @@ import lombok.Getter;
|
||||
public class MarkdownSource {
|
||||
@NotNull
|
||||
@NotBlank
|
||||
@Pattern(regexp = "^[A-Za-z_$][A-Za-z0-9_$]*(\\.[A-Za-z_$][A-Za-z0-9_$]*)*$")
|
||||
@Pattern(regexp = JAVA_IDENTIFIER_REGEX)
|
||||
private String type;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package io.kestra.plugin.core.trigger;
|
||||
|
||||
import io.kestra.core.exceptions.IllegalVariableEvaluationException;
|
||||
import io.kestra.core.exceptions.InvalidTriggerConfigurationException;
|
||||
import io.kestra.core.models.annotations.Plugin;
|
||||
import io.kestra.core.models.annotations.PluginProperty;
|
||||
import io.kestra.core.models.conditions.ConditionContext;
|
||||
@@ -96,16 +97,29 @@ public class ScheduleOnDates extends AbstractTrigger implements Schedulable, Tri
|
||||
}
|
||||
|
||||
@Override
|
||||
public ZonedDateTime nextEvaluationDate(ConditionContext conditionContext, Optional<? extends TriggerContext> last) throws Exception {
|
||||
// lastEvaluation date is the last one from the trigger context or the first date of the list
|
||||
return last
|
||||
.map(throwFunction(context -> nextDate(conditionContext.getRunContext(), date -> date.isAfter(context.getDate()))
|
||||
.orElse(ZonedDateTime.now().plusYears(1) // it's not ideal, but we need a date or the trigger will keep evaluated
|
||||
)))
|
||||
.orElse(conditionContext.getRunContext().render(dates).asList(ZonedDateTime.class).stream().sorted().findFirst().orElse(ZonedDateTime.now()))
|
||||
.truncatedTo(ChronoUnit.SECONDS);
|
||||
public ZonedDateTime nextEvaluationDate(ConditionContext conditionContext, Optional<? extends TriggerContext> last) {
|
||||
try {
|
||||
return last
|
||||
.map(throwFunction(context ->
|
||||
nextDate(conditionContext.getRunContext(), date -> date.isAfter(context.getDate()))
|
||||
.orElse(ZonedDateTime.now().plusYears(1))
|
||||
))
|
||||
.orElse(conditionContext.getRunContext()
|
||||
.render(dates)
|
||||
.asList(ZonedDateTime.class)
|
||||
.stream()
|
||||
.sorted()
|
||||
.findFirst()
|
||||
.orElse(ZonedDateTime.now()))
|
||||
.truncatedTo(ChronoUnit.SECONDS);
|
||||
} catch (IllegalVariableEvaluationException e) {
|
||||
log.warn("Failed to evaluate schedule dates for trigger '{}': {}", this.getId(), e.getMessage());
|
||||
return ZonedDateTime.now().plusYears(1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public ZonedDateTime nextEvaluationDate() {
|
||||
// TODO this may be the next date from now?
|
||||
|
||||
@@ -13,5 +13,6 @@ ALTER TABLE executions MODIFY COLUMN `state_current` ENUM (
|
||||
'RETRYING',
|
||||
'RETRIED',
|
||||
'SKIPPED',
|
||||
'BREAKPOINT',
|
||||
'SUBMITTED'
|
||||
) GENERATED ALWAYS AS (value ->> '$.state.current') STORED NOT NULL;
|
||||
) GENERATED ALWAYS AS (value ->> '$.state.current') STORED NOT NULL;
|
||||
@@ -7,6 +7,7 @@ import io.kestra.core.events.CrudEvent;
|
||||
import io.kestra.core.events.CrudEventType;
|
||||
import io.kestra.core.exceptions.DeserializationException;
|
||||
import io.kestra.core.exceptions.InternalException;
|
||||
import io.kestra.core.exceptions.InvalidTriggerConfigurationException;
|
||||
import io.kestra.core.metrics.MetricRegistry;
|
||||
import io.kestra.core.models.HasUID;
|
||||
import io.kestra.core.models.conditions.Condition;
|
||||
@@ -283,10 +284,22 @@ public abstract class AbstractScheduler implements Scheduler {
|
||||
workerTriggerResult.getExecution().get(),
|
||||
workerTriggerResult.getTriggerContext()
|
||||
);
|
||||
ZonedDateTime nextExecutionDate = this.nextEvaluationDate(workerTriggerResult.getTrigger());
|
||||
ZonedDateTime nextExecutionDate;
|
||||
try {
|
||||
nextExecutionDate = this.nextEvaluationDate(workerTriggerResult.getTrigger());
|
||||
} catch (InvalidTriggerConfigurationException e) {
|
||||
disableInvalidTrigger(workerTriggerResult.getTriggerContext(), e);
|
||||
return;
|
||||
}
|
||||
this.handleEvaluateWorkerTriggerResult(triggerExecution, nextExecutionDate);
|
||||
} else {
|
||||
ZonedDateTime nextExecutionDate = this.nextEvaluationDate(workerTriggerResult.getTrigger());
|
||||
ZonedDateTime nextExecutionDate;
|
||||
try {
|
||||
nextExecutionDate = this.nextEvaluationDate(workerTriggerResult.getTrigger());
|
||||
} catch (InvalidTriggerConfigurationException e) {
|
||||
disableInvalidTrigger(workerTriggerResult.getTriggerContext(), e);
|
||||
return;
|
||||
}
|
||||
this.triggerState.update(Trigger.of(workerTriggerResult.getTriggerContext(), nextExecutionDate));
|
||||
}
|
||||
}
|
||||
@@ -450,7 +463,7 @@ public abstract class AbstractScheduler implements Scheduler {
|
||||
// by default: do nothing
|
||||
}
|
||||
|
||||
private ZonedDateTime nextEvaluationDate(AbstractTrigger abstractTrigger) {
|
||||
private ZonedDateTime nextEvaluationDate(AbstractTrigger abstractTrigger) throws InvalidTriggerConfigurationException {
|
||||
if (abstractTrigger instanceof PollingTriggerInterface interval) {
|
||||
return interval.nextEvaluationDate();
|
||||
} else {
|
||||
@@ -458,7 +471,7 @@ public abstract class AbstractScheduler implements Scheduler {
|
||||
}
|
||||
}
|
||||
|
||||
private ZonedDateTime nextEvaluationDate(AbstractTrigger abstractTrigger, ConditionContext conditionContext, Optional<? extends TriggerContext> last) throws Exception {
|
||||
private ZonedDateTime nextEvaluationDate(AbstractTrigger abstractTrigger, ConditionContext conditionContext, Optional<? extends TriggerContext> last) throws Exception, InvalidTriggerConfigurationException {
|
||||
if (abstractTrigger instanceof PollingTriggerInterface interval) {
|
||||
return interval.nextEvaluationDate(conditionContext, last);
|
||||
} else {
|
||||
@@ -514,6 +527,10 @@ public abstract class AbstractScheduler implements Scheduler {
|
||||
triggerContext = lastTrigger.toBuilder()
|
||||
.nextExecutionDate(this.nextEvaluationDate(abstractTrigger, conditionContext, Optional.of(lastTrigger)))
|
||||
.build();
|
||||
} catch (InvalidTriggerConfigurationException e) {
|
||||
logError(conditionContext, flow, abstractTrigger, e);
|
||||
disableInvalidTrigger(flow, abstractTrigger, e);
|
||||
return null;
|
||||
} catch (Exception e) {
|
||||
logError(conditionContext, flow, abstractTrigger, e);
|
||||
return null;
|
||||
@@ -537,6 +554,47 @@ public abstract class AbstractScheduler implements Scheduler {
|
||||
.filter(Objects::nonNull).toList();
|
||||
}
|
||||
|
||||
private void disableInvalidTrigger(TriggerContext triggerContext, Throwable e) {
|
||||
try {
|
||||
var disabledTrigger = Trigger.builder()
|
||||
.tenantId(triggerContext.getTenantId())
|
||||
.namespace(triggerContext.getNamespace())
|
||||
.flowId(triggerContext.getFlowId())
|
||||
.triggerId(triggerContext.getTriggerId())
|
||||
.date(triggerContext.getDate())
|
||||
.backfill(triggerContext.getBackfill())
|
||||
.stopAfter(triggerContext.getStopAfter())
|
||||
.disabled(true)
|
||||
.updatedDate(Instant.now())
|
||||
.build();
|
||||
|
||||
triggerState.update(disabledTrigger);
|
||||
|
||||
triggerQueue.emit(disabledTrigger);
|
||||
|
||||
log.warn("Disabled trigger {}.{} due to invalid configuration: {}", disabledTrigger.getFlowId(), disabledTrigger.getTriggerId(), e.getMessage());
|
||||
} catch (Exception ex) {
|
||||
log.error("Failed to disable trigger {}.{}: {}", triggerContext.getFlowId(), triggerContext.getTriggerId(), ex.getMessage(), ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void disableInvalidTrigger(FlowWithSource flow, AbstractTrigger trigger, Throwable e) {
|
||||
var disabledTrigger = Trigger.builder()
|
||||
.tenantId(flow.getTenantId())
|
||||
.namespace(flow.getNamespace())
|
||||
.flowId(flow.getId())
|
||||
.triggerId(trigger.getId())
|
||||
.disabled(true)
|
||||
.updatedDate(Instant.now())
|
||||
.build();
|
||||
|
||||
disableInvalidTrigger(disabledTrigger, e);
|
||||
}
|
||||
|
||||
private void disableInvalidTrigger(FlowWithWorkerTrigger f, Throwable e) {
|
||||
disableInvalidTrigger(f.getTriggerContext(), e);
|
||||
}
|
||||
|
||||
abstract public void handleNext(List<FlowWithSource> flows, ZonedDateTime now, BiConsumer<List<Trigger>, ScheduleContextInterface> consumer);
|
||||
|
||||
public List<FlowWithTriggers> schedulerTriggers() {
|
||||
@@ -681,6 +739,10 @@ public abstract class AbstractScheduler implements Scheduler {
|
||||
ZonedDateTime nextExecutionDate = null;
|
||||
try {
|
||||
nextExecutionDate = this.nextEvaluationDate(f.getAbstractTrigger(), f.getConditionContext(), Optional.of(f.getTriggerContext()));
|
||||
} catch (InvalidTriggerConfigurationException e) {
|
||||
logError(f, e);
|
||||
disableInvalidTrigger(f, e);
|
||||
return;
|
||||
} catch (Exception e) {
|
||||
logError(f, e);
|
||||
}
|
||||
@@ -700,7 +762,15 @@ public abstract class AbstractScheduler implements Scheduler {
|
||||
.labels(LabelService.labelsExcludingSystem(f.getFlow()))
|
||||
.state(new State().withState(State.Type.FAILED))
|
||||
.build();
|
||||
ZonedDateTime nextExecutionDate = this.nextEvaluationDate(f.getAbstractTrigger());
|
||||
ZonedDateTime nextExecutionDate;
|
||||
try {
|
||||
nextExecutionDate = this.nextEvaluationDate(f.getAbstractTrigger());
|
||||
} catch (InvalidTriggerConfigurationException e2) {
|
||||
logError(f, e2);
|
||||
disableInvalidTrigger(f, e2);
|
||||
return;
|
||||
}
|
||||
|
||||
var trigger = f.getTriggerContext().resetExecution(State.Type.FAILED, nextExecutionDate);
|
||||
this.saveLastTriggerAndEmitExecution(execution, trigger, triggerToSave -> this.triggerState.save(triggerToSave, scheduleContext, "/kestra/services/scheduler/handle/save/on-error"));
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package io.kestra.scheduler;
|
||||
|
||||
import io.kestra.core.models.Label;
|
||||
import io.kestra.core.models.flows.FlowWithSource;
|
||||
import io.kestra.core.models.conditions.ConditionContext;
|
||||
import io.kestra.core.models.flows.*;
|
||||
import io.kestra.core.models.property.Property;
|
||||
import io.kestra.core.models.flows.GenericFlow;
|
||||
import io.kestra.core.models.triggers.AbstractTrigger;
|
||||
import io.kestra.core.models.triggers.PollingTriggerInterface;
|
||||
import io.kestra.core.repositories.FlowRepositoryInterface;
|
||||
import io.kestra.core.runners.SchedulerTriggerStateInterface;
|
||||
import io.kestra.core.tasks.test.FailingPollingTrigger;
|
||||
@@ -12,8 +14,6 @@ import io.kestra.core.utils.TestsUtils;
|
||||
import io.kestra.jdbc.runner.JdbcScheduler;
|
||||
import io.kestra.plugin.core.condition.Expression;
|
||||
import io.kestra.core.models.executions.Execution;
|
||||
import io.kestra.core.models.flows.Flow;
|
||||
import io.kestra.core.models.flows.State;
|
||||
import io.kestra.core.models.triggers.Trigger;
|
||||
import io.kestra.core.models.triggers.TriggerContext;
|
||||
import io.kestra.core.runners.FlowListeners;
|
||||
@@ -25,12 +25,15 @@ import io.kestra.core.utils.Await;
|
||||
import io.kestra.core.utils.IdUtils;
|
||||
import io.micronaut.context.ApplicationContext;
|
||||
import jakarta.inject.Inject;
|
||||
import lombok.*;
|
||||
import lombok.experimental.SuperBuilder;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
@@ -228,6 +231,31 @@ public class SchedulerPollingTriggerTest extends AbstractSchedulerTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDisableTriggerOnInvalidOverflowInterval() throws Exception {
|
||||
FlowListeners flowListenersServiceSpy = spy(this.flowListenersService);
|
||||
|
||||
OverflowIntervalTrigger overflow = OverflowIntervalTrigger.builder()
|
||||
.id("overflow-interval")
|
||||
.type(OverflowIntervalTrigger.class.getName())
|
||||
.build();
|
||||
|
||||
FlowWithSource flow = createPollingTriggerFlow(overflow);
|
||||
doReturn(List.of(flow)).when(flowListenersServiceSpy).flows();
|
||||
|
||||
try (
|
||||
AbstractScheduler scheduler = scheduler(flowListenersServiceSpy);
|
||||
Worker worker = applicationContext.createBean(TestMethodScopedWorker.class, IdUtils.create(), 8, null)
|
||||
) {
|
||||
worker.run();
|
||||
scheduler.run();
|
||||
|
||||
Trigger key = Trigger.of(flow, overflow);
|
||||
|
||||
Await.until(() -> this.triggerState.findLast(key).map(TriggerContext::getDisabled).get().booleanValue(), Duration.ofMillis(100), Duration.ofSeconds(15));
|
||||
}
|
||||
}
|
||||
|
||||
private FlowWithSource createPollingTriggerFlow(AbstractTrigger pollingTrigger) {
|
||||
return createFlow(Collections.singletonList(pollingTrigger));
|
||||
}
|
||||
@@ -246,4 +274,20 @@ public class SchedulerPollingTriggerTest extends AbstractSchedulerTest {
|
||||
flowListenersServiceSpy
|
||||
);
|
||||
}
|
||||
|
||||
@SuperBuilder
|
||||
@ToString
|
||||
@EqualsAndHashCode
|
||||
@Getter
|
||||
@NoArgsConstructor
|
||||
public static class OverflowIntervalTrigger extends AbstractTrigger implements PollingTriggerInterface {
|
||||
// we set a large interval which will throw an exception
|
||||
@Builder.Default
|
||||
private final Duration interval = Duration.ofSeconds(Long.MAX_VALUE);
|
||||
|
||||
@Override
|
||||
public Optional<Execution> evaluate(ConditionContext conditionContext, TriggerContext context) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1146
ui/package-lock.json
generated
1146
ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -120,6 +120,7 @@
|
||||
"patch-package": "^8.0.1",
|
||||
"playwright": "^1.55.0",
|
||||
"prettier": "^3.6.2",
|
||||
"rolldown-vite": "^7.1.16",
|
||||
"rollup-plugin-copy": "^3.5.0",
|
||||
"sass": "^1.92.3",
|
||||
"storybook": "^9.1.10",
|
||||
@@ -128,7 +129,7 @@
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.46.0",
|
||||
"uuid": "^13.0.0",
|
||||
"vite": "^6.3.5",
|
||||
"vite": "npm:rolldown-vite@latest",
|
||||
"vitest": "^3.2.4",
|
||||
"vue-tsc": "^3.1.1"
|
||||
},
|
||||
@@ -150,7 +151,8 @@
|
||||
"el-table-infinite-scroll": {
|
||||
"vue": "^3.5.21"
|
||||
},
|
||||
"storybook": "$storybook"
|
||||
"storybook": "$storybook",
|
||||
"vite": "npm:rolldown-vite@latest"
|
||||
},
|
||||
"lint-staged": {
|
||||
"**/*.{js,mjs,cjs,ts,vue}": "eslint --fix"
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
diff --git a/node_modules/patch-package/dist/makePatch.js b/node_modules/patch-package/dist/makePatch.js
|
||||
index d8d0925..b1fc429 100644
|
||||
--- a/node_modules/patch-package/dist/makePatch.js
|
||||
+++ b/node_modules/patch-package/dist/makePatch.js
|
||||
@@ -115,12 +115,12 @@ function makePatch({ packagePathSpecifier, appPath, packageManager, includePaths
|
||||
fs_extra_1.copySync(rcPath, path_1.join(tmpRepo.name, rcFile), { dereference: true });
|
||||
}
|
||||
});
|
||||
- if (packageManager === "yarn") {
|
||||
+ if (packageManager === "npm") {
|
||||
console_1.default.info(chalk_1.default.grey("•"), `Installing ${packageDetails.name}@${packageVersion} with yarn`);
|
||||
try {
|
||||
// try first without ignoring scripts in case they are required
|
||||
// this works in 99.99% of cases
|
||||
- spawnSafe_1.spawnSafeSync(`yarn`, ["install", "--ignore-engines"], {
|
||||
+ spawnSafe_1.spawnSafeSync(`npm`, ["install", "--ignore-engines"], {
|
||||
cwd: tmpRepoNpmRoot,
|
||||
logStdErrOnError: false,
|
||||
});
|
||||
@@ -128,7 +128,7 @@ function makePatch({ packagePathSpecifier, appPath, packageManager, includePaths
|
||||
catch (e) {
|
||||
// try again while ignoring scripts in case the script depends on
|
||||
// an implicit context which we haven't reproduced
|
||||
- spawnSafe_1.spawnSafeSync(`yarn`, ["install", "--ignore-engines", "--ignore-scripts"], {
|
||||
+ spawnSafe_1.spawnSafeSync(`npm`, ["install", "--ignore-engines", "--ignore-scripts"], {
|
||||
cwd: tmpRepoNpmRoot,
|
||||
});
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<div class="barWrapper" :class="{opened: activeTab?.length > 0}">
|
||||
<div v-if="Object.keys(buttons).length" class="barWrapper" :class="{opened: activeTab?.length > 0}">
|
||||
<button v-if="activeTab.length" class="barResizer" ref="resizeHandle" @mousedown="startResizing" />
|
||||
|
||||
<el-button
|
||||
v-for="(button, key) of {...buttonsList, ...props.additionalButtons}"
|
||||
v-for="(button, key) of {...buttons, ...props.additionalButtons}"
|
||||
:key="key"
|
||||
:type="activeTab === key ? 'primary' : 'default'"
|
||||
:tag="button.url ? 'a' : 'button'"
|
||||
@@ -51,29 +51,22 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, watch, type Ref, type Component, PropType} from "vue";
|
||||
import {useMouse, watchThrottled} from "@vueuse/core"
|
||||
import {useMouse, watchThrottled, useStorage} from "@vueuse/core"
|
||||
import ContextDocs from "./docs/ContextDocs.vue"
|
||||
import ContextNews from "./layout/ContextNews.vue"
|
||||
import DateAgo from "./layout/DateAgo.vue"
|
||||
|
||||
import MessageOutline from "vue-material-design-icons/MessageOutline.vue"
|
||||
import FileDocument from "vue-material-design-icons/FileDocument.vue"
|
||||
import Slack from "vue-material-design-icons/Slack.vue"
|
||||
import Github from "vue-material-design-icons/Github.vue"
|
||||
import Calendar from "vue-material-design-icons/Calendar.vue"
|
||||
import Close from "vue-material-design-icons/Close.vue"
|
||||
import OpenInNew from "vue-material-design-icons/OpenInNew.vue"
|
||||
import WeatherSunny from "vue-material-design-icons/WeatherSunny.vue"
|
||||
import WeatherNight from "vue-material-design-icons/WeatherNight.vue"
|
||||
import Star from "vue-material-design-icons/Star.vue"
|
||||
|
||||
import {useStorage} from "@vueuse/core"
|
||||
import {useI18n} from "vue-i18n";
|
||||
import Utils from "../utils/utils";
|
||||
import {useApiStore} from "../stores/api";
|
||||
import {useMiscStore} from "override/stores/misc";
|
||||
|
||||
const {t} = useI18n({useScope: "global"});
|
||||
import {useContextButtons} from "override/composables/contextButtons";
|
||||
const {buttons} = useContextButtons();
|
||||
|
||||
const apiStore = useApiStore();
|
||||
const miscStore = useMiscStore();
|
||||
@@ -99,65 +92,9 @@
|
||||
hasUnreadMarker: false;
|
||||
}>>,
|
||||
default: () => ({})
|
||||
},
|
||||
communityButton: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
const allButtonsList: Record<string, {
|
||||
title:string,
|
||||
icon: Component,
|
||||
component?: Component,
|
||||
url?: string,
|
||||
hasUnreadMarker?: boolean
|
||||
}> = {
|
||||
news: {
|
||||
title: t("contextBar.news"),
|
||||
icon: MessageOutline,
|
||||
component: ContextNews,
|
||||
hasUnreadMarker: true
|
||||
},
|
||||
docs: {
|
||||
title: t("contextBar.docs"),
|
||||
icon: FileDocument,
|
||||
component: ContextDocs
|
||||
},
|
||||
help: {
|
||||
title: t("contextBar.help"),
|
||||
icon: Slack,
|
||||
url: "https://kestra.io/slack"
|
||||
},
|
||||
issue: {
|
||||
title: t("contextBar.issue"),
|
||||
icon: Github,
|
||||
url: "https://github.com/kestra-io/kestra/issues/new/choose"
|
||||
},
|
||||
demo: {
|
||||
title: t("contextBar.demo"),
|
||||
icon: Calendar,
|
||||
url: "https://kestra.io/demo"
|
||||
},
|
||||
star: {
|
||||
title: t("contextBar.star"),
|
||||
icon: Star,
|
||||
url: "https://github.com/kestra-io/kestra"
|
||||
}
|
||||
}
|
||||
|
||||
const buttonsList = computed(() => {
|
||||
if (props.communityButton) {
|
||||
return allButtonsList;
|
||||
}
|
||||
let updatedButtons = allButtonsList;
|
||||
delete updatedButtons["issue"];
|
||||
delete updatedButtons["demo"];
|
||||
delete updatedButtons["star"];
|
||||
return updatedButtons;
|
||||
});
|
||||
|
||||
const panelWidth = ref(640)
|
||||
|
||||
const {startResizing, resizing} = useResizablePanel(activeTab)
|
||||
|
||||
@@ -19,10 +19,10 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {computed, useSlots} from "vue";
|
||||
import {useStorage} from "@vueuse/core";
|
||||
import MultiPanelEditorTabs from "./MultiPanelEditorTabs.vue";
|
||||
import MultiPanelTabs from "./MultiPanelTabs.vue";
|
||||
import {DeserializableEditorElement, Panel, Tab} from "../utils/multiPanelTypes";
|
||||
import {DeserializableEditorElement, Panel} from "../utils/multiPanelTypes";
|
||||
import {useStoredPanels} from "../composables/useStoredPanels";
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
editorElements: DeserializableEditorElement[];
|
||||
@@ -32,11 +32,7 @@
|
||||
preSerializePanels?: (panels: Panel[]) => any;
|
||||
}>(), {
|
||||
bottomVisible: false,
|
||||
preSerializePanels: (ps: Panel[]) => ps.map(p => ({
|
||||
tabs: p.tabs.map(t => t.value),
|
||||
activeTab: p.activeTab?.value,
|
||||
size: p.size,
|
||||
}))
|
||||
preSerializePanels: undefined
|
||||
});
|
||||
|
||||
const slots = useSlots();
|
||||
@@ -66,22 +62,7 @@
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* function called on mount to deserialize tabs from storage
|
||||
* NOTE: if a tab is not relevant anymore, it will be ignored
|
||||
* hence the "allowCreate = false".
|
||||
* @param tags
|
||||
*/
|
||||
function deserializeTabTags(tags: string[]): Tab[] {
|
||||
return tags.map(tag => {
|
||||
for (const element of props.editorElements) {
|
||||
const deserializedTab = element.deserialize(tag, false);
|
||||
if (deserializedTab) {
|
||||
return deserializedTab;
|
||||
}
|
||||
}
|
||||
}).filter(t => t !== undefined) as Tab[];
|
||||
}
|
||||
const panels = useStoredPanels(props.saveKey, props.editorElements, props.defaultActiveTabs, props.preSerializePanels);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "set-tab-value", tabValue: string): void | false;
|
||||
@@ -108,39 +89,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
const panels = useStorage<Panel[]>(
|
||||
props.saveKey,
|
||||
deserializeTabTags(props.defaultActiveTabs).map((t) => {
|
||||
return {
|
||||
activeTab: t,
|
||||
tabs: [t],
|
||||
size: 100 / props.defaultActiveTabs.length
|
||||
};
|
||||
}),
|
||||
undefined,
|
||||
{
|
||||
serializer: {
|
||||
write(v: Panel[]){
|
||||
return JSON.stringify(props.preSerializePanels(v));
|
||||
},
|
||||
read(v?: string) {
|
||||
if(!v) return null;
|
||||
const panels = JSON.parse(v);
|
||||
return panels
|
||||
.filter((p: any) => p.tabs.length)
|
||||
.map((p: {tabs: string[], activeTab: string, size: number}):Panel => {
|
||||
const tabs = deserializeTabTags(p.tabs);
|
||||
const activeTab = tabs.find((t: any) => t.value === p.activeTab) ?? tabs[0];
|
||||
return {
|
||||
activeTab,
|
||||
tabs,
|
||||
size: p.size
|
||||
};
|
||||
});
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
const openTabs = computed(() => panels.value.flatMap(p => p.tabs.map(t => t.value)));
|
||||
|
||||
|
||||
@@ -134,7 +134,7 @@
|
||||
if (lastSelected) {
|
||||
const dashboard = dashboards.value.find((d) => d.id === lastSelected);
|
||||
|
||||
if (dashboard) select(dashboard);
|
||||
if (dashboard) select(dashboard);
|
||||
else {
|
||||
selected.value = null;
|
||||
emits("dashboard", "default");
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
const grouped = {};
|
||||
|
||||
const rawData = generated.value.results;
|
||||
rawData.forEach((item) => {
|
||||
rawData?.forEach((item) => {
|
||||
const key = validColumns.map((col) => item[col]).join(", "); // Use '|' as a delimiter
|
||||
|
||||
if (!grouped[item[column]]) {
|
||||
@@ -146,7 +146,7 @@
|
||||
});
|
||||
|
||||
const labels = Object.keys(grouped);
|
||||
const xLabels = [...new Set(rawData.map((item) => item[column]))];
|
||||
const xLabels = [...new Set(rawData?.map((item) => item[column]))];
|
||||
|
||||
const datasets = xLabels.flatMap((xLabel) => {
|
||||
return Object.entries(grouped[xLabel]).map(subSectionsEntry => ({
|
||||
|
||||
@@ -153,7 +153,7 @@
|
||||
|
||||
let results = Object.create(null);
|
||||
|
||||
generated.value.results.forEach((value) => {
|
||||
generated.value.results?.forEach((value) => {
|
||||
const field = parseValue(value[aggregator.field.key]);
|
||||
const aggregated = value[aggregator.value.key];
|
||||
|
||||
|
||||
@@ -118,11 +118,16 @@
|
||||
|
||||
const dashboardID = (route: RouteLocation) => getDashboard(route, "id") as string;
|
||||
|
||||
const handlePageChange = (options: { page: number; size: number }) => {
|
||||
const handlePageChange = (options: { page?: number; size?: number | string }) => {
|
||||
if (pageNumber.value === options.page && pageSize.value === options.size) return;
|
||||
|
||||
pageNumber.value = options.page;
|
||||
pageSize.value = options.size;
|
||||
pageNumber.value = options.page ?? 1;
|
||||
const sizeNumber = typeof options.size === "string" ? parseInt(options.size, 10) : options.size;
|
||||
if (sizeNumber && isNaN(sizeNumber)) {
|
||||
pageSize.value = 25;
|
||||
return;
|
||||
};
|
||||
pageSize.value = sizeNumber ?? 25;
|
||||
|
||||
return getData(dashboardID(route));
|
||||
};
|
||||
@@ -140,8 +145,8 @@
|
||||
}, {deep: true, immediate: true});
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
section#table .el-scrollbar__thumb {
|
||||
<style lang="scss" scoped>
|
||||
section#table :deep(.el-scrollbar__thumb) {
|
||||
background-color: var(--ks-button-background-primary) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -169,7 +169,7 @@
|
||||
const parsedData = computed(() => {
|
||||
const rawData = generated.value.results;
|
||||
const xAxis = (() => {
|
||||
const values = rawData.map((v) => {
|
||||
const values = rawData?.map((v) => {
|
||||
return parseValue(v[chartOptions.column]);
|
||||
});
|
||||
|
||||
@@ -179,7 +179,7 @@
|
||||
const aggregatorKeys = aggregator.value.map(([key]) => key);
|
||||
|
||||
const reducer = (array, field, yAxisID) => {
|
||||
if (!array.length) return;
|
||||
if (!array?.length) return;
|
||||
|
||||
const {columns} = data;
|
||||
const {column, colorByColumn} = chartOptions;
|
||||
@@ -260,11 +260,11 @@
|
||||
|
||||
let duration: number[] = [];
|
||||
if(yBShown.value){
|
||||
const helper = Array.from(new Set(rawData.map((v) => parseValue(v.date)))).sort();
|
||||
const helper = Array.from(new Set(rawData?.map((v) => parseValue(v.date)))).sort();
|
||||
|
||||
// Step 1: Group durations by formatted date
|
||||
const groupedDurations = {};
|
||||
rawData.forEach(item => {
|
||||
rawData?.forEach(item => {
|
||||
const formattedDate = parseValue(item.date);
|
||||
groupedDurations[formattedDate] = (groupedDurations[formattedDate] || 0) + item.duration;
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<ContextInfoContent :title="routeInfo.title">
|
||||
<template #back-button>
|
||||
<template v-if="isOnline" #back-button>
|
||||
<button
|
||||
class="back-button"
|
||||
type="button"
|
||||
@@ -27,13 +27,16 @@
|
||||
</router-link>
|
||||
</template>
|
||||
<div ref="docWrapper" class="docs-controls">
|
||||
<ContextDocsSearch />
|
||||
<DocsMenu />
|
||||
<DocsLayout>
|
||||
<template #content>
|
||||
<MDCRenderer v-if="ast?.body" :body="ast.body" :data="ast.data" :key="ast" :components="proseComponents" />
|
||||
</template>
|
||||
</DocsLayout>
|
||||
<template v-if="isOnline">
|
||||
<ContextDocsSearch />
|
||||
<DocsMenu />
|
||||
<DocsLayout>
|
||||
<template #content>
|
||||
<MDCRenderer v-if="ast?.body" :body="ast.body" :data="ast.data" :key="ast" :components="proseComponents" />
|
||||
</template>
|
||||
</DocsLayout>
|
||||
</template>
|
||||
<Markdown v-else :source="OFFLINE_MD" class="m-3" />
|
||||
</div>
|
||||
</ContextInfoContent>
|
||||
</template>
|
||||
@@ -52,6 +55,12 @@
|
||||
import ContextInfoContent from "../ContextInfoContent.vue";
|
||||
import ContextChildTableOfContents from "./ContextChildTableOfContents.vue";
|
||||
|
||||
import {useNetwork} from "@vueuse/core"
|
||||
const {isOnline} = useNetwork()
|
||||
|
||||
import Markdown from "../../components/layout/Markdown.vue";
|
||||
const OFFLINE_MD = "You're seeing this because you are offline.\n\nHere's how to configure the right sidebar in Kestra to include custom links:\n\n```yaml\nkestra:\n ee:\n right-sidebar:\n custom-links:\n internal-docs:\n title: \"Internal Docs\"\n url: \"https://kestra.io/docs/\"\n support-portal:\n title: \"Support portal\"\n url: \"https://kestra.io/support/\"\n```";
|
||||
|
||||
const docStore = useDocStore();
|
||||
const {t} = useI18n({useScope: "global"});
|
||||
|
||||
@@ -111,6 +120,8 @@
|
||||
}
|
||||
|
||||
async function fetchDefaultDocFromDocIdIfPossible() {
|
||||
if(!isOnline.value) return;
|
||||
|
||||
try {
|
||||
const response = await docStore.fetchDocId(docStore.docId!);
|
||||
if (response) {
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
<div class="m-3" v-if="localSubflowStatus">
|
||||
<div class="progress">
|
||||
<div
|
||||
v-for="state in State.allStates()"
|
||||
:key="state.key"
|
||||
v-for="state in State.arrayAllStates()"
|
||||
:key="state.name"
|
||||
class="progress-bar"
|
||||
role="progressbar"
|
||||
:class="`bg-${state.colorClass} ${localSubflowStatus[State.RUNNING] > 0 ? 'progress-bar-striped' : ''}`"
|
||||
:style="`width: ${getPercentage(state.key)}%`"
|
||||
:aria-valuenow="getPercentage(state.key)"
|
||||
:style="`width: ${getPercentage(state.name)}%`"
|
||||
:aria-valuenow="getPercentage(state.name)"
|
||||
aria-valuemin="0"
|
||||
:aria-valuemax="max"
|
||||
/>
|
||||
@@ -17,93 +17,78 @@
|
||||
<router-link :to="goToExecutionsList(null)" class="el-button count-button">
|
||||
{{ $t("all executions") }} <span class="counter">{{ max }}</span>
|
||||
</router-link>
|
||||
<div v-for="state in State.allStates()" :key="state.key">
|
||||
<router-link :to="goToExecutionsList(state.key)" class="el-button count-button" v-if="localSubflowStatus[state.key] >= 0">
|
||||
{{ capitalizeFirstLetter(getStateToBeDisplayed(state.key)) }}
|
||||
<span class="counter">{{ localSubflowStatus[state.key] }}</span>
|
||||
<div v-for="state in State.arrayAllStates()" :key="state.name">
|
||||
<router-link :to="goToExecutionsList(state.name)" class="el-button count-button" v-if="localSubflowStatus[state.name] >= 0">
|
||||
{{ capitalizeFirstLetter(getStateToBeDisplayed(state.name)) }}
|
||||
<span class="counter">{{ localSubflowStatus[state.name] }}</span>
|
||||
<div class="dot rounded-5" :class="`bg-${state.colorClass}`" />
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import {cssVariable} from "@kestra-io/ui-libs";
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, onMounted, watch} from "vue";
|
||||
import {State} from "@kestra-io/ui-libs";
|
||||
import {stateDisplayValues} from "../../utils/constants";
|
||||
import {State} from "@kestra-io/ui-libs"
|
||||
import throttle from "lodash/throttle"
|
||||
import throttle from "lodash/throttle";
|
||||
|
||||
export default {
|
||||
computed: {
|
||||
State() {
|
||||
return State
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
localSubflowStatus: {},
|
||||
updateThrottled: throttle(function () {
|
||||
this.localSubflowStatus = this.subflowsStatus
|
||||
}, 500)
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.localSubflowStatus = this.subflowsStatus
|
||||
},
|
||||
props: {
|
||||
subflowsStatus: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
executionId: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
max: {
|
||||
type: Number,
|
||||
required:true
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
subflowsStatus() {
|
||||
this.updateThrottled();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
cssVariable,
|
||||
getPercentage(state) {
|
||||
if (!this.localSubflowStatus[state]) {
|
||||
return 0;
|
||||
}
|
||||
return Math.round((this.localSubflowStatus[state] / this.max) * 100);
|
||||
},
|
||||
capitalizeFirstLetter(str) {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
|
||||
},
|
||||
getStateToBeDisplayed(str){
|
||||
if(str === State.RUNNING){
|
||||
return stateDisplayValues.INPROGRESS;
|
||||
}else{
|
||||
return str;
|
||||
}
|
||||
},
|
||||
goToExecutionsList(state) {
|
||||
const queries = {}
|
||||
const props = defineProps<{
|
||||
subflowsStatus: Record<string, number>;
|
||||
executionId: string;
|
||||
max: number;
|
||||
}>();
|
||||
|
||||
queries["filters[triggerExecutionId][EQUALS]"] = this.executionId;
|
||||
const localSubflowStatus = ref<Record<string, number>>({});
|
||||
|
||||
if (state) {
|
||||
queries["filters[state][EQUALS]"] = state;
|
||||
}
|
||||
const updateThrottled = throttle(() => {
|
||||
localSubflowStatus.value = props.subflowsStatus;
|
||||
}, 500);
|
||||
|
||||
return {
|
||||
name: "executions/list",
|
||||
query: queries
|
||||
};
|
||||
}
|
||||
onMounted(() => {
|
||||
localSubflowStatus.value = props.subflowsStatus;
|
||||
});
|
||||
|
||||
watch(() => props.subflowsStatus, () => {
|
||||
updateThrottled();
|
||||
}, {deep: true});
|
||||
|
||||
const getPercentage = (state: string): number => {
|
||||
if (!localSubflowStatus.value[state]) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
return Math.round((localSubflowStatus.value[state] / props.max) * 100);
|
||||
};
|
||||
|
||||
const capitalizeFirstLetter = (str: string): string => {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
|
||||
};
|
||||
|
||||
const getStateToBeDisplayed = (str: string): string => {
|
||||
if (str === State.RUNNING) {
|
||||
return stateDisplayValues.INPROGRESS;
|
||||
} else {
|
||||
return str;
|
||||
}
|
||||
};
|
||||
|
||||
const goToExecutionsList = (state: string | null) => {
|
||||
const queries: Record<string, string> = {};
|
||||
|
||||
queries["filters[triggerExecutionId][EQUALS]"] = props.executionId;
|
||||
|
||||
if (state) {
|
||||
queries["filters[state][EQUALS]"] = state;
|
||||
}
|
||||
|
||||
return {
|
||||
name: "executions/list",
|
||||
query: queries
|
||||
};
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.dot {
|
||||
width: 6.413px;
|
||||
|
||||
@@ -49,35 +49,34 @@
|
||||
id: string;
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
value: {
|
||||
type: [String, Object, Boolean, Number],
|
||||
required: false,
|
||||
default: undefined
|
||||
},
|
||||
execution: {
|
||||
type: Object as () => Execution,
|
||||
required: false,
|
||||
default: undefined
|
||||
},
|
||||
restrictUri: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
}
|
||||
interface FileMetadata {
|
||||
size: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
value?: string | object | boolean | number;
|
||||
execution?: Execution;
|
||||
restrictUri?: boolean;
|
||||
}>(), {
|
||||
value: undefined,
|
||||
execution: undefined,
|
||||
restrictUri: false,
|
||||
});
|
||||
|
||||
const humanSize = ref<string>("");
|
||||
|
||||
const isFile = (value: any): boolean => {
|
||||
const isFile = (value: unknown): value is string => {
|
||||
return typeof value === "string" && (value.startsWith("kestra:///") || value.startsWith("file://") || value.startsWith("nsfile://"));
|
||||
};
|
||||
|
||||
const isFileValid = (value: any): boolean => {
|
||||
return isFile(value) && humanSize.value?.length > 0 && humanSize.value !== "0B";
|
||||
const isFileValid = (value: unknown): boolean => {
|
||||
return isFile(value) && humanSize.value.length > 0 && humanSize.value !== "0B";
|
||||
};
|
||||
|
||||
const isURI = (value: any): boolean => {
|
||||
const isURI = (value: unknown): value is string => {
|
||||
if (typeof value !== "string") {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (props.restrictUri) {
|
||||
@@ -93,17 +92,19 @@
|
||||
return `${apiUrl()}/executions/${props.execution?.id}/file?path=${encodeURI(value)}`;
|
||||
};
|
||||
|
||||
const getFileSize = (): void => {
|
||||
if (isFile(props.value)) {
|
||||
fetch(`${apiUrl()}/executions/${props.execution?.id}/file/metas?path=${props.value}`, {
|
||||
method: "GET"
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
humanSize.value = Utils.humanFileSize(data.size);
|
||||
}
|
||||
const getFileSize = async (): Promise<void> => {
|
||||
if (isFile(props.value) && props.execution?.id) {
|
||||
try {
|
||||
const response = await fetch(`${apiUrl()}/executions/${props.execution.id}/file/metas?path=${props.value}`, {
|
||||
method: "GET"
|
||||
});
|
||||
if (response.ok) {
|
||||
const data: FileMetadata = await response.json();
|
||||
humanSize.value = Utils.humanFileSize(data.size);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch file size:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -66,24 +66,26 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, getCurrentInstance, onMounted, ref} from "vue";
|
||||
import {computed, onMounted, ref} from "vue";
|
||||
import {useRoute} from "vue-router";
|
||||
// @ts-expect-error types to be done
|
||||
import DateRange from "../../layout/DateRange.vue";
|
||||
// @ts-expect-error types to be done
|
||||
import TimeSelect from "./TimeSelect.vue";
|
||||
import type momentType from "moment";
|
||||
import moment from "moment";
|
||||
|
||||
interface FilterValue = {
|
||||
interface FilterValue {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
timeRange?: string;
|
||||
};
|
||||
|
||||
type AbsoluteEvent = {
|
||||
interface AbsoluteEvent {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
};
|
||||
|
||||
type RelativeEvent = {
|
||||
interface RelativeEvent {
|
||||
timeRange?: string;
|
||||
};
|
||||
|
||||
@@ -105,8 +107,6 @@
|
||||
}>();
|
||||
|
||||
const route = useRoute();
|
||||
const instance = getCurrentInstance();
|
||||
const moment = instance?.proxy?.$moment as (typeof momentType) | undefined;
|
||||
|
||||
const normalizedQuery = computed<Record<string, string>>(() => {
|
||||
const entries = Object.entries(route.query).map(([key, value]) => [
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
data-test-id="time-selector"
|
||||
:modelValue="value"
|
||||
:placeholder="placeholder"
|
||||
@change="$emit('change', $event)"
|
||||
@change="emit('change', $event)"
|
||||
:clearable="clearable"
|
||||
>
|
||||
<template #prefix>
|
||||
@@ -36,7 +36,7 @@
|
||||
clearable?: boolean
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
const emit = defineEmits<{
|
||||
(e: "change", value: string): void
|
||||
}>();
|
||||
</script>
|
||||
|
||||
@@ -12,102 +12,84 @@
|
||||
</el-tooltip>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts" setup>
|
||||
import {ref, computed, watch, PropType} from "vue";
|
||||
import DateSelect from "./DateSelect.vue";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
DateSelect
|
||||
},
|
||||
emits: [
|
||||
"update:modelValue"
|
||||
],
|
||||
computed: {
|
||||
customAwarePlaceholder() {
|
||||
if (this.placeholder) {
|
||||
return this.placeholder;
|
||||
}
|
||||
return this.allowCustom ? this.$t("datepicker.custom") : undefined;
|
||||
},
|
||||
timeFilterPresets(){
|
||||
let values = [
|
||||
{value: "PT5M", label: this.label("5minutes")},
|
||||
{value: "PT15M", label: this.label("15minutes")},
|
||||
{value: "PT1H", label: this.label("1hour")},
|
||||
{value: "PT12H", label: this.label("12hours")},
|
||||
{value: "PT24H", label: this.label("24hours")},
|
||||
{value: "PT48H", label: this.label("48hours")},
|
||||
{value: "PT168H", label: this.label("7days")},
|
||||
{value: "PT720H", label: this.label("30days")},
|
||||
{value: "PT8760H", label: this.label("365days")},
|
||||
]
|
||||
|
||||
if(this.includeNever){
|
||||
values.push({value: undefined, label: "datepicker.never"})
|
||||
}
|
||||
|
||||
return values;
|
||||
},
|
||||
presetValues() {
|
||||
return this.timeFilterPresets.map(preset => preset.value);
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
timeRange: {
|
||||
handler(newValue, oldValue) {
|
||||
if (oldValue === undefined && this.presetValues.includes(newValue)) {
|
||||
this.onTimeRangeSelect(newValue);
|
||||
}
|
||||
},
|
||||
immediate: true
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
timeRangeSelect: undefined
|
||||
}
|
||||
},
|
||||
props: {
|
||||
allowCustom: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: undefined
|
||||
},
|
||||
timeRange: {
|
||||
type: String,
|
||||
default: undefined
|
||||
},
|
||||
fromNow: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
allowInfinite: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
clearable: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
includeNever: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onTimeRangeSelect(range) {
|
||||
this.timeRangeSelect = range;
|
||||
this.onTimeRangeChange(range);
|
||||
},
|
||||
onTimeRangeChange(range) {
|
||||
this.$emit("update:modelValue", {"timeRange": range});
|
||||
},
|
||||
label(duration) {
|
||||
return "datepicker." + (this.fromNow ? "last" : "") + duration;
|
||||
}
|
||||
}
|
||||
interface TimePreset {
|
||||
value?: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
name: "TimeRangePicker",
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
allowCustom: {type: Boolean, default: false},
|
||||
placeholder: {type: String as PropType<string | undefined>, default: undefined},
|
||||
timeRange: {type: String as PropType<string | undefined>, default: undefined},
|
||||
fromNow: {type: Boolean, default: true},
|
||||
allowInfinite: {type: Boolean, default: false},
|
||||
clearable: {type: Boolean, default: false},
|
||||
includeNever: {type: Boolean, default: false}
|
||||
})
|
||||
|
||||
const timeRangeSelect = ref<string | undefined>(undefined);
|
||||
|
||||
const label = (duration: string): string =>
|
||||
"datepicker." + (props.fromNow ? "last" : "") + duration;
|
||||
|
||||
const timeFilterPresets = computed<TimePreset[]>(() => {
|
||||
const values: TimePreset[] = [
|
||||
{value: "PT5M", label: label("5minutes")},
|
||||
{value: "PT15M", label: label("15minutes")},
|
||||
{value: "PT1H", label: label("1hour")},
|
||||
{value: "PT12H", label: label("12hours")},
|
||||
{value: "PT24H", label: label("24hours")},
|
||||
{value: "PT48H", label: label("48hours")},
|
||||
{value: "PT168H", label: label("7days")},
|
||||
{value: "PT720H", label: label("30days")},
|
||||
{value: "PT8760H", label: label("365days")}
|
||||
];
|
||||
|
||||
if (props.includeNever) {
|
||||
values.push({value: undefined, label: "datepicker.never"});
|
||||
}
|
||||
|
||||
return values;
|
||||
});
|
||||
|
||||
const presetValues = computed<(string | undefined)[]>(() =>
|
||||
timeFilterPresets.value.map(preset => preset.value)
|
||||
);
|
||||
|
||||
const customAwarePlaceholder = computed<string | undefined>(() => {
|
||||
if (props.placeholder) return props.placeholder;
|
||||
return props.allowCustom ? "datepicker.custom" : undefined;
|
||||
});
|
||||
|
||||
const onTimeRangeSelect = (range: string | undefined) => {
|
||||
timeRangeSelect.value = range;
|
||||
onTimeRangeChange(range);
|
||||
};
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", payload: { timeRange: string | undefined }): void;
|
||||
}>();
|
||||
|
||||
const onTimeRangeChange = (range: string | undefined) => {
|
||||
emit("update:modelValue", {timeRange: range});
|
||||
};
|
||||
|
||||
// Watcher
|
||||
watch(
|
||||
() => props.timeRange,
|
||||
(newValue, oldValue) => {
|
||||
if (oldValue === undefined && presetValues.value.includes(newValue)) {
|
||||
onTimeRangeSelect(newValue);
|
||||
}
|
||||
},
|
||||
{immediate: true}
|
||||
);
|
||||
</script>
|
||||
@@ -184,7 +184,7 @@
|
||||
formRef,
|
||||
id: this.flow.id,
|
||||
namespace: this.flow.namespace,
|
||||
inputs: this.inputsNoDefaults,
|
||||
inputs: this.selectedTrigger?.inputs ? { ...this.selectedTrigger.inputs, ...this.inputsNoDefaults } : this.inputsNoDefaults,
|
||||
labels: [...new Set(
|
||||
this.executionLabels
|
||||
.filter(label => label.key && label.value)
|
||||
@@ -193,7 +193,7 @@
|
||||
scheduleDate: this.scheduleDate
|
||||
});
|
||||
} else {
|
||||
executeTask(this, this.flow, this.inputsNoDefaults, {
|
||||
executeTask(this, this.flow, this.selectedTrigger?.inputs ? { ...this.selectedTrigger.inputs, ...this.inputsNoDefaults } : this.inputsNoDefaults, {
|
||||
redirect: this.redirect,
|
||||
newTab: this.newTab,
|
||||
id: this.flow.id,
|
||||
@@ -279,4 +279,4 @@
|
||||
box-shadow: 0px 0px 50px 2px #8405FF;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -6,23 +6,33 @@ import {DeserializableEditorElement, Panel} from "../../utils/multiPanelTypes";
|
||||
|
||||
export const CODE_PREFIX = "code"
|
||||
|
||||
function getTabFromFilesTab(tab: EditorTabProps){
|
||||
export function getTabFromFilesTab(tab: EditorTabProps) {
|
||||
return {
|
||||
value: `${CODE_PREFIX}-${tab.path}`,
|
||||
button: {
|
||||
label: tab.name,
|
||||
icon: () => h(TypeIcon, {name:tab.name})
|
||||
icon: () => h(TypeIcon, {name:tab.name}),
|
||||
},
|
||||
component: () => h(markRaw(EditorWrapper), {...tab}),
|
||||
dirty: tab.dirty,
|
||||
}
|
||||
}
|
||||
|
||||
export function getTabPropsFromFilePath(filePath: string, flow: boolean = false): EditorTabProps {
|
||||
return {
|
||||
name: filePath.split("/").pop()!,
|
||||
path: filePath,
|
||||
extension: filePath.split(".").pop()!,
|
||||
flow,
|
||||
dirty: false
|
||||
}
|
||||
}
|
||||
|
||||
export function useInitialFilesTabs(EDITOR_ELEMENTS: DeserializableEditorElement[]){
|
||||
const editorStore = useEditorStore()
|
||||
|
||||
const codeElement = EDITOR_ELEMENTS.find(e => e.value === CODE_PREFIX)!
|
||||
codeElement!.deserialize = (value: string) => setupInitialCodeTab(value, codeElement)
|
||||
codeElement.deserialize = (value: string) => setupInitialCodeTab(value, codeElement)
|
||||
|
||||
function setupInitialCodeTab(tab: string, codeElement: DeserializableEditorElement){
|
||||
const flow = CODE_PREFIX === tab
|
||||
@@ -30,13 +40,7 @@ export function useInitialFilesTabs(EDITOR_ELEMENTS: DeserializableEditorElement
|
||||
return
|
||||
}
|
||||
const filePath = flow ? "Flow.yaml" : tab.substring(5)
|
||||
const editorTab: EditorTabProps = {
|
||||
name: filePath.split("/").pop()!,
|
||||
path: filePath,
|
||||
extension: filePath.split(".").pop()!,
|
||||
flow,
|
||||
dirty: false
|
||||
}
|
||||
const editorTab = getTabPropsFromFilePath(filePath, flow)
|
||||
editorStore.openTab(editorTab)
|
||||
return flow ? codeElement : getTabFromFilesTab(editorTab)
|
||||
}
|
||||
@@ -44,7 +48,7 @@ export function useInitialFilesTabs(EDITOR_ELEMENTS: DeserializableEditorElement
|
||||
return {setupInitialCodeTab}
|
||||
}
|
||||
|
||||
export function useFilesPanels(panels: Ref<Panel[]>) {
|
||||
export function useFilesPanels(panels: Ref<Panel[]>, namespaceFiles = false) {
|
||||
const editorStore = useEditorStore()
|
||||
|
||||
const codeEditorTabs = computed(() => editorStore.tabs.filter((t) => !t.flow))
|
||||
@@ -95,7 +99,7 @@ export function useFilesPanels(panels: Ref<Panel[]>) {
|
||||
})
|
||||
|
||||
watch(codeEditorTabs, (newVal) => {
|
||||
const codeTabs = getPanelsFromCodeEditorTabs(newVal)
|
||||
const codeTabs = getPanelsFromCodeEditorTabs(newVal.map(tab => ({...tab, namespaceFiles})))
|
||||
|
||||
// Loop through tabs to see if any code tab should be removed due to file deletion
|
||||
const openedTabs = new Set(codeTabs.tabs.map(tab => tab.value))
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
:lang="extension === undefined ? 'yaml' : undefined"
|
||||
:extension="extension"
|
||||
:navbar="false"
|
||||
:readOnly="isReadOnly"
|
||||
:readOnly="!namespaceFiles && flowStore.isReadOnly"
|
||||
:creating="isCreating"
|
||||
:path="props.path"
|
||||
:diffOverviewBar="false"
|
||||
@@ -155,8 +155,7 @@
|
||||
|
||||
const namespace = computed(() => flowStore.flow?.namespace);
|
||||
const isCreating = computed(() => flowStore.isCreating);
|
||||
const isCurrentTabFlow = computed(() => props.flow)
|
||||
const isReadOnly = computed(() => flowStore.flow?.deleted || !flowStore.isAllowedEdit || flowStore.readOnlySystemLabel);
|
||||
const isCurrentTabFlow = computed(() => props.flow);
|
||||
|
||||
const timeout = ref<any>(null);
|
||||
const hash = ref<any>(null);
|
||||
@@ -241,9 +240,9 @@
|
||||
clearTimeout(timeout.value);
|
||||
const editorRef = editorRefElement.value
|
||||
if(!editorRef?.$refs.monacoEditor) return
|
||||
|
||||
|
||||
// Use saveAll() for consistency with the Save button behavior
|
||||
const result = flowStore.isCreating
|
||||
const result = flowStore.isCreating
|
||||
? await flowStore.save({content:(editorRef.$refs.monacoEditor as any).value})
|
||||
: await flowStore.saveAll();
|
||||
|
||||
|
||||
@@ -704,7 +704,7 @@
|
||||
|
||||
modifiedBackspaceTimeout = window.setTimeout(() => {
|
||||
modifiedEditor.trigger("keyboard", "editor.action.triggerSuggest", {});
|
||||
}, 250);
|
||||
}, 250);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -743,7 +743,7 @@
|
||||
fixedOverflowWidgets: true // Helps suggestion widget render above other elements
|
||||
});
|
||||
let localBackspaceTimeout: number | null = null;
|
||||
|
||||
|
||||
localEditor.value.onKeyDown((e) => {
|
||||
if (e.keyCode === monaco.KeyCode.Backspace) {
|
||||
if (localBackspaceTimeout) clearTimeout(localBackspaceTimeout);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -121,15 +121,11 @@ export function useHelpers() {
|
||||
maximized: true,
|
||||
},
|
||||
{
|
||||
maximized: true,
|
||||
name: "files",
|
||||
title: t("files"),
|
||||
component: NamespaceFilesEditorView,
|
||||
props: {
|
||||
namespace: namespace.value,
|
||||
isNamespace: true,
|
||||
isReadOnly: false,
|
||||
},
|
||||
props: {namespace: namespace.value},
|
||||
maximized: true,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -20,11 +20,11 @@
|
||||
<div @click="isPlugin && pluginsStore.updateDocumentation(taskObject as Parameters<typeof pluginsStore.updateDocumentation>[0])">
|
||||
<TaskObject
|
||||
v-loading="isLoading"
|
||||
v-if="(selectedTaskType || !isTaskDefinitionBasedOnType) && schemaProp"
|
||||
v-if="(selectedTaskType || !isTaskDefinitionBasedOnType) && resolvedLocalSchema"
|
||||
name="root"
|
||||
:modelValue="taskObject"
|
||||
@update:model-value="onTaskInput"
|
||||
:schema="schemaProp"
|
||||
:schema="resolvedLocalSchema"
|
||||
:properties="properties"
|
||||
:definitions="fullSchema.definitions"
|
||||
/>
|
||||
@@ -127,7 +127,7 @@
|
||||
|
||||
|
||||
const properties = computed(() => {
|
||||
const updatedProperties = schemaProp.value;
|
||||
const updatedProperties = resolvedProperties.value ?? {};
|
||||
if(isPluginDefaults.value){
|
||||
updatedProperties["id"] = undefined
|
||||
updatedProperties["forced"] = {
|
||||
@@ -150,22 +150,6 @@
|
||||
return updatedProperties
|
||||
});
|
||||
|
||||
const schemaProp = computed(() => {
|
||||
const prop = isTaskDefinitionBasedOnType.value
|
||||
? resolvedProperties.value
|
||||
: schemaAtBlockPath.value
|
||||
|
||||
if(!prop){
|
||||
return undefined;
|
||||
}
|
||||
prop.required = prop.required || [];
|
||||
prop.required.push("id", "data");
|
||||
if(isPluginDefaults.value){
|
||||
prop.required.push("forced");
|
||||
}
|
||||
return prop;
|
||||
});
|
||||
|
||||
function setup() {
|
||||
const parsed = YAML_UTILS.parse<PartialCodeElement>(modelValue.value);
|
||||
if(isPluginDefaults.value){
|
||||
@@ -261,11 +245,27 @@
|
||||
return resolvedTypes.value.map((type) => definitions.value?.[type]);
|
||||
});
|
||||
|
||||
const REQUIRED_FIELDS = ["id", "data"];
|
||||
|
||||
const resolvedLocalSchema = computed(() => {
|
||||
const localSchema = definitions.value?.[resolvedType.value];
|
||||
if(isTaskDefinitionBasedOnType.value && localSchema){
|
||||
localSchema.required = localSchema.required ?? [];
|
||||
for(const field of REQUIRED_FIELDS){
|
||||
if(!localSchema.required.includes(field) && localSchema.properties?.[field]){
|
||||
localSchema.required.push(field);
|
||||
}
|
||||
}
|
||||
}
|
||||
return isTaskDefinitionBasedOnType.value
|
||||
? localSchema
|
||||
: schemaAtBlockPath.value
|
||||
});
|
||||
|
||||
const resolvedProperties = computed<Schemas["properties"] | undefined>(() => {
|
||||
// try to resolve the type from local schema
|
||||
const defs = definitions.value ?? {}
|
||||
if (defs[resolvedType.value]) {
|
||||
return defs[resolvedType.value].properties
|
||||
if (resolvedLocalSchema.value) {
|
||||
return resolvedLocalSchema.value.properties
|
||||
}
|
||||
|
||||
if(resolvedTypes.value.length > 1){
|
||||
|
||||
@@ -9,29 +9,44 @@
|
||||
>
|
||||
<IconCodeBracesBox />
|
||||
</el-checkbox-button>
|
||||
<el-time-picker
|
||||
v-if="!pebble && schema.format === 'duration'"
|
||||
:modelValue="durationValue"
|
||||
type="time"
|
||||
:defaultValue="defaultDuration"
|
||||
:placeholder="`Choose a${/^[aeiou]/i.test(root || '') ? 'n' : ''} ${root || 'duration'}`"
|
||||
@update:model-value="onInputDuration"
|
||||
/>
|
||||
|
||||
<el-date-picker
|
||||
v-else-if="!pebble && schema.format === 'date-time'"
|
||||
v-if="!pebble && schema.format === 'date-time'"
|
||||
:modelValue="modelValue"
|
||||
type="date"
|
||||
:placeholder="`Choose a${/^[aeiou]/i.test(root || '') ? 'n' : ''} ${root || 'date'}`"
|
||||
@update:model-value="onInput($event.toISOString())"
|
||||
/>
|
||||
<el-input-number
|
||||
v-if="!pebble && showDurationDays"
|
||||
:modelValue="daysDurationValue"
|
||||
align="right"
|
||||
style="width:200px"
|
||||
:min="0"
|
||||
:controls="false"
|
||||
@update:model-value="onInputDaysDuration"
|
||||
>
|
||||
<template #suffix>
|
||||
<span class="duration-unit">{{ $t("days") }}</span>
|
||||
</template>
|
||||
</el-input-number>
|
||||
<el-time-picker
|
||||
v-if="!pebble && schema.format === 'duration'"
|
||||
:modelValue="timeDurationValue"
|
||||
type="time"
|
||||
:defaultValue="defaultDuration"
|
||||
:placeholder="`Choose a${/^[aeiou]/i.test(root || '') ? 'n' : ''} ${root || 'duration'}`"
|
||||
@update:model-value="onInputDuration"
|
||||
@clear="onInputDaysDuration(undefined)"
|
||||
/>
|
||||
<InputText
|
||||
v-else-if="disabled"
|
||||
v-if="disabled"
|
||||
:modelValue="modelValue"
|
||||
disabled
|
||||
class="w-100 disabled-field"
|
||||
/>
|
||||
<Editor
|
||||
v-else
|
||||
v-if="pebble || !schema.format"
|
||||
:modelValue="editorValue"
|
||||
:navbar="false"
|
||||
:fullHeight="false"
|
||||
@@ -44,91 +59,119 @@
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
<script lang="ts" setup>
|
||||
import {ref, computed, onMounted} from "vue";
|
||||
import $moment from "moment";
|
||||
import Editor from "../../../../components/inputs/Editor.vue";
|
||||
import InputText from "../inputs/InputText.vue";
|
||||
import IconCodeBracesBox from "vue-material-design-icons/CodeBracesBox.vue";
|
||||
</script>
|
||||
<script>
|
||||
import Task from "./MixinTask";
|
||||
|
||||
export default {
|
||||
inheritAttrs: false,
|
||||
mixins: [Task],
|
||||
components: {Editor},
|
||||
props:{
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
pebble: false,
|
||||
};
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
mounted(){
|
||||
if(!["duration", "date-time"].includes(this.schema.format) || !this.modelValue){
|
||||
this.pebble = false;
|
||||
} else if( this.schema.format === "duration" && this.values) {
|
||||
this.pebble = !this.$moment.duration(this.modelValue).isValid();
|
||||
} else if (this.schema.format === "date-time" && this.values) {
|
||||
this.pebble = isNaN(Date.parse(this.modelValue));
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isValid() {
|
||||
if (this.required && !this.modelValue) {
|
||||
return false;
|
||||
}
|
||||
const props = defineProps<{
|
||||
disabled?: boolean;
|
||||
modelValue?: string;
|
||||
schema: { format: string, default?: string };
|
||||
root?: string;
|
||||
}>();
|
||||
|
||||
if (this.schema.regex && this.modelValue) {
|
||||
return RegExp(this.schema.regex).test(this.modelValue);
|
||||
}
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: string | undefined): void;
|
||||
}>();
|
||||
|
||||
return true;
|
||||
},
|
||||
durationValue() {
|
||||
if (typeof this.values === "string") {
|
||||
const duration = this.$moment.duration(this.values);
|
||||
|
||||
return new Date(
|
||||
1981,
|
||||
1,
|
||||
1,
|
||||
duration.hours(),
|
||||
duration.minutes(),
|
||||
duration.seconds(),
|
||||
);
|
||||
}
|
||||
const pebble = ref(false);
|
||||
|
||||
return undefined;
|
||||
},
|
||||
defaultDuration() {
|
||||
return this.$moment().seconds(0).minutes(0).hours(0).toDate();
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onInputDuration(value) {
|
||||
const emitted =
|
||||
value === "" || value === null
|
||||
? undefined
|
||||
: this.$moment
|
||||
.duration({
|
||||
seconds: value.getSeconds(),
|
||||
minutes: value.getMinutes(),
|
||||
hours: value.getHours(),
|
||||
})
|
||||
.toString();
|
||||
const values = computed(() => {
|
||||
if (props.modelValue === undefined) {
|
||||
return props.schema?.default;
|
||||
}
|
||||
|
||||
return props.modelValue;
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (!["duration", "date-time"].includes(props.schema.format) || !props.modelValue) {
|
||||
pebble.value = false;
|
||||
} else if (props.schema.format === "duration" && values.value) {
|
||||
pebble.value = !$moment.duration(props.modelValue).isValid();
|
||||
} else if (props.schema.format === "date-time" && values.value) {
|
||||
pebble.value = isNaN(Date.parse(props.modelValue as string));
|
||||
}
|
||||
});
|
||||
|
||||
// FIXME: hardcoded condition only show days input for timeWindow durations
|
||||
const showDurationDays = computed(() => {
|
||||
return props.schema.format === "duration" && props.root?.startsWith("timeWindow")
|
||||
});
|
||||
|
||||
const daysDurationValue = computed<number | undefined>(() => {
|
||||
if (typeof values.value === "string") {
|
||||
const duration = $moment.duration(values.value);
|
||||
return Math.floor(duration.asDays());
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const timeDurationValue = computed<Date | undefined>(() => {
|
||||
if (typeof values.value === "string") {
|
||||
const duration = $moment.duration(values.value);
|
||||
return new Date(
|
||||
1981,
|
||||
1,
|
||||
1,
|
||||
duration.hours(),
|
||||
duration.minutes(),
|
||||
duration.seconds(),
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const defaultDuration = computed(() => {
|
||||
return $moment().seconds(0).minutes(0).hours(0).toDate();
|
||||
});
|
||||
|
||||
function onInputDuration(value: Date | "" | null) {
|
||||
const emitted =
|
||||
value === "" || value === null
|
||||
? undefined
|
||||
: $moment
|
||||
.duration({
|
||||
days: daysDurationValue.value || 0,
|
||||
seconds: value.getSeconds(),
|
||||
minutes: value.getMinutes(),
|
||||
hours: value.getHours(),
|
||||
})
|
||||
.toString();
|
||||
emit("update:modelValue", emitted);
|
||||
}
|
||||
|
||||
function onInputDaysDuration(value: number | undefined) {
|
||||
const currentTimeDuration = timeDurationValue.value;
|
||||
const emitted = (value === undefined)
|
||||
? undefined
|
||||
: currentTimeDuration === undefined
|
||||
? $moment
|
||||
.duration({
|
||||
days: value,
|
||||
})
|
||||
.toString()
|
||||
: $moment
|
||||
.duration({
|
||||
days: value,
|
||||
hours: currentTimeDuration.getHours(),
|
||||
minutes: currentTimeDuration.getMinutes(),
|
||||
seconds: currentTimeDuration.getSeconds(),
|
||||
})
|
||||
.toString()
|
||||
emit("update:modelValue", emitted);
|
||||
}
|
||||
|
||||
function onInput(value: string) {
|
||||
emit("update:modelValue", value);
|
||||
}
|
||||
|
||||
const editorValue = computed(() => props.modelValue);
|
||||
|
||||
this.$emit("update:modelValue", emitted);
|
||||
},
|
||||
onInput(value) {
|
||||
this.$emit("update:modelValue", value);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -174,4 +217,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
.duration-unit{
|
||||
color: var(--ks-content-inactive);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
:class="miscStore.configs?.secretsEnabled === undefined ? 'mt-0 p-0' : 'container'"
|
||||
>
|
||||
<EmptyTemplate v-if="miscStore.configs?.secretsEnabled === undefined" class="d-flex flex-column text-start m-0 p-0 mw-100">
|
||||
<div class="no-secret-manager-block d-flex flex-column gap-6">
|
||||
<div class="no-secret-manager-block d-flex flex-column gap-6 mt-3">
|
||||
<div class="header-block d-flex align-items-center">
|
||||
<div class="d-flex flex-column">
|
||||
<div class="d-flex flex-row gap-2">
|
||||
|
||||
71
ui/src/composables/useStoredPanels.ts
Normal file
71
ui/src/composables/useStoredPanels.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import {useStorage} from "@vueuse/core";
|
||||
import {DeserializableEditorElement, Panel, Tab} from "../utils/multiPanelTypes";
|
||||
|
||||
interface PreSerializedPanel {
|
||||
tabs: string[];
|
||||
activeTab: string | undefined;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export function useStoredPanels(key: string, editorElements: Pick<DeserializableEditorElement, "deserialize">[], defaultPanels: string[] = [], preSerializePanels?: (panels: Panel[]) => PreSerializedPanel[]) {
|
||||
const preSerializePanelsFn = preSerializePanels ?? ((ps: Panel[]) => ps.map(p => ({
|
||||
tabs: p.tabs.map(t => t.value),
|
||||
activeTab: p.activeTab?.value,
|
||||
size: p.size,
|
||||
})));
|
||||
|
||||
/**
|
||||
* function called on mount to deserialize tabs from storage
|
||||
* NOTE: if a tab is not relevant anymore, it will be ignored
|
||||
* hence the "allowCreate = false".
|
||||
* @param tags
|
||||
*/
|
||||
function deserializeTabTags(tags: string[]): Tab[] {
|
||||
return tags.map(tag => {
|
||||
for (const element of editorElements) {
|
||||
const deserializedTab = element.deserialize(tag, false);
|
||||
if (deserializedTab) {
|
||||
return deserializedTab;
|
||||
}
|
||||
}
|
||||
}).filter(t => t !== undefined);
|
||||
}
|
||||
|
||||
const panels = useStorage<Panel[]>(
|
||||
key,
|
||||
deserializeTabTags(defaultPanels).map((t) => {
|
||||
return {
|
||||
activeTab: t,
|
||||
tabs: [t],
|
||||
size: 100 / defaultPanels.length
|
||||
};
|
||||
}),
|
||||
undefined,
|
||||
{
|
||||
serializer: {
|
||||
write(v: Panel[]){
|
||||
return JSON.stringify(preSerializePanelsFn(v));
|
||||
},
|
||||
read(v?: string) {
|
||||
if(!v) return [];
|
||||
const rawPanels: PreSerializedPanel[] = JSON.parse(v);
|
||||
const convertedPanels = rawPanels
|
||||
.filter((p) => p.tabs.length)
|
||||
.map((p):Panel => {
|
||||
const tabsConverted = deserializeTabTags(p.tabs);
|
||||
const activeTab = tabsConverted.find((t) => t.value === p.activeTab) ?? tabsConverted[0];
|
||||
return {
|
||||
activeTab,
|
||||
tabs: tabsConverted,
|
||||
size: p.size
|
||||
};
|
||||
});
|
||||
|
||||
return convertedPanels
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return panels;
|
||||
}
|
||||
@@ -5,17 +5,30 @@
|
||||
<slot name="content">
|
||||
<DataTable class="blueprints" @page-changed="onPageChanged" ref="dataTable" :total="total" hideTopPagination divider>
|
||||
<template #navbar>
|
||||
<div v-if="ready && !system && !embed" class="tags-selection">
|
||||
<el-checkbox-group v-model="selectedTags" class="tags-checkbox-group">
|
||||
<el-checkbox-button
|
||||
v-for="tag in Object.values(tags || {})"
|
||||
:key="tag.id"
|
||||
:value="tag.id"
|
||||
class="hoverable"
|
||||
>
|
||||
{{ tag.name }}
|
||||
</el-checkbox-button>
|
||||
</el-checkbox-group>
|
||||
<div v-if="ready && !system && !embed">
|
||||
<div class="tags-selection">
|
||||
<el-checkbox-group v-model="selectedTags" class="tags-checkbox-group">
|
||||
<el-checkbox-button
|
||||
v-for="tag in Object.values(tags || {})"
|
||||
:key="tag.id"
|
||||
:label="tag.id"
|
||||
class="hoverable"
|
||||
>
|
||||
{{ tag.name }}
|
||||
</el-checkbox-button>
|
||||
</el-checkbox-group>
|
||||
</div>
|
||||
|
||||
<el-row class="search-bar-row" justify="center">
|
||||
<el-col :xs="24">
|
||||
<el-input
|
||||
v-model="searchText"
|
||||
:placeholder="$t('Search or choose filters...')"
|
||||
clearable
|
||||
@input="updateSearch"
|
||||
/>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
<nav v-else-if="system" class="header pb-3">
|
||||
<p class="mb-0 fw-lighter">
|
||||
@@ -26,93 +39,52 @@
|
||||
</p>
|
||||
</nav>
|
||||
</template>
|
||||
<template #top>
|
||||
<el-row class="mb-3 px-3" justify="center">
|
||||
<el-col :xs="24" :sm="18" :md="12" :lg="10" :xl="8">
|
||||
<el-input
|
||||
v-model="searchText"
|
||||
:placeholder="$t('search')"
|
||||
clearable
|
||||
@input="updateSearch"
|
||||
/>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</template>
|
||||
|
||||
<template #top />
|
||||
|
||||
<template #table>
|
||||
<el-alert type="info" v-if="ready && (!blueprints || blueprints.length === 0)" :closable="false">
|
||||
{{ $t('blueprints.empty') }}
|
||||
</el-alert>
|
||||
<el-card
|
||||
class="blueprint-card"
|
||||
:class="{'embed': embed}"
|
||||
v-for="blueprint in blueprints"
|
||||
:key="blueprint.id"
|
||||
@click="goToDetail(blueprint.id)"
|
||||
>
|
||||
<component
|
||||
class="blueprint-link"
|
||||
:is="embed ? 'div' : 'router-link'"
|
||||
:to="embed ? undefined : {name: 'blueprints/view', params: {blueprintId: blueprint.id, tab: blueprintType, kind: blueprintKind}}"
|
||||
<div class="card-grid">
|
||||
<el-card
|
||||
class="blueprint-card"
|
||||
v-for="blueprint in blueprints"
|
||||
:key="blueprint.id"
|
||||
@click="goToDetail(blueprint.id)"
|
||||
>
|
||||
<div class="left">
|
||||
<div class="blueprint">
|
||||
<div
|
||||
class="ps-0 title"
|
||||
:class="{'embed-title': embed, 'text-truncate': embed}"
|
||||
>
|
||||
<div class="card-content-wrapper">
|
||||
<div v-if="!system && blueprint.tags?.length > 0" class="tags-section">
|
||||
<span v-for="tag in processedTags(blueprint.tags)" :key="tag.original" class="tag-item">{{ tag.display }}</span>
|
||||
</div>
|
||||
<div class="text-section">
|
||||
<h3 class="title">
|
||||
{{ blueprint.title ?? blueprint.id }}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="bottom-section">
|
||||
<div class="task-icons">
|
||||
<TaskIcon v-for="task in [...new Set(blueprint.includedTasks)]" :key="task" :cls="task" :icons="pluginsStore.icons" />
|
||||
</div>
|
||||
<div v-if="embed" class="tags-w-icons-container">
|
||||
<div class="tags-w-icons">
|
||||
<div v-for="(tag, index) in blueprint.tags" :key="index">
|
||||
<el-tag size="small">
|
||||
{{ tag }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="tasks-container">
|
||||
<TaskIcon
|
||||
:icons="pluginsStore.icons"
|
||||
:cls="task"
|
||||
:key="task"
|
||||
v-for="task in [...new Set(blueprint.includedTasks)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="!system" class="tags text-uppercase">
|
||||
<div v-for="(tag, index) in blueprint.tags" :key="index" class="tag-box">
|
||||
<el-tag size="small">
|
||||
{{ tag }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<div class="action-button">
|
||||
<el-tooltip v-if="embed" trigger="click" content="Copied" placement="left" :autoClose="2000" effect="light">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="default"
|
||||
:icon="icon.ContentCopy"
|
||||
@click.prevent.stop="copy(blueprint.id)"
|
||||
class="p-2"
|
||||
/>
|
||||
</el-tooltip>
|
||||
<el-button v-else-if="userCanCreate" type="primary" size="default" @click.prevent.stop="blueprintToEditor(blueprint.id)">
|
||||
{{ $t('use') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!embed" class="tasks-container">
|
||||
<TaskIcon
|
||||
:icons="pluginsStore.icons"
|
||||
:cls="task"
|
||||
:key="task"
|
||||
v-for="task in [...new Set(blueprint.includedTasks)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="side buttons ms-auto">
|
||||
<slot name="buttons" :blueprint="blueprint" />
|
||||
<el-tooltip v-if="embed" trigger="click" content="Copied" placement="left" :autoClose="2000" effect="light">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="default"
|
||||
:icon="icon.ContentCopy"
|
||||
@click.prevent.stop="copy(blueprint.id)"
|
||||
class="copy-button p-2"
|
||||
/>
|
||||
</el-tooltip>
|
||||
<el-button v-else-if="userCanCreate" type="primary" size="default" @click.prevent.stop="blueprintToEditor(blueprint.id)">
|
||||
{{ $t('use') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</component>
|
||||
</el-card>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
<slot name="bottom-bar" />
|
||||
@@ -125,7 +97,6 @@
|
||||
import {useRoute, useRouter} from "vue-router";
|
||||
import {TaskIcon} from "@kestra-io/ui-libs";
|
||||
import ContentCopy from "vue-material-design-icons/ContentCopy.vue";
|
||||
// @ts-expect-error data-table does not have types yet
|
||||
import DataTable from "../../../../components/layout/DataTable.vue";
|
||||
import Errors from "../../../../components/errors/Errors.vue";
|
||||
import {editorViewTypes} from "../../../../utils/constants";
|
||||
@@ -188,6 +159,14 @@
|
||||
|
||||
const userCanCreate = computed(() => canCreate(props.blueprintKind));
|
||||
|
||||
const processedTags = (tags: string[]) => {
|
||||
return tags.map(tag => ({
|
||||
original: tag,
|
||||
display: tag.length <= 3 && tag === tag.toUpperCase() ? tag :
|
||||
tag.replace(/\b\w/g, l => l.toUpperCase())
|
||||
}));
|
||||
};
|
||||
|
||||
const updateSearch = (value: string) => {
|
||||
router.push({query: {...route.query, q: value || undefined}});
|
||||
};
|
||||
@@ -210,6 +189,16 @@
|
||||
function goToDetail(blueprintId: string) {
|
||||
if (props.embed) {
|
||||
emit("goToDetail", blueprintId);
|
||||
} else {
|
||||
router.push({
|
||||
name: "blueprints/view",
|
||||
params: {
|
||||
tenant: route.params.tenant,
|
||||
kind: props.blueprintKind,
|
||||
tab: route.params.tab,
|
||||
blueprintId: blueprintId
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -325,188 +314,9 @@
|
||||
@use 'element-plus/theme-chalk/src/mixins/mixins' as *;
|
||||
@import "@kestra-io/ui-libs/src/scss/variables";
|
||||
|
||||
.sub-nav {
|
||||
margin: 0 0 $spacer;
|
||||
|
||||
> * {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
// Two elements => one element on each side
|
||||
&:has(> :nth-child(2)) {
|
||||
margin: $spacer 0 .5rem 0;
|
||||
|
||||
.el-card & {
|
||||
// Enough space not to overlap with switch view when embedded
|
||||
margin-top: 1.6rem;
|
||||
|
||||
|
||||
// Embedded tabs looks weird without cancelling the margin (this brings a top-left tabs with bottom-right search)
|
||||
> :nth-child(1) {
|
||||
margin-top: -1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
> :nth-last-child(1) {
|
||||
margin-left: auto;
|
||||
padding: .5rem 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.blueprints {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
|
||||
.blueprint-card {
|
||||
cursor: pointer;
|
||||
border-radius: 0;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--ks-border-primary);
|
||||
|
||||
.blueprint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.tags-w-icons-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
margin-top: 7px;
|
||||
|
||||
.tags-w-icons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .35rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.el-tag {
|
||||
background-color: var(--ks-tag-background);
|
||||
padding: 13px 10px;
|
||||
color: var(--ks-tag-content);
|
||||
text-transform: capitalize;
|
||||
font-size: $small-font-size;
|
||||
border: 1px solid var(--ks-border-primary);
|
||||
|
||||
html.dark & {
|
||||
background-color: rgba(64, 69, 89, .7);
|
||||
}
|
||||
}
|
||||
|
||||
&.embed {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.blueprint-link {
|
||||
display: flex;
|
||||
color: inherit;
|
||||
text-decoration: inherit;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
|
||||
.left {
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
.title {
|
||||
width: 500px;
|
||||
font-weight: bold;
|
||||
font-size: $small-font-size;
|
||||
padding-left: 0;
|
||||
margin-right: 15px;
|
||||
|
||||
@media (max-width: 780px) {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.embed-title {
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.tags {
|
||||
margin: 10px 0;
|
||||
display: flex;
|
||||
|
||||
.tag-box {
|
||||
margin-right: .5rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.tasks-container {
|
||||
$plugin-icon-size: calc(var(--font-size-base) + 0.3rem);
|
||||
display: flex;
|
||||
gap: .25rem;
|
||||
width: fit-content;
|
||||
height: $plugin-icon-size;
|
||||
|
||||
:deep(> *) {
|
||||
width: $plugin-icon-size;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.side {
|
||||
&.buttons {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&.copy-button {
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
transform: translateY(-50%);
|
||||
top: 50%;
|
||||
z-index: 10;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@include res(lg) {
|
||||
&:not(.embed) .blueprint-link .left {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
|
||||
> :first-child {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.tags {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.tasks-container {
|
||||
margin: 0 $spacer;
|
||||
height: 2.0rem;
|
||||
|
||||
:deep(.wrapper) {
|
||||
width: 2.0rem;
|
||||
height: 2.0rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
html.dark &.embed {
|
||||
background-color: var(--ks-background-card);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tags-selection {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
@@ -517,7 +327,7 @@
|
||||
.tags-checkbox-group {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
gap: .3rem;
|
||||
gap: .5rem;
|
||||
flex-wrap: wrap;
|
||||
--el-button-bg-color: var(--ks-background-card);
|
||||
|
||||
@@ -536,4 +346,97 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
.search-bar-row {
|
||||
max-width: 800px;
|
||||
margin: 0 auto 1.5rem auto;
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(297px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.blueprint-card {
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--ks-border-primary);
|
||||
border-radius: 0.25rem;
|
||||
background-color: var(--ks-background-card);
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
box-shadow: 0px 2px 4px 0px var(--ks-card-shadow);
|
||||
min-height: 200px;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 0.5rem 1rem 0 var(--ks-card-shadow);
|
||||
}
|
||||
|
||||
:deep(.icon) {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
:deep(.el-card__body) {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.card-content-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tags-section {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
|
||||
.tag-item {
|
||||
border: 1px solid var(--ks-border-primary);
|
||||
color: var(--ks-content-primary);
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 12px;
|
||||
background: var(--ks-tag-background-active);
|
||||
}
|
||||
}
|
||||
|
||||
.text-section {
|
||||
flex-grow: 1;
|
||||
margin-top: 0.75rem;
|
||||
|
||||
.title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--ks-content-primary);
|
||||
line-height: 22px;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-section {
|
||||
margin-top: 1.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
|
||||
.task-icons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
flex-wrap: wrap;
|
||||
|
||||
:deep(.wrapper) {
|
||||
height: 1.5rem;
|
||||
width: 1.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
75
ui/src/override/composables/contextButtons.ts
Normal file
75
ui/src/override/composables/contextButtons.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import type {Component} from "vue";
|
||||
|
||||
import {useI18n} from "vue-i18n";
|
||||
|
||||
import {useNetwork} from "@vueuse/core";
|
||||
const {isOnline} = useNetwork();
|
||||
|
||||
import ContextNews from "../../components/layout/ContextNews.vue";
|
||||
import ContextDocs from "../../components/docs/ContextDocs.vue";
|
||||
|
||||
import MessageOutline from "vue-material-design-icons/MessageOutline.vue";
|
||||
import FileDocument from "vue-material-design-icons/FileDocument.vue";
|
||||
import Slack from "vue-material-design-icons/Slack.vue";
|
||||
import Github from "vue-material-design-icons/Github.vue";
|
||||
import Calendar from "vue-material-design-icons/Calendar.vue";
|
||||
import Star from "vue-material-design-icons/Star.vue";
|
||||
|
||||
interface Button {
|
||||
title: string;
|
||||
icon: Component;
|
||||
|
||||
component?: Component;
|
||||
hasUnreadMarker?: boolean;
|
||||
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export function useContextButtons() {
|
||||
const {t} = useI18n({useScope: "global"});
|
||||
|
||||
const buttons: Record<string, Button> = isOnline.value
|
||||
? {
|
||||
news: {
|
||||
title: t("contextBar.news"),
|
||||
icon: MessageOutline,
|
||||
|
||||
component: ContextNews,
|
||||
hasUnreadMarker: true,
|
||||
},
|
||||
docs: {
|
||||
title: t("contextBar.docs"),
|
||||
icon: FileDocument,
|
||||
|
||||
component: ContextDocs,
|
||||
hasUnreadMarker: false,
|
||||
},
|
||||
help: {
|
||||
title: t("contextBar.help"),
|
||||
icon: Slack,
|
||||
|
||||
url: "https://kestra.io/slack",
|
||||
},
|
||||
issue: {
|
||||
title: t("contextBar.issue"),
|
||||
icon: Github,
|
||||
|
||||
url: "https://github.com/kestra-io/kestra/issues/new/choose",
|
||||
},
|
||||
demo: {
|
||||
title: t("contextBar.demo"),
|
||||
icon: Calendar,
|
||||
|
||||
url: "https://kestra.io/demo",
|
||||
},
|
||||
star: {
|
||||
title: t("contextBar.star"),
|
||||
icon: Star,
|
||||
|
||||
url: "https://github.com/kestra-io/kestra",
|
||||
},
|
||||
}
|
||||
: {};
|
||||
|
||||
return {buttons};
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export interface EditorTabProps {
|
||||
flow?: boolean;
|
||||
content?: string;
|
||||
dirty?: boolean;
|
||||
namespaceFiles?: boolean;
|
||||
}
|
||||
|
||||
export const useEditorStore = defineStore("editor", () => {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import {defineStore} from "pinia"
|
||||
import {trackPluginDocumentationView} from "../utils/tabTracking";;
|
||||
import {ref, computed, toRaw} from "vue";
|
||||
import {trackPluginDocumentationView} from "../utils/tabTracking";
|
||||
import {apiUrlWithoutTenants} from "override/utils/route";
|
||||
import semver from "semver";
|
||||
import {useApiStore} from "./api";
|
||||
import {Schemas} from "../components/no-code/utils/types";
|
||||
import InitialFlowSchema from "./flow-schema.json"
|
||||
import {toRaw} from "vue";
|
||||
import {isEntryAPluginElementPredicate} from "@kestra-io/ui-libs";
|
||||
import {useAxios} from "../utils/axios";
|
||||
|
||||
export interface PluginComponent {
|
||||
icon?: string;
|
||||
@@ -36,26 +37,6 @@ export interface Plugin {
|
||||
additionalPlugins: PluginComponent[];
|
||||
}
|
||||
|
||||
interface State {
|
||||
plugin?: PluginComponent;
|
||||
versions?: string[];
|
||||
pluginAllProps?: any;
|
||||
deprecatedTypes?: string[];
|
||||
plugins?: Plugin[];
|
||||
icons?: Record<string, string>;
|
||||
pluginsDocumentation: Record<string, PluginComponent>;
|
||||
editorPlugin?: (PluginComponent & {cls: string});
|
||||
inputSchema?: any;
|
||||
inputsType?: any;
|
||||
schemaType?: Record<string, any>;
|
||||
currentlyLoading?: {
|
||||
type?: string;
|
||||
version?: string;
|
||||
};
|
||||
forceIncludeProperties?: string[];
|
||||
_iconsPromise: Promise<Record<string, string>> | undefined;
|
||||
}
|
||||
|
||||
interface LoadOptions {
|
||||
cls: string;
|
||||
version?: string;
|
||||
@@ -75,277 +56,305 @@ export function removeRefPrefix(ref?: string): string {
|
||||
return ref?.replace(/^#\/definitions\//, "") ?? "";
|
||||
}
|
||||
|
||||
export const usePluginsStore = defineStore("plugins", {
|
||||
state: (): State => ({
|
||||
plugin: undefined,
|
||||
versions: undefined,
|
||||
pluginAllProps: undefined,
|
||||
plugins: undefined,
|
||||
icons: undefined,
|
||||
pluginsDocumentation: {},
|
||||
editorPlugin: undefined,
|
||||
inputSchema: undefined,
|
||||
inputsType: undefined,
|
||||
schemaType: undefined,
|
||||
_iconsPromise: undefined
|
||||
}),
|
||||
getters: {
|
||||
flowSchema(state): {
|
||||
definitions: any,
|
||||
$ref: string,
|
||||
} {
|
||||
return state.schemaType?.flow ?? InitialFlowSchema;
|
||||
},
|
||||
flowDefinitions(): Record<string, any> | undefined {
|
||||
return this.flowSchema.definitions;
|
||||
},
|
||||
flowRootSchema(): Record<string, any> | undefined {
|
||||
return this.flowDefinitions?.[removeRefPrefix(this.flowSchema.$ref)];
|
||||
},
|
||||
flowRootProperties(): Record<string, any> | undefined {
|
||||
return this.flowRootSchema?.properties;
|
||||
},
|
||||
allTypes(): string[] {
|
||||
return this.plugins?.flatMap(plugin => Object.entries(plugin))
|
||||
?.filter(([key, value]) => isEntryAPluginElementPredicate(key, value))
|
||||
?.flatMap(([, value]: [string, PluginComponent[]]) => value.map(({cls}) => cls!)) ?? [];
|
||||
},
|
||||
deprecatedTypes(): string[] {
|
||||
return this.plugins?.flatMap(plugin => Object.entries(plugin))
|
||||
?.filter(([key, value]) => isEntryAPluginElementPredicate(key, value))
|
||||
?.flatMap(([, value]: [string, PluginComponent[]]) => value.filter(({deprecated}) => deprecated === true).map(({cls}) => cls!)) ?? [];
|
||||
export const usePluginsStore = defineStore("plugins", () => {
|
||||
const plugin = ref<PluginComponent>();
|
||||
const versions = ref<string[]>();
|
||||
const pluginAllProps = ref<any>();
|
||||
const plugins = ref<Plugin[]>();
|
||||
const apiIcons = ref<Record<string, string>>({});
|
||||
const pluginsIcons = ref<Record<string, string>>({});
|
||||
const icons = computed(() => {
|
||||
return {
|
||||
...pluginsIcons.value,
|
||||
...apiIcons.value
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
resolveRef(obj: JsonSchemaDef): JsonSchemaDef {
|
||||
if (obj?.$ref) {
|
||||
return this.flowDefinitions?.[removeRefPrefix(obj.$ref)];
|
||||
}
|
||||
if (obj?.allOf) {
|
||||
const def = obj.allOf.reduce((acc: any, item) => {
|
||||
if (item.$ref) {
|
||||
const ref = toRaw(this.flowDefinitions?.[removeRefPrefix(item.$ref)]);
|
||||
if (ref?.type === "object" && ref?.properties) {
|
||||
acc.properties = {
|
||||
...acc.properties,
|
||||
...ref.properties
|
||||
};
|
||||
}
|
||||
}
|
||||
if (item.type === "object" && item.properties) {
|
||||
})
|
||||
const pluginsDocumentation = ref<Record<string, PluginComponent>>({});
|
||||
const editorPlugin = ref<(PluginComponent & {cls: string})>();
|
||||
const inputSchema = ref<any>();
|
||||
const inputsType = ref<any>();
|
||||
const schemaType = ref<Record<string, any>>();
|
||||
const currentlyLoading = ref<{type?: string; version?: string}>();
|
||||
const forceIncludeProperties = ref<string[]>();
|
||||
const _iconsPromise = ref<Promise<Record<string, string>>>();
|
||||
|
||||
const axios = useAxios();
|
||||
|
||||
const flowSchema = computed(() => {
|
||||
return schemaType.value?.flow ?? InitialFlowSchema;
|
||||
});
|
||||
const flowDefinitions = computed(() => {
|
||||
return flowSchema.value.definitions;
|
||||
});
|
||||
const flowRootSchema = computed(() => {
|
||||
return flowDefinitions.value?.[removeRefPrefix(flowSchema.value.$ref)];
|
||||
});
|
||||
const flowRootProperties = computed(() => {
|
||||
return flowRootSchema.value?.properties;
|
||||
});
|
||||
const allTypes = computed(() => {
|
||||
return plugins.value?.flatMap(plugin => Object.entries(plugin))
|
||||
?.filter(([key, value]) => isEntryAPluginElementPredicate(key, value))
|
||||
?.flatMap(([, value]: [string, PluginComponent[]]) => value.map(({cls}) => cls!)) ?? [];
|
||||
});
|
||||
const deprecatedTypes = computed(() => {
|
||||
return plugins.value?.flatMap(plugin => Object.entries(plugin))
|
||||
?.filter(([key, value]) => isEntryAPluginElementPredicate(key, value))
|
||||
?.flatMap(([, value]: [string, PluginComponent[]]) => value.filter(({deprecated}) => deprecated === true).map(({cls}) => cls!)) ?? [];
|
||||
});
|
||||
|
||||
function resolveRef(obj: JsonSchemaDef): JsonSchemaDef {
|
||||
if (obj?.$ref) {
|
||||
return flowDefinitions.value?.[removeRefPrefix(obj.$ref)];
|
||||
}
|
||||
if (obj?.allOf) {
|
||||
const def = obj.allOf.reduce((acc: any, item) => {
|
||||
if (item.$ref) {
|
||||
const ref = toRaw(flowDefinitions.value?.[removeRefPrefix(item.$ref)]);
|
||||
if (ref?.type === "object" && ref?.properties) {
|
||||
acc.properties = {
|
||||
...acc.properties,
|
||||
...item.properties
|
||||
...ref.properties
|
||||
};
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
return def
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
|
||||
async filteredPlugins(excludedElements: string[]) {
|
||||
if (this.plugins === undefined) {
|
||||
this.plugins = await this.listWithSubgroup({includeDeprecated: false});
|
||||
}
|
||||
|
||||
return this.plugins.map(p => ({
|
||||
...p,
|
||||
...Object.fromEntries(excludedElements.map(e => [e, undefined]))
|
||||
})).filter(p => Object.entries(p)
|
||||
.filter(([key, value]) => isEntryAPluginElementPredicate(key, value))
|
||||
.some(([, value]: [string, PluginComponent[]]) => value.length !== 0))
|
||||
},
|
||||
|
||||
async list() {
|
||||
const response = await this.$http.get<Plugin[]>(`${apiUrlWithoutTenants()}/plugins`);
|
||||
this.plugins = response.data;
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async listWithSubgroup(options: Record<string, any>) {
|
||||
const response = await this.$http.get<Plugin[]>(`${apiUrlWithoutTenants()}/plugins/groups/subgroups`, {
|
||||
params: options
|
||||
});
|
||||
this.plugins = response.data;
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async load(options: LoadOptions) {
|
||||
if (options.cls === undefined) {
|
||||
throw new Error("missing required cls");
|
||||
}
|
||||
|
||||
const id = options.version ? `${options.cls}/${options.version}` : options.cls;
|
||||
const cachedPluginDoc = this.pluginsDocumentation[options.hash ? options.hash + id : id];
|
||||
if (!options.all && cachedPluginDoc) {
|
||||
this.plugin = cachedPluginDoc;
|
||||
return cachedPluginDoc;
|
||||
}
|
||||
|
||||
const baseUrl = options.version ?
|
||||
`${apiUrlWithoutTenants()}/plugins/${options.cls}/versions/${options.version}` :
|
||||
`${apiUrlWithoutTenants()}/plugins/${options.cls}`;
|
||||
|
||||
const url = options.hash ? `${baseUrl}?hash=${options.hash}` : baseUrl;
|
||||
|
||||
const response = await this.$http.get<PluginComponent>(url);
|
||||
|
||||
if (options.commit !== false) {
|
||||
if (options.all === true) {
|
||||
this.pluginAllProps = response.data;
|
||||
} else {
|
||||
this.plugin = response.data;
|
||||
}
|
||||
}
|
||||
|
||||
if (!options.all) {
|
||||
this.pluginsDocumentation = {
|
||||
...this.pluginsDocumentation,
|
||||
[options.hash ? options.hash+id : id]: response.data
|
||||
};
|
||||
}
|
||||
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async loadVersions(options: {cls: string; commit?: boolean}): Promise<{type: string, versions: string[]}> {
|
||||
const response = await this.$http.get(
|
||||
`${apiUrlWithoutTenants()}/plugins/${options.cls}/versions`
|
||||
);
|
||||
if (options.commit !== false) {
|
||||
this.versions = response.data.versions;
|
||||
}
|
||||
|
||||
return response.data;
|
||||
},
|
||||
|
||||
fetchIcons() {
|
||||
if (this.icons) {
|
||||
return Promise.resolve(this.icons);
|
||||
}
|
||||
|
||||
if (this._iconsPromise) {
|
||||
return this._iconsPromise;
|
||||
}
|
||||
|
||||
const apiStore = useApiStore();
|
||||
|
||||
const apiPromise = apiStore.pluginIcons().then(response => {
|
||||
// to avoid unnecessary dom updates and calculations in the reactivity rendering of Vue,
|
||||
// we do all our updates to a temporary object, then commit the changes all at once
|
||||
const tempIcons = toRaw(this.icons) ?? {};
|
||||
for (const [key, plugin] of Object.entries(response.data)) {
|
||||
if (tempIcons && tempIcons[key] === undefined) {
|
||||
tempIcons[key] = plugin as string;
|
||||
}
|
||||
}
|
||||
this.icons = tempIcons;
|
||||
});
|
||||
|
||||
const iconsPromise =
|
||||
this.$http.get(`${apiUrlWithoutTenants()}/plugins/icons`, {}).then(response => {
|
||||
const icons = response.data ?? {};
|
||||
this.icons = this.icons ? {
|
||||
...icons,
|
||||
...this.icons
|
||||
} : icons;
|
||||
});
|
||||
|
||||
this._iconsPromise = Promise.all([apiPromise, iconsPromise]).then(() => {
|
||||
return this.icons ?? {};
|
||||
})
|
||||
|
||||
return this._iconsPromise;
|
||||
},
|
||||
|
||||
groupIcons() {
|
||||
return this.$http.get(`${apiUrlWithoutTenants()}/plugins/icons/groups`, {})
|
||||
.then(response => {
|
||||
return response.data;
|
||||
});
|
||||
},
|
||||
|
||||
loadInputsType() {
|
||||
return this.$http.get(`${apiUrlWithoutTenants()}/plugins/inputs`, {}).then(response => {
|
||||
this.inputsType = response.data;
|
||||
|
||||
return response.data;
|
||||
});
|
||||
},
|
||||
loadInputSchema(options: {type: string}) {
|
||||
return this.$http.get(`${apiUrlWithoutTenants()}/plugins/inputs/${options.type}`, {}).then(response => {
|
||||
this.inputSchema = response.data;
|
||||
|
||||
return response.data;
|
||||
});
|
||||
},
|
||||
|
||||
loadSchemaType(options: {type: string} = {type: "flow"}) {
|
||||
return this.$http.get(`${apiUrlWithoutTenants()}/plugins/schemas/${options.type}`, {}).then(response => {
|
||||
this.schemaType = this.schemaType || {};
|
||||
this.schemaType[options.type] = response.data;
|
||||
return response.data;
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
async updateDocumentation(pluginElement?: ({type: string, version?: string, forceRefresh?: boolean} & Record<string, any>) | undefined) {
|
||||
if (!pluginElement?.type || !this.allTypes.includes(pluginElement.type)) {
|
||||
this.editorPlugin = undefined;
|
||||
this.currentlyLoading = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const {type, version, forceRefresh = false} = pluginElement;
|
||||
|
||||
// Avoid rerunning the same request twice in a row
|
||||
if (this.currentlyLoading?.type === type &&
|
||||
this.currentlyLoading?.version === version &&
|
||||
!forceRefresh) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!forceRefresh &&
|
||||
this.editorPlugin?.cls === type &&
|
||||
this.editorPlugin?.version === version) {
|
||||
return;
|
||||
}
|
||||
|
||||
let payload: LoadOptions = {cls: type, hash: pluginElement.hash};
|
||||
|
||||
if (version !== undefined) {
|
||||
// Check if the version is valid to avoid error
|
||||
// when loading plugin
|
||||
if (semver.valid(version) !== null ||
|
||||
"latest" === version.toString().toLowerCase() ||
|
||||
"oldest" === version.toString().toLowerCase()
|
||||
) {
|
||||
payload = {
|
||||
...payload,
|
||||
version
|
||||
if (item.type === "object" && item.properties) {
|
||||
acc.properties = {
|
||||
...acc.properties,
|
||||
...item.properties
|
||||
};
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
return def
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
this.currentlyLoading = {
|
||||
type,
|
||||
async function filteredPlugins(excludedElements: string[]) {
|
||||
if (plugins.value === undefined) {
|
||||
plugins.value = await listWithSubgroup({includeDeprecated: false});
|
||||
}
|
||||
|
||||
return plugins.value.map(p => ({
|
||||
...p,
|
||||
...Object.fromEntries(excludedElements.map(e => [e, undefined]))
|
||||
})).filter(p => Object.entries(p)
|
||||
.filter(([key, value]) => isEntryAPluginElementPredicate(key, value))
|
||||
.some(([, value]: [string, PluginComponent[]]) => value.length !== 0))
|
||||
}
|
||||
|
||||
async function list() {
|
||||
const response = await axios.get<Plugin[]>(`${apiUrlWithoutTenants()}/plugins`);
|
||||
plugins.value = response.data;
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function listWithSubgroup(options: Record<string, any>) {
|
||||
const response = await axios.get<Plugin[]>(`${apiUrlWithoutTenants()}/plugins/groups/subgroups`, {
|
||||
params: options
|
||||
});
|
||||
plugins.value = response.data;
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function load(options: LoadOptions) {
|
||||
if (options.cls === undefined) {
|
||||
throw new Error("missing required cls");
|
||||
}
|
||||
|
||||
const id = options.version ? `${options.cls}/${options.version}` : options.cls;
|
||||
const cachedPluginDoc = pluginsDocumentation.value[options.hash ? options.hash + id : id];
|
||||
if (!options.all && cachedPluginDoc) {
|
||||
plugin.value = cachedPluginDoc;
|
||||
return cachedPluginDoc;
|
||||
}
|
||||
|
||||
const baseUrl = options.version ?
|
||||
`${apiUrlWithoutTenants()}/plugins/${options.cls}/versions/${options.version}` :
|
||||
`${apiUrlWithoutTenants()}/plugins/${options.cls}`;
|
||||
|
||||
const url = options.hash ? `${baseUrl}?hash=${options.hash}` : baseUrl;
|
||||
|
||||
const response = await axios.get<PluginComponent>(url);
|
||||
|
||||
if (options.commit !== false) {
|
||||
if (options.all === true) {
|
||||
pluginAllProps.value = response.data;
|
||||
} else {
|
||||
plugin.value = response.data;
|
||||
}
|
||||
}
|
||||
|
||||
if (!options.all) {
|
||||
pluginsDocumentation.value = {
|
||||
...pluginsDocumentation.value,
|
||||
[options.hash ? options.hash+id : id]: response.data
|
||||
};
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function loadVersions(options: {cls: string; commit?: boolean}): Promise<{type: string, versions: string[]}> {
|
||||
const response = await axios.get(
|
||||
`${apiUrlWithoutTenants()}/plugins/${options.cls}/versions`
|
||||
);
|
||||
if (options.commit !== false) {
|
||||
versions.value = response.data.versions;
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
const iconsLoaded = ref(false)
|
||||
|
||||
function fetchIcons() {
|
||||
if (iconsLoaded.value) {
|
||||
return Promise.resolve(icons.value);
|
||||
}
|
||||
|
||||
if (_iconsPromise.value) {
|
||||
return _iconsPromise.value;
|
||||
}
|
||||
|
||||
const apiStore = useApiStore();
|
||||
|
||||
const apiPromise = apiStore.pluginIcons().then(response => {
|
||||
apiIcons.value = response.data ?? {};
|
||||
return response.data;
|
||||
});
|
||||
|
||||
const iconsPromise =
|
||||
axios.get(`${apiUrlWithoutTenants()}/plugins/icons`, {}).then(response => {
|
||||
pluginsIcons.value = response.data ?? {};
|
||||
return pluginsIcons.value;
|
||||
});
|
||||
|
||||
_iconsPromise.value = Promise.all([apiPromise, iconsPromise]).then(() => {
|
||||
iconsLoaded.value = true;
|
||||
return icons.value;
|
||||
})
|
||||
|
||||
return _iconsPromise.value;
|
||||
}
|
||||
|
||||
function groupIcons() {
|
||||
return axios.get(`${apiUrlWithoutTenants()}/plugins/icons/groups`, {})
|
||||
.then(response => {
|
||||
return response.data;
|
||||
});
|
||||
}
|
||||
|
||||
function loadInputsType() {
|
||||
return axios.get(`${apiUrlWithoutTenants()}/plugins/inputs`, {}).then(response => {
|
||||
inputsType.value = response.data;
|
||||
return response.data;
|
||||
});
|
||||
}
|
||||
|
||||
function loadInputSchema(options: {type: string}) {
|
||||
return axios.get(`${apiUrlWithoutTenants()}/plugins/inputs/${options.type}`, {}).then(response => {
|
||||
inputSchema.value = response.data;
|
||||
return response.data;
|
||||
});
|
||||
}
|
||||
|
||||
function loadSchemaType(options: {type: string} = {type: "flow"}) {
|
||||
return axios.get(`${apiUrlWithoutTenants()}/plugins/schemas/${options.type}`, {}).then(response => {
|
||||
schemaType.value = schemaType.value || {};
|
||||
schemaType.value[options.type] = response.data;
|
||||
return response.data;
|
||||
});
|
||||
}
|
||||
|
||||
async function updateDocumentation(pluginElement?: ({type: string, version?: string, forceRefresh?: boolean} & Record<string, any>) | undefined) {
|
||||
if (!pluginElement?.type || !allTypes.value.includes(pluginElement.type)) {
|
||||
editorPlugin.value = undefined;
|
||||
currentlyLoading.value = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const {type, version, forceRefresh = false} = pluginElement;
|
||||
|
||||
if (currentlyLoading.value?.type === type &&
|
||||
currentlyLoading.value?.version === version &&
|
||||
!forceRefresh) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!forceRefresh &&
|
||||
editorPlugin.value?.cls === type &&
|
||||
editorPlugin.value?.version === version) {
|
||||
return;
|
||||
}
|
||||
|
||||
let payload: LoadOptions = {cls: type, hash: pluginElement.hash};
|
||||
|
||||
if (version !== undefined) {
|
||||
if (semver.valid(version) !== null ||
|
||||
"latest" === version.toString().toLowerCase() ||
|
||||
"oldest" === version.toString().toLowerCase()
|
||||
) {
|
||||
payload = {
|
||||
...payload,
|
||||
version
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
currentlyLoading.value = {
|
||||
type,
|
||||
version,
|
||||
};
|
||||
|
||||
load(payload).then((pluginData) => {
|
||||
editorPlugin.value = {
|
||||
cls: type,
|
||||
version,
|
||||
...pluginData,
|
||||
};
|
||||
|
||||
this.load(payload).then((plugin) => {
|
||||
this.editorPlugin = {
|
||||
cls: type,
|
||||
version,
|
||||
...plugin,
|
||||
};
|
||||
trackPluginDocumentationView(type);
|
||||
|
||||
trackPluginDocumentationView(type);
|
||||
|
||||
this.forceIncludeProperties = Object.keys(pluginElement).filter(k => k !== "type" && k !== "version" && k !== "forceRefresh");
|
||||
});
|
||||
}
|
||||
},
|
||||
forceIncludeProperties.value = Object.keys(pluginElement).filter(k => k !== "type" && k !== "version" && k !== "forceRefresh");
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
// state
|
||||
plugin,
|
||||
versions,
|
||||
pluginAllProps,
|
||||
plugins,
|
||||
icons,
|
||||
pluginsDocumentation,
|
||||
editorPlugin,
|
||||
inputSchema,
|
||||
inputsType,
|
||||
schemaType,
|
||||
currentlyLoading,
|
||||
forceIncludeProperties,
|
||||
_iconsPromise,
|
||||
// getters
|
||||
flowSchema,
|
||||
flowDefinitions,
|
||||
flowRootSchema,
|
||||
flowRootProperties,
|
||||
allTypes,
|
||||
deprecatedTypes,
|
||||
// actions
|
||||
resolveRef,
|
||||
filteredPlugins,
|
||||
list,
|
||||
listWithSubgroup,
|
||||
load,
|
||||
loadVersions,
|
||||
fetchIcons,
|
||||
groupIcons,
|
||||
loadInputsType,
|
||||
loadInputSchema,
|
||||
loadSchemaType,
|
||||
updateDocumentation,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1010,6 +1010,8 @@ form.ks-horizontal {
|
||||
|
||||
.el-notification__content {
|
||||
text-align: left;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
&.large {
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
const maybeText = (allowSeparators: boolean) => "(?:\"[^\"]*\")|(?:'[^']*')|(?:(?:(?!\\}\\})" + (allowSeparators ? "[\\S\\n ]" : "[^~+,:\\n ]") + ")*)";
|
||||
const maybeAnotherPebbleExpression = "(?:[\\n ]*\\{\\{[\\n ]*" + maybeText(true) + "[\\n ]*\\}\\}[\\n ]*)*";
|
||||
const pebbleStart = "\\{\\{[\\n ]*";
|
||||
const fieldWithoutDotCapture = "([^\\(\\)\\}:~+.\\n '\"]*)(?![^\\(\\)\\}\\n ])";
|
||||
const dotAccessedFieldWithParentCapture = "([^\\(\\)\\}:~+\\n '\"]*)\\." + fieldWithoutDotCapture;
|
||||
const maybeTextFollowedBySeparator = "(?:" + maybeText(false) + "[~+ ]+)*";
|
||||
const paramKey = "[^\\n \\(\\)~+\\},:=]+";
|
||||
const paramValue = "(?:(?:(?:\"[^\"]*\"?)|(?:'[^']*'?)|[^,)]))*";
|
||||
const fieldWithoutDotCapture = "([^()}:~+.\\n '\"]*)(?![^()}\\n ])";
|
||||
const dotAccessedFieldWithParentCapture = "([^()}:~+\\n '\"]*)\\." + fieldWithoutDotCapture;
|
||||
const maybeTextFollowedBySeparator = "(?:" + maybeText(true) + "[\\n ]*(?:(?:[~+]+)|(?:\\}\\}[\\n ]*" + pebbleStart + "))[\\n ]*)*";
|
||||
const paramKey = "[^\\n ()~+},:=]+";
|
||||
const paramValue = "(?:(?:(?:\"[^\"]*\"?)|(?:'[^']*'?)|[^,)}]))*";
|
||||
const maybeParams = "(" +
|
||||
"(?:[\\n ]*" + paramKey + "[\\n ]*=[\\n ]*" + paramValue + "(?:[\\n ]*,[\\n ]*)?)+)?" +
|
||||
"([^\\n \\(\\)~+\\},:=]*)?";
|
||||
const functionWithMaybeParams = "([^\\n\\(\\)\\},:~ ]+)\\(" + maybeParams
|
||||
"([^\\n ()~+},:=]*)?";
|
||||
const functionWithMaybeParams = "([^\\n()},:~ ]+)\\(" + maybeParams
|
||||
|
||||
export default {
|
||||
beforeSeparator: (additionalSeparators: string[] = []) => `([^\\}:\\n ${additionalSeparators.join("")}]*)`,
|
||||
/** [fullMatch, dotForbiddenField] */
|
||||
capturePebbleVarRoot: `${maybeAnotherPebbleExpression}${pebbleStart}${maybeTextFollowedBySeparator}${fieldWithoutDotCapture}`,
|
||||
capturePebbleVarRoot: `${pebbleStart}${maybeTextFollowedBySeparator}${fieldWithoutDotCapture}`,
|
||||
/** [fullMatch, parentFieldMaybeIncludingDots, childField] */
|
||||
capturePebbleVarParent: `${maybeAnotherPebbleExpression}${pebbleStart}${maybeTextFollowedBySeparator}${dotAccessedFieldWithParentCapture}`,
|
||||
capturePebbleVarParent: `${pebbleStart}${maybeTextFollowedBySeparator}${dotAccessedFieldWithParentCapture}`,
|
||||
/** [fullMatch, functionName, textBetweenParenthesis, maybeTypedWordStart] */
|
||||
capturePebbleFunction: `${maybeAnotherPebbleExpression}${pebbleStart}${maybeTextFollowedBySeparator}${functionWithMaybeParams}`,
|
||||
capturePebbleFunction: `${pebbleStart}${maybeTextFollowedBySeparator}${functionWithMaybeParams}`,
|
||||
captureStringValue: "^[\"']([^\"']+)[\"']$"
|
||||
}
|
||||
|
||||
@@ -109,16 +109,7 @@ tasks:
|
||||
"\\")) | (.key + \\"->\\" + .value)"
|
||||
}} {{myFunc(my-param_1='value1', my-param_2="value2", myK`
|
||||
expect([...(regex.exec(shouldMatchLastFunction) ?? [])]).toEqual([
|
||||
`id: breaking-ui
|
||||
namespace: io.kestra.blx
|
||||
description: "Upload multiple files to s3 sequentially"
|
||||
|
||||
|
||||
tasks:
|
||||
- id: placeholder
|
||||
type: io.kestra.plugin.core.log.Log
|
||||
message: |-
|
||||
{{
|
||||
`{{
|
||||
"to_entries[] | select(.key | startswith(\\"" +
|
||||
inputs.selector +
|
||||
"\\")) | (.key + \\"->\\" + .value)"
|
||||
|
||||
@@ -5,35 +5,28 @@ import vue from "@vitejs/plugin-vue";
|
||||
import {commit} from "./plugins/commit"
|
||||
import {codecovVitePlugin} from "@codecov/vite-plugin";
|
||||
|
||||
export const manualChunks = {
|
||||
// bundle dashboard and all its dependencies in a single chunk
|
||||
"dashboard": [
|
||||
"src/components/dashboard/Dashboard.vue",
|
||||
"src/components/dashboard/components/Create.vue",
|
||||
"src/override/components/dashboard/Edit.vue"
|
||||
],
|
||||
// bundle flows and all its dependencies in a second chunk
|
||||
"flows": [
|
||||
"src/components/flows/Flows.vue",
|
||||
"src/components/flows/FlowCreate.vue",
|
||||
"src/components/flows/FlowsSearch.vue",
|
||||
"src/components/flows/FlowRoot.vue"
|
||||
],
|
||||
"markdownDeps": [
|
||||
"shiki/langs/yaml.mjs",
|
||||
"shiki/langs/python.mjs",
|
||||
"shiki/langs/javascript.mjs",
|
||||
"src/utils/markdownDeps.ts"
|
||||
]
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
base: "",
|
||||
build: {
|
||||
outDir: "../webserver/src/main/resources/ui",
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks
|
||||
advancedChunks: {
|
||||
groups: [
|
||||
{
|
||||
test: /src\/components\/dashboard/i,
|
||||
name: "dashboard",
|
||||
},
|
||||
{
|
||||
test: /src\/components\/flows/i,
|
||||
name: "flows",
|
||||
},
|
||||
{
|
||||
test: /(shiki\/langs)|(src\/utils\/markdownDeps)/,
|
||||
name: "markdownDeps",
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
# Keep the name static
|
||||
micronaut.openapi.filename=kestra
|
||||
micronaut.openapi.filename=kestra
|
||||
micronaut.openapi.constructor-arguments-as-required=false
|
||||
|
||||
@@ -44,12 +44,10 @@ import io.kestra.webserver.responses.BulkResponse;
|
||||
import io.kestra.webserver.responses.PagedResults;
|
||||
import io.kestra.webserver.services.ExecutionDependenciesStreamingService;
|
||||
import io.kestra.webserver.services.ExecutionStreamingService;
|
||||
import io.kestra.core.runners.SecureVariableRendererFactory;
|
||||
import io.kestra.webserver.utils.PageableUtils;
|
||||
import io.kestra.webserver.utils.RequestUtils;
|
||||
import io.kestra.webserver.utils.filepreview.FileRender;
|
||||
import io.kestra.webserver.utils.filepreview.FileRenderBuilder;
|
||||
import io.micronaut.context.ApplicationContext;
|
||||
import io.micronaut.context.annotation.Value;
|
||||
import io.micronaut.context.event.ApplicationEventPublisher;
|
||||
import io.micronaut.core.annotation.Introspected;
|
||||
@@ -680,6 +678,7 @@ public class ExecutionController {
|
||||
@Post(uri = "/{namespace}/{id}", consumes = MediaType.MULTIPART_FORM_DATA)
|
||||
@Operation(tags = {"Executions"}, summary = "Create a new execution for a flow")
|
||||
@ApiResponse(responseCode = "409", description = "if the flow is disabled")
|
||||
@ApiResponse(responseCode = "200", description = "On execution created", content = {@Content(schema = @Schema(implementation = ExecutionResponse.class))})
|
||||
@SingleResult
|
||||
public Publisher<ExecutionResponse> createExecution(
|
||||
@Parameter(description = "The flow namespace") @PathVariable String namespace,
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
package io.kestra.webserver.controllers.api;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import io.kestra.core.docs.JsonSchemaGenerator;
|
||||
import io.kestra.core.exceptions.FlowProcessingException;
|
||||
import io.kestra.core.exceptions.IllegalVariableEvaluationException;
|
||||
import io.kestra.core.exceptions.InternalException;
|
||||
import io.kestra.core.models.QueryFilter;
|
||||
import io.kestra.core.models.HasSource;
|
||||
import io.kestra.core.models.QueryFilter;
|
||||
import io.kestra.core.models.SearchResult;
|
||||
import io.kestra.core.models.flows.Flow;
|
||||
import io.kestra.core.models.flows.FlowId;
|
||||
import io.kestra.core.models.flows.FlowInterface;
|
||||
import io.kestra.core.models.flows.FlowScope;
|
||||
import io.kestra.core.models.flows.FlowWithException;
|
||||
import io.kestra.core.models.flows.FlowWithSource;
|
||||
import io.kestra.core.models.flows.GenericFlow;
|
||||
import io.kestra.core.models.flows.*;
|
||||
import io.kestra.core.models.hierarchies.FlowGraph;
|
||||
import io.kestra.core.models.tasks.Task;
|
||||
import io.kestra.core.models.topologies.FlowTopology;
|
||||
@@ -24,7 +17,6 @@ import io.kestra.core.models.validations.ManualConstraintViolation;
|
||||
import io.kestra.core.models.validations.ModelValidator;
|
||||
import io.kestra.core.models.validations.ValidateConstraintViolation;
|
||||
import io.kestra.core.repositories.FlowRepositoryInterface;
|
||||
import io.kestra.core.repositories.FlowTopologyRepositoryInterface;
|
||||
import io.kestra.core.serializers.JacksonMapper;
|
||||
import io.kestra.core.serializers.YamlParser;
|
||||
import io.kestra.core.services.FlowService;
|
||||
@@ -54,6 +46,7 @@ import io.micronaut.validation.Validated;
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.parameters.RequestBody;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
@@ -445,6 +438,7 @@ public class FlowController {
|
||||
@Put(uri = "{namespace}/{id}", consumes = MediaType.APPLICATION_YAML)
|
||||
@ExecuteOn(TaskExecutors.IO)
|
||||
@Operation(tags = {"Flows"}, summary = "Update a flow")
|
||||
@ApiResponse(responseCode = "200", description = "On success", content = {@Content(schema = @Schema(implementation = FlowWithSource.class))})
|
||||
public HttpResponse<FlowWithSource> updateFlow(
|
||||
@Parameter(description = "The flow namespace") @PathVariable String namespace,
|
||||
@Parameter(description = "The flow id") @PathVariable String id,
|
||||
|
||||
@@ -2,7 +2,6 @@ package io.kestra.webserver.controllers.api;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import io.kestra.core.models.QueryFilter;
|
||||
import io.kestra.core.models.collectors.ExecutionUsage;
|
||||
import io.kestra.core.models.collectors.FlowUsage;
|
||||
import io.kestra.core.plugins.PluginRegistry;
|
||||
@@ -15,6 +14,7 @@ import io.kestra.core.services.InstanceService;
|
||||
import io.kestra.core.utils.EditionProvider;
|
||||
import io.kestra.core.utils.NamespaceUtils;
|
||||
import io.kestra.core.utils.VersionProvider;
|
||||
import io.kestra.webserver.services.BasicAuthCredentials;
|
||||
import io.kestra.webserver.services.BasicAuthService;
|
||||
import io.micronaut.context.ApplicationContext;
|
||||
import io.micronaut.core.annotation.Nullable;
|
||||
@@ -28,7 +28,10 @@ import io.micronaut.scheduling.annotation.ExecuteOn;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.parameters.RequestBody;
|
||||
import jakarta.inject.Inject;
|
||||
import lombok.*;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Value;
|
||||
import lombok.experimental.SuperBuilder;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@@ -157,7 +160,7 @@ public class MiscController {
|
||||
public HttpResponse<Void> createBasicAuth(
|
||||
@RequestBody @Body BasicAuthCredentials basicAuthCredentials
|
||||
) {
|
||||
basicAuthService.save(basicAuthCredentials.getUid(), new BasicAuthService.BasicAuthConfiguration(basicAuthCredentials.getUsername(), basicAuthCredentials.getPassword()));
|
||||
basicAuthService.save(basicAuthCredentials);
|
||||
|
||||
return HttpResponse.noContent();
|
||||
}
|
||||
@@ -227,14 +230,6 @@ public class MiscController {
|
||||
Integer max;
|
||||
}
|
||||
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public static class BasicAuthCredentials {
|
||||
private String uid;
|
||||
private String username;
|
||||
private String password;
|
||||
}
|
||||
|
||||
@SuperBuilder(toBuilder = true)
|
||||
@Getter
|
||||
public static class ApiUsage {
|
||||
|
||||
@@ -2,7 +2,6 @@ package io.kestra.webserver.filter;
|
||||
|
||||
import io.kestra.core.utils.AuthUtils;
|
||||
import io.kestra.webserver.services.BasicAuthService;
|
||||
import io.kestra.webserver.services.BasicAuthService.SaltedBasicAuthConfiguration;
|
||||
import io.micronaut.context.annotation.Requires;
|
||||
import io.micronaut.http.HttpRequest;
|
||||
import io.micronaut.http.HttpResponse;
|
||||
@@ -45,13 +44,7 @@ public class AuthenticationFilter implements HttpServerFilter {
|
||||
|
||||
@Override
|
||||
public Publisher<MutableHttpResponse<?>> doFilter(HttpRequest<?> request, ServerFilterChain chain) {
|
||||
return Mono.fromCallable(() -> {
|
||||
SaltedBasicAuthConfiguration configuration = basicAuthService.configuration();
|
||||
if (configuration == null) {
|
||||
configuration = new SaltedBasicAuthConfiguration();
|
||||
}
|
||||
return configuration;
|
||||
})
|
||||
return Mono.fromCallable(() -> basicAuthService.configuration())
|
||||
.subscribeOn(Schedulers.boundedElastic())
|
||||
.flux()
|
||||
.flatMap(basicAuthConfiguration -> {
|
||||
@@ -61,7 +54,7 @@ public class AuthenticationFilter implements HttpServerFilter {
|
||||
&& !basicAuthService.isBasicAuthInitialized()
|
||||
);
|
||||
|
||||
boolean isOpenUrl = Optional.ofNullable(basicAuthConfiguration.getOpenUrls())
|
||||
boolean isOpenUrl = Optional.ofNullable(basicAuthConfiguration.openUrls())
|
||||
.map(Collection::stream)
|
||||
.map(stream -> stream.anyMatch(s -> request.getPath().startsWith(s)))
|
||||
.orElse(false);
|
||||
@@ -74,10 +67,10 @@ public class AuthenticationFilter implements HttpServerFilter {
|
||||
.or(() -> fromAuthorizationHeader(request))
|
||||
.map(BasicAuth::from);
|
||||
|
||||
if (basicAuth.isEmpty() ||
|
||||
!basicAuth.get().username().equals(basicAuthConfiguration.getUsername()) ||
|
||||
!AuthUtils.encodePassword(basicAuthConfiguration.getSalt(),
|
||||
basicAuth.get().password()).equals(basicAuthConfiguration.getPassword())
|
||||
if (basicAuth.isEmpty() || basicAuthConfiguration.credentials() == null ||
|
||||
!basicAuth.get().username().equals(basicAuthConfiguration.credentials().getUsername()) ||
|
||||
!AuthUtils.encodePassword(basicAuthConfiguration.credentials().getSalt(),
|
||||
basicAuth.get().password()).equals(basicAuthConfiguration.credentials().getPassword())
|
||||
) {
|
||||
Boolean isFromLoginPage = Optional.ofNullable(request.getHeaders().get("Referer")).map(referer -> referer.split("\\?")[0].endsWith("/login")).orElse(false);
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package io.kestra.webserver.services;
|
||||
|
||||
|
||||
public record BasicAuthCredentials(
|
||||
String uid,
|
||||
String username,
|
||||
String password
|
||||
) {
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public String getPassword() {
|
||||
return password;
|
||||
}
|
||||
|
||||
public String getUid() {
|
||||
return uid;
|
||||
}
|
||||
}
|
||||
@@ -13,20 +13,19 @@ import io.micronaut.context.annotation.ConfigurationProperties;
|
||||
import io.micronaut.context.annotation.Context;
|
||||
import io.micronaut.context.annotation.Requires;
|
||||
import io.micronaut.context.event.ApplicationEventPublisher;
|
||||
import jakarta.annotation.Nullable;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.inject.Singleton;
|
||||
import java.util.ArrayList;
|
||||
import lombok.*;
|
||||
|
||||
import jakarta.annotation.Nullable;
|
||||
import java.time.Instant;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.regex.Pattern;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@Context
|
||||
@Singleton
|
||||
@Requires(property = "kestra.server-type", pattern = "(WEBSERVER|STANDALONE)")
|
||||
@@ -51,14 +50,18 @@ public class BasicAuthService {
|
||||
|
||||
public BasicAuthService() {}
|
||||
|
||||
@VisibleForTesting
|
||||
@PostConstruct
|
||||
protected void init() {
|
||||
public void init() {
|
||||
if (basicAuthConfiguration == null ||
|
||||
(StringUtils.isBlank(basicAuthConfiguration.getUsername()) && StringUtils.isBlank(basicAuthConfiguration.getPassword()))){
|
||||
return;
|
||||
}
|
||||
try {
|
||||
save(basicAuthConfiguration);
|
||||
// save configured default credentials
|
||||
save(
|
||||
new BasicAuthCredentials(null, basicAuthConfiguration.getUsername(), basicAuthConfiguration.getPassword())
|
||||
);
|
||||
if (settingRepository.findByKey(BASIC_AUTH_ERROR_CONFIG).isPresent()) {
|
||||
settingRepository.delete(Setting.builder().key(BASIC_AUTH_ERROR_CONFIG).build());
|
||||
}
|
||||
@@ -70,31 +73,27 @@ public class BasicAuthService {
|
||||
}
|
||||
}
|
||||
|
||||
public void save(BasicAuthConfiguration basicAuthConfiguration) {
|
||||
save(null, basicAuthConfiguration);
|
||||
}
|
||||
|
||||
public void save(String uid, BasicAuthConfiguration basicAuthConfiguration) {
|
||||
public void save(BasicAuthCredentials basicAuthCredentials) {
|
||||
List<String> validationErrors = new ArrayList<>();
|
||||
|
||||
if (basicAuthConfiguration.getUsername() != null && !EMAIL_PATTERN.matcher(basicAuthConfiguration.getUsername()).matches()) {
|
||||
if (basicAuthCredentials.getUsername() != null && !EMAIL_PATTERN.matcher(basicAuthCredentials.getUsername()).matches()) {
|
||||
validationErrors.add("Invalid username for Basic Authentication. Please provide a valid email address.");
|
||||
}
|
||||
|
||||
if (basicAuthConfiguration.getUsername() == null) {
|
||||
if (basicAuthCredentials.getUsername() == null) {
|
||||
validationErrors.add("No user name set for Basic Authentication. Please provide a user name.");
|
||||
}
|
||||
|
||||
if (basicAuthConfiguration.getPassword() == null) {
|
||||
if (basicAuthCredentials.getPassword() == null) {
|
||||
validationErrors.add("No password set for Basic Authentication. Please provide a password.");
|
||||
}
|
||||
|
||||
if (basicAuthConfiguration.getPassword() != null && !PASSWORD_PATTERN.matcher(basicAuthConfiguration.getPassword()).matches()) {
|
||||
if (basicAuthCredentials.getPassword() != null && !PASSWORD_PATTERN.matcher(basicAuthCredentials.getPassword()).matches()) {
|
||||
validationErrors.add("Invalid password for Basic Authentication. The password must have 8 chars, one upper, one lower and one number");
|
||||
}
|
||||
|
||||
if ((basicAuthConfiguration.getUsername() != null && basicAuthConfiguration.getUsername().length() > EMAIL_PASSWORD_MAX_LEN) ||
|
||||
(basicAuthConfiguration.getPassword() != null && basicAuthConfiguration.getPassword().length() > EMAIL_PASSWORD_MAX_LEN)) {
|
||||
if ((basicAuthCredentials.getUsername() != null && basicAuthCredentials.getUsername().length() > EMAIL_PASSWORD_MAX_LEN) ||
|
||||
(basicAuthCredentials.getPassword() != null && basicAuthCredentials.getPassword().length() > EMAIL_PASSWORD_MAX_LEN)) {
|
||||
validationErrors.add("The length of email or password should not exceed 256 characters.");
|
||||
}
|
||||
|
||||
@@ -102,15 +101,16 @@ public class BasicAuthService {
|
||||
throw new ValidationErrorException(validationErrors);
|
||||
}
|
||||
|
||||
SaltedBasicAuthConfiguration previousConfiguration = this.configuration();
|
||||
String salt = previousConfiguration == null
|
||||
var previousConfiguredCredentials = this.configuration().credentials();
|
||||
String salt = previousConfiguredCredentials == null
|
||||
? null
|
||||
: previousConfiguration.getSalt();
|
||||
SaltedBasicAuthConfiguration saltedNewConfiguration = new SaltedBasicAuthConfiguration(
|
||||
: previousConfiguredCredentials.getSalt();
|
||||
SaltedBasicAuthCredentials saltedNewConfiguration = SaltedBasicAuthCredentials.salt(
|
||||
salt,
|
||||
basicAuthConfiguration
|
||||
basicAuthCredentials.getUsername(),
|
||||
basicAuthCredentials.getPassword()
|
||||
);
|
||||
if (!saltedNewConfiguration.equals(previousConfiguration)) {
|
||||
if (!saltedNewConfiguration.equals(previousConfiguredCredentials)) {
|
||||
settingRepository.save(
|
||||
Setting.builder()
|
||||
.key(BASIC_AUTH_SETTINGS_KEY)
|
||||
@@ -120,11 +120,11 @@ public class BasicAuthService {
|
||||
|
||||
ossAuthEventPublisher.publishEventAsync(
|
||||
OssAuthEvent.builder()
|
||||
.uid(uid)
|
||||
.uid(basicAuthCredentials.getUid())
|
||||
.iid(instanceService.fetch())
|
||||
.date(Instant.now())
|
||||
.ossAuth(OssAuthEvent.OssAuth.builder()
|
||||
.email(basicAuthConfiguration.getUsername())
|
||||
.email(basicAuthCredentials.getUsername())
|
||||
.build()
|
||||
).build()
|
||||
);
|
||||
@@ -138,26 +138,27 @@ public class BasicAuthService {
|
||||
.orElse(List.of());
|
||||
}
|
||||
|
||||
public SaltedBasicAuthConfiguration configuration() {
|
||||
return settingRepository.findByKey(BASIC_AUTH_SETTINGS_KEY)
|
||||
public ConfiguredBasicAuth configuration() {
|
||||
var credentials = settingRepository.findByKey(BASIC_AUTH_SETTINGS_KEY)
|
||||
.map(Setting::getValue)
|
||||
.map(value -> JacksonMapper.ofJson(false).convertValue(value, SaltedBasicAuthConfiguration.class))
|
||||
.map(value -> JacksonMapper.ofJson(false).convertValue(value, SaltedBasicAuthCredentials.class))
|
||||
.orElse(null);
|
||||
return new ConfiguredBasicAuth(this.basicAuthConfiguration != null ? this.basicAuthConfiguration.realm : null, this.basicAuthConfiguration != null ? this.basicAuthConfiguration.openUrls : null, credentials);
|
||||
}
|
||||
|
||||
public boolean isBasicAuthInitialized(){
|
||||
var configuration = configuration();
|
||||
|
||||
SaltedBasicAuthConfiguration configuration = configuration();
|
||||
|
||||
return configuration != null &&
|
||||
!StringUtils.isBlank(configuration.getUsername()) &&
|
||||
!StringUtils.isBlank(configuration.getPassword());
|
||||
return configuration.credentials() != null &&
|
||||
!StringUtils.isBlank(configuration.credentials().getUsername()) &&
|
||||
!StringUtils.isBlank(configuration.credentials().getPassword());
|
||||
}
|
||||
|
||||
@Getter
|
||||
@NoArgsConstructor
|
||||
@EqualsAndHashCode
|
||||
@ConfigurationProperties("kestra.server.basic-auth")
|
||||
@VisibleForTesting
|
||||
public static class BasicAuthConfiguration {
|
||||
private String username;
|
||||
protected String password;
|
||||
@@ -177,50 +178,40 @@ public class BasicAuthService {
|
||||
this.realm = Optional.ofNullable(realm).orElse("Kestra");
|
||||
this.openUrls = Optional.ofNullable(openUrls).orElse(Collections.emptyList());
|
||||
}
|
||||
}
|
||||
|
||||
public BasicAuthConfiguration(
|
||||
String username,
|
||||
String password
|
||||
) {
|
||||
this(username, password, null, null);
|
||||
}
|
||||
|
||||
public BasicAuthConfiguration(BasicAuthConfiguration basicAuthConfiguration) {
|
||||
if (basicAuthConfiguration != null) {
|
||||
this.username = basicAuthConfiguration.getUsername();
|
||||
this.password = basicAuthConfiguration.getPassword();
|
||||
this.realm = basicAuthConfiguration.getRealm();
|
||||
this.openUrls = basicAuthConfiguration.getOpenUrls();
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
BasicAuthConfiguration withUsernamePassword(String username, String password) {
|
||||
return new BasicAuthConfiguration(
|
||||
username,
|
||||
password,
|
||||
this.realm,
|
||||
this.openUrls
|
||||
);
|
||||
}
|
||||
public record ConfiguredBasicAuth(
|
||||
String realm,
|
||||
List<String> openUrls,
|
||||
SaltedBasicAuthCredentials credentials
|
||||
) {
|
||||
}
|
||||
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public static class SaltedBasicAuthConfiguration extends BasicAuthConfiguration {
|
||||
@EqualsAndHashCode
|
||||
public static class SaltedBasicAuthCredentials {
|
||||
private String salt;
|
||||
private String username;
|
||||
protected String password;
|
||||
|
||||
public SaltedBasicAuthConfiguration(String salt, BasicAuthConfiguration basicAuthConfiguration) {
|
||||
super(basicAuthConfiguration);
|
||||
this.salt = salt == null
|
||||
? AuthUtils.generateSalt()
|
||||
: salt;
|
||||
this.password = AuthUtils.encodePassword(this.salt, basicAuthConfiguration.getPassword());
|
||||
public SaltedBasicAuthCredentials(String salt, String username, String password) {
|
||||
Objects.requireNonNull(salt);
|
||||
Objects.requireNonNull(username);
|
||||
Objects.requireNonNull(password);
|
||||
this.salt = salt;
|
||||
this.username = username;
|
||||
this.password = password;
|
||||
}
|
||||
|
||||
public SaltedBasicAuthConfiguration() {
|
||||
super();
|
||||
public static SaltedBasicAuthCredentials salt(String salt, String username, String password) {
|
||||
var salt1 = salt == null
|
||||
? AuthUtils.generateSalt()
|
||||
: salt;
|
||||
return new SaltedBasicAuthCredentials(
|
||||
salt1,
|
||||
username,
|
||||
AuthUtils.encodePassword(salt1, password)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,34 @@
|
||||
package io.kestra.webserver.controllers.api;
|
||||
|
||||
import static io.kestra.webserver.services.BasicAuthService.BASIC_AUTH_ERROR_CONFIG;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
import io.kestra.core.junit.annotations.KestraTest;
|
||||
import io.kestra.core.models.Setting;
|
||||
import io.kestra.core.models.flows.FlowWithSource;
|
||||
import io.kestra.core.repositories.FlowRepositoryInterface;
|
||||
import io.kestra.core.repositories.SettingRepositoryInterface;
|
||||
import io.kestra.webserver.controllers.api.MiscController.BasicAuthCredentials;
|
||||
import io.kestra.core.utils.IdUtils;
|
||||
import io.kestra.webserver.services.BasicAuthCredentials;
|
||||
import io.kestra.webserver.services.BasicAuthService;
|
||||
import io.kestra.webserver.services.BasicAuthService.BasicAuthConfiguration;
|
||||
import io.micronaut.context.annotation.Property;
|
||||
import io.micronaut.core.type.Argument;
|
||||
import io.micronaut.http.HttpRequest;
|
||||
import io.micronaut.http.HttpStatus;
|
||||
import io.micronaut.http.MediaType;
|
||||
import io.micronaut.http.client.annotation.Client;
|
||||
import io.micronaut.http.client.exceptions.HttpClientResponseException;
|
||||
import io.micronaut.http.hateoas.JsonError;
|
||||
import io.micronaut.reactor.http.client.ReactorHttpClient;
|
||||
import jakarta.inject.Inject;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static io.kestra.webserver.services.BasicAuthService.BASIC_AUTH_ERROR_CONFIG;
|
||||
import static io.micronaut.http.HttpRequest.GET;
|
||||
import static io.micronaut.http.HttpRequest.POST;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
@KestraTest
|
||||
@Property(name = "kestra.system-flows.namespace", value = "some.system.ns")
|
||||
class MiscControllerTest {
|
||||
@@ -39,6 +45,9 @@ class MiscControllerTest {
|
||||
@Inject
|
||||
private SettingRepositoryInterface settingRepository;
|
||||
|
||||
@Inject
|
||||
private FlowRepositoryInterface flowRepository;
|
||||
|
||||
@Test
|
||||
void ping() {
|
||||
var response = client.toBlocking().retrieve("/ping", String.class);
|
||||
@@ -59,7 +68,7 @@ class MiscControllerTest {
|
||||
|
||||
@Test
|
||||
void getEmptyValidationErrors() {
|
||||
List<String> response = client.toBlocking().retrieve(HttpRequest.GET("/api/v1/basicAuthValidationErrors"), Argument.LIST_OF_STRING);
|
||||
List<String> response = client.toBlocking().retrieve(GET("/api/v1/basicAuthValidationErrors"), Argument.LIST_OF_STRING);
|
||||
|
||||
assertThat(response).isNotNull();
|
||||
}
|
||||
@@ -68,7 +77,7 @@ class MiscControllerTest {
|
||||
void getValidationErrors() {
|
||||
settingRepository.save(Setting.builder().key(BASIC_AUTH_ERROR_CONFIG).value(List.of("error1", "error2")).build());
|
||||
try {
|
||||
List<String> response = client.toBlocking().retrieve(HttpRequest.GET("/api/v1/basicAuthValidationErrors"), Argument.LIST_OF_STRING);
|
||||
List<String> response = client.toBlocking().retrieve(GET("/api/v1/basicAuthValidationErrors"), Argument.LIST_OF_STRING);
|
||||
|
||||
assertThat(response).containsExactly("error1", "error2");
|
||||
} finally {
|
||||
@@ -92,32 +101,86 @@ class MiscControllerTest {
|
||||
|
||||
@Test
|
||||
void basicAuth() {
|
||||
Assertions.assertDoesNotThrow(() -> client.toBlocking().retrieve("/api/v1/configs", MiscController.Configuration.class));
|
||||
assertThatCode(() -> client.toBlocking().retrieve("/api/v1/configs", MiscController.Configuration.class)).doesNotThrowAnyException();
|
||||
|
||||
String uid = "someUid";
|
||||
String username = "my.email@kestra.io";
|
||||
String password = "myPassword1";
|
||||
client.toBlocking().exchange(HttpRequest.POST("/api/v1/main/basicAuth", new MiscController.BasicAuthCredentials(uid, username, password)));
|
||||
client.toBlocking().exchange(HttpRequest.POST("/api/v1/main/basicAuth", new BasicAuthCredentials(uid, username, password)));
|
||||
try {
|
||||
assertThrows(
|
||||
HttpClientResponseException.class,
|
||||
assertThatThrownBy(
|
||||
() -> client.toBlocking().retrieve("/api/v1/main/dashboards", MiscController.Configuration.class)
|
||||
);
|
||||
assertThrows(
|
||||
HttpClientResponseException.class,
|
||||
)
|
||||
.as("expect 401 for unauthenticated GET /api/v1/main/dashboards")
|
||||
.isInstanceOfSatisfying(HttpClientResponseException.class, ex ->
|
||||
assertThat((CharSequence) ex.getStatus()).isEqualTo(HttpStatus.UNAUTHORIZED)
|
||||
);
|
||||
|
||||
assertThatThrownBy(
|
||||
() -> client.toBlocking().retrieve(
|
||||
HttpRequest.GET("/api/v1/main/dashboards")
|
||||
GET("/api/v1/main/dashboards")
|
||||
.basicAuth("bad.user@kestra.io", "badPassword"),
|
||||
MiscController.Configuration.class
|
||||
)
|
||||
);
|
||||
Assertions.assertDoesNotThrow(() -> client.toBlocking().retrieve(
|
||||
HttpRequest.GET("/api/v1/main/dashboards")
|
||||
).as("expect 401 for GET /api/v1/main/dashboards with wrong password")
|
||||
.isInstanceOfSatisfying(HttpClientResponseException.class, ex ->
|
||||
assertThat((CharSequence) ex.getStatus()).isEqualTo(HttpStatus.UNAUTHORIZED)
|
||||
);
|
||||
|
||||
assertThatCode(() -> client.toBlocking().retrieve(
|
||||
GET("/api/v1/main/dashboards")
|
||||
.basicAuth(username, password),
|
||||
MiscController.Configuration.class)
|
||||
);
|
||||
).as("expect success GET /api/v1/main/dashboards with good password")
|
||||
.doesNotThrowAnyException();
|
||||
} finally {
|
||||
basicAuthService.save(basicAuthConfiguration);
|
||||
basicAuthService.save(new BasicAuthCredentials(null, basicAuthConfiguration.getUsername(), basicAuthConfiguration.getPassword()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void canTriggerAWebhookWithoutBasicAuth() {
|
||||
String uid = "someUid2";
|
||||
String username = "my.email2@kestra.io";
|
||||
String password = "myPassword2";
|
||||
client.toBlocking().exchange(HttpRequest.POST("/api/v1/main/basicAuth", new BasicAuthCredentials(uid, username, password)));
|
||||
|
||||
try {
|
||||
var namespace = "namespace1";
|
||||
var flowId = "flowWithWebhook" + IdUtils.create();
|
||||
var key = "1KERKzRQZSMtLdMdNI7Nkr";
|
||||
var flowWithWebhook = """
|
||||
id: %s
|
||||
namespace: %s
|
||||
tasks:
|
||||
- id: out
|
||||
type: io.kestra.plugin.core.debug.Return
|
||||
format: "output1"
|
||||
triggers:
|
||||
- id: webhook_trigger
|
||||
type: io.kestra.plugin.core.trigger.Webhook
|
||||
key: %s
|
||||
disabled: false
|
||||
deleted: false
|
||||
""".formatted(flowId, namespace, key);
|
||||
|
||||
assertThatCode(() -> client.toBlocking().retrieve(
|
||||
POST("/api/v1/main/flows", flowWithWebhook)
|
||||
.contentType(MediaType.APPLICATION_YAML)
|
||||
.basicAuth(username, password),
|
||||
FlowWithSource.class)
|
||||
).as("can create a Flow with webhook when authenticated")
|
||||
.doesNotThrowAnyException();
|
||||
|
||||
assertThatCode(() -> client.toBlocking().retrieve(POST("/api/v1/main/executions/webhook/{namespace}/{flowId}/{key}"
|
||||
.replace("{namespace}", namespace)
|
||||
.replace("{flowId}", flowId)
|
||||
.replace("{key}", key)
|
||||
, flowWithWebhook), FlowWithSource.class)
|
||||
).as("can trigger this Flow webhook when not authenticated")
|
||||
.doesNotThrowAnyException();
|
||||
} finally {
|
||||
basicAuthService.save(new BasicAuthCredentials(null, basicAuthConfiguration.getUsername(), basicAuthConfiguration.getPassword()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import io.kestra.core.junit.annotations.KestraTest;
|
||||
import io.kestra.core.models.Setting;
|
||||
import io.kestra.core.repositories.SettingRepositoryInterface;
|
||||
import io.kestra.core.utils.IdUtils;
|
||||
import io.kestra.webserver.controllers.api.MiscController;
|
||||
import io.kestra.webserver.services.BasicAuthCredentials;
|
||||
import io.kestra.webserver.services.BasicAuthService;
|
||||
import io.micronaut.http.HttpRequest;
|
||||
import io.micronaut.http.HttpResponse;
|
||||
@@ -55,7 +55,7 @@ class AuthenticationFilterTest {
|
||||
assertThat(httpClientResponseException.getStatus().getCode()).isEqualTo(HttpStatus.UNAUTHORIZED.getCode());
|
||||
|
||||
httpClientResponseException = assertThrows(HttpClientResponseException.class, () -> client.toBlocking()
|
||||
.exchange(HttpRequest.POST("/api/v1/basicAuth", new MiscController.BasicAuthCredentials(
|
||||
.exchange(HttpRequest.POST("/api/v1/basicAuth", new BasicAuthCredentials(
|
||||
IdUtils.create(),
|
||||
"anonymous",
|
||||
"hacker"
|
||||
@@ -63,7 +63,7 @@ class AuthenticationFilterTest {
|
||||
assertThat(httpClientResponseException.getStatus().getCode()).isEqualTo(HttpStatus.UNAUTHORIZED.getCode());
|
||||
|
||||
HttpResponse<?> response = client.toBlocking()
|
||||
.exchange(HttpRequest.POST("/api/v1/basicAuth", new MiscController.BasicAuthCredentials(
|
||||
.exchange(HttpRequest.POST("/api/v1/basicAuth", new BasicAuthCredentials(
|
||||
IdUtils.create(),
|
||||
"anonymous@hacker",
|
||||
"hackerPassword1"
|
||||
@@ -88,7 +88,7 @@ class AuthenticationFilterTest {
|
||||
assertThat(response.getStatus().getCode()).isEqualTo(HttpStatus.OK.getCode());
|
||||
|
||||
response = client.toBlocking()
|
||||
.exchange(HttpRequest.POST("/api/v1/basicAuth", new MiscController.BasicAuthCredentials(
|
||||
.exchange(HttpRequest.POST("/api/v1/basicAuth", new BasicAuthCredentials(
|
||||
IdUtils.create(),
|
||||
basicAuthConfiguration.getUsername(),
|
||||
basicAuthConfiguration.getPassword()
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package io.kestra.webserver.filter;
|
||||
|
||||
import io.kestra.webserver.services.BasicAuthService;
|
||||
import io.kestra.webserver.services.BasicAuthService.BasicAuthConfiguration;
|
||||
import io.micronaut.context.annotation.Requires;
|
||||
import io.micronaut.context.env.Environment;
|
||||
import io.micronaut.http.HttpHeaders;
|
||||
@@ -12,19 +11,20 @@ import io.micronaut.http.filter.ClientFilterChain;
|
||||
import io.micronaut.http.filter.HttpClientFilter;
|
||||
import io.micronaut.http.filter.ServerFilterPhase;
|
||||
import jakarta.inject.Inject;
|
||||
import java.util.Base64;
|
||||
import org.reactivestreams.Publisher;
|
||||
|
||||
import java.util.Base64;
|
||||
|
||||
@Filter("/**")
|
||||
@Requires(env = Environment.TEST)
|
||||
public class TestAuthFilter implements HttpClientFilter {
|
||||
public static boolean ENABLED = true;
|
||||
|
||||
@Inject
|
||||
private BasicAuthConfiguration basicAuthConfiguration;
|
||||
private BasicAuthService basicAuthService;
|
||||
|
||||
@Inject
|
||||
private BasicAuthService basicAuthService;
|
||||
private BasicAuthService.BasicAuthConfiguration basicAuthConfiguration;
|
||||
|
||||
@Override
|
||||
public Publisher<? extends HttpResponse<?>> doFilter(MutableHttpRequest<?> request,
|
||||
@@ -32,8 +32,8 @@ public class TestAuthFilter implements HttpClientFilter {
|
||||
if (ENABLED) {
|
||||
//Basic auth may be removed from the database by jdbcTestUtils.drop(); / jdbcTestUtils.migrate();
|
||||
//We need it back to be able to run the tests and avoid NPE while checking the basic authorization
|
||||
if (basicAuthService.configuration() == null) {
|
||||
basicAuthService.save(basicAuthConfiguration);
|
||||
if (basicAuthService.configuration().credentials() == null) {
|
||||
basicAuthService.init();
|
||||
}
|
||||
//Add basic authorization header if no header are present in the query
|
||||
if (request.getHeaders().getAuthorization().isEmpty()) {
|
||||
|
||||
@@ -1,21 +1,5 @@
|
||||
package io.kestra.webserver.services;
|
||||
|
||||
import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
|
||||
import static com.github.tomakehurst.wiremock.client.WireMock.and;
|
||||
import static com.github.tomakehurst.wiremock.client.WireMock.equalTo;
|
||||
import static com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath;
|
||||
import static com.github.tomakehurst.wiremock.client.WireMock.post;
|
||||
import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor;
|
||||
import static com.github.tomakehurst.wiremock.client.WireMock.stubFor;
|
||||
import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
|
||||
import static com.github.tomakehurst.wiremock.client.WireMock.verify;
|
||||
import static io.kestra.webserver.services.BasicAuthService.BASIC_AUTH_ERROR_CONFIG;
|
||||
import static io.kestra.webserver.services.BasicAuthService.BASIC_AUTH_SETTINGS_KEY;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import com.github.tomakehurst.wiremock.junit5.WireMockTest;
|
||||
import io.kestra.core.exceptions.ValidationErrorException;
|
||||
import io.kestra.core.junit.annotations.KestraTest;
|
||||
@@ -23,22 +7,34 @@ import io.kestra.core.models.Setting;
|
||||
import io.kestra.core.repositories.SettingRepositoryInterface;
|
||||
import io.kestra.core.serializers.JacksonMapper;
|
||||
import io.kestra.core.services.InstanceService;
|
||||
import io.kestra.core.utils.AuthUtils;
|
||||
import io.kestra.core.utils.Await;
|
||||
import io.kestra.webserver.controllers.api.MiscController;
|
||||
import io.kestra.webserver.models.events.Event;
|
||||
import io.kestra.webserver.services.BasicAuthService.BasicAuthConfiguration;
|
||||
import io.micronaut.context.env.Environment;
|
||||
import jakarta.inject.Inject;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.stream.Stream;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.Getter;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.Arguments;
|
||||
import org.junit.jupiter.params.provider.MethodSource;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static com.github.tomakehurst.wiremock.client.WireMock.*;
|
||||
import static io.kestra.webserver.services.BasicAuthService.BASIC_AUTH_ERROR_CONFIG;
|
||||
import static io.kestra.webserver.services.BasicAuthService.BASIC_AUTH_SETTINGS_KEY;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@WireMockTest(httpPort = 28181)
|
||||
@KestraTest(environments = Environment.TEST)
|
||||
class BasicAuthServiceTest {
|
||||
@@ -69,26 +65,145 @@ class BasicAuthServiceTest {
|
||||
|
||||
@Test
|
||||
void isBasicAuthInitialized(){
|
||||
settingRepositoryInterface.save(Setting.builder()
|
||||
.key(BASIC_AUTH_SETTINGS_KEY)
|
||||
.value(new BasicAuthConfiguration("username", "password", null, null))
|
||||
.build());
|
||||
deleteSetting();
|
||||
basicAuthService.basicAuthConfiguration = new ConfigWrapper(
|
||||
new BasicAuthConfiguration(USER_NAME, PASSWORD, null, null)
|
||||
).config;
|
||||
basicAuthService.init();
|
||||
assertTrue(basicAuthService.isBasicAuthInitialized());
|
||||
|
||||
deleteSetting();
|
||||
assertFalse(basicAuthService.isBasicAuthInitialized());
|
||||
|
||||
settingRepositoryInterface.save(Setting.builder()
|
||||
.key(BASIC_AUTH_SETTINGS_KEY)
|
||||
.value(new BasicAuthConfiguration("username", null, null, null))
|
||||
.build());
|
||||
basicAuthService.basicAuthConfiguration = new ConfigWrapper(
|
||||
new BasicAuthConfiguration(USER_NAME, null, null, null)
|
||||
).config;
|
||||
basicAuthService.init();
|
||||
assertFalse(basicAuthService.isBasicAuthInitialized());
|
||||
|
||||
basicAuthService.basicAuthConfiguration = new ConfigWrapper(
|
||||
new BasicAuthConfiguration(null, null, null, null)
|
||||
).config;
|
||||
basicAuthService.init();
|
||||
assertFalse(basicAuthService.isBasicAuthInitialized());
|
||||
}
|
||||
|
||||
@Test
|
||||
void basicAuthAPICreation_shouldNot_discardYamlConfiguration(){
|
||||
// simulate starting Kestra for the first time
|
||||
deleteSetting();
|
||||
var defaultConfigWithoutBasicAuthCreds = new ConfigWrapper(
|
||||
new BasicAuthConfiguration(null, null, "Kestra2", List.of("/api/v1/main/executions/webhook/"))
|
||||
);
|
||||
basicAuthService.basicAuthConfiguration = defaultConfigWithoutBasicAuthCreds.config;
|
||||
basicAuthService.init();
|
||||
assertFalse(basicAuthService.isBasicAuthInitialized());
|
||||
|
||||
/**
|
||||
* simulate basic auth UI onboarding (createBasicAuth)
|
||||
* {@link io.kestra.webserver.controllers.api.MiscController#createBasicAuth(MiscController.BasicAuthCredentials)}
|
||||
*/
|
||||
basicAuthService.save(
|
||||
new BasicAuthCredentials(
|
||||
BASIC_AUTH_SETTINGS_KEY,
|
||||
"username1@example.com",
|
||||
"Password1"
|
||||
)
|
||||
);
|
||||
assertTrue(basicAuthService.isBasicAuthInitialized());
|
||||
|
||||
assertThat(basicAuthService.configuration())
|
||||
.as("Default configured realm and openUrls should not have been discarded after creating the basic auth user")
|
||||
.satisfies(configuration -> {
|
||||
assertThat(configuration.credentials().getUsername()).isEqualTo("username1@example.com");
|
||||
assertThat(configuration.credentials().getPassword()).isNotBlank();
|
||||
assertThat(configuration.realm()).isEqualTo("Kestra2");
|
||||
assertThat(configuration.openUrls()).isEqualTo(List.of("/api/v1/main/executions/webhook/"));
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void basicAuthAPICreation_shouldNot_discardYamlConfiguration_andBeBackwardCompatible_noDefaultCredentials() {
|
||||
// simulate starting Kestra for the first time
|
||||
deleteSetting();
|
||||
var defaultConfigWithoutBasicAuthCreds = new ConfigWrapper(
|
||||
new BasicAuthConfiguration(null, null, "Kestra2", List.of("/api/v1/main/executions/webhook/"))
|
||||
);
|
||||
basicAuthService.basicAuthConfiguration = defaultConfigWithoutBasicAuthCreds.config;
|
||||
settingRepositoryInterface.save(Setting.builder()
|
||||
.key(BASIC_AUTH_SETTINGS_KEY)
|
||||
.value(new BasicAuthConfiguration(null, null, null, null))
|
||||
.value(BasicAuthService.SaltedBasicAuthCredentials.salt(null, "username1@example.com", "Password1"))
|
||||
.build());
|
||||
assertFalse(basicAuthService.isBasicAuthInitialized());
|
||||
assertTrue(basicAuthService.isBasicAuthInitialized());
|
||||
basicAuthService.init();
|
||||
assertTrue(basicAuthService.isBasicAuthInitialized());
|
||||
|
||||
assertThat(basicAuthService.configuration())
|
||||
.as("Default configured realm and openUrls should not have been discarded after creating the basic auth user")
|
||||
.satisfies(configuration -> {
|
||||
assertThat(configuration.credentials().getUsername()).isEqualTo("username1@example.com");
|
||||
assertThat(configuration.credentials().getPassword()).isNotBlank();
|
||||
assertThat(configuration.realm()).isEqualTo("Kestra2");
|
||||
assertThat(configuration.openUrls()).isEqualTo(List.of("/api/v1/main/executions/webhook/"));
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void basicAuthAPICreation_shouldNot_discardYamlConfiguration_andBeBackwardCompatible_withDefaultCredentials() {
|
||||
// simulate starting Kestra for the first time
|
||||
deleteSetting();
|
||||
var defaultConfigWithoutBasicAuthCreds = new ConfigWrapper(
|
||||
new BasicAuthConfiguration("username1@example.com", "Password1", "Kestra2", List.of("/api/v1/main/executions/webhook/"))
|
||||
);
|
||||
basicAuthService.basicAuthConfiguration = defaultConfigWithoutBasicAuthCreds.config;
|
||||
basicAuthService.init();
|
||||
assertTrue(basicAuthService.isBasicAuthInitialized());
|
||||
|
||||
assertThat(basicAuthService.configuration())
|
||||
.as("Default configured realm and openUrls should not have been discarded after creating the basic auth user")
|
||||
.satisfies(configuration -> {
|
||||
assertThat(configuration.credentials().getUsername()).isEqualTo("username1@example.com");
|
||||
assertThat(configuration.credentials().getPassword()).isNotBlank();
|
||||
assertThat(configuration.realm()).isEqualTo("Kestra2");
|
||||
assertThat(configuration.openUrls()).isEqualTo(List.of("/api/v1/main/executions/webhook/"));
|
||||
});
|
||||
}
|
||||
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
@EqualsAndHashCode
|
||||
public static class LegacySaltedBasicAuthConfiguration {
|
||||
private String salt;
|
||||
private String username;
|
||||
protected String password;
|
||||
private String realm;
|
||||
private List<String> openUrls;
|
||||
}
|
||||
|
||||
@Test
|
||||
void basicAuthAPICreation_shouldStillWork_withLegacyPersistedConfiguration() {
|
||||
// given an old configuration containing legacy persisted fields 'realm' and 'openUrls'
|
||||
var salt = AuthUtils.generateSalt();
|
||||
settingRepositoryInterface.save(Setting.builder()
|
||||
.key(BASIC_AUTH_SETTINGS_KEY)
|
||||
.value(new LegacySaltedBasicAuthConfiguration(salt, "username1@example.com", AuthUtils.encodePassword(salt, "Password1"), "OldPersistedRealm", List.of("old-persisted-open-url")))
|
||||
.build());
|
||||
deleteSetting();
|
||||
|
||||
basicAuthService.basicAuthConfiguration = new ConfigWrapper(
|
||||
new BasicAuthConfiguration("username1@example.com", "Password1", "NewRealmFromConf", List.of("NewOpenurl-fromConf"))
|
||||
).config;
|
||||
basicAuthService.init();
|
||||
|
||||
// then
|
||||
assertThat(basicAuthService.configuration())
|
||||
.as("should be able to fetch deserialize legacy configuration that contained 'realm' and 'openUrls', we do not persist these fields anymore")
|
||||
.satisfies(configuration -> {
|
||||
assertThat(configuration.credentials().getUsername()).isEqualTo("username1@example.com");
|
||||
assertThat(configuration.credentials().getPassword()).isNotBlank();
|
||||
assertThat(configuration.realm()).isEqualTo("NewRealmFromConf");
|
||||
assertThat(configuration.openUrls()).isEqualTo(List.of("NewOpenurl-fromConf"));
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -106,29 +221,29 @@ class BasicAuthServiceTest {
|
||||
deleteSetting();
|
||||
basicAuthService.basicAuthConfiguration = configWrapper.config;
|
||||
basicAuthService.init();
|
||||
assertThat(basicAuthService.configuration()).isNull();
|
||||
assertThat(basicAuthService.configuration().credentials()).isNull();
|
||||
}
|
||||
|
||||
static Stream<ConfigWrapper> getConfigs() {
|
||||
return Stream.of(
|
||||
new ConfigWrapper(null),
|
||||
new ConfigWrapper(new BasicAuthConfiguration(null, null)),
|
||||
new ConfigWrapper(new BasicAuthConfiguration(null, PASSWORD)),
|
||||
new ConfigWrapper(new BasicAuthConfiguration("", PASSWORD)),
|
||||
new ConfigWrapper(new BasicAuthConfiguration(USER_NAME, null)),
|
||||
new ConfigWrapper(new BasicAuthConfiguration(USER_NAME, ""))
|
||||
new ConfigWrapper(new BasicAuthConfiguration(null, null, null, null)),
|
||||
new ConfigWrapper(new BasicAuthConfiguration(null, PASSWORD, null, null)),
|
||||
new ConfigWrapper(new BasicAuthConfiguration("", PASSWORD, null, null)),
|
||||
new ConfigWrapper(new BasicAuthConfiguration(USER_NAME, null, null, null)),
|
||||
new ConfigWrapper(new BasicAuthConfiguration(USER_NAME, "", null, null))
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveValidAuthConfig() throws TimeoutException {
|
||||
basicAuthService.save(new BasicAuthConfiguration(USER_NAME, PASSWORD));
|
||||
basicAuthService.save(new BasicAuthCredentials(null, USER_NAME, PASSWORD));
|
||||
awaitOssAuthEventApiCall(USER_NAME);
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_throw_exception_when_saving_invalid_config() {
|
||||
assertThrows(ValidationErrorException.class, () -> basicAuthService.save(new BasicAuthConfiguration(null, null)));
|
||||
assertThrows(ValidationErrorException.class, () -> basicAuthService.save(new BasicAuthCredentials(null, null, null)));
|
||||
}
|
||||
|
||||
@MethodSource("invalidConfigs")
|
||||
@@ -143,12 +258,12 @@ class BasicAuthServiceTest {
|
||||
|
||||
static Stream<Arguments> invalidConfigs() {
|
||||
return Stream.of(
|
||||
Arguments.of(new ConfigWrapper(new BasicAuthConfiguration("username", PASSWORD)), "Invalid username for Basic Authentication. Please provide a valid email address."),
|
||||
Arguments.of(new ConfigWrapper(new BasicAuthConfiguration(null, PASSWORD)), "No user name set for Basic Authentication. Please provide a user name."),
|
||||
Arguments.of(new ConfigWrapper(new BasicAuthConfiguration(USER_NAME + "a".repeat(244), PASSWORD)), "The length of email or password should not exceed 256 characters."),
|
||||
Arguments.of(new ConfigWrapper(new BasicAuthConfiguration(USER_NAME, "pas")), "Invalid password for Basic Authentication. The password must have 8 chars, one upper, one lower and one number"),
|
||||
Arguments.of(new ConfigWrapper(new BasicAuthConfiguration(USER_NAME, null)), "No password set for Basic Authentication. Please provide a password."),
|
||||
Arguments.of(new ConfigWrapper(new BasicAuthConfiguration(USER_NAME, PASSWORD + "a".repeat(246))), "The length of email or password should not exceed 256 characters.")
|
||||
Arguments.of(new ConfigWrapper(new BasicAuthConfiguration("username", PASSWORD, null, null)), "Invalid username for Basic Authentication. Please provide a valid email address."),
|
||||
Arguments.of(new ConfigWrapper(new BasicAuthConfiguration(null, PASSWORD, null, null)), "No user name set for Basic Authentication. Please provide a user name."),
|
||||
Arguments.of(new ConfigWrapper(new BasicAuthConfiguration(USER_NAME + "a".repeat(244), PASSWORD, null, null)), "The length of email or password should not exceed 256 characters."),
|
||||
Arguments.of(new ConfigWrapper(new BasicAuthConfiguration(USER_NAME, "pas", null, null)), "Invalid password for Basic Authentication. The password must have 8 chars, one upper, one lower and one number"),
|
||||
Arguments.of(new ConfigWrapper(new BasicAuthConfiguration(USER_NAME, null, null, null)), "No password set for Basic Authentication. Please provide a password."),
|
||||
Arguments.of(new ConfigWrapper(new BasicAuthConfiguration(USER_NAME, PASSWORD + "a".repeat(246), null, null)), "The length of email or password should not exceed 256 characters.")
|
||||
|
||||
);
|
||||
}
|
||||
@@ -164,10 +279,11 @@ class BasicAuthServiceTest {
|
||||
}
|
||||
|
||||
private void assertConfigurationMatchesApplicationYaml() {
|
||||
BasicAuthService.SaltedBasicAuthConfiguration actualConfiguration = basicAuthService.configuration();
|
||||
BasicAuthService.SaltedBasicAuthConfiguration applicationYamlConfiguration = new BasicAuthService.SaltedBasicAuthConfiguration(
|
||||
var actualConfiguration = basicAuthService.configuration().credentials();
|
||||
var applicationYamlConfiguration = BasicAuthService.SaltedBasicAuthCredentials.salt(
|
||||
actualConfiguration.getSalt(),
|
||||
basicAuthService.basicAuthConfiguration
|
||||
basicAuthService.basicAuthConfiguration.getUsername(),
|
||||
basicAuthService.basicAuthConfiguration.getPassword()
|
||||
);
|
||||
assertThat(actualConfiguration).isEqualTo(applicationYamlConfiguration);
|
||||
|
||||
|
||||
@@ -169,6 +169,7 @@ kestra:
|
||||
open-urls:
|
||||
- "/ping"
|
||||
- "/api/v1/executions/webhook/"
|
||||
- "/api/v1/main/executions/webhook/"
|
||||
liveness:
|
||||
enabled: false
|
||||
service:
|
||||
|
||||
Reference in New Issue
Block a user