Replace the default dashboard with custom dashboard (#8769)

* feat:
- Implement width property
- Replace custom dashboard
- Started to integrate the KPI chart

* feat(ui): introduce dashboard chart layout system

* feat(ui): introduce dashboard chart kpi card

* chore(ui): amend layout widths for sm screen size

* chore(ui): prevent editing of default dashboard

* chore(ui): centering the KPI text inside the box

* chore(ui): dashboard edit preview to respect set layouts

* chore(ui): initial work on setting the default flow & namespace dashboards

* fix(ui): make sure there is no naming clashes

* feat: KPI chart backend implementation

* feat: validation annotations

* chore(ui): make chart legend align to right side

* chore(ui): properly show chart labels

* chore(ui): improve state and ID components inside custom tabels

* chore(ui): add proper link to execution in tables

* feat: implemented Triggers as Datasource for custom dashboards

close kestra-io/kestra-ee#3740

* feat: modified the Markdown chart so now it accept different sources

* feat: rename KPI property to numerator & where

close #3739

* chore(ui): improve markdown component

* chore(ui): markdown charts

* chore(ui): markdown charts

* chore(ui): markdown charts remove padding

* chore(ui): markdown charts

* feat: fixes + define custom dashboard equivalent to current default dashboard with some modification

* fix: round double value

* chore(ui): improve  flows and ns charts

* chore(ui): make sure that table shows execution links only if namespace and flowId exist

* chore(ui): make sure markdown is properly shown on dashboard edititng

* fix: correctly do preview instead of load on homepage

* fix: correctly preview markdown chart and add description in default flow dashboard

* fix: apply review changes

* fix: modify test following classes modifications on charts

* tests: restore package-lock

* remove chromatic tools and a warning

---------

Co-authored-by: MilosPaunovic <paun992@hotmail.com>
Co-authored-by: Bart Ledoux <bledoux@kestra.io>
This commit is contained in:
YannC
2025-05-27 08:02:11 +02:00
committed by GitHub
parent 57bafd1240
commit ea402261d5
73 changed files with 2507 additions and 1115 deletions

View File

@@ -24,8 +24,10 @@ import io.kestra.core.models.annotations.PluginProperty;
import io.kestra.core.models.conditions.Condition;
import io.kestra.core.models.conditions.ScheduleCondition;
import io.kestra.core.models.dashboards.DataFilter;
import io.kestra.core.models.dashboards.DataFilterKPI;
import io.kestra.core.models.dashboards.charts.Chart;
import io.kestra.core.models.dashboards.charts.DataChart;
import io.kestra.core.models.dashboards.charts.DataChartKPI;
import io.kestra.core.models.property.Data;
import io.kestra.core.models.property.Property;
import io.kestra.core.models.tasks.Output;
@@ -668,10 +670,25 @@ public class JsonSchemaGenerator {
TypeVariable<? extends Class<? extends Chart<?>>> dataFilterType = clz.getTypeParameters()[1];
ParameterizedType chartAwareColumnDescriptor = ((ParameterizedType) ((WildcardType) ((ParameterizedType) dataFilterType.getBounds()[0]).getActualTypeArguments()[1]).getUpperBounds()[0]);
dataFilters.forEach(dataFilter -> {
Type fieldsEnum = ((ParameterizedType) dataFilter.getGenericSuperclass()).getActualTypeArguments()[0];
consumer.accept(typeContext.resolve(clz, fieldsEnum, typeContext.resolve(dataFilter, typeContext.resolve(chartAwareColumnDescriptor, fieldsEnum))));
});
} else if (DataChartKPI.class.isAssignableFrom(clz)) {
List<Class<? extends DataFilterKPI<?, ?>>> dataFilterKPIs = getRegisteredPlugins()
.stream()
.flatMap(registeredPlugin -> registeredPlugin.getDataFiltersKPI().stream())
.filter(Predicate.not(io.kestra.core.models.Plugin::isInternal))
.toList();
TypeVariable<? extends Class<? extends Chart<?>>> dataFilterType = clz.getTypeParameters()[1];
ParameterizedType chartAwareColumnDescriptor = ((ParameterizedType) ((WildcardType) ((ParameterizedType) dataFilterType.getBounds()[0]).getActualTypeArguments()[1]).getUpperBounds()[0]);
dataFilterKPIs.forEach(dataFilterKPI -> {
Type fieldsEnum = ((ParameterizedType) dataFilterKPI.getGenericSuperclass()).getActualTypeArguments()[0];
consumer.accept(typeContext.resolve(clz, fieldsEnum, typeContext.resolve(dataFilterKPI, typeContext.resolve(chartAwareColumnDescriptor, fieldsEnum))));
});
} else {
consumer.accept(typeContext.resolve(clz));
}

View File

@@ -1,7 +1,10 @@
package io.kestra.core.models.dashboards;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Builder;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
@@ -21,6 +24,11 @@ public class ChartOption {
private String description;
@Builder.Default
@Min(1)
@Max(12)
private int width = 6;
public List<String> neededColumns() {
return Collections.emptyList();
}

View File

@@ -4,6 +4,7 @@ import io.kestra.core.models.QueryFilter;
import io.kestra.core.models.annotations.Plugin;
import io.kestra.core.models.dashboards.filters.AbstractFilter;
import io.kestra.core.repositories.QueryBuilderInterface;
import io.kestra.plugin.core.dashboard.data.IData;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
@@ -24,13 +25,12 @@ import java.util.Set;
@NoArgsConstructor
@Plugin
@EqualsAndHashCode
public abstract class DataFilter<F extends Enum<F>, C extends ColumnDescriptor<F>> implements io.kestra.core.models.Plugin {
public abstract class DataFilter<F extends Enum<F>, C extends ColumnDescriptor<F>> implements io.kestra.core.models.Plugin, IData<F> {
@NotNull
@NotBlank
@Pattern(regexp = "\\p{javaJavaIdentifierStart}\\p{javaJavaIdentifierPart}*(\\.\\p{javaJavaIdentifierStart}\\p{javaJavaIdentifierPart}*)*")
private String type;
private Map<String, C> columns;
@Setter
@@ -42,8 +42,10 @@ public abstract class DataFilter<F extends Enum<F>, C extends ColumnDescriptor<F
return Collections.emptySet();
}
public void updateWhereWithGlobalFilters(List<QueryFilter> queryFilterList, ZonedDateTime startDate, ZonedDateTime endDate) {
this.where = whereWithGlobalFilters(queryFilterList, startDate, endDate, this.where);
}
public abstract Class<? extends QueryBuilderInterface<F>> repositoryClass();
public abstract void setGlobalFilter(List<QueryFilter> queryFilterList, ZonedDateTime startDate, ZonedDateTime endDate);
}

View File

@@ -0,0 +1,56 @@
package io.kestra.core.models.dashboards;
import io.kestra.core.models.QueryFilter;
import io.kestra.core.models.annotations.Plugin;
import io.kestra.core.models.dashboards.filters.AbstractFilter;
import io.kestra.core.repositories.QueryBuilderInterface;
import io.kestra.plugin.core.dashboard.data.IData;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.experimental.SuperBuilder;
import java.time.ZonedDateTime;
import java.util.Collections;
import java.util.List;
import java.util.Set;
@SuperBuilder(toBuilder = true)
@Getter
@NoArgsConstructor
@Plugin
@EqualsAndHashCode
public abstract class DataFilterKPI<F extends Enum<F>, C extends ColumnDescriptor<F>> implements io.kestra.core.models.Plugin, IData<F> {
@NotNull
@NotBlank
@Pattern(regexp = "\\p{javaJavaIdentifierStart}\\p{javaJavaIdentifierPart}*(\\.\\p{javaJavaIdentifierStart}\\p{javaJavaIdentifierPart}*)*")
private String type;
private C columns;
@Setter
private List<AbstractFilter<F>> numerator;
private List<AbstractFilter<F>> where;
public Set<F> aggregationForbiddenFields() {
return Collections.emptySet();
}
public DataFilterKPI<F, C> clearFilters() {
this.numerator = Collections.emptyList();
return this;
}
public void updateWhereWithGlobalFilters(List<QueryFilter> queryFilterList, ZonedDateTime startDate, ZonedDateTime endDate) {
this.numerator = whereWithGlobalFilters(queryFilterList, startDate, endDate, this.numerator);
}
public abstract Class<? extends QueryBuilderInterface<F>> repositoryClass();
}

View File

@@ -0,0 +1,32 @@
package io.kestra.core.models.dashboards.charts;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.kestra.core.models.annotations.Plugin;
import io.kestra.core.models.dashboards.DataFilterKPI;
import io.kestra.core.validations.DataChartKPIValidation;
import io.kestra.plugin.core.dashboard.chart.kpis.KpiOption;
import jakarta.validation.constraints.NotNull;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
@SuperBuilder(toBuilder = true)
@Getter
@NoArgsConstructor
@Plugin
@JsonInclude(JsonInclude.Include.NON_DEFAULT)
@EqualsAndHashCode
@DataChartKPIValidation
public abstract class DataChartKPI<P extends KpiOption, D extends DataFilterKPI<?, ?>> extends Chart<P> implements io.kestra.core.models.Plugin {
@NotNull
private D data;
public Integer minNumberOfAggregations() {
return null;
}
public Integer maxNumberOfAggregations() {
return null;
}
}

View File

@@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.module.SimpleModule;
import io.kestra.core.app.AppPluginInterface;
import io.kestra.core.models.conditions.Condition;
import io.kestra.core.models.dashboards.DataFilter;
import io.kestra.core.models.dashboards.DataFilterKPI;
import io.kestra.core.models.dashboards.charts.Chart;
import io.kestra.core.models.tasks.ExecutableTask;
import io.kestra.core.models.tasks.Task;
@@ -36,6 +37,7 @@ public class PluginModule extends SimpleModule {
addDeserializer(Task.class, new PluginDeserializer<>());
addDeserializer(Chart.class, new PluginDeserializer<>());
addDeserializer(DataFilter.class, new PluginDeserializer<>());
addDeserializer(DataFilterKPI.class, new PluginDeserializer<>());
addDeserializer(AbstractTrigger.class, new PluginDeserializer<>());
addDeserializer(Condition.class, new PluginDeserializer<>());
addDeserializer(TaskRunner.class, new PluginDeserializer<>());

View File

@@ -5,6 +5,7 @@ import io.kestra.core.app.AppPluginInterface;
import io.kestra.core.models.Plugin;
import io.kestra.core.models.conditions.Condition;
import io.kestra.core.models.dashboards.DataFilter;
import io.kestra.core.models.dashboards.DataFilterKPI;
import io.kestra.core.models.dashboards.charts.Chart;
import io.kestra.core.models.tasks.Task;
import io.kestra.core.models.tasks.logs.LogExporter;
@@ -109,6 +110,7 @@ public class PluginScanner {
List<Class<? extends AppBlockInterface>> appBlocks = new ArrayList<>();
List<Class<? extends Chart<?>>> charts = new ArrayList<>();
List<Class<? extends DataFilter<?, ?>>> dataFilters = new ArrayList<>();
List<Class<? extends DataFilterKPI<?, ?>>> dataFiltersKPI = new ArrayList<>();
List<Class<? extends LogExporter<?>>> logExporter = new ArrayList<>();
List<Class<? extends AdditionalPlugin>> additionalPlugins = new ArrayList<>();
List<String> guides = new ArrayList<>();
@@ -169,6 +171,11 @@ public class PluginScanner {
//noinspection unchecked
dataFilters.add((Class<? extends DataFilter<?, ?>>) dataFilter.getClass());
}
case DataFilterKPI<?, ?> dataFilterKPI -> {
log.debug("Loading DataFilterKPI plugin: '{}'", plugin.getClass());
//noinspection unchecked
dataFiltersKPI.add((Class<? extends DataFilterKPI<?, ?>>) dataFilterKPI.getClass());
}
case LogExporter<?> shipper -> {
log.debug("Loading LogExporter plugin: '{}'", plugin.getClass());
logExporter.add((Class<? extends LogExporter<?>>) shipper.getClass());
@@ -225,6 +232,7 @@ public class PluginScanner {
.taskRunners(taskRunners)
.charts(charts)
.dataFilters(dataFilters)
.dataFiltersKPI(dataFiltersKPI)
.guides(guides)
.logExporters(logExporter)
.additionalPlugins(additionalPlugins)

View File

@@ -5,6 +5,7 @@ import io.kestra.core.app.AppPluginInterface;
import io.kestra.core.models.annotations.PluginSubGroup;
import io.kestra.core.models.conditions.Condition;
import io.kestra.core.models.dashboards.DataFilter;
import io.kestra.core.models.dashboards.DataFilterKPI;
import io.kestra.core.models.dashboards.charts.Chart;
import io.kestra.core.models.tasks.Task;
import io.kestra.core.models.tasks.logs.LogExporter;
@@ -45,6 +46,7 @@ public class RegisteredPlugin {
private final List<Class<? extends AppBlockInterface>> appBlocks;
private final List<Class<? extends Chart<?>>> charts;
private final List<Class<? extends DataFilter<?, ?>>> dataFilters;
private final List<Class<? extends DataFilterKPI<?, ?>>> dataFiltersKPI;
private final List<Class<? extends LogExporter<?>>> logExporters;
private final List<Class<? extends AdditionalPlugin>> additionalPlugins;
private final List<String> guides;
@@ -62,6 +64,7 @@ public class RegisteredPlugin {
!appBlocks.isEmpty() ||
!charts.isEmpty() ||
!dataFilters.isEmpty() ||
!dataFiltersKPI.isEmpty() ||
!logExporters.isEmpty() ||
!additionalPlugins.isEmpty()
;
@@ -116,6 +119,10 @@ public class RegisteredPlugin {
return DataFilter.class;
}
if (this.getDataFiltersKPI().stream().anyMatch(r -> r.getName().equals(cls))) {
return DataFilterKPI.class;
}
if (this.getAppBlocks().stream().anyMatch(r -> r.getName().equals(cls))) {
return AppBlockInterface.class;
}
@@ -163,6 +170,7 @@ public class RegisteredPlugin {
result.put("app-blocks", Arrays.asList(this.getAppBlocks().toArray(Class[]::new)));
result.put("charts", Arrays.asList(this.getCharts().toArray(Class[]::new)));
result.put("data-filters", Arrays.asList(this.getDataFilters().toArray(Class[]::new)));
result.put("data-filters-kpi", Arrays.asList(this.getDataFiltersKPI().toArray(Class[]::new)));
result.put("log-exporters", Arrays.asList(this.getLogExporters().toArray(Class[]::new)));
result.put("additional-plugins", Arrays.asList(this.getAdditionalPlugins().toArray(Class[]::new)));
@@ -357,6 +365,12 @@ public class RegisteredPlugin {
b.append("] ");
}
if (!this.getDataFiltersKPI().isEmpty()) {
b.append("[DataFiltersKPI: ");
b.append(this.getDataFiltersKPI().stream().map(Class::getName).collect(Collectors.joining(", ")));
b.append("] ");
}
if (!this.getLogExporters().isEmpty()) {
b.append("[Log Exporters: ");
b.append(this.getLogExporters().stream().map(Class::getName).collect(Collectors.joining(", ")));

View File

@@ -3,7 +3,9 @@ package io.kestra.core.repositories;
import io.kestra.core.models.dashboards.ColumnDescriptor;
import io.kestra.core.models.dashboards.Dashboard;
import io.kestra.core.models.dashboards.DataFilter;
import io.kestra.core.models.dashboards.DataFilterKPI;
import io.kestra.core.models.dashboards.charts.DataChart;
import io.kestra.core.models.dashboards.charts.DataChartKPI;
import io.micronaut.data.model.Pageable;
import jakarta.annotation.Nullable;
@@ -31,4 +33,6 @@ public interface DashboardRepositoryInterface {
Dashboard delete(String tenantId, String id);
<F extends Enum<F>> ArrayListTotal<Map<String, Object>> generate(String tenantId, DataChart<?, DataFilter<F, ? extends ColumnDescriptor<F>>> dataChart, ZonedDateTime startDate, ZonedDateTime endDate, Pageable pageable) throws IOException;
<F extends Enum<F>> List<Map<String, Object>> generateKPI(String tenantId, DataChartKPI<?, DataFilterKPI<F, ? extends ColumnDescriptor<F>>> dataChart, ZonedDateTime startDate, ZonedDateTime endDate) throws IOException;
}

View File

@@ -2,6 +2,7 @@ package io.kestra.core.repositories;
import io.kestra.core.models.dashboards.ColumnDescriptor;
import io.kestra.core.models.dashboards.DataFilter;
import io.kestra.core.models.dashboards.DataFilterKPI;
import io.micronaut.data.model.Pageable;
import java.io.IOException;
@@ -18,4 +19,6 @@ public interface QueryBuilderInterface<F extends Enum<F>> {
F dateFilterField();
ArrayListTotal<Map<String, Object>> fetchData(String tenantId, DataFilter<F, ? extends ColumnDescriptor<F>> filter, ZonedDateTime startDate, ZonedDateTime endDate, Pageable pageable) throws IOException;
Double fetchValue(String tenantId, DataFilterKPI<F, ? extends ColumnDescriptor<F>> descriptors, ZonedDateTime startDate, ZonedDateTime endDate, boolean numeratorFilter) throws IOException;
}

View File

@@ -4,6 +4,7 @@ import io.kestra.core.models.QueryFilter;
import io.kestra.core.models.executions.Execution;
import io.kestra.core.models.triggers.Trigger;
import io.kestra.core.models.triggers.TriggerContext;
import io.kestra.plugin.core.dashboard.data.Triggers;
import io.micronaut.data.model.Pageable;
import jakarta.annotation.Nullable;
import reactor.core.publisher.Flux;
@@ -12,7 +13,7 @@ import java.util.List;
import java.util.Optional;
import java.util.function.Function;
public interface TriggerRepositoryInterface {
public interface TriggerRepositoryInterface extends QueryBuilderInterface<Triggers.Fields> {
Optional<Trigger> findLast(TriggerContext trigger);
Optional<Trigger> findByExecution(Execution execution);

View File

@@ -0,0 +1,25 @@
package io.kestra.core.utils;
import java.math.BigDecimal;
import java.math.RoundingMode;
public class MathUtils {
/**
* Rounds a double value to a specified number of decimal places.
*
* @param value The double value to be rounded.
* @param decimalPlaces The number of decimal places to round to.
* Must be a non-negative integer.
* @return The rounded double value.
* @throws IllegalArgumentException If decimalPlaces is negative.
*/
public static double roundDouble(double value, int decimalPlaces) {
if (decimalPlaces < 0) {
throw new IllegalArgumentException("The number of decimal places must be non-negative.");
}
BigDecimal bd = BigDecimal.valueOf(value);
bd = bd.setScale(decimalPlaces, RoundingMode.HALF_UP);
return bd.doubleValue();
}
}

View File

@@ -0,0 +1,16 @@
package io.kestra.core.validations;
import io.kestra.core.validations.validator.DataChartKPIValidator;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = DataChartKPIValidator.class)
public @interface DataChartKPIValidation {
String message() default "invalid data chart";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}

View File

@@ -0,0 +1,18 @@
package io.kestra.core.validations;
import io.kestra.core.validations.validator.ExecutionsDataFilterValidator;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = ExecutionsDataFilterValidator.class)
public @interface ExecutionsDataFilterKPIValidation {
String message() default "invalid executions data filter";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}

View File

@@ -1,19 +1,14 @@
package io.kestra.core.validations;
import io.kestra.core.validations.validator.RegexValidator;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import static java.lang.annotation.ElementType.ANNOTATION_TYPE;
import static java.lang.annotation.ElementType.CONSTRUCTOR;
import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.ElementType.METHOD;
import static java.lang.annotation.ElementType.PARAMETER;
import static java.lang.annotation.ElementType.TYPE_USE;
import static java.lang.annotation.ElementType.*;
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = RegexValidator.class)

View File

@@ -0,0 +1,56 @@
package io.kestra.core.validations.validator;
import io.kestra.core.models.dashboards.charts.DataChartKPI;
import io.kestra.core.validations.DataChartKPIValidation;
import io.kestra.plugin.core.dashboard.data.Executions;
import io.micronaut.context.annotation.Value;
import io.micronaut.core.annotation.AnnotationValue;
import io.micronaut.core.annotation.Introspected;
import io.micronaut.core.annotation.NonNull;
import io.micronaut.core.annotation.Nullable;
import io.micronaut.validation.validator.constraints.ConstraintValidator;
import io.micronaut.validation.validator.constraints.ConstraintValidatorContext;
import jakarta.inject.Singleton;
import java.util.ArrayList;
import java.util.List;
@Singleton
@Introspected
public class DataChartKPIValidator implements ConstraintValidator<DataChartKPIValidation, DataChartKPI<?, ?>> {
@Value("${kestra.repository.type}")
private String repositoryType;
@Override
public boolean isValid(
@Nullable DataChartKPI<?, ?> dataChart,
@NonNull AnnotationValue<DataChartKPIValidation> annotationMetadata,
@NonNull ConstraintValidatorContext context) {
if (dataChart == null) {
return true;
}
List<String> violations = new ArrayList<>();
if(dataChart.getData().getColumns() != null) {
if (dataChart.getData().getColumns().getAgg() == null) {
violations.add("Agg on column is required.");
}
}
if (dataChart.getData().getColumns().getField() != null && dataChart.getData().getColumns().getField().equals(Executions.Fields.LABELS)
&& !repositoryType.equals("elasticsearch")) {
violations.add("LABELS column is only supported with an ElasticSearch database.");
}
if (!violations.isEmpty()) {
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate("Invalid data chart: " + String.join(", ", violations))
.addConstraintViolation();
return false;
} else {
return true;
}
}
}

View File

@@ -0,0 +1,51 @@
package io.kestra.core.validations.validator;
import io.kestra.core.validations.ExecutionsDataFilterValidation;
import io.kestra.plugin.core.dashboard.data.Executions;
import io.kestra.plugin.core.dashboard.data.ExecutionsKPI;
import io.micronaut.core.annotation.AnnotationValue;
import io.micronaut.core.annotation.Introspected;
import io.micronaut.core.annotation.NonNull;
import io.micronaut.core.annotation.Nullable;
import io.micronaut.validation.validator.constraints.ConstraintValidator;
import io.micronaut.validation.validator.constraints.ConstraintValidatorContext;
import jakarta.inject.Singleton;
import java.util.ArrayList;
import java.util.List;
@Singleton
@Introspected
public class ExecutionsDataFilterKPIValidator implements ConstraintValidator<ExecutionsDataFilterValidation, ExecutionsKPI<?>> {
@Override
public boolean isValid(
@Nullable ExecutionsKPI<?> executionsDataFilter,
@NonNull AnnotationValue<ExecutionsDataFilterValidation> annotationMetadata,
@NonNull ConstraintValidatorContext context) {
if (executionsDataFilter == null) {
return true;
}
List<String> violations = new ArrayList<>();
if (executionsDataFilter.getColumns().getField() == Executions.Fields.LABELS && executionsDataFilter.getColumns().getLabelKey() == null) {
violations.add("Column must have a `labelKey`.");
}
executionsDataFilter.getNumerator().forEach(filter -> {
if (filter.getField() == Executions.Fields.LABELS && filter.getLabelKey() == null) {
violations.add("Label filters must have a `labelKey`.");
}
});
if (!violations.isEmpty()) {
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate("Invalid Chart: " + String.join(", ", violations))
.addConstraintViolation();
return false;
} else {
return true;
}
}
}

View File

@@ -0,0 +1,33 @@
package io.kestra.plugin.core.dashboard.chart;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.kestra.core.models.dashboards.ColumnDescriptor;
import io.kestra.core.models.dashboards.DataFilterKPI;
import io.kestra.core.models.dashboards.charts.DataChartKPI;
import io.kestra.plugin.core.dashboard.chart.kpis.KpiOption;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
@SuperBuilder(toBuilder = true)
@Getter
@NoArgsConstructor
@JsonInclude(JsonInclude.Include.NON_DEFAULT)
@EqualsAndHashCode
@Schema(
title = "Track a specific value."
)
public class KPI <F extends Enum<F>, D extends DataFilterKPI<F, ? extends ColumnDescriptor<F>>> extends DataChartKPI<KpiOption, D> {
@Override
public Integer minNumberOfAggregations() {
return 1;
}
@Override
public Integer maxNumberOfAggregations() {
return 1;
}
}

View File

@@ -1,11 +1,11 @@
package io.kestra.plugin.core.dashboard.chart;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.kestra.core.models.annotations.Example;
import io.kestra.core.models.annotations.Plugin;
import io.kestra.core.models.dashboards.ChartOption;
import io.kestra.core.models.dashboards.charts.Chart;
import io.kestra.plugin.core.dashboard.chart.mardown.sources.MarkdownSource;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.EqualsAndHashCode;
import lombok.Getter;
@@ -45,5 +45,12 @@ import lombok.experimental.SuperBuilder;
}
)
public class Markdown extends Chart<ChartOption> {
@Deprecated(forRemoval = true)
@Schema(
title = "[DEPRECATED]Markdown content to display",
description = "Use the String source instead"
)
private String content;
private MarkdownSource source;
}

View File

@@ -62,6 +62,7 @@ import lombok.experimental.SuperBuilder;
}
)
public class TimeSeries<F extends Enum<F>, D extends DataFilter<F, ? extends TimeSeriesColumnDescriptor<F>>> extends DataChart<TimeSeriesOption, D> {
@Override
public Integer minNumberOfAggregations() {
return 1;

View File

@@ -0,0 +1,22 @@
package io.kestra.plugin.core.dashboard.chart.kpis;
import io.kestra.core.models.dashboards.ChartOption;
import lombok.Builder;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
@SuperBuilder(toBuilder = true)
@Getter
@NoArgsConstructor
@EqualsAndHashCode
public class KpiOption extends ChartOption {
@Builder.Default
private NumberType numberType = NumberType.FLAT;
public enum NumberType {
FLAT,
PERCENTAGE
}
}

View File

@@ -0,0 +1,23 @@
package io.kestra.plugin.core.dashboard.chart.mardown.sources;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Getter;
@Schema(
title = "Flow Source to fetch description"
)
@Getter
public class FlowDescription extends MarkdownSource {
@Schema(
title = "Flow ID"
)
@NotNull
private String flowId;
@Schema(
title = "Flow Namespace"
)
@NotNull
private String namespace;
}

View File

@@ -0,0 +1,25 @@
package io.kestra.plugin.core.dashboard.chart.mardown.sources;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import lombok.Getter;
@Getter
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
property = "type",
visible = true
)
@JsonSubTypes({
@JsonSubTypes.Type(value = FlowDescription.class, name = "FlowDescription"),
@JsonSubTypes.Type(value = Text.class, name = "Text")
})
public class MarkdownSource {
@NotNull
@NotBlank
@Pattern(regexp = "\\p{javaJavaIdentifierStart}\\p{javaJavaIdentifierPart}*(\\.\\p{javaJavaIdentifierStart}\\p{javaJavaIdentifierPart}*)*")
private String type;
}

View File

@@ -0,0 +1,15 @@
package io.kestra.plugin.core.dashboard.chart.mardown.sources;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
@Getter
@Schema(
title = "Markdown from text"
)
public class Text extends MarkdownSource {
@Schema(
title = "Markdown content to display"
)
private String content;
}

View File

@@ -2,15 +2,10 @@ package io.kestra.plugin.core.dashboard.data;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonTypeName;
import io.kestra.core.models.QueryFilter;
import io.kestra.core.models.annotations.Example;
import io.kestra.core.models.annotations.Plugin;
import io.kestra.core.models.dashboards.ColumnDescriptor;
import io.kestra.core.models.dashboards.DataFilter;
import io.kestra.core.models.dashboards.filters.AbstractFilter;
import io.kestra.core.models.dashboards.filters.Contains;
import io.kestra.core.models.dashboards.filters.GreaterThanOrEqualTo;
import io.kestra.core.models.dashboards.filters.LessThanOrEqualTo;
import io.kestra.core.repositories.ExecutionRepositoryInterface;
import io.kestra.core.repositories.QueryBuilderInterface;
import io.kestra.core.validations.ExecutionsDataFilterValidation;
@@ -20,10 +15,6 @@ import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.List;
@SuperBuilder(toBuilder = true)
@Getter
@NoArgsConstructor
@@ -63,61 +54,9 @@ import java.util.List;
}
)
@JsonTypeName("Executions")
public class Executions<C extends ColumnDescriptor<Executions.Fields>> extends DataFilter<Executions.Fields, C> {
public class Executions<C extends ColumnDescriptor<Executions.Fields>> extends DataFilter<Executions.Fields, C> implements IExecutions {
@Override
public Class<? extends QueryBuilderInterface<Executions.Fields>> repositoryClass() {
return ExecutionRepositoryInterface.class;
}
@Override
public void setGlobalFilter(List<QueryFilter> filters, ZonedDateTime startDate, ZonedDateTime endDate) {
List<AbstractFilter<Fields>> where = this.getWhere() != null ? new ArrayList<>(this.getWhere()) : new ArrayList<>();
if (filters == null) {
return;
}
List<QueryFilter> namespaceFilters = filters.stream().filter(f -> f.field().equals(QueryFilter.Field.NAMESPACE)).toList();
if (!namespaceFilters.isEmpty()) {
where.removeIf(filter -> filter.getField().equals(Executions.Fields.NAMESPACE));
namespaceFilters.forEach(f -> {
where.add(f.toDashboardFilterBuilder(Executions.Fields.NAMESPACE, f.value()));
});
}
List<QueryFilter> labelFilters = filters.stream().filter(f -> f.field().equals(QueryFilter.Field.LABELS)).toList();
if (!labelFilters.isEmpty()) {
where.removeIf(filter -> filter.getField().equals(Fields.LABELS));
labelFilters.forEach(f -> {
where.add(Contains.<Executions.Fields>builder().field(Fields.LABELS).value(f.value()).build());
});
}
if (startDate != null || endDate != null) {
if (startDate != null) {
where.removeIf(f -> f.getField().equals(Fields.START_DATE));
where.add(GreaterThanOrEqualTo.<Executions.Fields>builder().field(Fields.START_DATE).value(startDate.toInstant()).build());
}
if (endDate != null) {
where.removeIf(f -> f.getField().equals(Fields.END_DATE));
where.add(LessThanOrEqualTo.<Executions.Fields>builder().field(Fields.END_DATE).value(endDate.toInstant()).build());
}
}
this.setWhere(where);
}
public enum Fields {
ID,
NAMESPACE,
FLOW_ID,
FLOW_REVISION,
STATE,
DURATION,
LABELS,
START_DATE,
END_DATE,
TRIGGER_EXECUTION_ID
}
}

View File

@@ -0,0 +1,61 @@
package io.kestra.plugin.core.dashboard.data;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonTypeName;
import io.kestra.core.models.annotations.Example;
import io.kestra.core.models.annotations.Plugin;
import io.kestra.core.models.dashboards.ColumnDescriptor;
import io.kestra.core.models.dashboards.DataFilterKPI;
import io.kestra.core.repositories.ExecutionRepositoryInterface;
import io.kestra.core.repositories.QueryBuilderInterface;
import io.kestra.core.validations.ExecutionsDataFilterKPIValidation;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
@SuperBuilder(toBuilder = true)
@Getter
@NoArgsConstructor
@JsonInclude(JsonInclude.Include.NON_DEFAULT)
@EqualsAndHashCode
//@ExecutionsDataFilterValidation
@Schema(
title = "Display a chart with executions in success in a given namespace.",
description = "Change."
)
@Plugin(
examples = {
@Example(
title = "Display a chart with executions in success in a given namespace.",
full = true,
code = {
"id: kpi_success_ratio\n" +
"type: io.kestra.plugin.core.dashboard.chart.KPI\n" +
"chartOptions:\n" +
" displayName: Success Ratio\n" +
" numberType: PERCENTAGE\n" +
" width: 3\n" +
"data:\n" +
" type: io.kestra.plugin.core.dashboard.data.ExecutionsKPI\n" +
" columns:\n" +
" field: ID\n" +
" agg: COUNT\n" +
" numerator:\n" +
" - type: IN\n" +
" field: STATE\n" +
" values:\n" +
" - SUCCESS\n"
}
)
}
)
@JsonTypeName("ExecutionsKPI")
@ExecutionsDataFilterKPIValidation
public class ExecutionsKPI<C extends ColumnDescriptor<ExecutionsKPI.Fields>> extends DataFilterKPI<ExecutionsKPI.Fields, C> implements IExecutions {
@Override
public Class<? extends QueryBuilderInterface<ExecutionsKPI.Fields>> repositoryClass() {
return ExecutionRepositoryInterface.class;
}
}

View File

@@ -0,0 +1,11 @@
package io.kestra.plugin.core.dashboard.data;
import io.kestra.core.models.QueryFilter;
import io.kestra.core.models.dashboards.filters.AbstractFilter;
import java.time.ZonedDateTime;
import java.util.List;
public interface IData<F extends Enum<F>> {
List<AbstractFilter<F>> whereWithGlobalFilters(List<QueryFilter> queryFilterList, ZonedDateTime startDate, ZonedDateTime endDate, List<AbstractFilter<F>> where);
}

View File

@@ -0,0 +1,63 @@
package io.kestra.plugin.core.dashboard.data;
import io.kestra.core.models.QueryFilter;
import io.kestra.core.models.dashboards.filters.AbstractFilter;
import io.kestra.core.models.dashboards.filters.Contains;
import io.kestra.core.models.dashboards.filters.GreaterThanOrEqualTo;
import io.kestra.core.models.dashboards.filters.LessThanOrEqualTo;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.List;
public interface IExecutions extends IData<IExecutions.Fields> {
default List<AbstractFilter<Fields>> whereWithGlobalFilters(List<QueryFilter> filters, ZonedDateTime startDate, ZonedDateTime endDate, List<AbstractFilter<Fields>> where) {
List<AbstractFilter<Fields>> updatedWhere = where != null ? new ArrayList<>(where) : new ArrayList<>();
if (filters != null) {
List<QueryFilter> namespaceFilters = filters.stream().filter(f -> f.field().equals(QueryFilter.Field.NAMESPACE)).toList();
if (!namespaceFilters.isEmpty()) {
updatedWhere.removeIf(filter -> filter.getField().equals(Fields.NAMESPACE));
namespaceFilters.forEach(f -> {
updatedWhere.add(f.toDashboardFilterBuilder(Fields.NAMESPACE, f.value()));
});
}
List<QueryFilter> labelFilters = filters.stream().filter(f -> f.field().equals(QueryFilter.Field.LABELS)).toList();
if (!labelFilters.isEmpty()) {
updatedWhere.removeIf(filter -> filter.getField().equals(Fields.LABELS));
labelFilters.forEach(f -> {
updatedWhere.add(Contains.<Fields>builder().field(Fields.LABELS).value(f.value()).build());
});
}
}
if (startDate != null || endDate != null) {
if (startDate != null) {
updatedWhere.removeIf(f -> f.getField().equals(Fields.START_DATE));
updatedWhere.add(GreaterThanOrEqualTo.<Fields>builder().field(Fields.START_DATE).value(startDate.toInstant()).build());
}
if (endDate != null) {
updatedWhere.removeIf(f -> f.getField().equals(Fields.END_DATE));
updatedWhere.add(LessThanOrEqualTo.<Fields>builder().field(Fields.END_DATE).value(endDate.toInstant()).build());
}
}
return updatedWhere;
}
enum Fields {
ID,
NAMESPACE,
FLOW_ID,
FLOW_REVISION,
STATE,
DURATION,
LABELS,
START_DATE,
END_DATE,
TRIGGER_EXECUTION_ID
}
}

View File

@@ -0,0 +1,53 @@
package io.kestra.plugin.core.dashboard.data;
import io.kestra.core.models.QueryFilter;
import io.kestra.core.models.dashboards.filters.AbstractFilter;
import io.kestra.core.models.dashboards.filters.GreaterThanOrEqualTo;
import io.kestra.core.models.dashboards.filters.LessThanOrEqualTo;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.List;
public interface ILogs extends IData<ILogs.Fields> {
default List<AbstractFilter<ILogs.Fields>> whereWithGlobalFilters(List<QueryFilter> filters, ZonedDateTime startDate, ZonedDateTime endDate, List<AbstractFilter<ILogs.Fields>> where) {
List<AbstractFilter<ILogs.Fields>> updatedWhere = where != null ? new ArrayList<>(where) : new ArrayList<>();
if (filters != null) {
List<QueryFilter> namespaceFilters = filters.stream().filter(f -> f.field().equals(QueryFilter.Field.NAMESPACE)).toList();
if (!namespaceFilters.isEmpty()) {
updatedWhere.removeIf(filter -> filter.getField().equals(Fields.NAMESPACE));
namespaceFilters.forEach(f -> {
updatedWhere.add(f.toDashboardFilterBuilder(Fields.NAMESPACE, f.value()));
});
}
}
if (startDate != null || endDate != null) {
updatedWhere.removeIf(f -> f.getField().equals(Fields.DATE));
if (startDate != null) {
updatedWhere.add(GreaterThanOrEqualTo.<Fields>builder().field(Fields.DATE).value(startDate.toInstant()).build());
}
if (endDate != null) {
updatedWhere.add(LessThanOrEqualTo.<Fields>builder().field(Fields.DATE).value(endDate.toInstant()).build());
}
}
return updatedWhere;
}
enum Fields {
NAMESPACE,
FLOW_ID,
EXECUTION_ID,
TASK_ID,
DATE,
TASK_RUN_ID,
ATTEMPT_NUMBER,
TRIGGER_ID,
LEVEL,
MESSAGE
}
}

View File

@@ -0,0 +1,41 @@
package io.kestra.plugin.core.dashboard.data;
import io.kestra.core.models.QueryFilter;
import io.kestra.core.models.dashboards.filters.*;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.List;
public interface IMetrics extends IData<IMetrics.Fields> {
default List<AbstractFilter<IMetrics.Fields>> whereWithGlobalFilters(List<QueryFilter> filters, ZonedDateTime startDate, ZonedDateTime endDate, List<AbstractFilter<IMetrics.Fields>> where) {
List<AbstractFilter<IMetrics.Fields>> updatedWhere = where != null ? new ArrayList<>(where) : new ArrayList<>();
if (filters == null) {
return updatedWhere;
}
List<QueryFilter> namespaceFilters = filters.stream().filter(f -> f.field().equals(QueryFilter.Field.NAMESPACE)).toList();
if (!namespaceFilters.isEmpty()) {
updatedWhere.removeIf(filter -> filter.getField().equals(Fields.NAMESPACE));
namespaceFilters.forEach(f -> {
updatedWhere.add(EqualTo.<Fields>builder().field(Fields.NAMESPACE).value(f.value()).build());
});
}
return updatedWhere;
}
enum Fields {
NAMESPACE,
FLOW_ID,
TASK_ID,
EXECUTION_ID,
TASK_RUN_ID,
TYPE,
NAME,
VALUE,
DATE
}
}

View File

@@ -0,0 +1,41 @@
package io.kestra.plugin.core.dashboard.data;
import io.kestra.core.models.QueryFilter;
import io.kestra.core.models.dashboards.filters.AbstractFilter;
import io.kestra.core.models.dashboards.filters.EqualTo;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.List;
public interface ITriggers extends IData<ITriggers.Fields> {
default List<AbstractFilter<ITriggers.Fields>> whereWithGlobalFilters(List<QueryFilter> filters, ZonedDateTime startDate, ZonedDateTime endDate, List<AbstractFilter<ITriggers.Fields>> where) {
List<AbstractFilter<ITriggers.Fields>> updatedWhere = where != null ? new ArrayList<>(where) : new ArrayList<>();
if (filters == null) {
return updatedWhere;
}
List<QueryFilter> namespaceFilters = filters.stream().filter(f -> f.field().equals(QueryFilter.Field.NAMESPACE)).toList();
if (!namespaceFilters.isEmpty()) {
updatedWhere.removeIf(filter -> filter.getField().equals(ITriggers.Fields.NAMESPACE));
namespaceFilters.forEach(f -> {
updatedWhere.add(EqualTo.<ITriggers.Fields>builder().field(ITriggers.Fields.NAMESPACE).value(f.value()).build());
});
}
return updatedWhere;
}
enum Fields {
ID,
NAMESPACE,
FLOW_ID,
TRIGGER_ID,
EXECUTION_ID,
NEXT_EXECUTION_DATE,
WORKER_ID
}
}

View File

@@ -1,14 +1,10 @@
package io.kestra.plugin.core.dashboard.data;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.kestra.core.models.QueryFilter;
import io.kestra.core.models.annotations.Example;
import io.kestra.core.models.annotations.Plugin;
import io.kestra.core.models.dashboards.ColumnDescriptor;
import io.kestra.core.models.dashboards.DataFilter;
import io.kestra.core.models.dashboards.filters.AbstractFilter;
import io.kestra.core.models.dashboards.filters.GreaterThanOrEqualTo;
import io.kestra.core.models.dashboards.filters.LessThanOrEqualTo;
import io.kestra.core.repositories.LogRepositoryInterface;
import io.kestra.core.repositories.QueryBuilderInterface;
import io.swagger.v3.oas.annotations.media.Schema;
@@ -17,9 +13,6 @@ import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
@SuperBuilder(toBuilder = true)
@@ -62,56 +55,14 @@ import java.util.Set;
)
}
)
public class Logs<C extends ColumnDescriptor<Logs.Fields>> extends DataFilter<Logs.Fields, C> {
public class Logs<C extends ColumnDescriptor<ILogs.Fields>> extends DataFilter<ILogs.Fields, C> implements ILogs {
@Override
public Class<? extends QueryBuilderInterface<Logs.Fields>> repositoryClass() {
public Class<? extends QueryBuilderInterface<ILogs.Fields>> repositoryClass() {
return LogRepositoryInterface.class;
}
@Override
public void setGlobalFilter(List<QueryFilter> filters, ZonedDateTime startDate, ZonedDateTime endDate) {
List<AbstractFilter<Fields>> where = this.getWhere() != null ? new ArrayList<>(this.getWhere()) : new ArrayList<>();
if (filters == null) {
return;
}
List<QueryFilter> namespaceFilters = filters.stream().filter(f -> f.field().equals(QueryFilter.Field.NAMESPACE)).toList();
if (!namespaceFilters.isEmpty()) {
where.removeIf(filter -> filter.getField().equals(Logs.Fields.NAMESPACE));
namespaceFilters.forEach(f -> {
where.add(f.toDashboardFilterBuilder(Logs.Fields.NAMESPACE, f.value()));
});
}
if (startDate != null || endDate != null) {
where.removeIf(f -> f.getField().equals(Fields.DATE));
if (startDate != null) {
where.add(GreaterThanOrEqualTo.<Logs.Fields>builder().field(Fields.DATE).value(startDate.toInstant()).build());
}
if (endDate != null) {
where.add(LessThanOrEqualTo.<Logs.Fields>builder().field(Fields.DATE).value(endDate.toInstant()).build());
}
}
this.setWhere(where);
}
@Override
public Set<Fields> aggregationForbiddenFields() {
return Set.of(Fields.MESSAGE);
}
public enum Fields {
NAMESPACE,
FLOW_ID,
EXECUTION_ID,
TASK_ID,
DATE,
TASK_RUN_ID,
ATTEMPT_NUMBER,
TRIGGER_ID,
LEVEL,
MESSAGE
}
}

View File

@@ -0,0 +1,68 @@
package io.kestra.plugin.core.dashboard.data;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.kestra.core.models.annotations.Example;
import io.kestra.core.models.annotations.Plugin;
import io.kestra.core.models.dashboards.ColumnDescriptor;
import io.kestra.core.models.dashboards.DataFilterKPI;
import io.kestra.core.repositories.LogRepositoryInterface;
import io.kestra.core.repositories.QueryBuilderInterface;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
import java.util.Set;
@SuperBuilder(toBuilder = true)
@Getter
@NoArgsConstructor
@JsonInclude(JsonInclude.Include.NON_DEFAULT)
@EqualsAndHashCode
@Schema(
title = "Display Log data in a dashboard chart.",
description = "Log data can be displayed in a chart with certain parameters such as Exectution date or Log level."
)
@Plugin(
examples = {
@Example(
title = "Display a chart with a count of Logs per date grouped by level.",
full = true,
code = {
"id: logs_timeseries\n" +
"type: io.kestra.plugin.core.dashboard.chart.TimeSeries\n" +
"chartOptions:\n" +
"displayName: Logs\n" +
"description: Logs count per date grouped by level\n" +
"legend:\n" +
"enabled: true\n" +
"column: date\n" +
"colorByColumn: level\n" +
"data:\n" +
"type: io.kestra.plugin.core.dashboard.data.Logs\n" +
"columns:\n" +
"date:\n" +
"field: DATE\n" +
"displayName: Execution Date\n" +
"level:\n" +
"field: LEVEL\n" +
"total:\n" +
"displayName: Total Executions\n" +
"agg: COUNT\n" +
"graphStyle: BARS\n"
}
)
}
)
public class LogsKPI<C extends ColumnDescriptor<LogsKPI.Fields>> extends DataFilterKPI<LogsKPI.Fields, C> implements ILogs {
@Override
public Class<? extends QueryBuilderInterface<Fields>> repositoryClass() {
return LogRepositoryInterface.class;
}
@Override
public Set<Fields> aggregationForbiddenFields() {
return Set.of(Fields.MESSAGE);
}
}

View File

@@ -1,15 +1,10 @@
package io.kestra.plugin.core.dashboard.data;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.kestra.core.models.QueryFilter;
import io.kestra.core.models.annotations.Example;
import io.kestra.core.models.annotations.Metric;
import io.kestra.core.models.annotations.Plugin;
import io.kestra.core.models.dashboards.ColumnDescriptor;
import io.kestra.core.models.dashboards.DataFilter;
import io.kestra.core.models.dashboards.filters.AbstractFilter;
import io.kestra.core.models.dashboards.filters.EqualTo;
import io.kestra.core.models.executions.metrics.Counter;
import io.kestra.core.repositories.MetricRepositoryInterface;
import io.kestra.core.repositories.QueryBuilderInterface;
import io.swagger.v3.oas.annotations.media.Schema;
@@ -18,10 +13,6 @@ import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.List;
@SuperBuilder(toBuilder = true)
@Getter
@NoArgsConstructor
@@ -60,41 +51,9 @@ import java.util.List;
)
}
)
public class Metrics<C extends ColumnDescriptor<Metrics.Fields>> extends DataFilter<Metrics.Fields, C> {
public class Metrics<C extends ColumnDescriptor<Metrics.Fields>> extends DataFilter<Metrics.Fields, C> implements IMetrics {
@Override
public Class<? extends QueryBuilderInterface<Metrics.Fields>> repositoryClass() {
return MetricRepositoryInterface.class;
}
@Override
public void setGlobalFilter(List<QueryFilter> filters, ZonedDateTime startDate, ZonedDateTime endDate) {
List<AbstractFilter<Fields>> where = this.getWhere() != null ? new ArrayList<>(this.getWhere()) : new ArrayList<>();
if (filters == null) {
return;
}
List<QueryFilter> namespaceFilters = filters.stream().filter(f -> f.field().equals(QueryFilter.Field.NAMESPACE)).toList();
if (!namespaceFilters.isEmpty()) {
where.removeIf(filter -> filter.getField().equals(Metrics.Fields.NAMESPACE));
namespaceFilters.forEach(f -> {
where.add(EqualTo.<Metrics.Fields>builder().field(Metrics.Fields.NAMESPACE).value(f.value()).build());
});
}
this.setWhere(where);
}
public enum Fields {
NAMESPACE,
FLOW_ID,
TASK_ID,
EXECUTION_ID,
TASK_RUN_ID,
TYPE,
NAME,
VALUE,
DATE
}
}

View File

@@ -0,0 +1,59 @@
package io.kestra.plugin.core.dashboard.data;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.kestra.core.models.annotations.Example;
import io.kestra.core.models.annotations.Plugin;
import io.kestra.core.models.dashboards.ColumnDescriptor;
import io.kestra.core.models.dashboards.DataFilterKPI;
import io.kestra.core.repositories.MetricRepositoryInterface;
import io.kestra.core.repositories.QueryBuilderInterface;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
@SuperBuilder(toBuilder = true)
@Getter
@NoArgsConstructor
@JsonInclude(JsonInclude.Include.NON_DEFAULT)
@EqualsAndHashCode
@Schema(
title = "Metrics are data exposed by tasks after execution.",
description = "A chart using Metrics could display the number of rows loaded in a bigQuery task or an output count from a SQL Query; anything exposed by an execution."
)
@Plugin(
examples = {
@Example(
title = "Display a chart with rows inserted by Namespace.",
full = true,
code = {
"id: table_metrics\n" +
"type: io.kestra.plugin.core.dashboard.chart.Table\n" +
"chartOptions:\n" +
"displayName: Rows Inserted by Namespace\n" +
"data:\n" +
"type: io.kestra.plugin.core.dashboard.data.Metrics\n" +
"columns:\n" +
"namespace:\n" +
"field: NAMESPACE\n" +
"inserted_rows:\n" +
"field: VALUE\n" +
"agg: SUM\n" +
"where:\n" +
"- field: NAME\n" +
"type: EQUAL_TO\n" +
"value: rows\n" +
"orderBy:\n" +
"- column: inserted_rows\n" +
"order: DESC\n"
}
)
}
)
public class MetricsKPI<C extends ColumnDescriptor<MetricsKPI.Fields>> extends DataFilterKPI<MetricsKPI.Fields, C> implements IMetrics {
@Override
public Class<? extends QueryBuilderInterface<Fields>> repositoryClass() {
return MetricRepositoryInterface.class;
}
}

View File

@@ -0,0 +1,44 @@
package io.kestra.plugin.core.dashboard.data;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonTypeName;
import io.kestra.core.models.annotations.Example;
import io.kestra.core.models.annotations.Plugin;
import io.kestra.core.models.dashboards.ColumnDescriptor;
import io.kestra.core.models.dashboards.DataFilter;
import io.kestra.core.repositories.QueryBuilderInterface;
import io.kestra.core.repositories.TriggerRepositoryInterface;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
@SuperBuilder(toBuilder = true)
@Getter
@NoArgsConstructor
@JsonInclude(JsonInclude.Include.NON_DEFAULT)
@EqualsAndHashCode
//@TriggersDataFilterValidation
@Schema(
title = "Display Execution data in a dashboard chart.",
description = "Execution data can be displayed in charts broken out by Namespace and filtered by State, for example."
)
@Plugin(
examples = {
@Example(
title = "Display a chart with a Triggers per Namespace broken out by State.",
full = true,
code = {
"id: executions_per_namespace_bars\n"
}
)
}
)
@JsonTypeName("Triggers")
public class Triggers<C extends ColumnDescriptor<Triggers.Fields>> extends DataFilter<Triggers.Fields, C> implements ITriggers {
@Override
public Class<? extends QueryBuilderInterface<Triggers.Fields>> repositoryClass() {
return TriggerRepositoryInterface.class;
}
}

View File

@@ -283,17 +283,17 @@ class JsonSchemaGeneratorTest {
var definitions = (Map<String, Map<String, Object>>) generate.get("definitions");
String executionTimeSeriesColumnDescriptorExecutionFieldsKey = "io.kestra.plugin.core.dashboard.data.Executions_io.kestra.plugin.core.dashboard.chart.timeseries.TimeSeriesColumnDescriptor_io.kestra.plugin.core.dashboard.data.Executions-Fields__";
String executionTimeSeriesColumnDescriptorExecutionFieldsKey = "io.kestra.plugin.core.dashboard.data.Executions_io.kestra.plugin.core.dashboard.chart.timeseries.TimeSeriesColumnDescriptor_io.kestra.plugin.core.dashboard.data.IExecutions-Fields__";
assertThat(
properties(definitions.get("io.kestra.plugin.core.dashboard.chart.TimeSeries_io.kestra.plugin.core.dashboard.data.Executions-Fields.io.kestra.plugin.core.dashboard.data.Executions_io.kestra.plugin.core.dashboard.chart.timeseries.TimeSeriesColumnDescriptor_io.kestra.plugin.core.dashboard.data.Executions-Fields___"))
properties(definitions.get("io.kestra.plugin.core.dashboard.chart.TimeSeries_io.kestra.plugin.core.dashboard.data.IExecutions-Fields.io.kestra.plugin.core.dashboard.data.Executions_io.kestra.plugin.core.dashboard.chart.timeseries.TimeSeriesColumnDescriptor_io.kestra.plugin.core.dashboard.data.IExecutions-Fields___"))
.get("data")
.get("$ref"),
Matchers.is("#/definitions/" + executionTimeSeriesColumnDescriptorExecutionFieldsKey)
);
String timeseriesColumnDescriptorExecutionFields = "io.kestra.plugin.core.dashboard.chart.timeseries.TimeSeriesColumnDescriptor_io.kestra.plugin.core.dashboard.data.Executions-Fields_";
String timeseriesColumnDescriptorExecutionFields = "io.kestra.plugin.core.dashboard.chart.timeseries.TimeSeriesColumnDescriptor_io.kestra.plugin.core.dashboard.data.IExecutions-Fields_";
assertThat(
((Map<String, String>) properties(definitions.get("io.kestra.plugin.core.dashboard.data.Executions_io.kestra.plugin.core.dashboard.chart.timeseries.TimeSeriesColumnDescriptor_io.kestra.plugin.core.dashboard.data.Executions-Fields__"))
((Map<String, String>) properties(definitions.get("io.kestra.plugin.core.dashboard.data.Executions_io.kestra.plugin.core.dashboard.chart.timeseries.TimeSeriesColumnDescriptor_io.kestra.plugin.core.dashboard.data.IExecutions-Fields__"))
.get("columns")
.get("additionalProperties")
).get("$ref"),

View File

@@ -1,16 +1,41 @@
package io.kestra.repository.h2;
import io.kestra.core.models.triggers.Trigger;
import io.kestra.core.utils.DateUtils;
import io.kestra.jdbc.repository.AbstractJdbcTriggerRepository;
import io.kestra.jdbc.services.JdbcFilterService;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import jakarta.inject.Singleton;
import org.jooq.Field;
import org.jooq.impl.DSL;
import java.util.Date;
@Singleton
@H2RepositoryEnabled
public class H2TriggerRepository extends AbstractJdbcTriggerRepository {
@Inject
public H2TriggerRepository(@Named("triggers") H2Repository<Trigger> repository) {
super(repository);
public H2TriggerRepository(@Named("triggers") H2Repository<Trigger> repository,
JdbcFilterService filterService) {
super(repository, filterService);
}
@Override
protected Field<Date> formatDateField(String dateField, DateUtils.GroupType groupType) {
switch (groupType) {
case MONTH:
return DSL.field("FORMATDATETIME(\"" + dateField + "\", 'yyyy-MM')", Date.class);
case WEEK:
return DSL.field("FORMATDATETIME(\"" + dateField + "\", 'YYYY-ww')", Date.class);
case DAY:
return DSL.field("FORMATDATETIME(\"" + dateField + "\", 'yyyy-MM-dd')", Date.class);
case HOUR:
return DSL.field("FORMATDATETIME(\"" + dateField + "\", 'yyyy-MM-dd HH:00:00')", Date.class);
case MINUTE:
return DSL.field("FORMATDATETIME(\"" + dateField + "\", 'yyyy-MM-dd HH:mm:00')", Date.class);
default:
throw new IllegalArgumentException("Unsupported GroupType: " + groupType);
}
}
}

View File

@@ -1,25 +1,48 @@
package io.kestra.repository.mysql;
import io.kestra.core.models.triggers.Trigger;
import io.kestra.core.utils.DateUtils;
import io.kestra.jdbc.repository.AbstractJdbcTriggerRepository;
import io.kestra.jdbc.services.JdbcFilterService;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import jakarta.inject.Singleton;
import org.jooq.Condition;
import org.jooq.Field;
import org.jooq.impl.DSL;
import java.util.Date;
import java.util.List;
@Singleton
@MysqlRepositoryEnabled
public class MysqlTriggerRepository extends AbstractJdbcTriggerRepository {
@Inject
public MysqlTriggerRepository(@Named("triggers") MysqlRepository<Trigger> repository) {
super(repository);
public MysqlTriggerRepository(@Named("triggers") MysqlRepository<Trigger> repository,
JdbcFilterService filterService) {
super(repository, filterService);
}
@Override
protected Condition fullTextCondition(String query) {
return query == null ? DSL.trueCondition() : jdbcRepository.fullTextCondition(List.of("namespace", "flow_id", "trigger_id", "execution_id"), query);
}
@Override
protected Field<Date> formatDateField(String dateField, DateUtils.GroupType groupType) {
switch (groupType) {
case MONTH:
return DSL.field("DATE_FORMAT({0}, '%Y-%m')", Date.class, DSL.field(dateField));
case WEEK:
return DSL.field("DATE_FORMAT({0}, '%x-%v')", Date.class, DSL.field(dateField));
case DAY:
return DSL.field("DATE({0})", Date.class, DSL.field(dateField));
case HOUR:
return DSL.field("DATE_FORMAT({0}, '%Y-%m-%d %H:00:00')", Date.class, DSL.field(dateField));
case MINUTE:
return DSL.field("DATE_FORMAT({0}, '%Y-%m-%d %H:%i:00')", Date.class, DSL.field(dateField));
default:
throw new IllegalArgumentException("Unsupported GroupType: " + groupType);
}
}
}

View File

@@ -1,16 +1,41 @@
package io.kestra.repository.postgres;
import io.kestra.core.models.triggers.Trigger;
import io.kestra.core.utils.DateUtils;
import io.kestra.jdbc.repository.AbstractJdbcTriggerRepository;
import io.kestra.jdbc.services.JdbcFilterService;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import jakarta.inject.Singleton;
import org.jooq.Field;
import org.jooq.impl.DSL;
import java.util.Date;
@Singleton
@PostgresRepositoryEnabled
public class PostgresTriggerRepository extends AbstractJdbcTriggerRepository {
@Inject
public PostgresTriggerRepository(@Named("triggers") PostgresRepository<Trigger> repository) {
super(repository);
public PostgresTriggerRepository(@Named("triggers") PostgresRepository<Trigger> repository,
JdbcFilterService filterService) {
super(repository, filterService);
}
@Override
protected Field<Date> formatDateField(String dateField, DateUtils.GroupType groupType) {
switch (groupType) {
case MONTH:
return DSL.field("TO_CHAR({0}, 'YYYY-MM')", Date.class, DSL.field(dateField));
case WEEK:
return DSL.field("TO_CHAR({0}, 'IYYY-IW')", Date.class, DSL.field(dateField));
case DAY:
return DSL.field("DATE({0})", Date.class, DSL.field(dateField));
case HOUR:
return DSL.field("TO_CHAR({0}, 'YYYY-MM-DD HH24:00:00')", Date.class, DSL.field(dateField));
case MINUTE:
return DSL.field("TO_CHAR({0}, 'YYYY-MM-DD HH24:MI:00')", Date.class, DSL.field(dateField));
default:
throw new IllegalArgumentException("Unsupported GroupType: " + groupType);
}
}
}

View File

@@ -5,10 +5,13 @@ import io.kestra.core.events.CrudEventType;
import io.kestra.core.models.dashboards.ColumnDescriptor;
import io.kestra.core.models.dashboards.Dashboard;
import io.kestra.core.models.dashboards.DataFilter;
import io.kestra.core.models.dashboards.DataFilterKPI;
import io.kestra.core.models.dashboards.charts.DataChart;
import io.kestra.core.models.dashboards.charts.DataChartKPI;
import io.kestra.core.repositories.ArrayListTotal;
import io.kestra.core.repositories.DashboardRepositoryInterface;
import io.kestra.core.repositories.QueryBuilderInterface;
import io.kestra.plugin.core.dashboard.chart.kpis.KpiOption;
import io.micronaut.context.event.ApplicationEventPublisher;
import io.micronaut.data.model.Pageable;
import jakarta.validation.ConstraintViolationException;
@@ -25,6 +28,8 @@ import java.util.List;
import java.util.Map;
import java.util.Optional;
import static io.kestra.core.utils.MathUtils.roundDouble;
@Slf4j
@AllArgsConstructor
public abstract class AbstractJdbcDashboardRepository extends AbstractJdbcRepository implements DashboardRepositoryInterface {
@@ -167,6 +172,31 @@ public abstract class AbstractJdbcDashboardRepository extends AbstractJdbcReposi
return queryBuilder.fetchData(tenantId, dataChart.getData(), startDate, endDate, pageable);
}
@Override
public <F extends Enum<F>> List<Map<String, Object>> generateKPI(String tenantId, DataChartKPI<?, DataFilterKPI<F, ? extends ColumnDescriptor<F>>> dataChart, ZonedDateTime startDate, ZonedDateTime endDate) throws IOException {
Map<Class<? extends QueryBuilderInterface<?>>, QueryBuilderInterface<?>> queryBuilderByHandledFields = new HashMap<>();
@SuppressWarnings("unchecked")
QueryBuilderInterface<F> queryBuilder = (QueryBuilderInterface<F>) queryBuilderByHandledFields.computeIfAbsent(
dataChart.getData().repositoryClass(),
clazz -> queryBuilders
.stream()
.filter(b -> clazz.isAssignableFrom(b.getClass()))
.findFirst()
.orElseThrow(() -> new UnsupportedOperationException("No query builder found for " + clazz))
);
Double filteredValue = queryBuilder.fetchValue(tenantId, dataChart.getData(), startDate, endDate, true);
if (dataChart.getChartOptions() != null && dataChart.getChartOptions().getNumberType().equals(KpiOption.NumberType.PERCENTAGE)) {
Double totalValue = queryBuilder.fetchValue(tenantId, dataChart.getData(), startDate, endDate, false);
Double percentageValue = (filteredValue / totalValue) * 100;
return List.of(Map.of("value", roundDouble(percentageValue, 2)));
}
return List.of(Map.of("value", roundDouble(filteredValue, 2)));
}
@Override
public Boolean isEnabled() {
return true;

View File

@@ -6,6 +6,7 @@ import io.kestra.core.models.Label;
import io.kestra.core.models.QueryFilter;
import io.kestra.core.models.dashboards.ColumnDescriptor;
import io.kestra.core.models.dashboards.DataFilter;
import io.kestra.core.models.dashboards.DataFilterKPI;
import io.kestra.core.models.dashboards.filters.AbstractFilter;
import io.kestra.core.models.dashboards.filters.Contains;
import io.kestra.core.models.dashboards.filters.In;
@@ -41,7 +42,10 @@ import org.jooq.impl.DSL;
import reactor.core.publisher.Flux;
import reactor.core.publisher.FluxSink;
import java.time.*;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.*;
@@ -1183,7 +1187,7 @@ public abstract class AbstractJdbcExecutionRepository extends AbstractJdbcReposi
);
// Apply Where filter
selectConditionStep = where(selectConditionStep, filterService, descriptors, fieldsMapping);
selectConditionStep = where(selectConditionStep, filterService, descriptors.getWhere(), fieldsMapping);
List<? extends ColumnDescriptor<Executions.Fields>> columnsWithoutDateWithOutAggs = columnsWithoutDate.values().stream()
.filter(column -> column.getAgg() == null)
@@ -1205,6 +1209,42 @@ public abstract class AbstractJdbcExecutionRepository extends AbstractJdbcReposi
});
}
public Double fetchValue(String tenantId, DataFilterKPI<Executions.Fields, ? extends ColumnDescriptor<Executions.Fields>> dataFilter, ZonedDateTime startDate, ZonedDateTime endDate, boolean numeratorFilter) {
return this.jdbcRepository.getDslContextWrapper().transactionResult(configuration -> {
DSLContext context = DSL.using(configuration);
ColumnDescriptor<Executions.Fields> columnDescriptor = dataFilter.getColumns();
String columnKey = this.getFieldsMapping().get(columnDescriptor.getField());
Field<?> field = columnToField(columnDescriptor, getFieldsMapping());
if (columnDescriptor.getAgg() != null) {
field = filterService.buildAggregation(field, columnDescriptor.getAgg());
}
List<AbstractFilter<Executions.Fields>> filters = new ArrayList<>(ListUtils.emptyOnNull(dataFilter.getWhere()));
if (numeratorFilter) {
filters.addAll(dataFilter.getNumerator());
}
SelectConditionStep selectStep = context
.select(field)
.from(this.jdbcRepository.getTable())
.where(this.defaultFilter(tenantId));
var selectConditionStep = where(
selectStep,
filterService,
filters,
getFieldsMapping()
);
Record result = selectConditionStep.fetchOne();
if (result != null) {
return result.getValue(field, Double.class);
} else {
return null;
}
});
}
@Override
protected <F extends Enum<F>> Field<?> columnToField(ColumnDescriptor<?> column, Map<F, String> fieldsMapping) {
if (column.getField() == null) {
@@ -1226,10 +1266,10 @@ public abstract class AbstractJdbcExecutionRepository extends AbstractJdbcReposi
@Override
@SuppressWarnings("unchecked")
protected <F extends Enum<F>> SelectConditionStep<Record> where(SelectConditionStep<Record> selectConditionStep, JdbcFilterService jdbcFilterService, DataFilter<F, ? extends ColumnDescriptor<F>> descriptors, Map<F, String> fieldsMapping) {
if (!ListUtils.isEmpty(descriptors.getWhere())) {
protected <F extends Enum<F>> SelectConditionStep<Record> where(SelectConditionStep<Record> selectConditionStep, JdbcFilterService jdbcFilterService, List<AbstractFilter<F>> filters, Map<F, String> fieldsMapping) {
if (!ListUtils.isEmpty(filters)) {
// Check if descriptors contain a filter of type Executions.Fields.STATE and apply the custom filter "statesFilter" if present
List<In<Executions.Fields>> stateFilters = descriptors.getWhere().stream()
List<In<Executions.Fields>> stateFilters = filters.stream()
.filter(descriptor -> descriptor.getField().equals(Executions.Fields.STATE) && descriptor instanceof In)
.map(descriptor -> (In<Executions.Fields>) descriptor)
.toList();
@@ -1244,7 +1284,7 @@ public abstract class AbstractJdbcExecutionRepository extends AbstractJdbcReposi
}
// Check if descriptors contain a filter of type EXECUTIONS.Fields.LABELS and apply the findCondition() method if present
List<Contains<Executions.Fields>> labelFilters = descriptors.getWhere().stream()
List<Contains<Executions.Fields>> labelFilters = filters.stream()
.filter(descriptor -> descriptor.getField().equals(Executions.Fields.LABELS) && descriptor instanceof Contains<F>)
.map(descriptor -> (Contains<Executions.Fields>) descriptor)
.toList();
@@ -1264,7 +1304,7 @@ public abstract class AbstractJdbcExecutionRepository extends AbstractJdbcReposi
}
// Remove the state filters from descriptors
List<AbstractFilter<F>> remainingFilters = descriptors.getWhere().stream()
List<AbstractFilter<F>> remainingFilters = filters.stream()
.filter(descriptor -> !descriptor.getField().equals(Executions.Fields.STATE) || !(descriptor instanceof In)) // Filter state
.filter(descriptor -> !descriptor.getField().equals(Executions.Fields.LABELS) || !(descriptor instanceof Contains<F>)) // Filter labels
.toList();
@@ -1279,21 +1319,4 @@ public abstract class AbstractJdbcExecutionRepository extends AbstractJdbcReposi
}
abstract protected Field<Date> formatDateField(String dateField, DateUtils.GroupType groupType);
protected <F extends Enum<F>> List<Field<Date>> generateDateFields(
DataFilter<F, ? extends ColumnDescriptor<F>> descriptors,
Map<F, String> fieldsMapping,
ZonedDateTime startDate,
ZonedDateTime endDate,
Set<F> dateFields
) {
return descriptors.getColumns().entrySet().stream()
.filter(entry -> entry.getValue().getAgg() == null && dateFields.contains(entry.getValue().getField()))
.map(entry -> {
Duration duration = Duration.between(startDate, endDate);
return formatDateField(fieldsMapping.get(entry.getValue().getField()), DateUtils.groupByType(duration)).as(entry.getKey());
})
.toList();
}
}

View File

@@ -3,6 +3,8 @@ package io.kestra.jdbc.repository;
import io.kestra.core.models.QueryFilter;
import io.kestra.core.models.dashboards.ColumnDescriptor;
import io.kestra.core.models.dashboards.DataFilter;
import io.kestra.core.models.dashboards.DataFilterKPI;
import io.kestra.core.models.dashboards.filters.AbstractFilter;
import io.kestra.core.models.executions.Execution;
import io.kestra.core.models.executions.LogEntry;
import io.kestra.core.models.executions.statistics.LogStatistics;
@@ -15,8 +17,8 @@ import io.kestra.plugin.core.dashboard.data.Logs;
import io.micronaut.data.model.Pageable;
import jakarta.annotation.Nullable;
import lombok.Getter;
import org.jooq.Record;
import org.jooq.*;
import org.jooq.Record;
import org.jooq.impl.DSL;
import org.slf4j.event.Level;
import reactor.core.publisher.Flux;
@@ -28,8 +30,8 @@ import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.Comparator;
import java.util.*;
import java.util.Comparator;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@@ -660,6 +662,53 @@ public abstract class AbstractJdbcLogRepository extends AbstractJdbcRepository i
return field("level").in(levels.stream().map(level -> level.name()).toList());
}
public Double fetchValue(String tenantId, DataFilterKPI<Logs.Fields, ? extends ColumnDescriptor<Logs.Fields>> dataFilter, ZonedDateTime startDate, ZonedDateTime endDate, boolean numeratorFilter) {
return this.jdbcRepository.getDslContextWrapper().transactionResult(configuration -> {
DSLContext context = DSL.using(configuration);
ColumnDescriptor<Logs.Fields> columnDescriptor = dataFilter.getColumns();
String columnKey = this.getFieldsMapping().get(columnDescriptor.getField());
Field<?> field = columnToField(columnDescriptor, getFieldsMapping());
if (columnDescriptor.getAgg() != null) {
field = filterService.buildAggregation(field, columnDescriptor.getAgg());
}
List<AbstractFilter<Logs.Fields>> filters = new ArrayList<>(ListUtils.emptyOnNull(dataFilter.getWhere()));
if (numeratorFilter) {
filters.addAll(dataFilter.getNumerator());
}
SelectConditionStep selectStep = context
.select(field)
.from(this.jdbcRepository.getTable())
.where(this.defaultFilter(tenantId));
var selectConditionStep = where(
selectStep,
filterService,
filters,
getFieldsMapping()
);
Record result = selectConditionStep.fetchOne();
if (result != null) {
return result.getValue(field, Double.class);
} else {
return null;
}
});
}
private Field<?> aggregate(String aggregation) {
return switch (aggregation) {
case "avg" -> DSL.avg(field("attempt_number", Double.class)).as("metric_value");
case "sum" -> DSL.sum(field("attempt_number", Double.class)).as("metric_value");
case "min" -> DSL.min(field("attempt_number", Double.class)).as("metric_value");
case "max" -> DSL.max(field("attempt_number", Double.class)).as("metric_value");
case "count" -> DSL.count().as("metric_value");
default -> throw new IllegalArgumentException("Invalid aggregation: " + aggregation);
};
}
@Override
public ArrayListTotal<Map<String, Object>> fetchData(
String tenantId,
@@ -692,7 +741,7 @@ public abstract class AbstractJdbcLogRepository extends AbstractJdbcRepository i
);
// Apply Where filter
selectConditionStep = where(selectConditionStep, filterService, descriptors, getWhereMapping());
selectConditionStep = where(selectConditionStep, filterService, descriptors.getWhere(), getWhereMapping());
List<? extends ColumnDescriptor<Logs.Fields>> columnsWithoutDateWithOutAggs = columnsWithoutDate.values().stream()
.filter(column -> column.getAgg() == null)
@@ -715,21 +764,4 @@ public abstract class AbstractJdbcLogRepository extends AbstractJdbcRepository i
}
abstract protected Field<Date> formatDateField(String dateField, DateUtils.GroupType groupType);
protected <F extends Enum<F>> List<Field<Date>> generateDateFields(
DataFilter<F, ? extends ColumnDescriptor<F>> descriptors,
Map<F, String> fieldsMapping,
ZonedDateTime startDate,
ZonedDateTime endDate,
Set<F> dateFields
) {
return descriptors.getColumns().entrySet().stream()
.filter(entry -> entry.getValue().getAgg() == null && dateFields.contains(entry.getValue().getField()))
.map(entry -> {
Duration duration = Duration.between(startDate, endDate);
return formatDateField(fieldsMapping.get(entry.getValue().getField()), DateUtils.groupByType(duration)).as(entry.getKey());
})
.toList();
}
}

View File

@@ -2,6 +2,8 @@ package io.kestra.jdbc.repository;
import io.kestra.core.models.dashboards.ColumnDescriptor;
import io.kestra.core.models.dashboards.DataFilter;
import io.kestra.core.models.dashboards.DataFilterKPI;
import io.kestra.core.models.dashboards.filters.AbstractFilter;
import io.kestra.core.models.executions.Execution;
import io.kestra.core.models.executions.MetricEntry;
import io.kestra.core.models.executions.metrics.MetricAggregation;
@@ -15,8 +17,8 @@ import io.kestra.plugin.core.dashboard.data.Metrics;
import io.micrometer.common.lang.Nullable;
import io.micronaut.data.model.Pageable;
import lombok.Getter;
import org.jooq.Record;
import org.jooq.*;
import org.jooq.Record;
import org.jooq.impl.DSL;
import reactor.core.publisher.Flux;
import reactor.core.publisher.FluxSink;
@@ -374,6 +376,43 @@ public abstract class AbstractJdbcMetricRepository extends AbstractJdbcRepositor
return mapper::get;
}
public Double fetchValue(String tenantId, DataFilterKPI<Metrics.Fields, ? extends ColumnDescriptor<Metrics.Fields>> dataFilter, ZonedDateTime startDate, ZonedDateTime endDate, boolean numeratorFilter) {
return this.jdbcRepository.getDslContextWrapper().transactionResult(configuration -> {
DSLContext context = DSL.using(configuration);
ColumnDescriptor<Metrics.Fields> columnDescriptor = dataFilter.getColumns();
String columnKey = this.getFieldsMapping().get(columnDescriptor.getField());
Field<?> field = columnToField(columnDescriptor, getFieldsMapping());
if (columnDescriptor.getAgg() != null) {
field = filterService.buildAggregation(field, columnDescriptor.getAgg());
}
List<AbstractFilter<Metrics.Fields>> filters = new ArrayList<>(ListUtils.emptyOnNull(dataFilter.getWhere()));
if (numeratorFilter) {
filters.addAll(dataFilter.getNumerator());
}
SelectConditionStep selectStep = context
.select(field)
.from(this.jdbcRepository.getTable())
.where(this.defaultFilter(tenantId));
var selectConditionStep = where(
selectStep,
filterService,
filters,
getFieldsMapping()
);
Record result = selectConditionStep.fetchOne();
if (result != null) {
return result.getValue(field, Double.class);
} else {
return null;
}
});
}
@Override
public ArrayListTotal<Map<String, Object>> fetchData(
String tenantId,
@@ -406,7 +445,7 @@ public abstract class AbstractJdbcMetricRepository extends AbstractJdbcRepositor
);
// Apply Where filter
selectConditionStep = where(selectConditionStep, filterService, descriptors, fieldsMapping);
selectConditionStep = where(selectConditionStep, filterService, descriptors.getWhere(), fieldsMapping);
List<? extends ColumnDescriptor<Metrics.Fields>> columnsWithoutDateWithOutAggs = columnsWithoutDate.values().stream()
.filter(column -> column.getAgg() == null)
@@ -429,21 +468,4 @@ public abstract class AbstractJdbcMetricRepository extends AbstractJdbcRepositor
}
abstract protected Field<Date> formatDateField(String dateField, DateUtils.GroupType groupType);
protected <F extends Enum<F>> List<Field<Date>> generateDateFields(
DataFilter<F, ? extends ColumnDescriptor<F>> descriptors,
Map<F, String> fieldsMapping,
ZonedDateTime startDate,
ZonedDateTime endDate,
Set<F> dateFields
) {
return descriptors.getColumns().entrySet().stream()
.filter(entry -> entry.getValue().getAgg() == null && dateFields.contains(entry.getValue().getField()))
.map(entry -> {
Duration duration = Duration.between(startDate, endDate);
return formatDateField(fieldsMapping.get(entry.getValue().getField()), DateUtils.groupByType(duration)).as(entry.getKey());
})
.toList();
}
}

View File

@@ -4,6 +4,7 @@ import io.kestra.core.models.QueryFilter;
import io.kestra.core.models.dashboards.ColumnDescriptor;
import io.kestra.core.models.dashboards.DataFilter;
import io.kestra.core.models.dashboards.Order;
import io.kestra.core.models.dashboards.filters.AbstractFilter;
import io.kestra.core.models.executions.LogEntry;
import io.kestra.core.models.flows.FlowScope;
import io.kestra.core.models.flows.State;
@@ -16,8 +17,8 @@ import io.micronaut.context.annotation.Value;
import io.micronaut.core.annotation.Nullable;
import io.micronaut.data.model.Pageable;
import lombok.Getter;
import org.jooq.Record;
import org.jooq.*;
import org.jooq.Record;
import org.jooq.impl.DSL;
import org.slf4j.event.Level;
@@ -25,12 +26,8 @@ import java.sql.Timestamp;
import java.time.Duration;
import java.time.OffsetDateTime;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import java.util.*;
import java.util.stream.Stream;
import static io.kestra.core.utils.NamespaceUtils.SYSTEM_FLOWS_DEFAULT_NAMESPACE;
@@ -160,8 +157,8 @@ public abstract class AbstractJdbcRepository {
* @param <F> the type of the fields enum
* @return the select condition step with the applied filters
*/
protected <F extends Enum<F>> SelectConditionStep<Record> where(SelectConditionStep<Record> selectConditionStep, JdbcFilterService jdbcFilterService, DataFilter<F, ? extends ColumnDescriptor<F>> descriptors, Map<F, String> fieldsMapping) {
return jdbcFilterService.addFilters(selectConditionStep, fieldsMapping, descriptors.getWhere());
protected <F extends Enum<F>> SelectConditionStep<Record> where(SelectConditionStep<Record> selectConditionStep, JdbcFilterService jdbcFilterService, List<AbstractFilter<F>> filters, Map<F, String> fieldsMapping) {
return jdbcFilterService.addFilters(selectConditionStep, fieldsMapping, filters);
}
/**
@@ -470,4 +467,26 @@ public abstract class AbstractJdbcRepository {
return select;
}
protected Field<Date> formatDateField(String dateField, DateUtils.GroupType groupType) {
throw new UnsupportedOperationException("formatDateField() not implemented");
}
protected <F extends Enum<F>> List<Field<Date>> generateDateFields(
DataFilter<F, ? extends ColumnDescriptor<F>> descriptors,
Map<F, String> fieldsMapping,
ZonedDateTime startDate,
ZonedDateTime endDate,
Set<F> dateFields
) {
return descriptors.getColumns().entrySet().stream()
.filter(entry -> entry.getValue().getAgg() == null && dateFields.contains(entry.getValue().getField()))
.map(entry -> {
Duration duration = Duration.between(startDate, endDate);
return formatDateField(fieldsMapping.get(entry.getValue().getField()), DateUtils.groupByType(duration)).as(entry.getKey());
})
.toList();
}
}

View File

@@ -2,6 +2,10 @@ package io.kestra.jdbc.repository;
import io.kestra.core.models.QueryFilter;
import io.kestra.core.models.conditions.ConditionContext;
import io.kestra.core.models.dashboards.ColumnDescriptor;
import io.kestra.core.models.dashboards.DataFilter;
import io.kestra.core.models.dashboards.DataFilterKPI;
import io.kestra.core.models.dashboards.filters.AbstractFilter;
import io.kestra.core.models.executions.Execution;
import io.kestra.core.models.flows.Flow;
import io.kestra.core.models.triggers.AbstractTrigger;
@@ -10,28 +14,60 @@ import io.kestra.core.models.triggers.TriggerContext;
import io.kestra.core.repositories.ArrayListTotal;
import io.kestra.core.repositories.TriggerRepositoryInterface;
import io.kestra.core.schedulers.ScheduleContextInterface;
import io.kestra.core.utils.DateUtils;
import io.kestra.core.utils.ListUtils;
import io.kestra.jdbc.runner.JdbcQueueIndexerInterface;
import io.kestra.jdbc.runner.JdbcSchedulerContext;
import io.kestra.jdbc.services.JdbcFilterService;
import io.kestra.plugin.core.dashboard.data.ITriggers;
import io.kestra.plugin.core.dashboard.data.Triggers;
import io.micronaut.data.model.Pageable;
import jakarta.annotation.Nullable;
import lombok.Getter;
import org.jooq.*;
import org.jooq.Record;
import org.jooq.impl.DSL;
import reactor.core.publisher.Flux;
import reactor.core.publisher.FluxSink;
import java.time.ZonedDateTime;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
public abstract class AbstractJdbcTriggerRepository extends AbstractJdbcRepository implements TriggerRepositoryInterface, JdbcQueueIndexerInterface<Trigger> {
public static final Field<Object> NAMESPACE_FIELD = field("namespace");
protected io.kestra.jdbc.AbstractJdbcRepository<Trigger> jdbcRepository;
public AbstractJdbcTriggerRepository(io.kestra.jdbc.AbstractJdbcRepository<Trigger> jdbcRepository) {
private final JdbcFilterService filterService;
@Getter
private final Map<Triggers.Fields, String> fieldsMapping = Map.of(
Triggers.Fields.ID, "key",
Triggers.Fields.NAMESPACE, "namespace",
Triggers.Fields.FLOW_ID, "flow_id",
Triggers.Fields.TRIGGER_ID, "trigger_id",
Triggers.Fields.EXECUTION_ID, "execution_id",
Triggers.Fields.NEXT_EXECUTION_DATE, "next_execution_date",
Triggers.Fields.WORKER_ID, "worker_id"
);
@Override
public Set<Triggers.Fields> dateFields() {
return Set.of();
}
@Override
public Triggers.Fields dateFilterField() {
return null;
}
public AbstractJdbcTriggerRepository(io.kestra.jdbc.AbstractJdbcRepository<Trigger> jdbcRepository,
JdbcFilterService filterService) {
this.jdbcRepository = jdbcRepository;
this.filterService = filterService;
}
@Override
@@ -381,4 +417,99 @@ public abstract class AbstractJdbcTriggerRepository extends AbstractJdbcReposito
return s -> mapper.getOrDefault(s, s);
}
@Override
public ArrayListTotal<Map<String, Object>> fetchData(
String tenantId,
DataFilter<Triggers.Fields, ? extends ColumnDescriptor<Triggers.Fields>> descriptors,
ZonedDateTime startDate,
ZonedDateTime endDate,
Pageable pageable
) {
return this.jdbcRepository
.getDslContextWrapper()
.transactionResult(configuration -> {
DSLContext context = DSL.using(configuration);
Map<String, ? extends ColumnDescriptor<Triggers.Fields>> columnsWithoutDate = descriptors.getColumns().entrySet().stream()
.filter(entry -> entry.getValue().getField() == null || !dateFields().contains(entry.getValue().getField()))
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
// Generate custom fields for date as they probably need formatting
List<Field<Date>> dateFields = generateDateFields(descriptors, fieldsMapping, startDate, endDate, dateFields());
// Init request
SelectConditionStep<Record> selectConditionStep = select(
context,
filterService,
columnsWithoutDate,
dateFields,
this.getFieldsMapping(),
this.jdbcRepository.getTable(),
tenantId
);
// Apply Where filter
selectConditionStep = where(selectConditionStep, filterService, descriptors.getWhere(), fieldsMapping);
List<? extends ColumnDescriptor<Triggers.Fields>> columnsWithoutDateWithOutAggs = columnsWithoutDate.values().stream()
.filter(column -> column.getAgg() == null)
.toList();
// Apply GroupBy for aggregation
SelectHavingStep<Record> selectHavingStep = groupBy(
selectConditionStep,
columnsWithoutDateWithOutAggs,
dateFields,
fieldsMapping
);
// Apply OrderBy
SelectSeekStepN<Record> selectSeekStep = orderBy(selectHavingStep, descriptors);
// Fetch and paginate if provided
return fetchSeekStep(selectSeekStep, pageable);
});
}
public Double fetchValue(String tenantId, DataFilterKPI<ITriggers.Fields, ? extends ColumnDescriptor<ITriggers.Fields>> dataFilter, ZonedDateTime startDate, ZonedDateTime endDate, boolean numeratorFilter) {
return this.jdbcRepository.getDslContextWrapper().transactionResult(configuration -> {
DSLContext context = DSL.using(configuration);
ColumnDescriptor<ITriggers.Fields> columnDescriptor = dataFilter.getColumns();
String columnKey = this.getFieldsMapping().get(columnDescriptor.getField());
Field<?> field = columnToField(columnDescriptor, getFieldsMapping());
if (columnDescriptor.getAgg() != null) {
field = filterService.buildAggregation(field, columnDescriptor.getAgg());
}
List<AbstractFilter<ITriggers.Fields>> filters = new ArrayList<>(ListUtils.emptyOnNull(dataFilter.getWhere()));
if (numeratorFilter) {
filters.addAll(dataFilter.getNumerator());
}
SelectConditionStep selectStep = context
.select(field)
.from(this.jdbcRepository.getTable())
.where(this.defaultFilter(tenantId));
var selectConditionStep = where(
selectStep,
filterService,
filters,
getFieldsMapping()
);
Record result = selectConditionStep.fetchOne();
if (result != null) {
return result.getValue(field, Double.class);
} else {
return null;
}
});
}
abstract protected Field<Date> formatDateField(String dateField, DateUtils.GroupType groupType);
}

View File

@@ -7,7 +7,6 @@ const config: StorybookConfig = {
addons: [
"@storybook/addon-essentials",
"@storybook/addon-themes",
"@chromatic-com/storybook",
"@storybook/experimental-addon-test"
],
framework: {

121
ui/package-lock.json generated
View File

@@ -64,7 +64,6 @@
"yaml": "^2.7.1"
},
"devDependencies": {
"@chromatic-com/storybook": "^3.2.5",
"@codecov/vite-plugin": "^1.9.1",
"@esbuild-plugins/node-modules-polyfill": "^0.2.2",
"@eslint/js": "^9.27.0",
@@ -959,27 +958,6 @@
"integrity": "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==",
"license": "Apache-2.0"
},
"node_modules/@chromatic-com/storybook": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/@chromatic-com/storybook/-/storybook-3.2.6.tgz",
"integrity": "sha512-FDmn5Ry2DzQdik+eq2sp/kJMMT36Ewe7ONXUXM2Izd97c7r6R/QyGli8eyh/F0iyqVvbLveNYFyF0dBOJNwLqw==",
"dev": true,
"license": "MIT",
"dependencies": {
"chromatic": "^11.15.0",
"filesize": "^10.0.12",
"jsonfile": "^6.1.0",
"react-confetti": "^6.1.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=16.0.0",
"yarn": ">=1.22.18"
},
"peerDependencies": {
"storybook": "^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0"
}
},
"node_modules/@codecov/bundler-plugin-core": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@codecov/bundler-plugin-core/-/bundler-plugin-core-1.9.1.tgz",
@@ -6225,9 +6203,9 @@
}
},
"node_modules/@types/react": {
"version": "19.1.4",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.4.tgz",
"integrity": "sha512-EB1yiiYdvySuIITtD5lhW4yPyJ31RkJkkDw794LaQYrxCSaQV/47y5o1FMC4zF9ZyjUjzJMZwbovEnT5yHTW6g==",
"version": "19.1.5",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.5.tgz",
"integrity": "sha512-piErsCVVbpMMT2r7wbawdZsq4xMvIAhQuac2gedQHysu1TZYEigE6pnFfgZT+/jQnrRuF5r+SHzuehFjfRjr4g==",
"dev": true,
"license": "MIT",
"peer": true,
@@ -6905,30 +6883,30 @@
}
},
"node_modules/@volar/language-core": {
"version": "2.4.13",
"resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.13.tgz",
"integrity": "sha512-MnQJ7eKchJx5Oz+YdbqyFUk8BN6jasdJv31n/7r6/WwlOOv7qzvot6B66887l2ST3bUW4Mewml54euzpJWA6bg==",
"version": "2.4.14",
"resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.14.tgz",
"integrity": "sha512-X6beusV0DvuVseaOEy7GoagS4rYHgDHnTrdOj5jeUb49fW5ceQyP9Ej5rBhqgz2wJggl+2fDbbojq1XKaxDi6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@volar/source-map": "2.4.13"
"@volar/source-map": "2.4.14"
}
},
"node_modules/@volar/source-map": {
"version": "2.4.13",
"resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.13.tgz",
"integrity": "sha512-l/EBcc2FkvHgz2ZxV+OZK3kMSroMr7nN3sZLF2/f6kWW66q8+tEL4giiYyFjt0BcubqJhBt6soYIrAPhg/Yr+Q==",
"version": "2.4.14",
"resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.14.tgz",
"integrity": "sha512-5TeKKMh7Sfxo8021cJfmBzcjfY1SsXsPMMjMvjY7ivesdnybqqS+GxGAoXHAOUawQTwtdUxgP65Im+dEmvWtYQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@volar/typescript": {
"version": "2.4.13",
"resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.13.tgz",
"integrity": "sha512-Ukz4xv84swJPupZeoFsQoeJEOm7U9pqsEnaGGgt5ni3SCTa22m8oJP5Nng3Wed7Uw5RBELdLxxORX8YhJPyOgQ==",
"version": "2.4.14",
"resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.14.tgz",
"integrity": "sha512-p8Z6f/bZM3/HyCdRNFZOEEzts51uV8WHeN8Tnfnm2EBv6FDB2TQLzfVx7aJvnl8ofKAOnS64B2O8bImBFaauRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@volar/language-core": "2.4.13",
"@volar/language-core": "2.4.14",
"path-browserify": "^1.0.1",
"vscode-uri": "^3.0.8"
}
@@ -8502,30 +8480,6 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/chromatic": {
"version": "11.28.2",
"resolved": "https://registry.npmjs.org/chromatic/-/chromatic-11.28.2.tgz",
"integrity": "sha512-aCmUPcZUs4/p9zRZdMreOoO/5JqO2DiJC3md1/vRx8dlMRcmR/YI5ZbgXZcai2absVR+6hsXZ5XiPxV2sboTuQ==",
"dev": true,
"license": "MIT",
"bin": {
"chroma": "dist/bin.js",
"chromatic": "dist/bin.js",
"chromatic-cli": "dist/bin.js"
},
"peerDependencies": {
"@chromatic-com/cypress": "^0.*.* || ^1.0.0",
"@chromatic-com/playwright": "^0.*.* || ^1.0.0"
},
"peerDependenciesMeta": {
"@chromatic-com/cypress": {
"optional": true
},
"@chromatic-com/playwright": {
"optional": true
}
}
},
"node_modules/ci-info": {
"version": "3.9.0",
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz",
@@ -11113,16 +11067,6 @@
"node": ">=4"
}
},
"node_modules/filesize": {
"version": "10.1.6",
"resolved": "https://registry.npmjs.org/filesize/-/filesize-10.1.6.tgz",
"integrity": "sha512-sJslQKU2uM33qH5nqewAwVB2QgR6w1aMNsYUp3aN5rMRyXEwJGmZvaWzeJFNTOXWlHQyBFCWrdj3fV/fsTOX8w==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">= 10.4.0"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -12742,6 +12686,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/is-promise": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz",
"integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==",
"dev": true,
"license": "MIT"
},
"node_modules/is-regex": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
@@ -14367,13 +14318,6 @@
"promise": "^7.0.1"
}
},
"node_modules/jstransformer/node_modules/is-promise": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz",
"integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==",
"dev": true,
"license": "MIT"
},
"node_modules/jump.js": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/jump.js/-/jump.js-1.0.2.tgz",
@@ -18163,22 +18107,6 @@
"node": ">=0.10.0"
}
},
"node_modules/react-confetti": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/react-confetti/-/react-confetti-6.4.0.tgz",
"integrity": "sha512-5MdGUcqxrTU26I2EU7ltkWPwxvucQTuqMm8dUz72z2YMqTD6s9vMcDUysk7n9jnC+lXuCPeJJ7Knf98VEYE9Rg==",
"dev": true,
"license": "MIT",
"dependencies": {
"tween-functions": "^1.2.0"
},
"engines": {
"node": ">=16"
},
"peerDependencies": {
"react": "^16.3.0 || ^17.0.1 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-dom": {
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
@@ -20320,13 +20248,6 @@
"node": ">=0.6.11 <=0.7.0 || >=0.7.3"
}
},
"node_modules/tween-functions": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/tween-functions/-/tween-functions-1.2.0.tgz",
"integrity": "sha512-PZBtLYcCLtEcjL14Fzb1gSxPBeL7nWvGhO5ZFPGqziCcr8uvHp0NDmdjBchp6KHL+tExcg0m3NISmKxhU394dA==",
"dev": true,
"license": "BSD"
},
"node_modules/type": {
"version": "2.7.3",
"resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz",

View File

@@ -76,7 +76,6 @@
"yaml": "^2.7.1"
},
"devDependencies": {
"@chromatic-com/storybook": "^3.2.5",
"@codecov/vite-plugin": "^1.9.1",
"@esbuild-plugins/node-modules-polyfill": "^0.2.2",
"@eslint/js": "^9.27.0",

View File

@@ -0,0 +1,205 @@
title: Overview
description: Default overview dashboard
timeWindow:
default: P30D # P30DT30H
max: P365D
charts:
- id: kpi_success_ratio
type: io.kestra.plugin.core.dashboard.chart.KPI
chartOptions:
displayName: Success Ratio
numberType: PERCENTAGE
width: 3
data:
type: io.kestra.plugin.core.dashboard.data.ExecutionsKPI
columns:
field: ID
agg: COUNT
numerator:
- type: IN
field: STATE
values:
- SUCCESS
- id: kpi_failed_ratio
type: io.kestra.plugin.core.dashboard.chart.KPI
chartOptions:
displayName: Failed Ratio
numberType: PERCENTAGE
width: 3
data:
type: io.kestra.plugin.core.dashboard.data.ExecutionsKPI
columns:
field: ID
agg: COUNT
numerator:
- type: IN
field: STATE
values:
- FAILED
- id: kpi_in_progress_ration
type: io.kestra.plugin.core.dashboard.chart.KPI
chartOptions:
displayName: In progress Ratio
numberType: PERCENTAGE
width: 3
data:
type: io.kestra.plugin.core.dashboard.data.ExecutionsKPI
columns:
field: ID
agg: COUNT
numerator:
- type: IN
field: STATE
values:
- RUNNING
- PAUSED
- KILLING
- RETRYING
- RESTARTED
- id: kpi_pending_ration
type: io.kestra.plugin.core.dashboard.chart.KPI
chartOptions:
displayName: In Pending Ratio
numberType: PERCENTAGE
width: 3
data:
type: io.kestra.plugin.core.dashboard.data.ExecutionsKPI
columns:
field: ID
agg: COUNT
numerator:
- type: IN
field: STATE
values:
- CREATED
- QUEUED
- id: description
type: io.kestra.plugin.core.dashboard.chart.Markdown
chartOptions:
displayName: Description
width: 12
source:
type: FlowDescription
flowId: {{flowId}}
namespace: {{namespace}}
- id: total_executions_timeseries
type: io.kestra.plugin.core.dashboard.chart.TimeSeries
chartOptions:
width: 9
displayName: Total Executions
description: Executions duration and count per date
legend:
enabled: true
column: date
colorByColumn: state
data:
type: io.kestra.plugin.core.dashboard.data.Executions
columns:
date:
field: START_DATE
displayName: Date
state:
field: STATE
total:
displayName: Executions
agg: COUNT
graphStyle: BARS
duration:
displayName: Duration
field: DURATION
agg: SUM
graphStyle: LINES
- id: total_executions_pie
type: io.kestra.plugin.core.dashboard.chart.Pie
chartOptions:
displayName: Total Executions
graphStyle: DONUT
tooltip: ALL
width: 3
data:
type: io.kestra.plugin.core.dashboard.data.Executions
columns:
id:
field: ID
displayName: Execution Id
agg: COUNT
state:
displayName: State
field: STATE
- id: executions_in_progress
type: io.kestra.plugin.core.dashboard.chart.Table
data:
type: io.kestra.plugin.core.dashboard.data.Executions
columns:
id:
field: ID
displayName: Execution Id
namespace:
field: NAMESPACE
displayName: Namespace
flow:
field: FLOW_ID
displayName: Flow
duration:
field: DURATION
displayName: Duration
state:
displayName: State
field: STATE
- id: next_executions
chartOptions:
displayName: Next Executions
width: 6
type: io.kestra.plugin.core.dashboard.chart.Table
data:
type: io.kestra.plugin.core.dashboard.data.Triggers
columns:
flowId:
field: FLOW_ID
displayName: FlowId
nextExec:
field: NEXT_EXECUTION_DATE
displayName: nextExec
- id: executions_per_namespace_bars
type: io.kestra.plugin.core.dashboard.chart.Bar
chartOptions:
displayName: Executions (per namespace)
description: Executions count per namespace
legend:
enabled: true
column: namespace
width: 12
data:
type: io.kestra.plugin.core.dashboard.data.Executions
columns:
namespace:
field: NAMESPACE
state:
field: STATE
total:
displayName: Executions
agg: COUNT
- id: logs_timeseries
type: io.kestra.plugin.core.dashboard.chart.TimeSeries
chartOptions:
displayName: Logs
description: Logs count per date grouped by level
legend:
enabled: true
column: date
colorByColumn: level
width: 12
data:
type: io.kestra.plugin.core.dashboard.data.Logs
columns:
date:
field: DATE
displayName: Execution Date
level:
field: LEVEL
total:
displayName: Total Executions
agg: COUNT
graphStyle: BARS

View File

@@ -0,0 +1,197 @@
title: Overview
description: Default overview dashboard
timeWindow:
default: P30D # P30DT30H
max: P365D
charts:
- id: kpi_success_ratio
type: io.kestra.plugin.core.dashboard.chart.KPI
chartOptions:
displayName: Success Ratio
numberType: PERCENTAGE
width: 3
data:
type: io.kestra.plugin.core.dashboard.data.ExecutionsKPI
columns:
field: ID
agg: COUNT
numerator:
- type: IN
field: STATE
values:
- SUCCESS
- id: kpi_failed_ratio
type: io.kestra.plugin.core.dashboard.chart.KPI
chartOptions:
displayName: Failed Ratio
numberType: PERCENTAGE
width: 3
data:
type: io.kestra.plugin.core.dashboard.data.ExecutionsKPI
columns:
field: ID
agg: COUNT
numerator:
- type: IN
field: STATE
values:
- FAILED
- id: kpi_in_progress_ration
type: io.kestra.plugin.core.dashboard.chart.KPI
chartOptions:
displayName: In progress Ratio
numberType: PERCENTAGE
width: 3
data:
type: io.kestra.plugin.core.dashboard.data.ExecutionsKPI
columns:
field: ID
agg: COUNT
numerator:
- type: IN
field: STATE
values:
- RUNNING
- PAUSED
- KILLING
- RETRYING
- RESTARTED
- id: kpi_pending_ration
type: io.kestra.plugin.core.dashboard.chart.KPI
chartOptions:
displayName: In Pending Ratio
numberType: PERCENTAGE
width: 3
data:
type: io.kestra.plugin.core.dashboard.data.ExecutionsKPI
columns:
field: ID
agg: COUNT
numerator:
- type: IN
field: STATE
values:
- CREATED
- QUEUED
- id: total_executions_timeseries
type: io.kestra.plugin.core.dashboard.chart.TimeSeries
chartOptions:
width: 9
displayName: Total Executions
description: Executions duration and count per date
legend:
enabled: true
column: date
colorByColumn: state
data:
type: io.kestra.plugin.core.dashboard.data.Executions
columns:
date:
field: START_DATE
displayName: Date
state:
field: STATE
total:
displayName: Executions
agg: COUNT
graphStyle: BARS
duration:
displayName: Duration
field: DURATION
agg: SUM
graphStyle: LINES
- id: total_executions_pie
type: io.kestra.plugin.core.dashboard.chart.Pie
chartOptions:
displayName: Total Executions
graphStyle: DONUT
tooltip: ALL
width: 3
data:
type: io.kestra.plugin.core.dashboard.data.Executions
columns:
id:
field: ID
displayName: Execution Id
agg: COUNT
state:
displayName: State
field: STATE
- id: executions_in_progress
type: io.kestra.plugin.core.dashboard.chart.Table
data:
type: io.kestra.plugin.core.dashboard.data.Executions
columns:
id:
field: ID
displayName: Execution Id
namespace:
field: NAMESPACE
displayName: Namespace
flow:
field: FLOW_ID
displayName: Flow
duration:
field: DURATION
displayName: Duration
state:
displayName: State
field: STATE
- id: next_executions
chartOptions:
displayName: Next Executions
width: 6
type: io.kestra.plugin.core.dashboard.chart.Table
data:
type: io.kestra.plugin.core.dashboard.data.Triggers
columns:
flowId:
field: FLOW_ID
displayName: FlowId
nextExec:
field: NEXT_EXECUTION_DATE
displayName: nextExec
- id: executions_per_namespace_bars
type: io.kestra.plugin.core.dashboard.chart.Bar
chartOptions:
displayName: Executions (per namespace)
description: Executions count per namespace
legend:
enabled: true
column: namespace
width: 12
data:
type: io.kestra.plugin.core.dashboard.data.Executions
columns:
namespace:
field: NAMESPACE
state:
field: STATE
total:
displayName: Executions
agg: COUNT
- id: logs_timeseries
type: io.kestra.plugin.core.dashboard.chart.TimeSeries
chartOptions:
displayName: Logs
description: Logs count per date grouped by level
legend:
enabled: true
column: date
colorByColumn: level
width: 12
data:
type: io.kestra.plugin.core.dashboard.data.Logs
columns:
date:
field: DATE
displayName: Execution Date
level:
field: LEVEL
total:
displayName: Total Executions
agg: COUNT
graphStyle: BARS

View File

@@ -0,0 +1,196 @@
title: Overview
description: Default overview dashboard
timeWindow:
default: P30D # P30DT30H
max: P365D
charts:
- id: kpi_success_ratio
type: io.kestra.plugin.core.dashboard.chart.KPI
chartOptions:
displayName: Success Ratio
numberType: PERCENTAGE
width: 3
data:
type: io.kestra.plugin.core.dashboard.data.ExecutionsKPI
columns:
field: ID
agg: COUNT
numerator:
- type: IN
field: STATE
values:
- SUCCESS
- id: kpi_failed_ratio
type: io.kestra.plugin.core.dashboard.chart.KPI
chartOptions:
displayName: Failed Ratio
numberType: PERCENTAGE
width: 3
data:
type: io.kestra.plugin.core.dashboard.data.ExecutionsKPI
columns:
field: ID
agg: COUNT
numerator:
- type: IN
field: STATE
values:
- FAILED
- id: kpi_in_progress_ration
type: io.kestra.plugin.core.dashboard.chart.KPI
chartOptions:
displayName: In progress Ratio
numberType: PERCENTAGE
width: 3
data:
type: io.kestra.plugin.core.dashboard.data.ExecutionsKPI
columns:
field: ID
agg: COUNT
numerator:
- type: IN
field: STATE
values:
- RUNNING
- PAUSED
- KILLING
- RETRYING
- RESTARTED
- id: kpi_pending_ration
type: io.kestra.plugin.core.dashboard.chart.KPI
chartOptions:
displayName: In Pending Ratio
numberType: PERCENTAGE
width: 3
data:
type: io.kestra.plugin.core.dashboard.data.ExecutionsKPI
columns:
field: ID
agg: COUNT
numerator:
- type: IN
field: STATE
values:
- CREATED
- QUEUED
- id: total_executions_timeseries
type: io.kestra.plugin.core.dashboard.chart.TimeSeries
chartOptions:
width: 9
displayName: Total Executions
description: Executions duration and count per date
legend:
enabled: true
column: date
colorByColumn: state
data:
type: io.kestra.plugin.core.dashboard.data.Executions
columns:
date:
field: START_DATE
displayName: Date
state:
field: STATE
total:
displayName: Executions
agg: COUNT
graphStyle: BARS
duration:
displayName: Duration
field: DURATION
agg: SUM
graphStyle: LINES
- id: total_executions_pie
type: io.kestra.plugin.core.dashboard.chart.Pie
chartOptions:
displayName: Total Executions
graphStyle: DONUT
tooltip: ALL
width: 3
data:
type: io.kestra.plugin.core.dashboard.data.Executions
columns:
id:
field: ID
displayName: Execution Id
agg: COUNT
state:
displayName: State
field: STATE
- id: executions_in_progress
type: io.kestra.plugin.core.dashboard.chart.Table
data:
type: io.kestra.plugin.core.dashboard.data.Executions
columns:
id:
field: ID
displayName: Execution Id
namespace:
field: NAMESPACE
displayName: Namespace
flow:
field: FLOW_ID
displayName: Flow
duration:
field: DURATION
displayName: Duration
state:
displayName: State
field: STATE
- id: next_executions
chartOptions:
displayName: Next Executions
width: 6
type: io.kestra.plugin.core.dashboard.chart.Table
data:
type: io.kestra.plugin.core.dashboard.data.Triggers
columns:
flowId:
field: FLOW_ID
displayName: FlowId
nextExec:
field: NEXT_EXECUTION_DATE
displayName: nextExec
- id: executions_per_namespace_bars
type: io.kestra.plugin.core.dashboard.chart.Bar
chartOptions:
displayName: Executions (per namespace)
description: Executions count per namespace
legend:
enabled: true
column: namespace
width: 12
data:
type: io.kestra.plugin.core.dashboard.data.Executions
columns:
namespace:
field: NAMESPACE
state:
field: STATE
total:
displayName: Executions
agg: COUNT
- id: logs_timeseries
type: io.kestra.plugin.core.dashboard.chart.TimeSeries
chartOptions:
displayName: Logs
description: Logs count per date grouped by level
legend:
enabled: true
column: date
colorByColumn: level
width: 12
data:
type: io.kestra.plugin.core.dashboard.data.Logs
columns:
date:
field: DATE
displayName: Execution Date
level:
field: LEVEL
total:
displayName: Total Executions
agg: COUNT
graphStyle: BARS

View File

@@ -1,646 +1,115 @@
<template>
<Header
v-if="!embed"
:title="custom.shown ? custom.dashboard.title : t('overview')"
:breadcrumb="[
{
label: t(custom.shown ? 'custom_dashboard' : 'dashboard_label'),
link: {},
},
]"
:id="custom.dashboard.id ?? undefined"
/>
<section id="header" v-if="!embed">
<Header
:title="dashboard.title ?? t('overview')"
:breadcrumb="[{label: t('dashboard_label'), link: {}}]"
:id="dashboard.id"
/>
</section>
<div class="dashboard-filters">
<!-- Force re-rendering when switching between custom and default -->
<section id="filter">
<KestraFilter
:key="custom.shown"
:prefix="custom.shown ? 'custom_dashboard' : 'dashboard'"
:include="
custom.shown
? ['relative_date', 'absolute_date', 'namespace', 'labels']
: [
'namespace',
'state',
'scope',
'absolute_date',
]
"
:prefix="dashboard.id"
:include="['relative_date', 'absolute_date', 'namespace', 'labels']"
:buttons="{
refresh: {
shown: true,
callback: custom.shown ? refreshCustom : fetchAll,
},
refresh: {shown: true, callback: () => load()},
settings: {shown: false},
}"
:dashboards="{shown: customDashboardsEnabled && route.name === 'home'}"
@dashboard="(v) => handleCustomUpdate(v)"
:is-default-dashboard="!custom.shown"
:dashboards="{shown: route.name === 'home'}"
@dashboard="(value) => load(value)"
/>
</div>
</section>
<div v-if="custom.shown">
<p v-if="custom.dashboard.description" class="description">
<small>{{ custom.dashboard.description }}</small>
</p>
<el-row class="custom">
<el-col
v-for="(chart, index) in custom.dashboard.charts"
:key="index + JSON.stringify(route.query)"
:xs="24"
:sm="12"
>
<div class="p-4 d-flex flex-column">
<p class="m-0 fs-6 fw-bold">
{{ chart.chartOptions?.displayName ?? chart.id }}
</p>
<p
v-if="chart.chartOptions?.description"
class="m-0 fw-light"
>
<small>{{ chart.chartOptions.description }}</small>
</p>
<section id="description" v-if="dashboard.description">
<small>{{ dashboard.description }}</small>
</section>
<div class="mt-4 flex-grow-1">
<component
:is="types[chart.type]"
:source="chart.content"
:chart
:identifier="custom.id"
/>
</div>
</div>
</el-col>
</el-row>
</div>
<div v-else class="dashboard">
<Card
:icon="CheckBold"
:label="t('dashboard.success_ratio')"
:tooltip="t('dashboard.success_ratio_tooltip')"
:value="stats.success"
:loading="executionsLoading"
:redirect="{
name: 'executions/list',
query: {
state: State.SUCCESS,
scope: 'USER',
size: 100,
page: 1,
},
}"
/>
<Card
:icon="Alert"
:label="t('dashboard.failure_ratio')"
:tooltip="t('dashboard.failure_ratio_tooltip')"
:value="stats.failed"
:loading="executionsLoading"
:redirect="{
name: 'executions/list',
query: {
state: State.FAILED,
scope: 'USER',
size: 100,
page: 1,
},
}"
/>
<Card
:icon="FileTree"
:label="t('flows')"
:value="numbers.flows"
:loading="numbersLoading"
:redirect="{
name: 'flows/list',
query: {scope: 'USER', size: 100, page: 1},
}"
/>
<Card
:icon="LightningBolt"
:label="t('triggers')"
:value="numbers.triggers"
:loading="numbersLoading"
:redirect="{
name: 'admin/triggers',
query: {size: 100, page: 1},
}"
/>
<ExecutionsBar
:data="graphData"
:total="stats.total"
:loading="executionsLoading"
class="card card-2/3"
/>
<ExecutionsDoughnut
:data="graphData"
:total="stats.total"
:loading="executionsLoading"
class="card card-1/3"
/>
<div v-if="props.flow" class="h-100 p-4 card card-1/2">
<span class="d-flex justify-content-between">
<span class="fs-6 fw-bold">
{{ t("dashboard.description") }}
</span>
<el-button
:icon="BookOpenOutline"
@click="descriptionDialog = true"
>
{{ t("open") }}
</el-button>
<el-dialog
v-model="descriptionDialog"
:title="$t('description')"
>
<Markdown
:source="description"
:html="false"
class="p-4 description"
/>
</el-dialog>
</span>
<Markdown :source="description" :html="false" class="p-4 description" />
</div>
<ExecutionsInProgress
v-else
:flow="props.flowId"
:namespace="props.namespace"
:loading="executionsLoading"
class="card card-1/2"
/>
<ExecutionsNextScheduled
v-if="props.flow"
:flow="props.flowId"
:namespace="props.namespace"
:loading="executionsLoading"
class="card card-1/2"
/>
<ExecutionsNextScheduled
v-else-if="isAllowedTriggers"
:flow="props.flowId"
:namespace="props.namespace"
:loading="executionsLoading"
class="card card-1/2"
/>
<ExecutionsEmptyNextScheduled
v-else
:loading="executionsLoading"
class="card card-1/2"
/>
<ExecutionsNamespace
v-if="!props.flow && Object.keys(namespaceExecutions).length > 1"
class="card card-1"
:data="namespaceExecutions"
:total="stats.total"
/>
<Logs
v-if="!props.flow"
:data="logs"
:loading="executionsLoading"
class="card card-1"
/>
</div>
<ChartsSection :charts :show-default="dashboard.id === 'default'" />
</template>
<script setup>
import {computed, onBeforeMount, ref, watch} from "vue";
import {useRoute, useRouter} from "vue-router";
import {useStore} from "vuex";
import {useI18n} from "vue-i18n";
import {apiUrl} from "override/utils/route";
import {State} from "@kestra-io/ui-libs"
import {onBeforeMount, ref} from "vue";
import Header from "./components/Header.vue";
import Card from "./components/Card.vue";
import KestraFilter from "../filter/KestraFilter.vue";
import ChartsSection from "./components/ChartsSection.vue";
import ExecutionsBar from "./components/charts/executions/Bar.vue";
import ExecutionsDoughnut from "./components/charts/executions/Doughnut.vue";
import ExecutionsNamespace from "./components/charts/executions/Namespace.vue";
import Logs from "./components/charts/logs/Bar.vue";
import ExecutionsInProgress from "./components/tables/executions/InProgress.vue";
import ExecutionsNextScheduled from "./components/tables/executions/NextScheduled.vue";
import ExecutionsEmptyNextScheduled from "./components/tables/executions/EmptyNextScheduled.vue";
import Markdown from "../layout/Markdown.vue";
import TimeSeries from "./components/charts/custom/TimeSeries.vue";
import Bar from "./components/charts/custom/Bar.vue";
import Pie from "./components/charts/custom/Pie.vue";
import Table from "./components/tables/custom/Table.vue";
import CheckBold from "vue-material-design-icons/CheckBold.vue";
import Alert from "vue-material-design-icons/Alert.vue";
import LightningBolt from "vue-material-design-icons/LightningBolt.vue";
import FileTree from "vue-material-design-icons/FileTree.vue";
import BookOpenOutline from "vue-material-design-icons/BookOpenOutline.vue";
import permission from "../../models/permission";
import action from "../../models/action";
import _cloneDeep from "lodash/cloneDeep.js";
import {useRoute, useRouter} from "vue-router";
const router = useRouter();
const route = useRoute();
import {useStore} from "vuex";
const store = useStore();
import {useI18n} from "vue-i18n";
const {t} = useI18n({useScope: "global"});
const user = store.getters["auth/user"];
const props = defineProps({
embed: {
type: Boolean,
default: false,
},
flow: {
type: Boolean,
default: false,
},
flowId: {
type: String,
required: false,
default: null,
},
namespace: {
type: String,
required: false,
default: null,
},
restoreURL: {
type: Boolean,
default: true,
},
id: {
type: String,
required: false,
default: null,
},
containerClass: {
type: String,
required: false,
default: null,
},
embed: {type: Boolean, default: false},
isFlow: {type: Boolean, default: false},
isNamespace: {type: Boolean, default: false},
});
const customDashboardsEnabled = computed(
() => store.state.misc?.configs?.isCustomDashboardsEnabled,
);
import yaml from "yaml";
import {YamlUtils as YAML_UTILS} from "@kestra-io/ui-libs";
// Custom Dashboards
const custom = ref({id: Math.random(), shown: false, dashboard: {}});
const handleCustomUpdate = async (v) => {
let dashboard = {};
import YAML_MAIN from "../../assets/dashboard/default_main_definition.yaml?raw";
import YAML_FLOW from "../../assets/dashboard/default_flow_definition.yaml?raw";
import YAML_NAMESPACE from "../../assets/dashboard/default_namespace_definition.yaml?raw";
if (route.name === "home") {
const initial = (dashboard) => ({id: "default", ...YAML_UTILS.parse(dashboard)});
const dashboard = ref({});
const charts = ref([]);
const loadCharts = async (allCharts) => {
charts.value = [];
for (const chart of allCharts) {
charts.value.push({...chart, content: yaml.stringify(chart), raw: chart});
}
};
const load = async (id = "default", defaultYAML = YAML_MAIN) => {
if (!["home", "flows/update", "namespaces/update"].includes(route.name)) return;
if(!props.isFlow && !props.isNamespace) {
router.replace({
params: {...route.params, id: v?.id ?? "default"},
query: route.params.id != v?.id ? {} : {...route.query},
params: {...route.params, id},
query: route.params.id !== id ? {} : {...route.query},
});
if (v && v.id !== "default") {
dashboard = await store.dispatch("dashboard/load", v.id);
}
custom.value = {
id: Math.random(),
shown: !v || v.id === "default" ? false : true,
dashboard,
};
}
};
const refreshCustom = async () => {
const ID = custom.value.dashboard.id;
let dashboard = await store.dispatch("dashboard/load", ID);
custom.value = {id: Math.random(), shown: true, dashboard};
};
const types = {
"io.kestra.plugin.core.dashboard.chart.TimeSeries": TimeSeries,
"io.kestra.plugin.core.dashboard.chart.Bar": Bar,
"io.kestra.plugin.core.dashboard.chart.Markdown": Markdown,
"io.kestra.plugin.core.dashboard.chart.Table": Table,
"io.kestra.plugin.core.dashboard.chart.Pie": Pie,
};
const descriptionDialog = ref(false);
const description = props.flow
? (store.state?.flow?.flow?.description ??
t("dashboard.no_flow_description"))
: undefined;
const defaultNumbers = {flows: 0, triggers: 0};
const numbers = ref({...defaultNumbers});
const numbersLoading = ref(false);
const fetchNumbers = () => {
if (props.flowId) {
return;
}
numbersLoading.value = true;
store.$http
.post(`${apiUrl(store)}/stats/summary`, mergeQuery())
.then((response) => {
if (!response.data) return;
numbers.value = {...defaultNumbers, ...response.data};
})
.finally(() => {
numbersLoading.value = false;
});
dashboard.value = id === "default" ? initial(defaultYAML) : await store.dispatch("dashboard/load", id);
loadCharts(dashboard.value.charts);
};
const executions = ref({raw: {}, all: {}, yesterday: {}, today: {}});
const stats = computed(() => {
const counts = executions?.value?.all?.executionCounts || {};
const terminatedStates = State.getTerminatedStates();
const statesToCount = Object.fromEntries(
Object.entries(counts).filter(([key]) =>
terminatedStates.includes(key),
),
);
const templateYamlFlow = () => {
let yamlFlow = YAML_FLOW;
yamlFlow = yamlFlow.replace(/{{namespace}}/g, route.params.namespace);
yamlFlow = yamlFlow.replace(/{{flowId}}/g, route.params.id);
const total = Object.values(counts).reduce(
(sum, count) => sum + count,
0,
);
const totalTerminated = Object.values(statesToCount).reduce(
(sum, count) => sum + count,
0,
);
const successStates = ["SUCCESS", "CANCELLED", "WARNING", "SKIPPED"];
const failedStates = ["FAILED", "KILLED"];
const sumStates = (states) =>
states.reduce((sum, state) => sum + (statesToCount[state] || 0), 0);
const successRatio =
totalTerminated > 0 ? (sumStates(successStates) / totalTerminated) * 100 : 0;
const failedRatio = totalTerminated > 0 ? (sumStates(failedStates) / totalTerminated) * 100 : 0;
return {
total: total,
totalTerminated: totalTerminated,
success: `${successRatio.toFixed(2)}%`,
failed: `${failedRatio.toFixed(2)}%`,
};
});
const transformer = (data) => {
return data.reduce((accumulator, value) => {
accumulator = accumulator || {executionCounts: {}, duration: {}};
for (const key in value.executionCounts) {
accumulator.executionCounts[key] =
(accumulator.executionCounts[key] || 0) +
value.executionCounts[key];
}
for (const key in value.duration) {
accumulator.duration[key] =
(accumulator.duration[key] || 0) + value.duration[key];
}
return accumulator;
}, null);
};
const mergeQuery = () => {
let queryFilter = _cloneDeep(route.query);
if (props.namespace) {
queryFilter["namespace"] = props.namespace;
}
if (props.flowId) {
queryFilter["flowId"] = props.flowId;
}
return queryFilter;
return yamlFlow;
}
const executionsLoading = ref(false);
const fetchExecutions = () => {
executionsLoading.value = true;
return store.dispatch("stat/daily", mergeQuery())
.then((response) => {
const sorted = response.sort(
(a, b) => new Date(b.date) - new Date(a.date),
);
executions.value = {
raw: sorted,
all: transformer(sorted),
yesterday: sorted.at(-2),
today: sorted.at(-1),
};
})
.finally(() => {
executionsLoading.value = false;
});
};
const graphData = computed(() => store.state.stat.daily || []);
const namespaceExecutions = ref({});
const fetchNamespaceExecutions = () => {
store.dispatch("stat/dailyGroupByNamespace", mergeQuery()).then((response) => {
namespaceExecutions.value = response;
});
};
const logs = ref([]);
const fetchLogs = () => {
store.dispatch("stat/logDaily", mergeQuery()).then((response) => {
logs.value = response;
});
};
const fetchAll = async () => {
if (!custom.value.shown) {
try {
executionsLoading.value = true;
await Promise.all([
fetchNumbers(),
fetchExecutions(),
fetchNamespaceExecutions(),
fetchLogs(),
]).catch(error => {
console.error("Failed to fetch dashboard data:", error);
});
} finally {
executionsLoading.value = false;
}
}
};
const isAllowedTriggers = computed(() => {
return (
user &&
user.isAllowed(permission.FLOW, action.READ, props.value?.namespace)
);
});
onBeforeMount(() => {
handleCustomUpdate(route.params?.id ? {id: route.params?.id} : undefined);
if (props.isFlow) load("default", templateYamlFlow());
else if (props.isNamespace) load("default", YAML_NAMESPACE);
});
watch(
route,
async () => {
await handleCustomUpdate(route.params?.id ? {id: route.params?.id} : undefined);
fetchAll();
},
{immediate: true, deep: true},
);
</script>
<style lang="scss" scoped>
@import "@kestra-io/ui-libs/src/scss/variables";
$spacing: 20px;
.dashboard-filters,
.dashboard {
section#filter {
margin: 2rem 0.25rem 0;
padding: 0 2rem;
margin: 0;
.description {
border: none !important;
color: var(ks-content-secondary);
}
}
$media-md: 500px;
$media-lg: 1000px;
.dashboard{
container-type: inline-size;
padding-bottom: 1rem;
margin: 1rem 0;
width: 100%;
display: flex;
flex-direction: column;
gap: 1rem;
display: grid;
grid-template-columns: repeat(12, 1fr);
}
.card {
box-shadow: 0px 2px 4px 0px var(--ks-card-shadow);
background: var(--ks-background-card);
color: var(--ks-content-primary);
border: 1px solid var(--ks-border-primary);
border-radius: $border-radius;
overflow: hidden;
flex-shrink: 0;
grid-column: span 12;
@container (width > #{$media-md}) {
grid-column: span 6;
}
@container (width > #{$media-lg}) {
grid-column: span 3;
}
}
@container (width > #{$media-md}) {
.card-1\/2, .card-2\/3, .card-1\/3 {
grid-column: span 12;
}
}
.card-1\/2{
@container (width > #{$media-lg}) {
grid-column: span 6;
}
}
.card-2\/3{
@container (width > #{$media-lg}) {
grid-column: span 8;
}
}
.card-1\/3{
@container (width > #{$media-lg}) {
grid-column: span 4;
}
}
.card-1{
@container (width > #{$media-md}) {
grid-column: span 12;
}
}
.dashboard-filters {
margin: 24px 0 0 0;
padding-bottom: 0;
& .el-row {
padding: 0 5px;
}
& .el-col {
padding-bottom: 0 !important;
}
}
.description {
padding: 0 2rem 1rem 2rem;
margin: 0;
section#description {
margin: 0 0.25rem;
padding: 0 2rem 1rem;
color: var(--ks-content-secondary);
}
.custom {
padding: 0 2rem 1rem 2rem;
&.el-row {
width: 100%;
& .el-col {
padding-bottom: $spacing;
&:nth-of-type(even) > div {
margin-left: 1rem;
}
& > div {
height: 100%;
background: var(--ks-background-card);
border: 1px solid var(--ks-border-primary);
border-radius: $border-radius;
}
}
}
}
:deep(.legend) {
&::-webkit-scrollbar {
height: 5px;
width: 5px;
}
&::-webkit-scrollbar-track {
background: var(--ks-background-body);
}
&::-webkit-scrollbar-thumb {
background: var(--ks-border-primary);
border-radius: 5px;
}
&::-webkit-scrollbar-thumb:hover {
background: var(--ks-button-background-primary-hover);
}
}
</style>

View File

@@ -0,0 +1,86 @@
<template>
<section id="charts">
<el-row :gutter="8">
<el-col
v-for="(chart, index) in props.charts"
:key="`${chart.id}__${index}`"
:xs="24"
:sm="(chart.chartOptions?.width || 6) * 4"
:md="(chart.chartOptions?.width || 6) * 2"
>
<div class="d-flex flex-column">
<p v-if="!chart.type === 'io.kestra.plugin.core.dashboard.chart.KPI'" class="m-0">
<span class="fs-6 fw-bold">{{ labels(chart).title }}</span>
<template v-if="labels(chart)?.description">
<br>
<small class="fw-light">
{{ labels(chart).description }}
</small>
</template>
</p>
<div class="flex-grow-1">
<component
:is="TYPES[chart.type]"
:default="route.params.id === 'default'"
:source="chart.content"
:chart="chart"
:show-default="props.showDefault"
/>
</div>
</div>
</el-col>
</el-row>
</section>
</template>
<script setup>
import {useRoute} from "vue-router";
const route = useRoute();
import TimeSeries from "./charts/custom/TimeSeries.vue";
import Bar from "./charts/custom/Bar.vue";
import Markdown from "./MarkdownPanel.vue";
import Table from "./tables/custom/Table.vue";
import Pie from "./charts/custom/Pie.vue";
import KPI from "./charts/custom/KPI.vue";
const TYPES = {
"io.kestra.plugin.core.dashboard.chart.TimeSeries": TimeSeries,
"io.kestra.plugin.core.dashboard.chart.Bar": Bar,
"io.kestra.plugin.core.dashboard.chart.Markdown": Markdown,
"io.kestra.plugin.core.dashboard.chart.Table": Table,
"io.kestra.plugin.core.dashboard.chart.Pie": Pie,
"io.kestra.plugin.core.dashboard.chart.KPI": KPI,
};
const props = defineProps({
charts: {type: Array, required: true, default: () => []},
showDefault: {type: Boolean, default: false},
});
const labels = (chart) => ({
title: chart?.chartOptions?.displayName ?? chart?.id,
description: chart?.chartOptions?.description,
});
</script>
<style lang="scss" scoped>
@import "@kestra-io/ui-libs/src/scss/variables";
section#charts {
padding: 0 2rem 1rem;
& .el-row .el-col {
margin-bottom: 0.5rem;
& > div {
height: 100%;
padding: 1.5rem;
background: var(--ks-background-card);
border: 1px solid var(--ks-border-primary);
border-radius: $border-radius;
}
}
}
</style>

View File

@@ -46,45 +46,8 @@
{{ $t("save") }}
</el-button>
</div>
<div class="w-100" v-if="currentView === views.DASHBOARD">
<el-row class="custom">
<el-col
v-for="chart in charts"
:key="JSON.stringify(chart)"
:xs="24"
:sm="12"
>
<div
v-if="chart.data"
class="p-4 d-flex flex-column"
>
<p class="m-0 fs-6 fw-bold">
{{ chart.data.chartOptions?.displayName ?? chart.id }}
</p>
<p
v-if="chart.chartOptions?.description"
class="m-0 fw-light"
>
<small>{{ chart.data.chartOptions.description }}</small>
</p>
<div class="mt-4 flex-grow-1">
<component
:is="types[chart.data.type]"
:source="chart.data.content"
:chart="chart.data"
:identifier="chart.data.id"
is-preview
/>
</div>
</div>
<div v-else class="d-flex justify-content-center align-items-center text-container">
<el-tooltip :content="chart.error">
{{ chart.error }}
</el-tooltip>
</div>
</el-col>
</el-row>
<div class="w-100 p-4" v-if="currentView === views.DASHBOARD">
<ChartsSection :charts="charts.map(chart => chart.data)" />
</div>
<div class="main-editor" v-else>
<div
@@ -161,6 +124,7 @@
import {YamlUtils as YAML_UTILS} from "@kestra-io/ui-libs";
import PluginDocumentation from "../../plugins/PluginDocumentation.vue";
import ChartsSection from "./ChartsSection.vue";
import ValidationErrors from "../../flows/ValidationError.vue"
import BookOpenVariant from "vue-material-design-icons/BookOpenVariant.vue";
import ChartBar from "vue-material-design-icons/ChartBar.vue";
@@ -178,11 +142,12 @@
import yaml from "yaml";
import ContentSave from "vue-material-design-icons/ContentSave.vue";
import intro from "../../../assets/docs/dashboard_home.md?raw";
import Markdown from "../../layout/Markdown.vue";
import Markdown from "./MarkdownPanel.vue";
import TimeSeries from "./charts/custom/TimeSeries.vue";
import Bar from "./charts/custom/Bar.vue";
import Pie from "./charts/custom/Pie.vue";
import Table from "./tables/custom/Table.vue";
import KPI from "./charts/custom/KPI.vue";
export default {
computed: {
@@ -286,13 +251,13 @@
},
async loadChart(chart) {
const yamlChart = yaml.stringify(chart);
const result = {error: null, data: null};
const result = {error: null, data: null, raw: {}};
await this.$store.dispatch("dashboard/validateChart", yamlChart)
.then(errors => {
if (errors.constraints) {
result.error = errors.constraints;
} else {
result.data = {...chart, content: yamlChart};
result.data = {...chart, content: yamlChart, raw: chart};
}
});
return result;
@@ -320,6 +285,7 @@
"io.kestra.plugin.core.dashboard.chart.Markdown": shallowRef(Markdown),
"io.kestra.plugin.core.dashboard.chart.Table": shallowRef(Table),
"io.kestra.plugin.core.dashboard.chart.Pie": shallowRef(Pie),
"io.kestra.plugin.core.dashboard.chart.KPI": shallowRef(KPI)
}
}
},

View File

@@ -2,7 +2,7 @@
<TopNavBar :title="routeInfo.title" :breadcrumb="props.breadcrumb">
<template #additional-right v-if="canCreate">
<ul>
<li v-if="props.id">
<li v-if="props.id && props.id !== 'default'">
<router-link
:to="{
name: 'dashboards/update',

View File

@@ -0,0 +1,74 @@
<template>
<template v-if="source">
<section id="markdown">
<Markdown :source />
</section>
</template>
<NoData v-else :text="t('custom_dashboard_empty')" />
</template>
<script setup lang="ts">
import {onMounted, ref, watch} from "vue";
import Markdown from "../../layout/Markdown.vue";
import NoData from "../../layout/NoData.vue";
import {useRoute} from "vue-router";
const route = useRoute();
import {useStore} from "vuex";
const store = useStore();
import {useI18n} from "vue-i18n";
const {t} = useI18n({useScope: "global"});
import {decodeSearchParams} from "../../filter/utils/helpers.ts";
const props = defineProps({
chart: {type: Object, required: true},
showDefault: {type: Boolean, default: false}
});
const source = ref();
const generate = async (id) => {
let decodedParams = decodeSearchParams(route.query, undefined, []);
if (!props.showDefault) {
let params = {id, chartId: props.chart.id};
if (route.query.namespace) {
params.namespace = route.query.namespace;
}
if (route.query.labels) {
params.labels = Object.fromEntries(
route.query.labels.map((l) => l.split(":"))
);
}
if (decodedParams) {
params = {...params, filters: decodedParams};
}
const result = await store.dispatch("dashboard/generate", params);
const description = result.results?.[0]?.description;
source.value = description ? description : t("dashboard.no_flow_description");
} else {
const result = await store.dispatch("dashboard/chartPreview", {
chart: props.chart.content,
globalFilter: {filter: decodedParams},
})
source.value = result.results[0]?.description;
}
};
watch(route, async (r) => {
if (props.chart.source?.type === "FlowDescription") generate(r.params?.id);
else source.value = props.chart.content ?? props.chart.source.content;
});
onMounted(() => {
if (props.chart.source?.type === "FlowDescription") generate(route.params?.id);
else source.value = props.chart.content ?? props.chart.source.content;
});
</script>

View File

@@ -4,7 +4,7 @@
v-if="generated !== undefined"
:data="parsedData"
:options="options"
:plugins="chartOptions.legend.enabled ? [customBarLegend] : []"
:plugins="chartOptions?.legend?.enabled ? [customBarLegend] : []"
class="chart"
/>
<NoData v-else />
@@ -17,6 +17,8 @@
import {Bar} from "vue-chartjs";
import moment from "moment";
import {customBarLegend} from "../legend.js";
import {useTheme} from "../../../../../utils/utils.js";
import {defaultConfig, getConsistentHEXColor, chartClick} from "../../../../../utils/charts.js";
@@ -30,15 +32,12 @@
const store = useStore();
const router = useRouter();
const dashboard = computed(() => store.state.dashboard.dashboard);
const route = useRoute();
defineOptions({inheritAttrs: false});
const props = defineProps({
identifier: {type: [Number, String], required: true},
chart: {type: Object, required: true},
isPreview: {type: Boolean, required: false, default: false}
showDefault: {type: Boolean, default: false}
});
const {data, chartOptions} = props.chart;
@@ -64,7 +63,7 @@
borderColor: "transparent",
borderWidth: 2,
plugins: {
...(chartOptions.legend.enabled
...(chartOptions?.legend?.enabled
? {
customBarLegend: {
containerID,
@@ -158,10 +157,11 @@
});
const generated = ref();
const generate = async () => {
if (!props.isPreview) {
const generate = async (id) => {
let decodedParams = decodeSearchParams(route.query, undefined, []);
if (!props.showDefault) {
let params = {
id: dashboard.value.id,
id,
chartId: props.chart.id
};
if (route.query.namespace) {
@@ -170,23 +170,18 @@
if (route.query.labels) {
params.labels = Object.fromEntries(route.query.labels.map(l => l.split(":")));
}
let decodedParams = decodeSearchParams(route.query, undefined, []);
if (decodedParams) {
params = {...params, filters: decodedParams}
}
generated.value = await store.dispatch("dashboard/generate", params);
}
else {
generated.value = await store.dispatch("dashboard/chartPreview", props.chart.content)
generated.value = await store.dispatch("dashboard/chartPreview", {chart: props.chart.content, globalFilter: {filter: decodedParams}})
}
};
watch(route, async () => await generate());
watch(
() => props.identifier,
() => generate(),
);
onMounted(() => generate());
watch(route, async (route) => await generate(route.params?.id));
onMounted(() => generate(route.params.id));
</script>
<style lang="scss" scoped>

View File

@@ -0,0 +1,84 @@
<template>
<template v-if="generated">
<section id="kpi">
<span class="pb-2">{{ label }}</span>
<p class="m-0 fs-2 fw-bold">
<span>{{ generated?.results[0]?.value }}</span>
<span v-if="percentage">%</span>
</p>
</section>
</template>
<NoData v-else :text="t('custom_dashboard_empty')" />
</template>
<script setup lang="ts">
import {onMounted, computed, ref, watch} from "vue";
import NoData from "../../../../layout/NoData.vue";
import {useRoute} from "vue-router";
const route = useRoute();
import {useStore} from "vuex";
const store = useStore();
import {useI18n} from "vue-i18n";
const {t} = useI18n({useScope: "global"});
import {decodeSearchParams} from "../../../../filter/utils/helpers.ts";
const props = defineProps({
chart: {type: Object, required: true},
showDefault: {type: Boolean, default: false},
});
const label = computed(
() => props.chart?.chartOptions?.displayName || props.chart?.id,
);
const percentage = computed(
() => props.chart?.chartOptions?.numberType === "PERCENTAGE"
);
const generated = ref();
const generate = async (id) => {
// TODO: Tweak once the API is wrapped up
let decodedParams = decodeSearchParams(route.query, undefined, []);
if (!props.showDefault) {
let params = {id, chartId: props.chart.id};
if (route.query.namespace) {
params.namespace = route.query.namespace;
}
if (route.query.labels) {
params.labels = Object.fromEntries(
route.query.labels.map((l) => l.split(":")),
);
}
if (decodedParams) {
params = {...params, filters: decodedParams};
}
generated.value = await store.dispatch("dashboard/generate", params);
} else {
generated.value = await store.dispatch("dashboard/chartPreview", {
chart: props.chart.content,
globalFilter: {filter: decodedParams},
});
}
};
watch(route, async (route) => await generate(route.params?.id));
onMounted(() => generate(route.params.id));
</script>
<style scoped lang="scss">
@import "@kestra-io/ui-libs/src/scss/variables";
section#kpi {
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
}
</style>

View File

@@ -2,14 +2,14 @@
<div
class="d-flex flex-row align-items-center justify-content-center h-100"
>
<div class="w-75">
<div>
<component
:is="chartOptions.graphStyle === 'PIE' ? Pie : Doughnut"
v-if="generated !== undefined"
:data="parsedData"
:options="options"
:plugins="
chartOptions.legend.enabled
chartOptions?.legend?.enabled
? [isDuration ? totalsDurationLegend : totalsLegend, centerPlugin, thicknessPlugin]
: [centerPlugin, thicknessPlugin]
"
@@ -43,13 +43,10 @@
const store = useStore();
const dashboard = computed(() => store.state.dashboard.dashboard);
defineOptions({inheritAttrs: false});
const props = defineProps({
identifier: {type: [Number, String], required: true},
chart: {type: Object, required: true},
isPreview: {type: Boolean, required: false, default: false}
showDefault: {type: Boolean, default: false}
});
const containerID = `${props.chart.id}__${Math.random()}`;
@@ -63,7 +60,7 @@
const options = computed(() => {
return defaultConfig({
plugins: {
...(chartOptions.legend.enabled
...(chartOptions?.legend?.enabled
? {
totalsLegend: {
containerID,
@@ -185,34 +182,28 @@
});
const generated = ref();
const generate = async () => {
if (!props.isPreview) {
let params = {
id: dashboard.value.id,
chartId: props.chart.id
};
const generate = async (id) => {
let decodedParams = decodeSearchParams(route.query, undefined, []);
if (!props.showDefault) {
let params = {id, chartId: props.chart.id};
if (route.query.namespace) {
params.namespace = route.query.namespace;
}
if (route.query.labels) {
params.labels = Object.fromEntries(route.query.labels.map(l => l.split(":")));
}
let decodedParams = decodeSearchParams(route.query, undefined, []);
if (decodedParams) {
params = {...params, filters: decodedParams}
}
generated.value = await store.dispatch("dashboard/generate", params);
} else {
generated.value = await store.dispatch("dashboard/chartPreview", props.chart.content)
generated.value = await store.dispatch("dashboard/chartPreview", {chart: props.chart.content, globalFilter: {filter: decodedParams}})
}
};
watch(route, async () => await generate());
watch(
() => props.identifier,
() => generate(),
);
onMounted(() => generate());
watch(route, async (route) => await generate(route.params?.id));
onMounted(() => generate(route.params.id));
</script>
<style lang="scss" scoped>

View File

@@ -4,9 +4,9 @@
v-if="generated !== undefined"
:data="parsedData"
:options
:plugins="chartOptions.legend.enabled ? [customBarLegend] : []"
:plugins="chartOptions?.legend?.enabled ? [customBarLegend] : []"
class="chart"
:class="chartOptions.legend.enabled ? 'with-legend' : ''"
:class="chartOptions?.legend?.enabled ? 'with-legend' : ''"
/>
<NoData v-else />
</template>
@@ -31,16 +31,13 @@
const store = useStore();
const dashboard = computed(() => store.state.dashboard.dashboard);
const route = useRoute();
const router = useRouter();
defineOptions({inheritAttrs: false});
const props = defineProps({
identifier: {type: [Number, String], required: true},
chart: {type: Object, required: true},
isPreview: {type: Boolean, required: false, default: false}
showDefault: {type: Boolean, default: false}
});
const containerID = `${props.chart.id}__${Math.random()}`;
@@ -68,7 +65,7 @@
borderColor: "transparent",
borderWidth: 2,
plugins: {
...(chartOptions.legend.enabled
...(chartOptions?.legend?.enabled
? {
customBarLegend: {
containerID,
@@ -241,10 +238,11 @@
});
const generated = ref();
const generate = async () => {
if (!props.isPreview) {
const generate = async (id) => {
let decodedParams = decodeSearchParams(route.query, undefined, []);
if (!props.showDefault) {
let params = {
id: dashboard.value.id,
id,
chartId: props.chart.id
};
if (route.query.namespace) {
@@ -253,22 +251,17 @@
if (route.query.labels) {
params.labels = Object.fromEntries(route.query.labels.map(l => l.split(":")));
}
let decodedParams = decodeSearchParams(route.query, undefined, []);
if (decodedParams) {
params = {...params, filters: decodedParams}
}
generated.value = await store.dispatch("dashboard/generate", params);
} else {
generated.value = await store.dispatch("dashboard/chartPreview", props.chart.content)
generated.value = await store.dispatch("dashboard/chartPreview", {chart: props.chart.content, globalFilter: {filter: decodedParams}})
}
};
watch(route, async () => await generate());
watch(
() => props.identifier,
() => generate(),
);
onMounted(() => generate());
watch(route, async (route) => await generate(route.params?.id));
onMounted(() => generate(route.params.id));
</script>
<style lang="scss" scoped>

View File

@@ -2,17 +2,17 @@ import Utils from "../../../../utils/utils";
import {cssVariable} from "@kestra-io/ui-libs";
import {getConsistentHEXColor} from "../../../../utils/charts.js";
const getOrCreateLegendList = (chart, id, direction = "row") => {
const getOrCreateLegendList = (chart, id, direction = "row", width = "100%") => {
const legendContainer = document.getElementById(id);
legendContainer.style.width = "100%";
legendContainer.style.width = width;
legendContainer.style.justifyItems = "end";
let listContainer = legendContainer?.querySelector("ul");
if (!listContainer) {
listContainer = document.createElement("ul");
listContainer.classList.add("w-100", "fw-light", "legend", direction === "row" ? "small" : "tall");
listContainer.classList.add("mb-3", "fw-light", "legend", direction === "row" ? "small" : "tall");
listContainer.style.display = "flex";
listContainer.style.flexDirection = direction;
listContainer.style.margin = 0;
@@ -175,7 +175,7 @@ export const customBarLegend = {
const generateTotalsLegend = (isDuration) => ({
id: "totalsLegend",
afterUpdate(chart, args, options) {
const ul = getOrCreateLegendList(chart, options.containerID, "column");
const ul = getOrCreateLegendList(chart, options.containerID, "column", "auto");
while (ul.firstChild) {
ul.firstChild.remove();

View File

@@ -1,15 +1,36 @@
<template>
<template v-if="data !== undefined">
<el-table :id="containerID" :data="data.results" :height="240">
<el-table
:id="containerID"
:data="data.results"
:height="240"
size="small"
>
<el-table-column
v-for="(column, index) in Object.entries(props.chart.data.columns)"
:key="index"
:label="column[0]"
v-for="key in Object.keys(props.chart.data.columns)"
:label="key"
:key
>
<template #default="scope">
{{
column[1].field === "DURATION" ? Utils.humanDuration(scope.row[column[0]]) : scope.row[column[0]]
}}
<template v-if="key === 'id'">
<RouterLink
v-if="scope.row.namespace && scope.row.flowId"
:to="{
name: 'executions/update',
params: {
namespace: scope.row.namespace,
flowId: scope.row.flowId,
id: scope.row.id,
},
}"
>
<code>{{ scope.row.id.slice(0, 8) }}</code>
</RouterLink>
<code v-else>{{ scope.row.id }}</code>
</template>
<Status v-else-if="key === 'state'" size="small" :status="scope.row[key]" />
<span v-else-if="key === 'duration'">{{ Utils.humanDuration(scope.row[key]) }}</span>
<span v-else>{{ scope.row[key] }}</span>
</template>
</el-table-column>
</el-table>
@@ -26,9 +47,10 @@
</template>
<script lang="ts" setup>
import {computed, onMounted, ref, watch} from "vue";
import {onMounted, ref, watch} from "vue";
import {useI18n} from "vue-i18n";
import Status from "../../../../Status.vue";
import NoData from "../../../../layout/NoData.vue";
import Pagination from "../../../../layout/Pagination.vue";
@@ -46,56 +68,60 @@
defineOptions({inheritAttrs: false});
const props = defineProps({
identifier: {type: [Number, String], required: true},
chart: {type: Object, required: true},
isPreview: {type: Boolean, required: false, default: false}
showDefault: {type: Boolean, default: false},
});
const containerID = `${props.chart.id}__${Math.random()}`;
const dashboard = computed(() => store.state.dashboard.dashboard);
const currentPage = ref(1);
const pageSize = ref(10);
const handlePageChange = (options) => {
currentPage.value = options.page;
pageSize.value = options.size;
generate();
generate(route.params.id);
};
const data = ref();
const generate = async () => {
if (!props.isPreview) {
const generate = async (id) => {
let decodedParams = decodeSearchParams(route.query, undefined, []);
if (!props.showDefault) {
let params = {
id: dashboard.value.id,
chartId: props.chart.id
id,
chartId: props.chart.id,
};
if (route.query.namespace) {
params.namespace = route.query.namespace;
}
if (route.query.labels) {
params.labels = Object.fromEntries(route.query.labels.map(l => l.split(":")));
params.labels = Object.fromEntries(
route.query.labels.map((l) => l.split(":")),
);
}
if (props.chart.chartOptions?.pagination?.enabled) {
params.pageNumber = currentPage.value;
params.pageSize = pageSize.value;
}
let decodedParams = decodeSearchParams(route.query, undefined, []);
if (decodedParams) {
params = {...params, filters: decodedParams}
params = {...params, filters: decodedParams};
}
data.value = await store.dispatch("dashboard/generate", params);
} else {
data.value = await store.dispatch("dashboard/chartPreview", props.chart.content)
data.value = await store.dispatch("dashboard/chartPreview", {
chart: props.chart.content,
globalFilter: {filter: decodedParams},
});
}
};
watch(route, async () => await generate());
watch(
() => props.identifier,
() => generate(),
);
onMounted(() => generate());
watch(route, async (route) => await generate(route.params?.id));
onMounted(() => generate(route.params.id));
</script>
<style lang="scss" scoped>
code {
color: var(--ks-content-id);
}
</style>

View File

@@ -243,7 +243,7 @@
},
placeholder: {type: String, default: undefined},
searchCallback: {type: Function, default: undefined},
isDefaultDashboard: {type: Boolean, default: false}
isDefaultDashboard: {type: Boolean, default: false} // TODO: remove this when the default dashboard is removed
});
const TEXT_PREFIX = `${t("filters.text_search")}: `;

View File

@@ -28,7 +28,7 @@
/>
<el-dropdown-item
@click="selectDashboard(null)"
@click="selectDashboard({id: 'default'})"
:class="{'mt-3': filtered.length < 10}"
>
<small>{{ t("default_dashboard") }}</small>
@@ -109,14 +109,16 @@
const selectedDashboard = ref(null)
const DASHBOARD_KEY = storageKeys.DASHBORD_SELECTED + (routeTenant.value ? `_${routeTenant.value}` : "")
const selectDashboard = (dashboard: any) => {
selectedDashboard.value = dashboard?.title;
if (dashboard?.id) {
localStorage.setItem(storageKeys.DASHBORD_SELECTED + "_" + routeTenant.value, dashboard.id);
localStorage.setItem(DASHBOARD_KEY, dashboard.id);
} else {
localStorage.removeItem(storageKeys.DASHBORD_SELECTED + "_" + routeTenant.value);
localStorage.removeItem(DASHBOARD_KEY);
}
emits("dashboard", dashboard)
emits("dashboard", dashboard.id)
}
const editDashboard = (dashboard: any) => {
@@ -135,13 +137,14 @@
selectDashboard(dashboard);
} else {
selectedDashboard.value = null;
emits("dashboard", "default")
}
}
});
}
const fetchLastDashboard = () => {
return localStorage.getItem(storageKeys.DASHBORD_SELECTED + "_" + routeTenant.value)
return localStorage.getItem(DASHBOARD_KEY)
}
onBeforeMount(() => {

View File

@@ -1,11 +1,8 @@
<template>
<Dashboard
v-if="loaded && total && flow"
:restore-u-r-l="false"
flow
:flow-id="flow.id"
:namespace="flow.namespace"
embed
:is-flow="true"
/>
<NoExecutions v-else-if="loaded && flow && !total" />
</template>

View File

@@ -82,7 +82,7 @@ export function useHelpers() {
name: "overview",
title: t("overview"),
component: Dashboard,
props: {namespace, containerClass: "full-container flex-0"},
props: {isNamespace: true},
},
{
name: "flows",

View File

@@ -12,7 +12,25 @@ export default [
{name: "welcome", path: "/:tenant?/welcome", component: () => import("../components/onboarding/Welcome.vue")},
//Dashboards
{name: "home", path: "/:tenant?/dashboards/:id?", component: () => import("../components/dashboard/Dashboard.vue")},
{
name: "home",
path: "/:tenant?/dashboards/:id?",
component: () => import("../components/dashboard/Dashboard.vue"),
beforeEnter: (to, from, next) => {
if (!to.params.id) {
next({
name: "home",
params: {
...to.params,
id: "default",
},
query: to.query,
});
} else {
next();
}
},
},
{name: "dashboards/create", path: "/:tenant?/dashboards/new", component: () => import("../components/dashboard/components/DashboardCreate.vue")},
{name: "dashboards/update", path: "/:tenant?/dashboards/:id/edit", component: () => import("override/components/dashboard/components/DashboardEdit.vue")},

View File

@@ -20,11 +20,21 @@ export default {
}).then(response => response.data);
},
load({commit}, id) {
return this.$http.get(`${apiUrl(this)}/dashboards/${id}`).then(response => {
const dashboard = response.data;
commit("setDashboard", dashboard);
return dashboard;
});
return this.$http
.get(`${apiUrl(this)}/dashboards/${id}`, {
validateStatus: (status) => {
return status === 200 || status === 404;
},
})
.then((response) => {
let dashboard;
if (response.status === 200) dashboard = response.data;
else if (response.status === 404) dashboard = {title: "Default", id};
commit("setDashboard", dashboard);
return dashboard;
});
},
create(_, source) {
return this.$http.post(`${apiUrl(this)}/dashboards`, source, yamlContentHeader).then(response => response.data);
@@ -35,9 +45,19 @@ export default {
delete(_, id) {
return this.$http.delete(`${apiUrl(this)}/dashboards/${id}`).then(response => response.data);
},
generate(_, {id, chartId, ...filters}) {
generate(_, {id, chartId, ...filters}) {
const filtersObj = Object.keys(filters).length > 0 ? filters : null;
return this.$http.post(`${apiUrl(this)}/dashboards/${id}/charts/${chartId}`, filtersObj).then(response => response.data);
return this.$http
.post(
`${apiUrl(this)}/dashboards/${id}/charts/${chartId}`,
filtersObj,
{
validateStatus: (status) => {
return status === 200 || status === 404;
},
},
)
.then((response) => response.data);
},
validate(_, source) {
return this.$http.post(`${apiUrl(this)}/dashboards/validate`, source, yamlContentHeader).then(response => {
@@ -54,7 +74,7 @@ export default {
});
},
chartPreview(_, chart) {
return this.$http.post(`${apiUrl(this)}/dashboards/charts/preview`, chart, yamlContentHeader)
return this.$http.post(`${apiUrl(this)}/dashboards/charts/preview`, chart)
.then(response => response.data);
}
},

View File

@@ -1,3 +1,5 @@
import {provide, ref} from "vue";
import {TOPOLOGY_CLICK_INJECTION_KEY} from "../../../../src/components/code/injectionKeys";
import {useStore} from "vuex";
import {vueRouter} from "storybook-vue3-router";
import LowCodeEditor from "../../../../src/components/inputs/LowCodeEditor.vue";
@@ -17,6 +19,7 @@ export default {
const Template= (args) => ({
setup() {
const store = useStore()
provide(TOPOLOGY_CLICK_INJECTION_KEY, ref())
store.$http = {
get(){
return Promise.resolve({data: {}})

View File

@@ -2,18 +2,28 @@ package io.kestra.webserver.controllers.api;
import io.kestra.core.models.QueryFilter;
import io.kestra.core.models.dashboards.Dashboard;
import io.kestra.core.models.dashboards.DataFilter;
import io.kestra.core.models.dashboards.DataFilterKPI;
import io.kestra.core.models.dashboards.charts.Chart;
import io.kestra.core.models.dashboards.charts.DataChart;
import io.kestra.core.models.dashboards.charts.DataChartKPI;
import io.kestra.core.models.flows.Flow;
import io.kestra.core.models.validations.ModelValidator;
import io.kestra.core.models.validations.ValidateConstraintViolation;
import io.kestra.core.repositories.ArrayListTotal;
import io.kestra.core.repositories.DashboardRepositoryInterface;
import io.kestra.core.repositories.FlowRepositoryInterface;
import io.kestra.core.serializers.YamlParser;
import io.kestra.core.tenant.TenantService;
import io.kestra.core.utils.IdUtils;
import io.kestra.plugin.core.dashboard.chart.Markdown;
import io.kestra.plugin.core.dashboard.chart.mardown.sources.FlowDescription;
import io.kestra.webserver.models.GlobalFilter;
import io.kestra.webserver.responses.PagedResults;
import io.kestra.webserver.utils.PageableUtils;
import io.kestra.webserver.utils.TimeLineSearch;
import io.micronaut.core.annotation.Introspected;
import io.micronaut.core.annotation.Nullable;
import io.micronaut.http.HttpResponse;
import io.micronaut.http.HttpStatus;
import io.micronaut.http.MediaType;
@@ -24,9 +34,9 @@ import io.micronaut.validation.Validated;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.parameters.RequestBody;
import jakarta.annotation.Nullable;
import jakarta.inject.Inject;
import jakarta.validation.ConstraintViolationException;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import lombok.extern.slf4j.Slf4j;
@@ -43,10 +53,14 @@ import static io.kestra.core.utils.DateUtils.validateTimeline;
@Controller("/api/v1/main/dashboards")
@Slf4j
public class DashboardController {
protected static final YamlParser YAML_PARSER = new YamlParser();
@Inject
private DashboardRepositoryInterface dashboardRepository;
@Inject
private FlowRepositoryInterface flowRepository;
@Inject
protected TenantService tenantService;
@@ -196,31 +210,99 @@ public class DashboardController {
return null;
}
Integer pageNumber = globalFilter.getPageNumber();
Integer pageSize = globalFilter.getPageSize();
if (chart instanceof DataChart dataChart) {
Integer pageNumber = globalFilter.getPageNumber();
Integer pageSize = globalFilter.getPageSize();
dataChart.getData().setGlobalFilter(filters, startDate, endDate);
DataFilter<?, ?> dataChartDatas = dataChart.getData();
dataChartDatas.updateWhereWithGlobalFilters(filters, startDate, endDate);
// StartDate & EndDate are only set in the globalFilter for JDBC
// TODO: Check if we can remove them from generate() for ElasticSearch as they are already set in the where property
return PagedResults.of(this.dashboardRepository.generate(tenantId, dataChart, startDate, endDate, pageNumber != null && pageSize != null ? PageableUtils.from(pageNumber, pageSize) : null));
} else if (chart instanceof DataChartKPI dataChartKPI) {
DataFilterKPI<?, ?> dataChartDatas = dataChartKPI.getData();
dataChartDatas.updateWhereWithGlobalFilters(filters, startDate, endDate);
return PagedResults.of(new ArrayListTotal<>(this.dashboardRepository.generateKPI(tenantId, dataChartKPI, startDate, endDate), 1));
} else if (chart instanceof Markdown markdownChart) {
if (markdownChart.getSource() != null && markdownChart.getSource() instanceof FlowDescription flowDescription) {
Optional<Flow> optionalFlow = flowRepository.findById(this.tenantService.resolveTenant(), flowDescription.getNamespace(), flowDescription.getFlowId());
if (optionalFlow.isPresent()) {
Flow flow = optionalFlow.get();
Map<String, Object> descriptionMap = Map.of(
"description", flow.getDescription() != null ? flow.getDescription() : ""
);
return PagedResults.of(new ArrayListTotal<>(List.of(descriptionMap), 1));
} else {
throw new IllegalArgumentException("Flow not found");
}
}
}
throw new IllegalArgumentException("Only data charts can be generated.");
}
@ExecuteOn(TaskExecutors.IO)
@Post(uri = "charts/preview", consumes = MediaType.APPLICATION_YAML)
@Post(uri = "charts/preview")
@Operation(tags = {"Dashboards"}, summary = "Preview a chart data")
@SuppressWarnings({"rawtypes", "unchecked"})
public PagedResults<Map<String, Object>> previewChartData(
@RequestBody(description = "The chart definition as YAML") @Body String chart
public PagedResults<Map<String, Object>> previewChart(
@Parameter(description = "The chart") @Body @Valid PreviewRequest previewRequest
) throws IOException {
Chart<?> parsed = YamlParser.parse(chart, Chart.class);
Chart<?> chart = YAML_PARSER.parse(previewRequest.chart(), Chart.class);
GlobalFilter globalFilter = previewRequest.globalFilter();
return PagedResults.of(this.dashboardRepository.generate(tenantService.resolveTenant(), (DataChart) parsed, ZonedDateTime.now().minusDays(8), ZonedDateTime.now(), null));
List<QueryFilter> filters =
globalFilter != null ? globalFilter.getFilters() : null;
ZonedDateTime endDate = null;
ZonedDateTime startDate = null;
if (filters != null) {
TimeLineSearch timeLineSearch = TimeLineSearch.extractFrom(filters);
validateTimeline(timeLineSearch.getStartDate(), timeLineSearch.getEndDate());
endDate = timeLineSearch.getEndDate();
startDate = timeLineSearch.getStartDate();
} else {
endDate = ZonedDateTime.now();
startDate = endDate.minusDays(8);
}
if (endDate.isBefore(startDate)) {
throw new IllegalArgumentException("`endDate` must be after `startDate`.");
}
if (chart instanceof DataChart dataChart) {
DataFilter<?, ?> dataChartDatas = dataChart.getData();
dataChartDatas.updateWhereWithGlobalFilters(filters, startDate, endDate);
// StartDate & EndDate are only set in the globalFilter for JDBC
// TODO: Check if we can remove them from generate() for ElasticSearch as they are already set in the where property
return PagedResults.of(this.dashboardRepository.generate(this.tenantService.resolveTenant(), dataChart, startDate, endDate, null));
} else if (chart instanceof DataChartKPI dataChartKPI) {
DataFilterKPI<?, ?> dataChartDatas = dataChartKPI.getData();
dataChartDatas.updateWhereWithGlobalFilters(filters, startDate, endDate);
return PagedResults.of(new ArrayListTotal<>(this.dashboardRepository.generateKPI(this.tenantService.resolveTenant(), dataChartKPI, startDate, endDate),1));
} else if (chart instanceof Markdown markdownChart) {
if (markdownChart.getSource() != null && markdownChart.getSource() instanceof FlowDescription flowDescription) {
Optional<Flow> optionalFlow = flowRepository.findById(this.tenantService.resolveTenant(), flowDescription.getNamespace(), flowDescription.getFlowId());
if (optionalFlow.isPresent()) {
Flow flow = optionalFlow.get();
Map<String, Object> descriptionMap = Map.of(
"description", flow.getDescription() != null ? flow.getDescription() : ""
);
return PagedResults.of(new ArrayListTotal<>(List.of(descriptionMap), 1));
} else {
throw new IllegalArgumentException("Flow not found");
}
}
}
throw new IllegalArgumentException("Chart is not an instance of DataChart.");
}
@ExecuteOn(TaskExecutors.IO)
@@ -249,5 +331,9 @@ public class DashboardController {
return validateConstraintViolationBuilder.build();
}
@Introspected
public record PreviewRequest(
@Parameter(description = "The chart") String chart,
@Parameter(description = "The filters to apply, some can override chart definition like labels & namespace") @Nullable GlobalFilter globalFilter) {}
}

View File

@@ -56,6 +56,7 @@ public class StatsController {
@ExecuteOn(TaskExecutors.IO)
@Post(uri = "summary")
@Operation(tags = {"Stats"}, summary = "Get summary statistics")
@Deprecated
public SummaryStatistics summary(@Body @Valid SummaryRequest request) {
return new SummaryStatistics(
flowRepositoryInterface.countForNamespace(tenantService.resolveTenant(), request.namespace()),
@@ -66,6 +67,7 @@ public class StatsController {
@ExecuteOn(TaskExecutors.IO)
@Post(uri = "executions/daily")
@Operation(tags = {"Stats"}, summary = "Get daily statistics for executions")
@Deprecated
public List<DailyExecutionStatistics> dailyStatistics(@Body @Valid StatisticRequest statisticRequest) {
// @TODO: seems to be converted back to utc by micronaut
return executionRepository.dailyStatistics(
@@ -84,6 +86,7 @@ public class StatsController {
@ExecuteOn(TaskExecutors.IO)
@Post(uri = "taskruns/daily")
@Operation(tags = {"Stats"}, summary = "Get daily statistics for taskRuns")
@Deprecated
public List<DailyExecutionStatistics> taskRunsDailyStatistics(@Body @Valid StatisticRequest statisticRequest) {
return executionRepository.dailyStatistics(
statisticRequest.q(),
@@ -101,6 +104,7 @@ public class StatsController {
@ExecuteOn(TaskExecutors.IO)
@Post(uri = "executions/daily/group-by-flow")
@Operation(tags = {"Stats"}, summary = "Get daily statistics for executions group by namespaces and flows")
@Deprecated
public Map<String, Map<String, List<DailyExecutionStatistics>>> dailyGroupByFlowStatistics(@Body @Valid ByFlowStatisticRequest statisticRequest) {
return executionRepository.dailyGroupByFlowStatistics(
statisticRequest.q(),
@@ -117,6 +121,7 @@ public class StatsController {
@ExecuteOn(TaskExecutors.IO)
@Post(uri = "executions/daily/group-by-namespace")
@Operation(tags = {"Stats"}, summary = "Get daily statistics for executions grouped by namespace")
@Deprecated
public Map<String, ExecutionCountStatistics> dailyStatisticsGroupByNamespace(@Body @Valid ByNamespaceStatisticRequest request) {
return executionRepository.executionCountsGroupedByNamespace(
tenantService.resolveTenant(),
@@ -129,6 +134,7 @@ public class StatsController {
@ExecuteOn(TaskExecutors.IO)
@Post(uri = "executions/latest/group-by-flow")
@Operation(tags = {"Stats"}, summary = "Get latest execution by flows")
@Deprecated
public List<Execution> lastExecutions(
@Parameter(description = "A list of flows filter") @Body @Valid LastExecutionsRequest lastExecutionsRequest
) {
@@ -141,6 +147,7 @@ public class StatsController {
@ExecuteOn(TaskExecutors.IO)
@Post(uri = "logs/daily")
@Operation(tags = {"Stats"}, summary = "Get daily statistics for logs")
@Deprecated
public List<LogStatistics> logsDailyStatistics(@Body @Valid LogStatisticRequest logStatisticRequest) {
return logRepositoryInterface.statistics(
logStatisticRequest.q(),

View File

@@ -20,6 +20,9 @@ public class TimeLineSearch {
Duration timeRange = null;
for (QueryFilter filter : filters) {
if (filter.field() == null) {
continue;
}
switch (filter.field()) {
case START_DATE -> startDate = ZonedDateTime.parse(filter.value().toString());
case END_DATE -> endDate = ZonedDateTime.parse(filter.value().toString());