Compare commits

...

19 Commits

Author SHA1 Message Date
Miloš Paunović
12fd7f81c0 feat(core): make the right sidebar usable in offline mode (#12022)
Closes https://github.com/kestra-io/kestra-ee/issues/4810.
2025-10-15 14:18:36 +02:00
Akshay Yadav
3cd340f972 fix(secrets-page): adjust top margin for empty secrets block (#12020) 2025-10-15 13:27:09 +02:00
Khushal Sarode
721dc61aa4 Converting TimeSelect.vue into typescript script (#11990)
Co-authored-by: Bart Ledoux <bledoux@kestra.io>
2025-10-15 11:16:35 +02:00
Barthélémy Ledoux
d12a33e9ba feat(flows): add days rendering to the duration picker (#11987)
Co-authored-by: Piyush Bhaskar <102078527+Piyush-r-bhaskar@users.noreply.github.com>
2025-10-15 11:15:13 +02:00
Miloš Paunović
56caaa2a91 refactor(core): prevent npe & console errors for charts (#12018) 2025-10-15 10:56:25 +02:00
Barthélémy Ledoux
52dda7621c fix: keep required fields from schema (#11984) 2025-10-15 10:42:09 +02:00
Roman Acevedo
88ab8e2a71 fix(security): webhook requiring basicauth
- fix https://github.com/kestra-io/kestra-ee/issues/5416

The issue was that BasicAuthConfiguration.openUrls was discarded by mistake after a basic auth creds creation.

What has been done:
- make BasicAuthConfiguration a POJO representing the yaml configuration
- dont persist BasicAuthConfiguration
- when fetching the configured Basic auth setup, fetch Credentials from DB and additional configuration from BasicAuthConfiguration
2025-10-15 10:41:34 +02:00
Barthélémy Ledoux
545ed57000 refactor: use multipanel for namespace files (#11972) 2025-10-15 10:31:54 +02:00
Bikash Agarwala
31de6660fa fix(core): limit the maximum height of the notification content element (#11977)
Closes https://github.com/kestra-io/kestra/issues/11924.

Co-authored-by: MilosPaunovic <paun992@hotmail.com>
2025-10-15 10:25:00 +02:00
Lucas Barreto Oliveira
db1ef67a69 fix(frontend): Backfill SELECT input override from trigger (#10627) (#11943)
Co-authored-by: Barthélémy Ledoux <bledoux@kestra.io>
2025-10-14 17:34:16 +02:00
Aditya Ray
b7fbb3af66 refactor(ui): convert VarValue.vue to TypeScript (#11971)
Co-authored-by: Barthélémy Ledoux <ledouxb@me.com>
Co-authored-by: Barthélémy Ledoux <bledoux@kestra.io>
2025-10-14 17:21:38 +02:00
Bikash Agarwala
50f412a11e feat(ui): Redesign blueprint browser cards #11534 (#11947)
Co-authored-by: Piyush Bhaskar <impiyush0012@gmail.com>
2025-10-14 18:39:21 +05:30
Barthélémy Ledoux
5bced31e1b refactor: convert usePluginsStore to Composition API (#11965) 2025-10-14 14:37:08 +02:00
brian-mulier-p
76d349d57e fix(flows): pebble autocompletion performance optimization (#11981)
closes #11881
2025-10-14 11:32:50 +02:00
YannC
63df8e3e46 Fix: openapi tweaks (#11970)
* fix: added some on @ApiResponse annotation + added nullable annotation for TaskRun class

* fix: review changes
2025-10-14 10:23:31 +02:00
Malay Dewangan
4e4e082b79 fix(trigger): prevent scheduler crash on large duration (#10897)
* add tests
2025-10-13 19:13:18 +05:30
wangk
7b67f9a0f5 fix(system): missing BREAKPOINT state for MySQL (#11954)
* Update V1_45__taskrun_submitted.sql

* fix(core) Add missing breakpoint type
2025-10-13 15:07:17 +02:00
Barthélémy Ledoux
b59098e61f refactor: use rolldown-vite for build speed (#11904) 2025-10-13 12:17:41 +02:00
Anukalp Pandey
43f02e7e33 Convert ForEachStatus.vue to TypeScript with Composition API (#11810)
Co-authored-by: Anukalp <pandeyanukalp@gmail.com>
Co-authored-by: Miloš Paunović <paun992@hotmail.com>
Co-authored-by: Piyush Bhaskar <impiyush0012@gmail.com>
2025-10-13 15:41:18 +05:30
69 changed files with 2458 additions and 2879 deletions

View File

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

View File

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

View File

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

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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

View File

@@ -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
*/

View File

@@ -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;

View File

@@ -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;

View File

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

View File

@@ -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;

View File

@@ -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)

View File

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

View File

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

View File

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

View File

@@ -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;
}

View File

@@ -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_$]*)*$";
}

View File

@@ -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;
}

View File

@@ -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?

View File

@@ -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;

View File

@@ -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"));
}

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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"

View File

@@ -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,
});
}

View File

@@ -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)

View File

@@ -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)));

View File

@@ -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");

View File

@@ -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 => ({

View File

@@ -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];

View File

@@ -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>

View File

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

View File

@@ -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) {

View File

@@ -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;

View File

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

View File

@@ -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]) => [

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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))

View File

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

View File

@@ -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);

View File

@@ -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,
},
];

View File

@@ -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){

View File

@@ -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>

View File

@@ -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">

View 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;
}

View File

@@ -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>

View 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};
}

View File

@@ -11,6 +11,7 @@ export interface EditorTabProps {
flow?: boolean;
content?: string;
dirty?: boolean;
namespaceFiles?: boolean;
}
export const useEditorStore = defineStore("editor", () => {

View File

@@ -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,
};
});

View File

@@ -1010,6 +1010,8 @@ form.ks-horizontal {
.el-notification__content {
text-align: left;
max-height: 200px;
overflow-y: auto;
}
&.large {

View File

@@ -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: "^[\"']([^\"']+)[\"']$"
}

View File

@@ -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)"

View File

@@ -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",
},
],
}
}
}
},

View File

@@ -1,2 +1,3 @@
# Keep the name static
micronaut.openapi.filename=kestra
micronaut.openapi.filename=kestra
micronaut.openapi.constructor-arguments-as-required=false

View File

@@ -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,

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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);

View File

@@ -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;
}
}

View File

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

View File

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

View File

@@ -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()

View File

@@ -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()) {

View File

@@ -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);

View File

@@ -169,6 +169,7 @@ kestra:
open-urls:
- "/ping"
- "/api/v1/executions/webhook/"
- "/api/v1/main/executions/webhook/"
liveness:
enabled: false
service: