diff --git a/openbas-api/src/main/java/io/openbas/migration/V3_46__Add_table_inject_dependencies.java b/openbas-api/src/main/java/io/openbas/migration/V3_46__Add_table_inject_dependencies.java new file mode 100644 index 0000000000..21ca0f6e99 --- /dev/null +++ b/openbas-api/src/main/java/io/openbas/migration/V3_46__Add_table_inject_dependencies.java @@ -0,0 +1,58 @@ +package io.openbas.migration; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.openbas.database.model.InjectDependencyConditions; +import org.flywaydb.core.api.migration.BaseJavaMigration; +import org.flywaydb.core.api.migration.Context; +import org.springframework.stereotype.Component; + +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.List; + +@Component +public class V3_46__Add_table_inject_dependencies extends BaseJavaMigration { + + @Override + public void migrate(Context context) throws Exception { + ObjectMapper mapper = new ObjectMapper(); + Statement select = context.getConnection().createStatement(); + select.execute(""" + CREATE TABLE injects_dependencies ( + inject_parent_id VARCHAR(255) NOT NULL REFERENCES injects(inject_id) ON DELETE CASCADE, + inject_children_id VARCHAR(255) NOT NULL REFERENCES injects(inject_id) ON DELETE CASCADE, + dependency_condition JSONB, + dependency_created_at TIMESTAMP DEFAULT now(), + dependency_updated_at TIMESTAMP DEFAULT now(), + PRIMARY KEY(inject_parent_id, inject_children_id) + ); + CREATE INDEX idx_injects_dependencies ON injects_dependencies(inject_children_id); + """); + + // Migration datas + ResultSet results = select.executeQuery("SELECT * FROM injects WHERE inject_depends_from_another IS NOT NULL"); + PreparedStatement statement = context.getConnection().prepareStatement( + """ + INSERT INTO injects_dependencies(inject_parent_id, inject_children_id, dependency_condition) + VALUES (?, ?, to_json(?::json)) + """ + ); + while (results.next()) { + String injectId = results.getString("inject_id"); + String parentId = results.getString("inject_depends_from_another"); + InjectDependencyConditions.InjectDependencyCondition injectDependencyCondition = new InjectDependencyConditions.InjectDependencyCondition(); + injectDependencyCondition.setMode(InjectDependencyConditions.DependencyMode.and); + InjectDependencyConditions.Condition condition = new InjectDependencyConditions.Condition(); + condition.setKey("Execution"); + condition.setOperator(InjectDependencyConditions.DependencyOperator.eq); + condition.setValue(true); + injectDependencyCondition.setConditions(List.of(condition)); + statement.setString(1, parentId); + statement.setString(2, injectId); + statement.setString(3, mapper.writeValueAsString(injectDependencyCondition)); + statement.addBatch(); + } + statement.executeBatch(); + } +} diff --git a/openbas-api/src/main/java/io/openbas/rest/inject/InjectApi.java b/openbas-api/src/main/java/io/openbas/rest/inject/InjectApi.java index 690e765168..ef1acdf524 100644 --- a/openbas-api/src/main/java/io/openbas/rest/inject/InjectApi.java +++ b/openbas-api/src/main/java/io/openbas/rest/inject/InjectApi.java @@ -41,6 +41,7 @@ import java.time.Duration; import java.time.Instant; +import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.stream.Collectors; @@ -297,7 +298,20 @@ public Inject createInjectForExercise(@PathVariable String exerciseId, @Valid @R inject.setUser(userRepository.findById(currentUser().getId()).orElseThrow(ElementNotFoundException::new)); inject.setExercise(exercise); // Set dependencies - inject.setDependsOn(resolveOptionalRelation(input.getDependsOn(), injectRepository)); + if(input.getDependsOn() != null) { + inject.getDependsOn().addAll( + input.getDependsOn() + .stream() + .map(injectDependencyInput -> { + InjectDependency dependency = new InjectDependency(); + dependency.setInjectDependencyCondition(injectDependencyInput.getConditions()); + dependency.setCompositeId(new InjectDependencyId()); + dependency.getCompositeId().setInjectChildren(inject); + dependency.getCompositeId().setInjectParent(injectRepository.findById(injectDependencyInput.getRelationship().getInjectParentId()).orElse(null)); + return dependency; + }).toList() + ); + } inject.setTeams(fromIterable(teamRepository.findAllById(input.getTeams()))); inject.setAssets(fromIterable(assetService.assets(input.getAssets()))); inject.setAssetGroups(fromIterable(assetGroupService.assetGroups(input.getAssetGroups()))); @@ -467,7 +481,20 @@ public Inject createInjectForScenario( inject.setUser(this.userRepository.findById(currentUser().getId()).orElseThrow(ElementNotFoundException::new)); inject.setScenario(scenario); // Set dependencies - inject.setDependsOn(resolveOptionalRelation(input.getDependsOn(), this.injectRepository)); + if(input.getDependsOn() != null) { + inject.getDependsOn().addAll( + input.getDependsOn() + .stream() + .map(injectDependencyInput -> { + InjectDependency dependency = new InjectDependency(); + dependency.setInjectDependencyCondition(injectDependencyInput.getConditions()); + dependency.setCompositeId(new InjectDependencyId()); + dependency.getCompositeId().setInjectChildren(inject); + dependency.getCompositeId().setInjectParent(injectRepository.findById(injectDependencyInput.getRelationship().getInjectParentId()).orElse(null)); + return dependency; + }).toList() + ); + } inject.setTeams(fromIterable(teamRepository.findAllById(input.getTeams()))); inject.setAssets(fromIterable(assetService.assets(input.getAssets()))); inject.setAssetGroups(fromIterable(assetGroupService.assetGroups(input.getAssetGroups()))); @@ -575,7 +602,42 @@ private Inject updateInject(@NotBlank final String injectId, @NotNull InjectInpu inject.setUpdateAttributes(input); // Set dependencies - inject.setDependsOn(updateRelation(input.getDependsOn(), inject.getDependsOn(), this.injectRepository)); + if(input.getDependsOn() != null) { + input.getDependsOn().forEach(entry -> { + Optional<InjectDependency> existingDependency = inject.getDependsOn().stream() + .filter(injectDependency -> injectDependency.getCompositeId().getInjectParent().getId().equals(entry.getRelationship().getInjectParentId())) + .findFirst(); + if(existingDependency.isPresent()) { + existingDependency.get().getInjectDependencyCondition().setConditions(entry.getConditions().getConditions()); + existingDependency.get().getInjectDependencyCondition().setMode(entry.getConditions().getMode()); + } else { + InjectDependency injectDependency = new InjectDependency(); + injectDependency.getCompositeId().setInjectChildren(inject); + injectDependency.getCompositeId().setInjectParent(injectRepository.findById(entry.getRelationship().getInjectParentId()).orElse(null)); + injectDependency.setInjectDependencyCondition(new InjectDependencyConditions.InjectDependencyCondition()); + injectDependency.getInjectDependencyCondition().setConditions(entry.getConditions().getConditions()); + injectDependency.getInjectDependencyCondition().setMode(entry.getConditions().getMode()); + inject.getDependsOn().add(injectDependency); + } + }); + } + + List<InjectDependency> injectDepencyToRemove = new ArrayList<>(); + if(inject.getDependsOn() != null && !inject.getDependsOn().isEmpty()) { + if (input.getDependsOn() != null && !input.getDependsOn().isEmpty()) { + inject.getDependsOn().forEach( + injectDependency -> { + if (!input.getDependsOn().stream().map((injectDependencyInput -> injectDependencyInput.getRelationship().getInjectParentId())).toList().contains(injectDependency.getCompositeId().getInjectParent().getId())) { + injectDepencyToRemove.add(injectDependency); + } + } + ); + } else { + injectDepencyToRemove.addAll(inject.getDependsOn()); + } + inject.getDependsOn().removeAll(injectDepencyToRemove); + } + inject.setTeams(fromIterable(this.teamRepository.findAllById(input.getTeams()))); inject.setAssets(fromIterable(this.assetService.assets(input.getAssets()))); inject.setAssetGroups(fromIterable(this.assetGroupService.assetGroups(input.getAssetGroups()))); diff --git a/openbas-api/src/main/java/io/openbas/rest/inject/form/InjectDependencyIdInput.java b/openbas-api/src/main/java/io/openbas/rest/inject/form/InjectDependencyIdInput.java new file mode 100644 index 0000000000..3103102bc3 --- /dev/null +++ b/openbas-api/src/main/java/io/openbas/rest/inject/form/InjectDependencyIdInput.java @@ -0,0 +1,25 @@ +package io.openbas.rest.inject.form; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import io.openbas.database.model.Inject; +import io.openbas.database.model.InjectDependencyConditions; +import io.openbas.database.model.InjectDependencyId; +import io.openbas.helper.MonoIdDeserializer; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.Getter; +import lombok.Setter; + +@Setter +@Getter +public class InjectDependencyIdInput { + + @JsonProperty("inject_parent_id") + private String injectParentId; + + @JsonProperty("inject_children_id") + private String injectChildrenId; + +} diff --git a/openbas-api/src/main/java/io/openbas/rest/inject/form/InjectDependencyInput.java b/openbas-api/src/main/java/io/openbas/rest/inject/form/InjectDependencyInput.java new file mode 100644 index 0000000000..8ce475cb98 --- /dev/null +++ b/openbas-api/src/main/java/io/openbas/rest/inject/form/InjectDependencyInput.java @@ -0,0 +1,22 @@ +package io.openbas.rest.inject.form; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.openbas.database.model.*; +import jakarta.persistence.EmbeddedId; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +@Setter +@Getter +public class InjectDependencyInput { + + @JsonProperty("dependency_relationship") + private InjectDependencyIdInput relationship; + + @JsonProperty("dependency_condition") + private InjectDependencyConditions.InjectDependencyCondition conditions; + +} diff --git a/openbas-api/src/main/java/io/openbas/rest/inject/form/InjectInput.java b/openbas-api/src/main/java/io/openbas/rest/inject/form/InjectInput.java index 8571fc4003..ad663f90f1 100644 --- a/openbas-api/src/main/java/io/openbas/rest/inject/form/InjectInput.java +++ b/openbas-api/src/main/java/io/openbas/rest/inject/form/InjectInput.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.node.ObjectNode; import io.openbas.database.model.Inject; +import io.openbas.database.model.InjectDependency; import io.openbas.database.model.InjectorContract; import jakarta.validation.constraints.NotNull; import lombok.Getter; @@ -10,6 +11,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.Map; @Setter @Getter @@ -28,7 +30,7 @@ public class InjectInput { private ObjectNode content; @JsonProperty("inject_depends_on") - private String dependsOn; + private List<InjectDependencyInput> dependsOn = new ArrayList<>(); @JsonProperty("inject_depends_duration") private Long dependsDuration; diff --git a/openbas-api/src/main/java/io/openbas/rest/inject/output/InjectOutput.java b/openbas-api/src/main/java/io/openbas/rest/inject/output/InjectOutput.java index 38278ae5df..8f02397141 100644 --- a/openbas-api/src/main/java/io/openbas/rest/inject/output/InjectOutput.java +++ b/openbas-api/src/main/java/io/openbas/rest/inject/output/InjectOutput.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.node.ObjectNode; +import io.openbas.database.model.InjectDependency; import io.openbas.database.model.InjectorContract; import io.openbas.helper.InjectModelHelper; import io.openbas.injectors.email.EmailContract; @@ -12,6 +13,8 @@ import lombok.Data; import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; @Data public class InjectOutput { @@ -39,7 +42,7 @@ public class InjectOutput { private Long dependsDuration; @JsonProperty("inject_depends_on") - private String dependsOn; + private List<InjectDependency> dependsOn; @JsonProperty("inject_injector_contract") private InjectorContract injectorContract; @@ -79,20 +82,19 @@ public InjectOutput( String exerciseId, String scenarioId, Long dependsDuration, - String dependsOn, InjectorContract injectorContract, String[] tags, String[] teams, String[] assets, String[] assetGroups, - String injectType) { + String injectType, + InjectDependency injectDependency) { this.id = id; this.title = title; this.enabled = enabled; this.exercise = exerciseId; this.scenario = scenarioId; this.dependsDuration = dependsDuration; - this.dependsOn = dependsOn; this.injectorContract = injectorContract; this.tags = tags != null ? new HashSet<>(Arrays.asList(tags)) : new HashSet<>(); @@ -111,5 +113,10 @@ public InjectOutput( this.injectType = injectType; this.teams = teams != null ? new ArrayList<>(Arrays.asList(teams)) : new ArrayList<>(); this.content = content; + + if (injectDependency != null) { + this.dependsOn = List.of(injectDependency); + } + } } diff --git a/openbas-api/src/main/java/io/openbas/scheduler/jobs/InjectsExecutionJob.java b/openbas-api/src/main/java/io/openbas/scheduler/jobs/InjectsExecutionJob.java index 784c17e599..30a7d8c6fc 100644 --- a/openbas-api/src/main/java/io/openbas/scheduler/jobs/InjectsExecutionJob.java +++ b/openbas-api/src/main/java/io/openbas/scheduler/jobs/InjectsExecutionJob.java @@ -12,17 +12,21 @@ import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.jetbrains.annotations.NotNull; import org.quartz.DisallowConcurrentExecution; import org.quartz.Job; import org.quartz.JobExecutionContext; import org.quartz.JobExecutionException; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.ApplicationContext; +import org.springframework.expression.Expression; +import org.springframework.expression.ExpressionParser; +import org.springframework.expression.spel.standard.SpelExpressionParser; import org.springframework.stereotype.Component; import java.time.Instant; -import java.util.List; -import java.util.Map; +import java.util.*; import java.util.logging.Level; import java.util.logging.Logger; @@ -47,10 +51,14 @@ public class InjectsExecutionJob implements Job { private final QueueService queueService; private final ExecutionExecutorService executionExecutorService; private final AtomicTestingService atomicTestingService; + private final InjectDependenciesRepository injectDependenciesRepository; + private final InjectExpectationRepository injectExpectationRepository; private final List<ExecutionStatus> executionStatusesNotReady = List.of(ExecutionStatus.QUEUING, ExecutionStatus.DRAFT, ExecutionStatus.EXECUTING, ExecutionStatus.PENDING); + private final List<InjectExpectation.EXPECTATION_STATUS> expectationStatusesSuccess = List.of(InjectExpectation.EXPECTATION_STATUS.SUCCESS); + @Resource protected ObjectMapper mapper; @@ -148,24 +156,26 @@ private void executeInject(ExecutableInject executableInject) { Inject inject = executableInject.getInjection().getInject(); // We are now checking if we depend on another inject and if it did not failed - if (inject.getDependsOn() != null - && inject.getDependsOn().getStatus().isPresent() - && ( inject.getDependsOn().getStatus().get().getName().equals(ExecutionStatus.ERROR) - || executionStatusesNotReady.contains(inject.getDependsOn().getStatus().get().getName()))) { + Optional<List<String>> errorMessages = null; + if(executableInject.getExercise() != null) { + errorMessages = getErrorMessagesPreExecution(executableInject.getExercise().getId(), inject); + } + if (errorMessages != null && errorMessages.isPresent()) { InjectStatus status = new InjectStatus(); if (inject.getStatus().isEmpty()) { status.setInject(inject); } else { status = inject.getStatus().get(); } - String errorMsg = inject.getDependsOn().getStatus().get().getName().equals(ExecutionStatus.ERROR) ? - "The inject is depending on another inject that failed" - : "The inject is depending on another inject that is not executed yet"; - status.getTraces().add(InjectStatusExecution.traceError(errorMsg)); - status.setName(ExecutionStatus.ERROR); - status.setTrackingSentDate(Instant.now()); - status.setCommandsLines(atomicTestingService.getCommandsLinesFromInject(inject)); - injectStatusRepository.save(status); + + InjectStatus finalStatus = status; + errorMessages.get().forEach( + errorMsg -> finalStatus.getTraces().add(InjectStatusExecution.traceError(errorMsg)) + ); + finalStatus.setName(ExecutionStatus.ERROR); + finalStatus.setTrackingSentDate(Instant.now()); + finalStatus.setCommandsLines(atomicTestingService.getCommandsLinesFromInject(inject)); + injectStatusRepository.save(finalStatus); } else { inject.getInjectorContract().ifPresent(injectorContract -> { @@ -225,6 +235,94 @@ private void executeInject(ExecutableInject executableInject) { } } + /** + * Get error messages if pre execution conditions are not met + * @param exerciseId the id of the exercise + * @param inject the inject to check + * @return an optional of list of error message + */ + private Optional<List<String>> getErrorMessagesPreExecution(String exerciseId, Inject inject) { + List<InjectDependency> injectDependencies = injectDependenciesRepository.findParents(List.of(inject.getId())); + if (!injectDependencies.isEmpty()) { + List<Inject> parents = injectDependencies.stream() + .map(injectDependency -> injectDependency.getCompositeId().getInjectParent()).toList(); + + Map<String, Boolean> mapCondition = getStringBooleanMap(parents, exerciseId, injectDependencies); + + List<String> results = null; + + for (InjectDependency injectDependency : injectDependencies) { + String expressionToEvaluate = injectDependency.getInjectDependencyCondition().toString(); + List<String> conditions = injectDependency.getInjectDependencyCondition().getConditions().stream().map(InjectDependencyConditions.Condition::toString).toList(); + for(String condition : conditions) { + expressionToEvaluate = expressionToEvaluate.replaceAll(condition.split("==")[0].trim(), String.format("#this['%s']", condition.split("==")[0].trim())); + } + + ExpressionParser parser = new SpelExpressionParser(); + Expression exp = parser.parseExpression(expressionToEvaluate); + boolean canBeExecuted = Boolean.TRUE.equals(exp.getValue(mapCondition, Boolean.class)); + if (!canBeExecuted) { + if (results == null) { + results = new ArrayList<>(); + results.add("This inject depends on other injects expectations that are not met. The following conditions were not as expected : "); + } + results.addAll(labelFromCondition(injectDependency.getCompositeId().getInjectParent(), injectDependency.getInjectDependencyCondition())); + } + } + return results == null ? Optional.empty() : Optional.of(results); + } + return Optional.empty(); + } + + /** + * Get a map containing the expectations and if they are met or not + * @param parents the parents injects + * @param exerciseId the id of the exercise + * @param injectDependencies the list of dependencies + * @return a map of expectations and their value + */ + private @NotNull Map<String, Boolean> getStringBooleanMap(List<Inject> parents, String exerciseId, List<InjectDependency> injectDependencies) { + Map<String, Boolean> mapCondition = new HashMap<>(); + + injectDependencies.forEach(injectDependency -> { + injectDependency.getInjectDependencyCondition().getConditions().stream().forEach(condition -> { + mapCondition.put(condition.getKey(), false); + }); + }); + + parents.forEach(parent -> { + mapCondition.put("Execution", + parent.getStatus().isPresent() + && !parent.getStatus().get().getName().equals(ExecutionStatus.ERROR) + && !executionStatusesNotReady.contains(parent.getStatus().get().getName())); + + List<InjectExpectation> expectations = injectExpectationRepository.findAllForExerciseAndInject(exerciseId, parent.getId()); + expectations.forEach(injectExpectation -> { + String name = StringUtils.capitalize(injectExpectation.getType().toString().toLowerCase()); + if(injectExpectation.getType().equals(InjectExpectation.EXPECTATION_TYPE.MANUAL)) { + name = injectExpectation.getName(); + } + if(InjectExpectation.EXPECTATION_TYPE.CHALLENGE.equals(injectExpectation.getType()) + || InjectExpectation.EXPECTATION_TYPE.ARTICLE.equals(injectExpectation.getType())) { + if(injectExpectation.getUser() == null && injectExpectation.getScore() != null) { + mapCondition.put(name, injectExpectation.getScore() >= injectExpectation.getExpectedScore()); + } + } else { + mapCondition.put(name, expectationStatusesSuccess.contains(injectExpectation.getResponse())); + } + }); + }); + return mapCondition; + } + + private List<String> labelFromCondition(Inject injectParent, InjectDependencyConditions.InjectDependencyCondition condition) { + List<String> result = new ArrayList<>(); + for (InjectDependencyConditions.Condition conditionElement : condition.getConditions()) { + result.add(String.format("Inject '%s' - %s is %s", injectParent.getTitle(), conditionElement.getKey(), conditionElement.isValue())); + } + return result; + } + public void updateExercise(String exerciseId) { Exercise exercise = exerciseRepository.findById(exerciseId).orElseThrow(); exercise.setUpdatedAt(now()); diff --git a/openbas-api/src/main/java/io/openbas/service/AtomicTestingService.java b/openbas-api/src/main/java/io/openbas/service/AtomicTestingService.java index f947391e3c..8aca82e63e 100644 --- a/openbas-api/src/main/java/io/openbas/service/AtomicTestingService.java +++ b/openbas-api/src/main/java/io/openbas/service/AtomicTestingService.java @@ -228,7 +228,9 @@ public Inject copyInject(@NotNull Inject injectOrigin, boolean isAtomic) { injectDuplicate.setTeams(injectOrigin.getTeams().stream().toList()); injectDuplicate.setEnabled(injectOrigin.isEnabled()); injectDuplicate.setDependsDuration(injectOrigin.getDependsDuration()); - injectDuplicate.setDependsOn(injectOrigin.getDependsOn()); + if(injectOrigin.getDependsOn() != null) { + injectDuplicate.setDependsOn(injectOrigin.getDependsOn().stream().toList()); + } injectDuplicate.setCountry(injectOrigin.getCountry()); injectDuplicate.setCity(injectOrigin.getCity()); injectDuplicate.setInjectorContract(injectOrigin.getInjectorContract().orElse(null)); diff --git a/openbas-api/src/main/java/io/openbas/service/InjectService.java b/openbas-api/src/main/java/io/openbas/service/InjectService.java index d1c6c905fc..c6e825984a 100644 --- a/openbas-api/src/main/java/io/openbas/service/InjectService.java +++ b/openbas-api/src/main/java/io/openbas/service/InjectService.java @@ -1199,7 +1199,8 @@ private void selectForInject(CriteriaBuilder cb, CriteriaQuery<Tuple> cq, Root<I Join<Inject, Scenario> injectScenarioJoin = createLeftJoin(injectRoot, "scenario"); Join<Inject, InjectorContract> injectorContractJoin = createLeftJoin(injectRoot, "injectorContract"); Join<InjectorContract, Injector> injectorJoin = injectorContractJoin.join("injector", JoinType.LEFT); - Join<Inject, Inject> injectDependsJoin = createLeftJoin(injectRoot, "dependsOn"); + Join<Inject, InjectDependency> injectDependency = createLeftJoin(injectRoot, "dependsOn"); + // Array aggregations Expression<String[]> tagIdsExpression = createJoinArrayAggOnId(cb, injectRoot, "tags"); Expression<String[]> teamIdsExpression = createJoinArrayAggOnId(cb, injectRoot, "teams"); @@ -1216,13 +1217,13 @@ private void selectForInject(CriteriaBuilder cb, CriteriaQuery<Tuple> cq, Root<I injectExerciseJoin.get("id").alias("inject_exercise"), injectScenarioJoin.get("id").alias("inject_scenario"), injectRoot.get("dependsDuration").alias("inject_depends_duration"), - injectDependsJoin.get("id").alias("inject_depends_from_another"), injectorContractJoin.alias("inject_injector_contract"), tagIdsExpression.alias("inject_tags"), teamIdsExpression.alias("inject_teams"), assetIdsExpression.alias("inject_assets"), assetGroupIdsExpression.alias("inject_asset_groups"), - injectorJoin.get("type").alias("inject_type") + injectorJoin.get("type").alias("inject_type"), + injectDependency.alias("inject_depends_on") ).distinct(true); // GROUP BY @@ -1231,7 +1232,8 @@ private void selectForInject(CriteriaBuilder cb, CriteriaQuery<Tuple> cq, Root<I injectExerciseJoin.get("id"), injectScenarioJoin.get("id"), injectorContractJoin.get("id"), - injectorJoin.get("id") + injectorJoin.get("id"), + injectDependency.get("id") )); } @@ -1247,13 +1249,13 @@ private List<InjectOutput> execInject(TypedQuery<Tuple> query) { tuple.get("inject_exercise", String.class), tuple.get("inject_scenario", String.class), tuple.get("inject_depends_duration", Long.class), - tuple.get("inject_depends_from_another", String.class), tuple.get("inject_injector_contract", InjectorContract.class), tuple.get("inject_tags", String[].class), tuple.get("inject_teams", String[].class), tuple.get("inject_assets", String[].class), tuple.get("inject_asset_groups", String[].class), - tuple.get("inject_type", String.class) + tuple.get("inject_type", String.class), + tuple.get("inject_depends_on", InjectDependency.class) )) .toList(); } diff --git a/openbas-api/src/main/java/io/openbas/service/ScenarioToExerciseService.java b/openbas-api/src/main/java/io/openbas/service/ScenarioToExerciseService.java index 7669482a2c..c933c5b67a 100644 --- a/openbas-api/src/main/java/io/openbas/service/ScenarioToExerciseService.java +++ b/openbas-api/src/main/java/io/openbas/service/ScenarioToExerciseService.java @@ -247,7 +247,15 @@ public Exercise toExercise( scenarioInjects.forEach(scenarioInject -> { if(scenarioInject.getDependsOn() != null) { Inject injectToUpdate = mapExerciseInjectsByScenarioInject.get(scenarioInject.getId()); - injectToUpdate.setDependsOn(mapExerciseInjectsByScenarioInject.get(scenarioInject.getDependsOn().getId())); + injectToUpdate.getDependsOn().clear(); + injectToUpdate.getDependsOn().addAll(scenarioInject.getDependsOn().stream().map((injectDependency -> { + InjectDependency dep = new InjectDependency(); + dep.setCompositeId(injectDependency.getCompositeId()); + dep.setInjectDependencyCondition(injectDependency.getInjectDependencyCondition()); + dep.getCompositeId().setInjectParent(mapExerciseInjectsByScenarioInject.get(dep.getCompositeId().getInjectParent().getId())); + dep.getCompositeId().setInjectChildren(injectToUpdate); + return dep; + })).toList()); this.injectRepository.save(injectToUpdate); } }); diff --git a/openbas-front/src/actions/injects/Inject.d.ts b/openbas-front/src/actions/injects/Inject.d.ts index 9d70a9a315..f1b3154851 100644 --- a/openbas-front/src/actions/injects/Inject.d.ts +++ b/openbas-front/src/actions/injects/Inject.d.ts @@ -39,3 +39,40 @@ export type InjectExpectationStore = Omit<InjectExpectation, 'inject_expectation inject_expectation_team: string | undefined; inject_expectation_inject: string | undefined; }; + +export interface ConditionElement { + name: string, + value: boolean, + key: string, + index: number, +} + +export interface ConditionType { + parentId?: string, + childrenId?: string, + mode?: string, + conditionElement?: ConditionElement[], +} + +export interface Dependency { + inject?: InjectOutputType, + index: number, +} + +export interface Content { + expectations: { + expectation_type: string, + expectation_name: string, + }[] +} + +export interface ConvertedContentType { + fields: { + key: string + value: string, + predefinedExpectations: { + expectation_type: string, + expectation_name: string, + }[] + }[], +} diff --git a/openbas-front/src/admin/components/assets/asset_groups/AssetGroups.tsx b/openbas-front/src/admin/components/assets/asset_groups/AssetGroups.tsx index 352f69fc23..3c1530e947 100644 --- a/openbas-front/src/admin/components/assets/asset_groups/AssetGroups.tsx +++ b/openbas-front/src/admin/components/assets/asset_groups/AssetGroups.tsx @@ -25,7 +25,7 @@ import ExportButton from '../../../../components/common/ExportButton'; import { useQueryableWithLocalStorage } from '../../../../components/common/queryable/useQueryableWithLocalStorage'; import SortHeadersComponentV2 from '../../../../components/common/queryable/sort/SortHeadersComponentV2'; import { Header } from '../../../../components/common/SortHeadersList'; -import FilterModeChip from '../../../../components/common/queryable/filter/FilterModeChip'; +import ClickableModeChip from '../../../../components/common/chips/ClickableModeChip'; import FilterChipValues from '../../../../components/common/queryable/filter/FilterChipValues'; const useStyles = makeStyles(() => ({ @@ -77,7 +77,7 @@ const computeRuleValues = (assetGroup: AssetGroupOutput, t: (value: string) => s <> {assetGroup.asset_group_dynamic_filter.filters.map((filter, idx) => ( <React.Fragment key={filter.key}> - {idx !== 0 && <FilterModeChip mode={assetGroup.asset_group_dynamic_filter?.mode} />} + {idx !== 0 && <ClickableModeChip mode={assetGroup.asset_group_dynamic_filter?.mode} />} <Chip key={filter.key} variant={'filled'} diff --git a/openbas-front/src/admin/components/common/injects/InjectChainsForm.js b/openbas-front/src/admin/components/common/injects/InjectChainsForm.js deleted file mode 100644 index 90ee59112e..0000000000 --- a/openbas-front/src/admin/components/common/injects/InjectChainsForm.js +++ /dev/null @@ -1,280 +0,0 @@ -import React, { useState } from 'react'; -import { makeStyles } from '@mui/styles'; -import { Accordion, AccordionDetails, AccordionSummary, FormControl, IconButton, InputLabel, MenuItem, Select, Tooltip, Typography } from '@mui/material'; -import { Add, DeleteOutlined, ExpandMore } from '@mui/icons-material'; -import { useFormatter } from '../../../../components/i18n'; - -const useStyles = makeStyles(() => ({ - container: { - display: 'inline-flex', - alignItems: 'center', - }, - importerStyle: { - display: 'flex', - alignItems: 'center', - marginTop: 20, - }, -})); - -const InjectForm = ({ - values, - form, - injects, -}) => { - const classes = useStyles(); - const { t } = useFormatter(); - - const [parents, setParents] = useState( - injects.filter((currentInject) => currentInject.inject_id === values.inject_depends_on) - .map((inject, index) => { - return { inject, index }; - }), - ); - const [childrens, setChildrens] = useState( - injects.filter((currentInject) => currentInject.inject_depends_on === values.inject_id) - .map((inject, index) => { - return { inject, index }; - }), - ); - - const handleChangeParent = (_event, parent) => { - const rx = /\.\$select-parent-(.*)-inject-(.*)/g; - const arr = rx.exec(parent.key); - - const newParents = parents - .map((element) => { - if (element.index === parseInt(arr[1], 10)) { - return { - inject: injects.find((currentInject) => currentInject.inject_id === arr[2]), - index: element.index, - }; - } - return element; - }); - - setParents(newParents); - - // We take any parent that is not undefined or undefined (since there is only one parent for now, this'll - // be changed when we allow for multiple parents) - const anyParent = newParents.find((inject) => inject !== undefined); - - form.mutators.setValue( - 'inject_depends_on', - anyParent?.inject.inject_id || null, - ); - }; - - const addParent = () => { - setParents([...parents, { inject: undefined, index: parents.length }]); - }; - - const handleChangeChildren = (_event, child) => { - const rx = /\.\$select-children-(.*)-inject-(.*)/g; - const arr = rx.exec(child.key); - - const newChildrens = childrens - .map((element) => { - if (element.index === parseInt(arr[1], 10)) { - return { - inject: injects.find((currentInject) => currentInject.inject_id === arr[2]), - index: element.index, - }; - } - return element; - }); - - setChildrens(newChildrens); - - form.mutators.setValue('inject_depends_to', newChildrens.map((inject) => inject.inject?.inject_id)); - }; - - const addChildren = () => { - setChildrens([...childrens, { inject: undefined, index: childrens.length }]); - }; - - const deleteParent = (parent) => { - const parentIndexInArray = parents.findIndex((currentParent) => currentParent.index === parent.index); - - if (parentIndexInArray > -1) { - const newParents = [ - ...parents.slice(0, parentIndexInArray), - ...parents.slice(parentIndexInArray + 1), - ]; - setParents(newParents); - const anyParent = newParents.find((inject) => inject !== undefined); - - form.mutators.setValue( - 'inject_depends_on', - anyParent?.inject.inject_id || null, - ); - } - }; - - const deleteChildren = (children) => { - const childrenIndexInArray = childrens.findIndex((currentChildren) => currentChildren.index === children.index); - - if (childrenIndexInArray > -1) { - const newChildrens = [ - ...childrens.slice(0, childrenIndexInArray), - ...childrens.slice(childrenIndexInArray + 1), - ]; - setChildrens(newChildrens); - - form.mutators.setValue('inject_depends_to', newChildrens.map((inject) => inject.inject?.inject_id)); - } - }; - - return ( - <> - <div className={classes.importerStyle}> - <Typography variant="h2" sx={{ m: 0 }}> - {t('Parent')} - </Typography> - <IconButton - color="secondary" - aria-label="Add" - size="large" - disabled={parents.length > 0} - onClick={addParent} - > - <Add fontSize="small"/> - </IconButton> - </div> - - {parents.map((parent, index) => { - return ( - <Accordion - key={`accordion-parent-${parent.index}`} - variant="outlined" - style={{ width: '100%', marginBottom: '10px' }} - > - <AccordionSummary - expandIcon={<ExpandMore/>} - > - <div className={classes.container}> - <Typography> - #{index + 1} {parent.inject?.inject_title} - </Typography> - <Tooltip title={t('Delete')}> - <IconButton color="error" - onClick={() => { deleteParent(parent); }} - > - <DeleteOutlined fontSize="small"/> - </IconButton> - </Tooltip> - </div> - </AccordionSummary> - <AccordionDetails> - <FormControl style={{ width: '100%' }}> - <InputLabel id="inject_id">{t('Inject')}</InputLabel> - <Select - labelId="condition" - fullWidth={true} - value={parents[parent.index].inject ? parents[parent.index].inject.inject_id : ''} - onChange={handleChangeParent} - > - {injects - .filter((currentInject) => currentInject.inject_depends_duration < values.inject_depends_duration - && (parents.find((parentSearch) => currentInject.inject_id === parentSearch.inject?.inject_id) === undefined - || parents[parent.index].inject?.inject_id === currentInject.inject_id)) - .map((currentInject) => { - return (<MenuItem key={`select-parent-${index}-inject-${currentInject.inject_id}`} - value={currentInject.inject_id} - >{currentInject.inject_title}</MenuItem>); - })} - </Select> - </FormControl> - <FormControl style={{ width: '100%', marginTop: '15px' }}> - <InputLabel id="condition">{t('Condition')}</InputLabel> - <Select - labelId="condition" - value={'Success'} - fullWidth={true} - disabled - > - <MenuItem value="Success">{t('Execution successful')}</MenuItem> - </Select> - </FormControl> - </AccordionDetails> - </Accordion> - ); - })} - - <div className={classes.importerStyle}> - <Typography variant="h2" sx={{ m: 0 }}> - {t('Childrens')} - </Typography> - <IconButton - color="secondary" - aria-label="Add" - size="large" - onClick={addChildren} - > - <Add fontSize="small"/> - </IconButton> - </div> - {childrens.map((children, index) => { - return ( - <Accordion - key={`accordion-children-${children.index}`} - variant="outlined" - style={{ width: '100%', marginBottom: '10px' }} - > - <AccordionSummary - expandIcon={<ExpandMore/>} - > - <div className={classes.container}> - <Typography> - #{index + 1} {children.inject?.inject_title} - </Typography> - <Tooltip title={t('Delete')}> - <IconButton color="error" - onClick={() => { deleteChildren(children); }} - > - <DeleteOutlined fontSize="small"/> - </IconButton> - </Tooltip> - </div> - </AccordionSummary> - <AccordionDetails> - <FormControl style={{ width: '100%' }}> - <InputLabel id="inject_id">{t('Inject')}</InputLabel> - <Select - labelId="condition" - fullWidth={true} - value={childrens.find((childrenSearch) => children.index === childrenSearch.index).inject - ? childrens.find((childrenSearch) => children.index === childrenSearch.index).inject.inject_id : ''} - onChange={handleChangeChildren} - > - {injects - .filter((currentInject) => currentInject.inject_depends_duration > values.inject_depends_duration - && (childrens.find((childrenSearch) => currentInject.inject_id === childrenSearch.inject?.inject_id) === undefined - || childrens.find((childrenSearch) => children.index === childrenSearch.index).inject?.inject_id === currentInject.inject_id)) - .map((currentInject) => { - return ( - <MenuItem key={`select-children-${children.index}-inject-${currentInject.inject_id}`} - value={currentInject.inject_id} - >{currentInject.inject_title}</MenuItem>); - })} - </Select> - </FormControl> - <FormControl style={{ width: '100%', marginTop: '15px' }}> - <InputLabel id="condition">{t('Condition')}</InputLabel> - <Select - labelId="condition" - value={'Success'} - fullWidth={true} - disabled - > - <MenuItem value="Success">{t('Execution successful')}</MenuItem> - </Select> - </FormControl> - </AccordionDetails> - </Accordion> - ); - })} - </> - ); -}; - -export default InjectForm; diff --git a/openbas-front/src/admin/components/common/injects/InjectChainsForm.tsx b/openbas-front/src/admin/components/common/injects/InjectChainsForm.tsx new file mode 100644 index 0000000000..accc9804fa --- /dev/null +++ b/openbas-front/src/admin/components/common/injects/InjectChainsForm.tsx @@ -0,0 +1,993 @@ +import React, { ReactElement, ReactNode, useEffect, useState } from 'react'; +import { makeStyles } from '@mui/styles'; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Box, + Button, + FormControl, + IconButton, + InputLabel, + MenuItem, + Select, + SelectChangeEvent, + Tooltip, + Typography, +} from '@mui/material'; +import { Add, DeleteOutlined, ExpandMore } from '@mui/icons-material'; +import { FormApi } from 'final-form'; +import { Value } from 'classnames'; +import { useFormatter } from '../../../../components/i18n'; +import ClickableModeChip from '../../../../components/common/chips/ClickableModeChip'; +import ClickableChip from '../../../../components/common/chips/ClickableChip'; +import { capitalize } from '../../../../utils/String'; +import type { Inject, InjectDependency, InjectDependencyCondition, InjectOutput } from '../../../../utils/api-types'; +import type { ConditionElement, ConditionType, Content, ConvertedContentType, Dependency, InjectOutputType } from '../../../../actions/injects/Inject'; +import type { Element } from '../../../../components/common/chips/ClickableChip'; + +const useStyles = makeStyles(() => ({ + container: { + display: 'inline-flex', + alignItems: 'center', + }, + importerStyle: { + display: 'flex', + alignItems: 'center', + marginTop: 20, + }, + labelExecutionCondition: { + color: '#7c8088', + }, +})); + +interface Props { + values: Inject & { inject_depends_to: InjectDependency[]; }, + form: FormApi<Inject & { inject_depends_to: InjectDependency[]; }, Partial<Inject & { inject_depends_to: InjectDependency[]; }>>, + injects?: InjectOutputType[], +} + +const InjectForm: React.FC<Props> = ({ values, form, injects }) => { + const classes = useStyles(); + const { t } = useFormatter(); + + // List of parents + const [parents, setParents] = useState<Dependency[]>( + () => { + if (values.inject_depends_on) { + return values.inject_depends_on?.filter((searchInject) => searchInject.dependency_relationship?.inject_children_id === values.inject_id) + .map((inject, index) => { + return { + inject: injects?.find((currentInject) => currentInject.inject_id === inject.dependency_relationship?.inject_parent_id), + index, + }; + }); + } + return []; + }, + + ); + + // List of childrens + const [childrens, setChildrens] = useState<Dependency[]>( + () => { + if (injects !== undefined) { + return injects?.filter( + (searchInject) => searchInject.inject_depends_on?.find( + (dependsOnSearch) => dependsOnSearch.dependency_relationship?.inject_parent_id === values.inject_id, + ) !== undefined, + ) + .map((inject, index) => { + return { + inject, + index, + }; + }); + } + return []; + }, + ); + + // Property to deactivate the add children button if there are no children available anymore + const [addChildrenButtonDisabled, setAddChildrenButtonDisabled] = useState(false); + useEffect(() => { + const availableChildrensNumber = injects ? injects.filter((currentInject) => currentInject.inject_depends_duration > values.inject_depends_duration).length : 0; + setAddChildrenButtonDisabled(childrens ? childrens.length >= availableChildrensNumber : true); + }, [childrens]); + + /** + * Transform an inject dependency into ConditionElement + * @param injectDependsOn an array of injectDependency + */ + const getConditionContentParent = (injectDependsOn: (InjectDependency | undefined)[]) => { + const conditions: ConditionType[] = []; + if (injectDependsOn) { + injectDependsOn.forEach((parent) => { + if (parent !== undefined) { + conditions.push({ + parentId: parent.dependency_relationship?.inject_parent_id, + childrenId: parent.dependency_relationship?.inject_children_id, + mode: parent.dependency_condition?.mode, + conditionElement: parent.dependency_condition?.conditions?.map((dependencyCondition, indexCondition) => { + return { + name: dependencyCondition.key, + value: dependencyCondition.value!, + key: dependencyCondition.key, + index: indexCondition, + }; + }), + }); + } + }); + } + return conditions; + }; + + /** + * Transform an inject dependency into ConditionElement + * @param injectDependsTo an array of injectDependency + */ + const getConditionContentChildren = (injectDependsTo: (InjectDependency | undefined)[]) => { + const conditions: ConditionType[] = []; + injectDependsTo.forEach((children) => { + if (children !== undefined) { + conditions.push({ + parentId: values.inject_id, + childrenId: children.dependency_relationship?.inject_children_id, + mode: children.dependency_condition?.mode, + conditionElement: children.dependency_condition?.conditions?.map((dependencyCondition, indexCondition) => { + return { + name: dependencyCondition.key, + value: dependencyCondition.value!, + key: dependencyCondition.key, + index: indexCondition, + }; + }), + }); + } + }); + return conditions; + }; + + const [parentConditions, setParentConditions] = useState(getConditionContentParent(values.inject_depends_on ? values.inject_depends_on : [])); + const [childrenConditions, setChildrenConditions] = useState(getConditionContentChildren(values.inject_depends_to)); + + /** + * Get the inject dependency object from dependency ones + * @param deps the inject depencies + */ + const injectDependencyFromDependency = (deps: Dependency[]) => { + return deps.flatMap((dependency) => (dependency.inject?.inject_depends_on !== null ? dependency.inject?.inject_depends_on : [])); + }; + + /** + * Handle the change of the parent + * @param _event the event + * @param parent the parent key + */ + const handleChangeParent = (_event: SelectChangeEvent<Value>, parent: ReactNode) => { + const rx = /\.\$select-parent-(.*)-inject-(.*)/g; + if (!parent) return; + let key = ''; + const parentElement = parent as ReactElement; + if ('key' in parentElement && parentElement.key !== null) { + key = parentElement.key; + } + if (key === null) { + return; + } + const arr = rx.exec(key); + + if (parents === undefined || arr === null || injects === undefined) return; + const newInject = injects.find((currentInject) => currentInject.inject_id === arr[2]); + const newParents = parents + .map((element) => { + if (element.index === parseInt(arr[1], 10)) { + const previousInject = injects.find((value) => value.inject_id === element.inject?.inject_id); + if (previousInject?.inject_depends_on !== undefined) { + previousInject!.inject_depends_on = previousInject!.inject_depends_on?.filter( + (dependsOn) => dependsOn.dependency_relationship?.inject_children_id !== values.inject_id, + ); + } + return { + inject: newInject!, + index: element.index, + }; + } + return element; + }); + + setParents(newParents); + + const baseInjectDependency: InjectDependency = { + dependency_relationship: { + inject_parent_id: newInject?.inject_id, + inject_children_id: values.inject_id, + }, + dependency_condition: { + conditions: [ + { + key: 'Execution', + operator: 'eq', + value: true, + }, + ], + mode: 'and', + }, + }; + + form.mutators.setValue( + 'inject_depends_on', + [baseInjectDependency], + ); + + if (newInject!.inject_depends_on !== null) { + setParentConditions(getConditionContentParent(injectDependencyFromDependency(newParents))); + } + }; + + /** + * Add a new parent inject + */ + const addParent = () => { + setParents([...parents, { inject: undefined, index: parents.length }]); + }; + + /** + * Handle the change of a children + * @param _event + * @param child + */ + const handleChangeChildren = (_event: SelectChangeEvent<string>, child: ReactNode) => { + const rx = /\.\$select-children-(.*)-inject-(.*)/g; + if (!child) return; + let key = ''; + const childElement = (child as ReactElement); + if ('key' in (childElement as ReactElement) && childElement.key !== null) { + key = childElement.key; + } + if (key === null) { + return; + } + const arr = rx.exec(key); + + if (childrens === undefined || arr === null || injects === undefined) return; + const newInject = injects.find((currentInject) => currentInject.inject_id === arr[2]); + const newChildrens = childrens + .map((element) => { + if (element.index === parseInt(arr[1], 10)) { + const baseInjectDependency: InjectDependency = { + dependency_relationship: { + inject_parent_id: values.inject_id, + inject_children_id: newInject?.inject_id, + }, + dependency_condition: { + conditions: [ + { + key: 'Execution', + operator: 'eq', + value: true, + }, + ], + mode: 'and', + }, + }; + newInject!.inject_depends_on = [baseInjectDependency]; + return { + inject: newInject!, + index: element.index, + }; + } + return element; + }); + + setChildrens(newChildrens); + + const dependsTo = injectDependencyFromDependency(newChildrens); + form.mutators.setValue('inject_depends_to', dependsTo); + + if (newInject!.inject_depends_on !== null) { + setChildrenConditions(getConditionContentChildren(dependsTo.filter((dep) => dep !== undefined))); + } + }; + + /** + * Add a new children inject + */ + const addChildren = () => { + setChildrens([...childrens, { inject: undefined, index: childrens.length }]); + }; + + /** + * Delete a parent inject + * @param parent + */ + const deleteParent = (parent: Dependency) => { + const parentIndexInArray = parents.findIndex((currentParent) => currentParent.index === parent.index); + + if (parentIndexInArray > -1) { + const newParents = [ + ...parents.slice(0, parentIndexInArray), + ...parents.slice(parentIndexInArray + 1), + ]; + setParents(newParents); + + form.mutators.setValue( + 'inject_depends_on', + injectDependencyFromDependency(newParents), + ); + } + }; + + /** + * Delete a children inject + * @param children + */ + const deleteChildren = (children: Dependency) => { + const childrenIndexInArray = childrens.findIndex((currentChildren) => currentChildren.inject?.inject_id === children.inject?.inject_id); + + if (childrenIndexInArray > -1) { + const newChildrens = [ + ...childrens.slice(0, childrenIndexInArray), + ...childrens.slice(childrenIndexInArray + 1), + ]; + setChildrens(newChildrens); + + form.mutators.setValue('inject_depends_to', injectDependencyFromDependency(newChildrens)); + } + }; + + /** + * Returns an updated depends on from a ConditionType + * @param conditions + * @param switchIds + */ + const updateDependsCondition = (conditions: ConditionType) => { + const result: InjectDependencyCondition = { + mode: conditions.mode === 'and' ? 'and' : 'or', + conditions: conditions.conditionElement?.map((value) => { + return { + value: value.value, + key: value.key, + operator: 'eq', + }; + }), + }; + return result; + }; + + /** + * Returns an updated depends on from a ConditionType + * @param conditions + * @param switchIds + */ + const updateDependsOn = (conditions: ConditionType) => { + const result: InjectDependency = { + dependency_relationship: { + inject_parent_id: conditions.parentId, + inject_children_id: conditions.childrenId, + }, + dependency_condition: updateDependsCondition(conditions), + }; + return result; + }; + + /** + * Get the list of available expectations + * @param inject + */ + const getAvailableExpectations = (inject: InjectOutputType | undefined) => { + if (inject?.inject_content !== null && inject?.inject_content !== undefined && (inject.inject_content as Content).expectations !== undefined) { + const expectations = (inject.inject_content as Content).expectations.map((expectation) => (expectation.expectation_type === 'MANUAL' ? expectation.expectation_name : capitalize(expectation.expectation_type))); + return ['Execution', ...expectations]; + } + if (inject?.inject_injector_contract !== undefined + && (inject?.inject_injector_contract.convertedContent as unknown as ConvertedContentType).fields.find((field) => field.key === 'expectations')) { + const predefinedExpectations = (inject.inject_injector_contract.convertedContent as unknown as ConvertedContentType).fields?.find((field) => field.key === 'expectations') + ?.predefinedExpectations.map((expectation) => (expectation.expectation_type === 'MANUAL' ? expectation.expectation_name : capitalize(expectation.expectation_type))); + if (predefinedExpectations !== undefined) { + return ['Execution', ...predefinedExpectations]; + } + } + return ['Execution']; + }; + + /** + * Add a new condition to a parent inject + * @param parent + */ + const addConditionParent = (parent: Dependency) => { + const currentConditions = parentConditions.find((currentCondition) => parent.inject!.inject_id === currentCondition.parentId); + + if (parent.inject !== undefined && currentConditions !== undefined) { + let expectationString = 'Execution'; + if (currentConditions?.conditionElement !== undefined) { + expectationString = getAvailableExpectations(parent.inject) + .find((expectation) => !currentConditions?.conditionElement?.find((conditionElement) => conditionElement.key === expectation)); + } + currentConditions.conditionElement?.push({ + key: expectationString, + name: expectationString, + value: true, + index: currentConditions.conditionElement?.length, + }); + + setParentConditions(parentConditions); + + const element = parentConditions.find((conditionElement) => conditionElement.childrenId === values.inject_id); + + const dep: InjectDependency = { + dependency_relationship: { + inject_parent_id: element?.parentId, + inject_children_id: element?.childrenId, + }, + dependency_condition: { + mode: element?.mode === '&&' ? 'and' : 'or', + conditions: element?.conditionElement ? element?.conditionElement.map((value) => { + return { + key: value.key, + value: value.value, + operator: 'eq', + }; + }) : [], + }, + }; + + form.mutators.setValue( + 'inject_depends_on', + [dep], + ); + } + }; + + /** + * Add a new condition to a children inject + * @param children + */ + const addConditionChildren = (children: Dependency) => { + const currentConditions = childrenConditions.find((currentCondition) => children.inject!.inject_id === currentCondition.childrenId); + + if (children.inject !== undefined && currentConditions !== undefined) { + const updatedChildren = childrens.find((currentChildren) => currentChildren.inject?.inject_id === children.inject?.inject_id); + let expectationString = 'Execution'; + if (currentConditions?.conditionElement !== undefined) { + expectationString = getAvailableExpectations(values as InjectOutput as InjectOutputType) + .find((expectation) => !currentConditions?.conditionElement?.find((conditionElement) => conditionElement.key === expectation)); + } + currentConditions.conditionElement?.push({ + key: expectationString, + name: expectationString, + value: true, + index: currentConditions.conditionElement?.length, + }); + + if (updatedChildren?.inject?.inject_depends_on !== undefined) { + updatedChildren.inject.inject_depends_on = [updateDependsOn(currentConditions)]; + } + + setChildrenConditions(childrenConditions); + form.mutators.setValue( + 'inject_depends_to', + injectDependencyFromDependency(childrens), + ); + } + }; + + /** + * Handle a change in a condition of a parent element + * @param newElement + * @param conditions + * @param condition + * @param parent + */ + const changeParentElement = (newElement: Element, conditions: ConditionType, condition: ConditionElement, parent: Dependency) => { + const newConditionElements = conditions.conditionElement?.map((newConditionElement) => { + if (newConditionElement.index === condition.index) { + return { + index: condition.index, + key: newElement.key, + name: `${conditions.parentId}-${newElement.key}-Success`, + value: newElement.value === 'Success', + }; + } + return newConditionElement; + }); + const newParentConditions = parentConditions.map((parentCondition) => { + if (parentCondition.parentId === parent.inject?.inject_id) { + return { + ...parentCondition, + conditionElement: newConditionElements, + }; + } + return parentCondition; + }); + setParentConditions(newParentConditions); + + const element = newParentConditions?.find((conditionElement) => conditionElement.parentId === conditions.parentId); + const dep: InjectDependency = { + dependency_relationship: { + inject_parent_id: element?.parentId, + inject_children_id: element?.childrenId, + }, + dependency_condition: { + mode: element?.mode === '&&' ? 'and' : 'or', + conditions: element?.conditionElement ? element?.conditionElement.map((value) => { + return { + key: value.key, + value: value.value, + operator: 'eq', + }; + }) : [], + }, + }; + + form.mutators.setValue( + 'inject_depends_on', + [dep], + ); + }; + + /** + * Handle a change in a condition of a children element + * @param newElement + * @param conditions + * @param condition + * @param children + */ + const changeChildrenElement = (newElement: Element, conditions: ConditionType, condition: ConditionElement, children: Dependency) => { + const newConditionElements = conditions.conditionElement?.map((newConditionElement) => { + if (newConditionElement.index === condition.index) { + return { + index: condition.index, + key: newElement.key, + name: `${conditions.childrenId}-${newElement.key}-Success`, + value: newElement.value === 'Success', + }; + } + return newConditionElement; + }); + const newChildrenConditions = childrenConditions.map((childrenCondition) => { + if (childrenCondition.childrenId === children.inject?.inject_id) { + return { + ...childrenCondition, + conditionElement: newConditionElements, + }; + } + return childrenCondition; + }); + setChildrenConditions(newChildrenConditions); + + const updatedChildren = childrens.find((currentChildren) => currentChildren.inject?.inject_id === children.inject?.inject_id); + const newCondition = newChildrenConditions.find((childrenCondition) => childrenCondition.childrenId === children.inject?.inject_id); + if (updatedChildren?.inject?.inject_depends_on !== undefined && newCondition !== undefined) { + updatedChildren.inject.inject_depends_on = [updateDependsOn(newCondition)]; + } + form.mutators.setValue( + 'inject_depends_to', + injectDependencyFromDependency(childrens), + ); + }; + + /** + * Changes the mode (AND/OR) in a parent inject + * @param conditions + * @param condition + */ + const changeModeParent = (conditions: ConditionType[] | undefined, condition: ConditionType) => { + const newConditionElements = conditions?.map((currentCondition) => { + if (currentCondition.parentId === condition.parentId) { + return { + ...currentCondition, + mode: currentCondition.mode === 'and' ? 'or' : 'and', + }; + } + return currentCondition; + }); + if (newConditionElements !== undefined) { + setParentConditions(newConditionElements); + } + + const element = newConditionElements?.find((conditionElement) => conditionElement.parentId === condition.parentId); + const dep: InjectDependency = { + dependency_relationship: { + inject_parent_id: element?.parentId, + inject_children_id: element?.childrenId, + }, + dependency_condition: { + mode: element?.mode === '&&' ? 'and' : 'or', + conditions: element?.conditionElement ? element?.conditionElement.map((value) => { + return { + key: value.key, + value: value.value, + operator: 'eq', + }; + }) : [], + }, + }; + + form.mutators.setValue( + 'inject_depends_on', + [dep], + ); + }; + + /** + * Changes the mode (AND/OR) in a children inject + * @param conditions + * @param condition + */ + const changeModeChildren = (conditions: ConditionType[] | undefined, condition: ConditionType) => { + const newConditionElements = conditions?.map((currentCondition) => { + if (currentCondition.childrenId === condition.childrenId) { + return { + ...currentCondition, + mode: currentCondition.mode === 'and' ? 'or' : 'and', + }; + } + return currentCondition; + }); + if (newConditionElements !== undefined) { + setChildrenConditions(newConditionElements); + } + + const newCurrentCondition = newConditionElements?.find((currentCondition) => currentCondition.childrenId === condition.childrenId); + const updatedChildren = childrens.find((currentChildren) => currentChildren.inject?.inject_id === newCurrentCondition?.childrenId); + if (updatedChildren?.inject?.inject_depends_on !== undefined && newCurrentCondition !== undefined) { + updatedChildren.inject.inject_depends_on = [updateDependsOn(newCurrentCondition)]; + } + form.mutators.setValue( + 'inject_depends_to', + injectDependencyFromDependency(childrens), + ); + }; + + /** + * Delete a condition from a parent inject + * @param conditions + * @param condition + */ + const deleteConditionParent = (conditions: ConditionType, condition: ConditionElement) => { + const newConditionElements = parentConditions.map((currentCondition) => { + if (currentCondition.parentId === conditions.parentId) { + return { + ...currentCondition, + conditionElement: currentCondition.conditionElement?.filter((element) => element.index !== condition.index), + }; + } + return currentCondition; + }); + setParentConditions(newConditionElements); + + const element = newConditionElements.find((conditionElement) => conditionElement.parentId === conditions.parentId); + const dep: InjectDependency = { + dependency_relationship: { + inject_parent_id: element?.parentId, + inject_children_id: element?.childrenId, + }, + dependency_condition: { + mode: element?.mode === '&&' ? 'and' : 'or', + conditions: element?.conditionElement ? element?.conditionElement.map((value) => { + return { + key: value.key, + value: value.value, + operator: 'eq', + }; + }) : [], + }, + }; + + form.mutators.setValue( + 'inject_depends_on', + [dep], + ); + }; + + /** + * Delete a condition from a children inject + * @param conditions + * @param condition + */ + const deleteConditionChildren = (conditions: ConditionType, condition: ConditionElement) => { + const newConditionElements = childrenConditions.map((currentCondition) => { + if (currentCondition.childrenId === conditions.childrenId) { + return { + ...currentCondition, + conditionElement: currentCondition.conditionElement?.filter((element) => element.index !== condition.index), + }; + } + return currentCondition; + }); + setChildrenConditions(newConditionElements); + + const updatedChildren = childrens.find((currentChildren) => currentChildren.inject?.inject_id === conditions.childrenId); + if (updatedChildren?.inject?.inject_depends_on !== undefined && conditions !== undefined) { + const newCondition = newConditionElements.find((currentCondition) => currentCondition.childrenId === conditions.childrenId); + if (newCondition !== undefined) updatedChildren.inject.inject_depends_on = [updateDependsOn(newCondition)]; + } + form.mutators.setValue( + 'inject_depends_to', + injectDependencyFromDependency(childrens), + ); + }; + + /** + * Whether or not we can add a new condition + * @param inject + * @param conditions + */ + const canAddConditions = (inject: InjectOutputType, conditions?: ConditionType) => { + const expectationsNumber = getAvailableExpectations(inject).length; + if (conditions === undefined || conditions.conditionElement === undefined) return true; + + return conditions?.conditionElement.length < expectationsNumber; + }; + + /** + * Return a clickable parent chip + * @param parent + */ + const getClickableParentChip = (parent: Dependency) => { + const parentChip = parentConditions.find((parentCondition) => parent.inject !== undefined && parentCondition.parentId === parent.inject.inject_id); + if (parentChip === undefined || parentChip.conditionElement === undefined) return (<></>); + return parentChip.conditionElement.map((condition, conditionIndex) => { + const conditions = parentConditions + .find((parentCondition) => parent.inject !== undefined && parentCondition.parentId === parent.inject.inject_id); + if (conditions?.conditionElement !== undefined) { + return (<div key={`${condition.name}-${condition.index}`} style={{ display: 'contents' }}> + <ClickableChip + selectedElement={{ key: condition.key, operator: 'is', value: condition.value ? 'Success' : 'Fail' }} + pristine={true} + availableKeys={getAvailableExpectations(parent.inject)} + availableOperators={['is']} + availableValues={['Success', 'Fail']} + onDelete={ + conditions.conditionElement.length > 1 ? () => { deleteConditionParent(conditions, condition); } : undefined + } + onChange={(newElement) => { + changeParentElement(newElement, conditions, condition, parent); + }} + /> + {conditionIndex < conditions.conditionElement.length - 1 + && <ClickableModeChip + mode={conditions.mode} + onClick={() => { changeModeParent(parentConditions, conditions); }} + /> + }</div>); + } + return (<></>); + }); + }; + + /** + * Return a clickable children chip + * @param parent + */ + const getClickableChildrenChip = (children: Dependency) => { + const childrenChip = childrenConditions.find((childrenCondition) => children.inject !== undefined && childrenCondition.childrenId === children.inject.inject_id); + if (childrenChip?.conditionElement === undefined) return (<></>); + return childrenChip + .conditionElement.map((condition, conditionIndex) => { + const conditions = childrenConditions + .find((childrenCondition) => childrenCondition.childrenId === children.inject?.inject_id); + if (conditions?.conditionElement !== undefined) { + return (<div key={`${condition.name}-${condition.index}`} style={{ display: 'contents' }}> + <ClickableChip + selectedElement={{ key: condition.key, operator: 'is', value: condition.value ? 'Success' : 'Fail' }} + pristine={true} + availableKeys={getAvailableExpectations(injects?.find((currentInject) => currentInject.inject_id === values.inject_id))} + availableOperators={['is']} + availableValues={['Success', 'Fail']} + onDelete={ + conditions.conditionElement.length > 1 ? () => { deleteConditionChildren(conditions, condition); } : undefined + } + onChange={(newElement) => { + changeChildrenElement(newElement, conditions, condition, children); + }} + /> + {conditionIndex < conditions.conditionElement.length - 1 + && <ClickableModeChip + mode={conditions?.mode} + onClick={() => { changeModeChildren(childrenConditions, conditions); }} + /> + }</div>); + } + return (<></>); + }); + }; + + return ( + <> + <div className={classes.importerStyle}> + <Typography variant="h2" sx={{ m: 0 }}> + {t('Parent')} + </Typography> + <IconButton + color="secondary" + aria-label="Add" + size="large" + disabled={parents.length > 0 + || injects?.filter((currentInject) => currentInject.inject_depends_duration < values.inject_depends_duration).length === 0} + onClick={addParent} + > + <Add fontSize="small"/> + </IconButton> + </div> + + {parents.map((parent, index) => { + return ( + <Accordion + key={`accordion-parent-${parent.index}`} + variant="outlined" + style={{ width: '100%', marginBottom: '10px' }} + > + <AccordionSummary + expandIcon={<ExpandMore/>} + > + <div className={classes.container}> + <Typography> + #{index + 1} {parent.inject?.inject_title} + </Typography> + <Tooltip title={t('Delete')}> + <IconButton color="error" + onClick={() => { deleteParent(parent); }} + > + <DeleteOutlined fontSize="small"/> + </IconButton> + </Tooltip> + </div> + </AccordionSummary> + <AccordionDetails> + <FormControl style={{ width: '100%' }}> + <InputLabel id="inject_id">{t('Inject')}</InputLabel> + <Select + labelId="condition" + fullWidth={true} + value={parents[parent.index].inject ? parents[parent.index].inject?.inject_id : ''} + onChange={handleChangeParent} + > + {injects?.filter((currentInject) => currentInject.inject_depends_duration < values.inject_depends_duration + && (parents.find((parentSearch) => currentInject.inject_id === parentSearch.inject?.inject_id) === undefined + || parents[parent.index].inject?.inject_id === currentInject.inject_id)) + .map((currentInject) => { + return (<MenuItem key={`select-parent-${index}-inject-${currentInject.inject_id}`} + value={currentInject.inject_id} + >{currentInject.inject_title}</MenuItem>); + })} + </Select> + </FormControl> + <FormControl style={{ width: '100%', marginTop: '15px' }}> + <label className={classes.labelExecutionCondition}>{t('Execution condition:')}</label> + <Box + sx={{ + padding: '12px 4px', + display: 'flex', + flexWrap: 'wrap', + gap: 1, + }} + > + {getClickableParentChip(parent)} + </Box> + <div style={{ justifyContent: 'left' }}> + <Button + color="secondary" + aria-label="Add" + size="large" + onClick={() => { + addConditionParent(parent); + }} + style={{ justifyContent: 'start' }} + disabled={!canAddConditions(parent.inject!, parentConditions.find((parentCondition) => parentCondition.parentId === parent.inject?.inject_id))} + > + <Add fontSize="small"/> + <Typography> + {t('Add condition')} + </Typography> + </Button> + </div> + </FormControl> + </AccordionDetails> + </Accordion> + ); + })} + + <div className={classes.importerStyle}> + <Typography variant="h2" sx={{ m: 0 }}> + {t('Childrens')} + </Typography> + <IconButton + color="secondary" + aria-label="Add" + size="large" + disabled={addChildrenButtonDisabled} + onClick={addChildren} + > + <Add fontSize="small"/> + </IconButton> + </div> + {childrens.map((children, index) => { + return ( + <Accordion + key={`accordion-children-${children.index}`} + variant="outlined" + style={{ width: '100%', marginBottom: '10px' }} + > + <AccordionSummary + expandIcon={<ExpandMore/>} + > + <div className={classes.container}> + <Typography> + #{index + 1} {children.inject?.inject_title} + </Typography> + <Tooltip title={t('Delete')}> + <IconButton color="error" + onClick={() => { deleteChildren(children); }} + > + <DeleteOutlined fontSize="small"/> + </IconButton> + </Tooltip> + </div> + </AccordionSummary> + <AccordionDetails> + <FormControl style={{ width: '100%' }}> + <InputLabel id="inject_id">{t('Inject')}</InputLabel> + <Select + labelId="condition" + fullWidth={true} + value={childrens.find((childrenSearch) => children.index === childrenSearch.index)?.inject + ? childrens.find((childrenSearch) => children.index === childrenSearch.index)?.inject?.inject_id : ''} + onChange={handleChangeChildren} + > + {injects?.filter((currentInject) => currentInject.inject_depends_duration > values.inject_depends_duration + && (childrens.find((childrenSearch) => currentInject.inject_id === childrenSearch.inject?.inject_id) === undefined + || childrens.find((childrenSearch) => children.index === childrenSearch.index)?.inject?.inject_id === currentInject.inject_id)) + .map((currentInject) => { + return ( + <MenuItem key={`select-children-${children.index}-inject-${currentInject.inject_id}`} + value={currentInject.inject_id} + >{currentInject.inject_title}</MenuItem>); + })} + </Select> + </FormControl> + <FormControl style={{ width: '100%', marginTop: '15px' }}> + <label className={classes.labelExecutionCondition}>{t('Execution condition:')}</label> + + <Box + sx={{ + padding: '12px 4px', + display: 'flex', + flexWrap: 'wrap', + gap: 1, + }} + > + {getClickableChildrenChip(children)} + </Box> + <div style={{ justifyContent: 'left' }}> + <Button + color="secondary" + aria-label="Add" + size="large" + onClick={() => { + addConditionChildren(children); + }} + disabled={!canAddConditions( + values as InjectOutput as InjectOutputType, + childrenConditions.find((childrenCondition) => childrenCondition.childrenId === children.inject?.inject_id), + )} + style={{ justifyContent: 'start' }} + > + <Add fontSize="small"/> + <Typography> + {t('Add condition')} + </Typography> + </Button> + </div> + </FormControl> + </AccordionDetails> + </Accordion> + ); + })} + </> + ); +}; + +export default InjectForm; diff --git a/openbas-front/src/admin/components/common/injects/Injects.tsx b/openbas-front/src/admin/components/common/injects/Injects.tsx index 31618b2f48..4a0d359c65 100644 --- a/openbas-front/src/admin/components/common/injects/Injects.tsx +++ b/openbas-front/src/admin/components/common/injects/Injects.tsx @@ -49,7 +49,6 @@ const useStyles = makeStyles(() => ({ color: '#00b1ff', border: '1px solid #00b1ff', }, - itemHead: { textTransform: 'uppercase', }, @@ -216,10 +215,13 @@ const Injects: FunctionComponent<Props> = ({ setInjects([created as InjectOutputType, ...injects]); } }; + const onUpdate = (result: { result: string, entities: { injects: Record<string, InjectStore> } }) => { if (result.entities) { const updated = result.entities.injects[result.result]; - setInjects(injects.map((i) => (i.inject_id !== updated.inject_id ? i as InjectOutputType : (updated as InjectOutputType)))); + setInjects(injects.map((i) => { + return (i.inject_id !== updated.inject_id ? i as InjectOutputType : (updated as InjectOutputType)); + })); } }; @@ -240,10 +242,12 @@ const Injects: FunctionComponent<Props> = ({ onCreate(result); }); }; + const onUpdateInject = async (data: Inject) => { if (selectedInjectId) { await injectContext.onUpdateInject(selectedInjectId, data).then((result: { result: string, entities: { injects: Record<string, InjectStore> } }) => { onUpdate(result); + return result; }); } }; @@ -379,7 +383,7 @@ const Injects: FunctionComponent<Props> = ({ 'inject_description', 'inject_injector_contract', 'inject_content', - 'inject_depends_from_another', + 'inject_depends_on', 'inject_depends_duration', 'inject_teams', 'inject_assets', diff --git a/openbas-front/src/admin/components/common/injects/UpdateInjectDetails.js b/openbas-front/src/admin/components/common/injects/UpdateInjectDetails.js index ecf2905511..55813d8758 100644 --- a/openbas-front/src/admin/components/common/injects/UpdateInjectDetails.js +++ b/openbas-front/src/admin/components/common/injects/UpdateInjectDetails.js @@ -209,7 +209,7 @@ const UpdateInjectDetails = ({ inject_asset_groups: assetGroupIds, inject_documents: documents, inject_depends_duration, - inject_depends_on: data.inject_depends_on, + inject_depends_on: data.inject_depends_on ? data.inject_depends_on : [], }; await onUpdateInject(values); } @@ -324,7 +324,7 @@ const UpdateInjectDetails = ({ </div>} /> <CardContent classes={{ root: classes.injectorContractContent }}> - {tPick(contractContent.label)} + {contractContent !== null ? tPick(contractContent.label) : ''} </CardContent> </Card> <Form diff --git a/openbas-front/src/admin/components/common/injects/UpdateInjectLogicalChains.js b/openbas-front/src/admin/components/common/injects/UpdateInjectLogicalChains.tsx similarity index 59% rename from openbas-front/src/admin/components/common/injects/UpdateInjectLogicalChains.js rename to openbas-front/src/admin/components/common/injects/UpdateInjectLogicalChains.tsx index 43ac86e834..f3e5845eaa 100644 --- a/openbas-front/src/admin/components/common/injects/UpdateInjectLogicalChains.js +++ b/openbas-front/src/admin/components/common/injects/UpdateInjectLogicalChains.tsx @@ -7,8 +7,13 @@ import { HelpOutlined } from '@mui/icons-material'; import { useFormatter } from '../../../../components/i18n'; import PlatformIcon from '../../../../components/PlatformIcon'; import InjectChainsForm from './InjectChainsForm'; +import type { Theme } from '../../../../components/Theme'; +import type { Inject, InjectDependency } from '../../../../utils/api-types'; +import type { InjectOutputType } from '../../../../actions/injects/Inject'; +import { useHelper } from '../../../../store'; +import type { InjectHelper } from '../../../../actions/injects/inject-helper'; -const useStyles = makeStyles((theme) => ({ +const useStyles = makeStyles<Theme>((theme) => ({ injectorContract: { margin: '10px 0 20px 0', width: '100%', @@ -24,66 +29,85 @@ const useStyles = makeStyles((theme) => ({ }, })); -const UpdateInjectLogicalChains = ({ - inject, - handleClose, - onUpdateInject, - injects, -}) => { +interface Props { + inject: Inject, + handleClose: () => void; + onUpdateInject?: (data: Inject[]) => Promise<void>; + injects?: InjectOutputType[], +} + +const UpdateInjectLogicalChains: React.FC<Props> = ({ inject, handleClose, onUpdateInject, injects }) => { const { t, tPick } = useFormatter(); const classes = useStyles(); + const { injectsMap } = useHelper((helper: InjectHelper) => ({ + injectsMap: helper.getInjectsMap(), + })); + const initialValues = { ...inject, - inject_depends_to: injects - .filter((currentInject) => currentInject.inject_depends_on === inject.inject_id) - .map((currentInject) => currentInject.inject_id), + inject_depends_to: injects !== undefined ? injects + .filter((currentInject) => currentInject.inject_depends_on !== undefined + && currentInject.inject_depends_on !== null + && currentInject.inject_depends_on + .find((searchInject) => searchInject.dependency_relationship?.inject_parent_id === inject.inject_id) + !== undefined) + .flatMap((currentInject) => { + return currentInject.inject_depends_on; + }) : undefined, + inject_depends_on: inject.inject_depends_on, }; - const onSubmit = async (data) => { + const onSubmit = async (data: Inject & { inject_depends_to: InjectDependency[] }) => { const injectUpdate = { ...data, inject_id: data.inject_id, - inject_injector_contract: data.inject_injector_contract.injector_contract_id, + inject_injector_contract: data.inject_injector_contract?.injector_contract_id, inject_depends_on: data.inject_depends_on, }; - const injectsToUpdate = []; + const injectsToUpdate: Inject[] = []; - const childrenIds = data.inject_depends_to; + const childrenIds = data.inject_depends_to.map((childrenInject: InjectDependency) => childrenInject.dependency_relationship?.inject_children_id); - const injectsWithoutDependencies = injects - .filter((currentInject) => currentInject.inject_depends_on === data.inject_id + const injectsWithoutDependencies = injects ? injects + .filter((currentInject) => currentInject.inject_depends_on !== null + && currentInject.inject_depends_on?.find((searchInject) => searchInject.dependency_relationship?.inject_parent_id === data.inject_id) !== undefined && !childrenIds.includes(currentInject.inject_id)) .map((currentInject) => { return { - ...currentInject, + ...injectsMap[currentInject.inject_id], inject_id: currentInject.inject_id, inject_injector_contract: currentInject.inject_injector_contract.injector_contract_id, inject_depends_on: undefined, - }; - }); + } as unknown as Inject; + }) : []; injectsToUpdate.push(...injectsWithoutDependencies); childrenIds.forEach((childrenId) => { + if (injects === undefined || childrenId === undefined) return; const children = injects.find((currentInject) => currentInject.inject_id === childrenId); if (children !== undefined) { - const injectChildrenUpdate = { - ...children, + const injectDependsOnUpdate = data.inject_depends_to + .find((dependsTo) => dependsTo.dependency_relationship?.inject_children_id === childrenId); + + const injectChildrenUpdate: Inject = { + ...injectsMap[children.inject_id], inject_id: children.inject_id, inject_injector_contract: children.inject_injector_contract.injector_contract_id, - inject_depends_on: inject.inject_id, + inject_depends_on: injectDependsOnUpdate ? [injectDependsOnUpdate] : [], }; injectsToUpdate.push(injectChildrenUpdate); } }); - - await onUpdateInject([injectUpdate, ...injectsToUpdate]); + if (onUpdateInject) { + await onUpdateInject([injectUpdate as Inject, ...injectsToUpdate]); + } handleClose(); }; - const injectorContractContent = JSON.parse(inject.inject_injector_contract.injector_contract_content); + const injectorContractContent = inject.inject_injector_contract?.injector_contract_content ? JSON.parse(inject.inject_injector_contract?.injector_contract_content) : undefined; return ( <> <Card elevation={0} classes={{ root: classes.injectorContract }}> @@ -91,7 +115,7 @@ const UpdateInjectLogicalChains = ({ classes={{ root: classes.injectorContractHeader }} avatar={injectorContractContent?.config?.type ? <Avatar sx={{ width: 24, height: 24 }} src={`/api/images/injectors/${injectorContractContent.config.type}`} /> : <Avatar sx={{ width: 24, height: 24 }}><HelpOutlined /></Avatar>} - title={inject?.contract_attack_patterns_external_ids?.join(', ')} + title={inject?.inject_attack_patterns?.map((value) => value.attack_pattern_external_id)?.join(', ')} action={<div style={{ display: 'flex', alignItems: 'center' }}> {inject?.inject_injector_contract?.injector_contract_platforms?.map( (platform) => <PlatformIcon key={platform} width={20} platform={platform} marginRight={10} />, @@ -133,7 +157,7 @@ const UpdateInjectLogicalChains = ({ variant="contained" color="secondary" type="submit" - disabled={Object.keys(errors).length > 0 } + disabled={errors !== undefined && Object.keys(errors).length > 0 } > {t('Update')} </Button> diff --git a/openbas-front/src/components/ChainedTimeline.tsx b/openbas-front/src/components/ChainedTimeline.tsx index d159b3a7ed..5236a084f6 100644 --- a/openbas-front/src/components/ChainedTimeline.tsx +++ b/openbas-front/src/components/ChainedTimeline.tsx @@ -36,7 +36,8 @@ import NodePhantom from './nodes/NodePhantom'; import { useFormatter } from './i18n'; import type { AssetGroupsHelper } from '../actions/asset_groups/assetgroup-helper'; import type { EndpointHelper } from '../actions/assets/asset-helper'; -import type { Inject } from '../utils/api-types'; +import type { Inject, InjectDependency } from '../utils/api-types'; +import ChainingUtils from './common/chaining/ChainingUtils'; const useStyles = makeStyles(() => ({ container: { @@ -121,6 +122,8 @@ const ChainedTimelineFlow: FunctionComponent<Props> = ({ ]; const gapSize = 125; const newNodeSize = 50; + const nodeHeightClearance = 220; + const nodeWidthClearance = 350; let startDate: string | undefined; @@ -147,48 +150,128 @@ const ChainedTimelineFlow: FunctionComponent<Props> = ({ return Math.round(((position.x) / (gapSize / minutesPerGapAllowed[minutesPerGapIndex])) * 60); }; + /** + * Move item from an index to another one + * @param array the array to update + * @param to the target index + * @param from the origin index + */ + const moveItem = (array: NodeInject[], to: number, from: number) => { + const item = array[from]; + array.splice(from, 1); + array.splice(to, 0, item); + return array; + }; + + /** + * Calculate a bounding box for an index + * @param currentNode the node to calculate the bounding box for + * @param nodesAvailable the nodes + */ + const calculateBoundingBox = (currentNode: NodeInject, nodesAvailable: NodeInject[]) => { + if (currentNode.data.inject?.inject_depends_on) { + const nodesId = currentNode.data.inject?.inject_depends_on.map((value) => value.dependency_relationship?.inject_parent_id); + const dependencies = nodesAvailable.filter((dependencyNode) => nodesId.includes(dependencyNode.id)); + const minX = Math.min(currentNode.position.x, ...dependencies.map((value) => value.data.boundingBox!.topLeft.x)); + const minY = Math.min(currentNode.position.y, ...dependencies.map((value) => value.data.boundingBox!.topLeft.y)); + const maxX = Math.max(currentNode.position.x + nodeWidthClearance, ...dependencies.map((value) => value.data.boundingBox!.bottomRight.x)); + const maxY = Math.max(currentNode.position.y + nodeHeightClearance, ...dependencies.map((value) => value.data.boundingBox!.bottomRight.y)); + return { + topLeft: { x: minX, y: minY }, + bottomRight: { x: maxX, y: maxY }, + }; + } + return { + topLeft: currentNode.position, + bottomRight: { x: currentNode.position.x + nodeWidthClearance, y: currentNode.position.y + nodeHeightClearance }, + }; + }; + /** * Calculate injects position when dragging stopped * @param nodeInjects the list of injects */ const calculateInjectPosition = (nodeInjects: NodeInject[]) => { - nodeInjects.forEach((nodeInject, index) => { - let row = 0; - let rowFound = true; + let reorganizedInjects = nodeInjects; + + nodeInjects.forEach((node, i) => { + let childrens = reorganizedInjects.slice(i).filter((nextNode) => nextNode.id !== node.id + && nextNode.data.inject?.inject_depends_on !== undefined + && nextNode.data.inject?.inject_depends_on !== null + && nextNode.data.inject!.inject_depends_on + .find((dependsOn) => dependsOn.dependency_relationship?.inject_parent_id === node.id) !== undefined); + + childrens = childrens.sort((a, b) => a.data.inject!.inject_depends_duration - b.data.inject!.inject_depends_duration); + + childrens.forEach((children, j) => { + reorganizedInjects = moveItem(reorganizedInjects, i + j + 1, reorganizedInjects.indexOf(children, i)); + }); + }); + + reorganizedInjects.forEach((nodeInject, index) => { const nodeInjectPosition = nodeInject.position; const nodeInjectData = nodeInject.data; - do { - const previousNodes = nodeInjects.slice(0, index) - .filter((previousNode) => nodeInject.position.x >= previousNode.position.x && nodeInject.position.x < previousNode.position.x + 250); - - for (let i = 0; i < previousNodes.length; i += 1) { - const previousNode = previousNodes[i]; - if (previousNode.position.y + 150 > row * 150 && previousNode.position.y <= row * 150) { - row += 1; - rowFound = false; - } else { - nodeInjectPosition.y = 150 * row; - nodeInjectData.fixedY = nodeInject.position.y; - rowFound = true; - } + + const previousNodes = reorganizedInjects.slice(0, index) + .filter((previousNode) => previousNode.data.boundingBox !== undefined + && nodeInjectData.boundingBox !== undefined + && nodeInjectData.boundingBox?.topLeft.x >= previousNode.data.boundingBox.topLeft.x + && nodeInjectData.boundingBox?.topLeft.x < previousNode.data.boundingBox.bottomRight.x); + + const arrayOfY = previousNodes + .map((previousNode) => (previousNode.data.boundingBox?.bottomRight.y ? previousNode.data.boundingBox?.bottomRight.y : 0)); + const maxY = Math.max(0, ...arrayOfY); + + nodeInjectPosition.y = 0; + let rowFound = false; + for (let row = 1; row <= (maxY / nodeHeightClearance) + 1; row += 1) { + if (!arrayOfY.includes(row * nodeHeightClearance)) { + nodeInjectPosition.y = (row - 1) * nodeHeightClearance; + rowFound = true; + break; } - } while (!rowFound); + } + + if (!rowFound) { + nodeInjectPosition.y = previousNodes.length === 0 ? 0 : maxY; + } + if (nodeInject.data.inject?.inject_depends_on) { + const nodesId = nodeInject.data.inject?.inject_depends_on.map((value) => value.dependency_relationship?.inject_parent_id); + const dependencies = reorganizedInjects.filter((dependencyNode) => nodesId.includes(dependencyNode.id)); + const minY = dependencies.length > 0 ? Math.min(...dependencies.map((value) => value.data.boundingBox!.topLeft.y)) : 0; + + nodeInjectPosition.y = nodeInjectPosition.y < minY ? minY : nodeInjectPosition.y; + } + + nodeInjectData.fixedY = nodeInjectPosition.y; + nodeInjectData.boundingBox = calculateBoundingBox(nodeInject, reorganizedInjects); + reorganizedInjects[index] = nodeInject; }); }; const updateEdges = () => { - const newEdges = injects.filter((inject) => inject.inject_depends_on != null).map((inject) => { - return ({ - id: `${inject.inject_id}->${inject.inject_depends_on}`, - target: `${inject.inject_id}`, - targetHandle: `target-${inject.inject_id}`, - source: `${inject.inject_depends_on}`, - sourceHandle: `source-${inject.inject_depends_on}`, - label: '', - labelShowBg: false, - labelStyle: { fill: theme.palette.text?.primary, fontSize: 9 }, + const newEdges = injects.filter((inject) => inject.inject_depends_on !== null && inject.inject_depends_on !== undefined) + .flatMap((inject) => { + const results = []; + if (inject.inject_depends_on !== undefined) { + for (let i = 0; i < inject.inject_depends_on.length; i += 1) { + if (inject.inject_depends_on[i].dependency_relationship?.inject_children_id === inject.inject_id) { + results.push({ + id: `${inject.inject_depends_on[i].dependency_relationship?.inject_parent_id}->${inject.inject_depends_on[i].dependency_relationship?.inject_children_id}`, + target: `${inject.inject_depends_on[i].dependency_relationship?.inject_children_id}`, + targetHandle: `target-${inject.inject_depends_on[i].dependency_relationship?.inject_children_id}`, + source: `${inject.inject_depends_on[i].dependency_relationship?.inject_parent_id}`, + sourceHandle: `source-${inject.inject_depends_on[i].dependency_relationship?.inject_parent_id}`, + label: ChainingUtils.fromInjectDependencyToLabel(inject.inject_depends_on[i]), + labelShowBg: false, + labelStyle: { fill: theme.palette.text?.primary, fontSize: 14 }, + }); + } + } + } + return results; }); - }); + setEdges(newEdges); }; @@ -216,6 +299,10 @@ const ChainedTimelineFlow: FunctionComponent<Props> = ({ fixedY: 0, startDate, onSelectedInject, + boundingBox: { + topLeft: { x: (inject.inject_depends_duration / 60) * (gapSize / minutesPerGapAllowed[minutesPerGapIndex]), y: 0 }, + bottomRight: { x: (inject.inject_depends_duration / 60) * (gapSize / minutesPerGapAllowed[minutesPerGapIndex]) + nodeWidthClearance, y: nodeHeightClearance }, + }, targets: inject.inject_assets!.map((asset) => assets[asset]?.asset_name) .concat(inject.inject_asset_groups!.map((assetGroup) => assetGroups[assetGroup]?.asset_group_name)) .concat(inject.inject_teams!.map((team) => teams[team]?.team_name)), @@ -279,6 +366,8 @@ const ChainedTimelineFlow: FunctionComponent<Props> = ({ inject_injector_contract: injectFromMap.inject_injector_contract.injector_contract_id, inject_id: node.id, inject_depends_duration: convertCoordinatesToTime(node.position), + inject_depends_on: injectFromMap.inject_depends_on !== null + ? injectFromMap.inject_depends_on : null, }; onUpdateInject([inject]); setCurrentUpdatedNode(node); @@ -316,11 +405,27 @@ const ChainedTimelineFlow: FunctionComponent<Props> = ({ const inject = injects.find((currentInject) => currentInject.inject_id === connection.target); const injectParent = injects.find((currentInject) => currentInject.inject_id === connection.source); if (inject !== undefined && injectParent !== undefined && inject.inject_depends_duration > injectParent.inject_depends_duration) { + const newDependsOn: InjectDependency = { + dependency_relationship: { + inject_children_id: inject.inject_id, + inject_parent_id: injectParent.inject_id, + }, + dependency_condition: + { + mode: 'and', + conditions: [ + { + key: 'Execution', operator: 'eq', value: true, + }, + ], + }, + }; + const injectToUpdate = { ...injectsMap[inject.inject_id], inject_injector_contract: inject.inject_injector_contract.injector_contract_id, inject_id: inject.inject_id, - inject_depends_on: injectParent?.inject_id, + inject_depends_on: [newDependsOn], }; onUpdateInject([injectToUpdate]); } @@ -335,8 +440,12 @@ const ChainedTimelineFlow: FunctionComponent<Props> = ({ setDraggingOnGoing(true); const { position } = node; const { data } = node; - const dependsOn = nodes.find((currentNode) => (currentNode.id === data.inject?.inject_depends_on)); - const dependsTo = nodes.filter((currentNode) => (currentNode.data.inject?.inject_depends_on === node.id)) + const dependsOn = nodes.find((currentNode) => (data.inject?.inject_depends_on !== null + && data.inject?.inject_depends_on!.find((value) => value.dependency_relationship?.inject_parent_id === currentNode.id))); + const dependsTo = nodes + .filter((currentNode) => (currentNode.data.inject?.inject_depends_on !== undefined + && currentNode.data.inject?.inject_depends_on !== null + && currentNode.data.inject?.inject_depends_on.find((value) => value.dependency_relationship?.inject_parent_id === node.id) !== undefined)) .sort((a, b) => a.data.inject!.inject_depends_duration - b.data.inject!.inject_depends_duration)[0]; const aSecond = gapSize / (minutesPerGapAllowed[minutesPerGapIndex] * 60); if (dependsOn?.position && position.x <= dependsOn?.position.x) { @@ -462,11 +571,26 @@ const ChainedTimelineFlow: FunctionComponent<Props> = ({ inject_depends_on: undefined, }; updates.push(injectToRemoveEdge); + const newDependsOn: InjectDependency = { + dependency_relationship: { + inject_children_id: injectToUpdate.inject_id, + inject_parent_id: edge.source, + }, + dependency_condition: + { + mode: 'and', + conditions: [ + { + key: 'Execution', operator: 'eq', value: true, + }, + ], + }, + }; const injectToUpdateEdge = { ...injectsMap[injectToUpdate.inject_id], inject_injector_contract: injectToUpdate.inject_injector_contract.injector_contract_id, inject_id: injectToUpdate.inject_id, - inject_depends_on: edge.source, + inject_depends_on: [newDependsOn], }; updates.push(injectToUpdateEdge); onUpdateInject(updates); @@ -475,11 +599,26 @@ const ChainedTimelineFlow: FunctionComponent<Props> = ({ const inject = injects.find((currentInject) => currentInject.inject_id === edge.target); const parent = injects.find((currentInject) => currentInject.inject_id === connectionState.toNode?.id); if (inject !== undefined && parent !== undefined && parent.inject_depends_duration < inject.inject_depends_duration) { + const newDependsOn: InjectDependency = { + dependency_relationship: { + inject_children_id: inject.inject_id, + inject_parent_id: connectionState.toNode?.id, + }, + dependency_condition: + { + mode: 'and', + conditions: [ + { + key: 'Execution', operator: 'eq', value: true, + }, + ], + }, + }; const injectToUpdate = { ...injectsMap[inject.inject_id], inject_injector_contract: inject.inject_injector_contract.injector_contract_id, inject_id: inject.inject_id, - inject_depends_on: connectionState.toNode?.id, + inject_depends_on: [newDependsOn], }; onUpdateInject([injectToUpdate]); } diff --git a/openbas-front/src/components/common/chaining/ChainingUtils.tsx b/openbas-front/src/components/common/chaining/ChainingUtils.tsx new file mode 100644 index 0000000000..6946df710e --- /dev/null +++ b/openbas-front/src/components/common/chaining/ChainingUtils.tsx @@ -0,0 +1,14 @@ +import type { InjectDependency } from '../../../utils/api-types'; + +const fromInjectDependencyToLabel = (dependency: InjectDependency) => { + let label = ''; + if (dependency.dependency_condition?.conditions !== undefined) { + label = dependency.dependency_condition.conditions + .map((value) => `${value.key} is ${value.value ? 'Success' : 'Failure'}`) + .join(dependency.dependency_condition.mode === 'and' ? ' AND ' : ' OR '); + } + + return label; +}; + +export default { fromInjectDependencyToLabel }; diff --git a/openbas-front/src/components/common/chips/ChipUtils.tsx b/openbas-front/src/components/common/chips/ChipUtils.tsx new file mode 100644 index 0000000000..67dc6a6970 --- /dev/null +++ b/openbas-front/src/components/common/chips/ChipUtils.tsx @@ -0,0 +1,14 @@ +import React from 'react'; + +// -- OPERATOR -- + +const convertOperatorToIcon = (t: (text: string) => string, operator?: string) => { + switch (operator) { + case 'is': + return <> {t('is')}</>; + default: + return null; + } +}; + +export default convertOperatorToIcon; diff --git a/openbas-front/src/components/common/chips/ClickableChip.tsx b/openbas-front/src/components/common/chips/ClickableChip.tsx new file mode 100644 index 0000000000..8cdd60f489 --- /dev/null +++ b/openbas-front/src/components/common/chips/ClickableChip.tsx @@ -0,0 +1,181 @@ +import React, { FunctionComponent, useRef, useState } from 'react'; +import { Box, Chip, SelectChangeEvent, Tooltip } from '@mui/material'; +import { makeStyles } from '@mui/styles'; +import classNames from 'classnames'; +import type { Theme } from '../../Theme'; +import { useFormatter } from '../../i18n'; +import convertOperatorToIcon from './ChipUtils'; +import ClickableChipPopover from './ClickableChipPopover'; + +const useStyles = makeStyles((theme: Theme) => ({ + mode: { + display: 'inline-block', + height: '100%', + backgroundColor: theme.palette.action?.selected, + margin: '0 4px', + padding: '0 4px', + }, + modeTooltip: { + margin: '0 4px', + }, + container: { + gap: '4px', + display: 'flex', + overflow: 'hidden', + maxWidth: '400px', + alignItems: 'center', + lineHeight: '32px', + whiteSpace: 'nowrap', + textOverflow: 'ellipsis', + }, + interactive: { + cursor: 'pointer', + '&:hover': { + textDecorationLine: 'underline', + }, + }, +})); + +export interface Element { + key: string; + operator?: string; + value?: string; +} + +interface Props { + onChange: (newElement: Element) => void; + pristine: boolean; + selectedElement: Element, + availableKeys: string[], + availableOperators: string[], + availableValues: string[], + onDelete?: () => void, +} + +const ClickableChip: FunctionComponent<Props> = ({ + onChange, + pristine, + selectedElement, + availableKeys, + availableOperators, + availableValues, + onDelete, +}) => { + // Standard hooks + const { t } = useFormatter(); + const classes = useStyles(); + + const chipRef = useRef<HTMLDivElement>(null); + const [open, setOpen] = useState(!pristine); + const [availableOptions, setAvailableOptions] = useState<string[]>([]); + const [selectedValue, setSelectedValue] = useState<string>(); + const [propertyToChange, setPropertyToChange] = useState<string>(''); + const handleOpen = () => setOpen(true); + const handleClose = () => setOpen(false); + + const handleRemoveFilter = () => { + if (onDelete) onDelete(); + }; + + const handleChange = (event: SelectChangeEvent) => { + const newValue = selectedElement; + switch (propertyToChange) { + case 'key': { + newValue.key = event.target.value; + break; + } + case 'operator': { + newValue.operator = event.target.value; + break; + } + case 'value': { + newValue.value = event.target.value; + break; + } + default: + break; + } + onChange(newValue); + setOpen(false); + }; + + const handleClickOpen = (options: string[], property: string, optionValue?: string) => { + setAvailableOptions(options); + if (optionValue) setSelectedValue(optionValue); + if (options.length > 1) handleOpen(); + setPropertyToChange(property); + }; + + const toValues = (opts: string[] | undefined, isTooltip: boolean) => { + if (opts !== undefined) { + return opts.map((o, idx) => { + let or = <></>; + if (idx > 0) { + or = <div className={classNames({ + [classes.mode]: !isTooltip, + [classes.modeTooltip]: isTooltip, + })} + > + {t('OR')} + </div>; + } + return (<div key={o}>{or}<span> {o}</span></div>); + }); + } + return (<span key={'undefined'}> {t('undefined')}</span>); + }; + + const filterValues = (isTooltip: boolean) => { + return ( + <span className={classes.container}> + <strong + className={availableKeys.length > 1 ? classes.interactive : undefined} + onClick={() => handleClickOpen(availableKeys, 'key', selectedElement.key)} + > + {t(selectedElement.key)} + </strong> + <Box sx={{ display: 'flex', flexDirection: 'row', overflow: 'hidden' }} + className={availableOperators.length > 1 ? classes.interactive : undefined} + onClick={() => handleClickOpen(availableOperators, 'operator', selectedElement.operator)} + > + {convertOperatorToIcon(t, selectedElement.operator)} + </Box> + <Box sx={{ display: 'flex', flexDirection: 'row', overflow: 'hidden' }} + className={availableValues.length > 1 ? classes.interactive : undefined} + onClick={() => handleClickOpen(availableValues, 'value', selectedElement.value)} + > + {toValues(selectedElement.value ? [selectedElement.value] : [], isTooltip)} + </Box> + </span> + ); + }; + + const chipVariant = 'filled'; + + return ( + <> + <Tooltip + title={filterValues(true)} + > + <Chip + variant={chipVariant} + label={filterValues(false)} + onDelete={onDelete ? handleRemoveFilter : undefined} + sx={{ borderRadius: 1 }} + ref={chipRef} + /> + </Tooltip> + {chipRef?.current + && <ClickableChipPopover + handleChangeValue={handleChange} + open={open} + onClose={handleClose} + anchorEl={chipRef.current} + availableValues={availableOptions} + element={selectedValue} + /> + } + </> + ); +}; +export default ClickableChip; diff --git a/openbas-front/src/components/common/chips/ClickableChipPopover.tsx b/openbas-front/src/components/common/chips/ClickableChipPopover.tsx new file mode 100644 index 0000000000..8ac0579da8 --- /dev/null +++ b/openbas-front/src/components/common/chips/ClickableChipPopover.tsx @@ -0,0 +1,69 @@ +import React, { FunctionComponent } from 'react'; +import { MenuItem, Popover, Select, SelectChangeEvent } from '@mui/material'; + +interface Props { + handleChangeValue: (event: SelectChangeEvent) => void; + open: boolean; + onClose: () => void; + anchorEl?: HTMLElement; + availableValues: string[]; + element?: string, +} + +const ClickableChipPopover: FunctionComponent<Props> = ({ + handleChangeValue, + open, + onClose, + anchorEl, + availableValues, + element, +}) => { + // Standard hooks + + const displayOperatorAndFilter = () => { + // Specific field + + return ( + <> + <Select + value={element || availableValues[0]} + label="Values" + variant="standard" + fullWidth + onChange={handleChangeValue} + style={{ marginBottom: 15 }} + > + {availableValues?.map((value) => ( + <MenuItem key={value} value={value}> + {value} + </MenuItem> + ))} + </Select> + </> + ); + }; + + return ( + <Popover + open={open} + anchorEl={anchorEl} + onClose={onClose} + anchorOrigin={{ + vertical: 'bottom', + horizontal: 'left', + }} + PaperProps={{ elevation: 1, style: { marginTop: 10 } }} + > + <div + style={{ + width: 250, + + padding: 8, + }} + > + {displayOperatorAndFilter()} + </div> + </Popover> + ); +}; +export default ClickableChipPopover; diff --git a/openbas-front/src/components/common/queryable/filter/FilterModeChip.tsx b/openbas-front/src/components/common/chips/ClickableModeChip.tsx similarity index 83% rename from openbas-front/src/components/common/queryable/filter/FilterModeChip.tsx rename to openbas-front/src/components/common/chips/ClickableModeChip.tsx index cd6c8c2aa5..d0e3d5a79f 100644 --- a/openbas-front/src/components/common/queryable/filter/FilterModeChip.tsx +++ b/openbas-front/src/components/common/chips/ClickableModeChip.tsx @@ -1,8 +1,8 @@ import React, { FunctionComponent } from 'react'; import { makeStyles } from '@mui/styles'; import classNames from 'classnames'; -import { useFormatter } from '../../../i18n'; -import type { Theme } from '../../../Theme'; +import { useFormatter } from '../../i18n'; +import type { Theme } from '../../Theme'; const useStyles = makeStyles((theme: Theme) => ({ mode: { @@ -24,10 +24,10 @@ const useStyles = makeStyles((theme: Theme) => ({ interface Props { onClick?: () => void; - mode?: 'and' | 'or'; + mode?: string; } -const FilterModeChip: FunctionComponent<Props> = ({ +const ClickableModeChip: FunctionComponent<Props> = ({ onClick, mode, }) => { @@ -52,4 +52,4 @@ const FilterModeChip: FunctionComponent<Props> = ({ ); }; -export default FilterModeChip; +export default ClickableModeChip; diff --git a/openbas-front/src/components/common/queryable/filter/FilterChips.tsx b/openbas-front/src/components/common/queryable/filter/FilterChips.tsx index 51a23a6483..c601f33413 100644 --- a/openbas-front/src/components/common/queryable/filter/FilterChips.tsx +++ b/openbas-front/src/components/common/queryable/filter/FilterChips.tsx @@ -3,7 +3,7 @@ import { Box } from '@mui/material'; import { FilterHelpers } from './FilterHelpers'; import type { Filter, FilterGroup, PropertySchemaDTO } from '../../../../utils/api-types'; import FilterChip from './FilterChip'; -import FilterModeChip from './FilterModeChip'; +import ClickableModeChip from '../../chips/ClickableModeChip'; interface Props { propertySchemas: PropertySchemaDTO[]; @@ -48,7 +48,7 @@ const FilterChips: FunctionComponent<Props> = ({ } return ( <React.Fragment key={filter.key}> - {idx !== 0 && <FilterModeChip onClick={handleSwitchMode} mode={filterGroup?.mode} />} + {idx !== 0 && <ClickableModeChip onClick={handleSwitchMode} mode={filterGroup?.mode} />} <FilterChip filter={filter} helpers={helpers} diff --git a/openbas-front/src/components/common/queryable/pagination/PaginationComponentV2.tsx b/openbas-front/src/components/common/queryable/pagination/PaginationComponentV2.tsx index f73ce4edfa..c78dd20338 100644 --- a/openbas-front/src/components/common/queryable/pagination/PaginationComponentV2.tsx +++ b/openbas-front/src/components/common/queryable/pagination/PaginationComponentV2.tsx @@ -14,7 +14,7 @@ import TextSearchComponent from '../textSearch/TextSearchComponent'; import FilterAutocomplete, { OptionPropertySchema } from '../filter/FilterAutocomplete'; import useFilterableProperties from '../filter/useFilterableProperties'; import FilterChips from '../filter/FilterChips'; -import FilterModeChip from '../filter/FilterModeChip'; +import ClickableModeChip from '../../chips/ClickableModeChip'; import InjectorContractSwitchFilter from '../../../../admin/components/common/filters/InjectorContractSwitchFilter'; import TablePaginationComponentV2 from './TablePaginationComponentV2'; @@ -184,7 +184,7 @@ const PaginationComponentV2 = <T extends object>({ onDelete={() => queryableHelpers.filterHelpers.handleRemoveFilterByKey(MITRE_FILTER_KEY)} /> {(searchPaginationInput.filterGroup?.filters?.filter((f) => availableFilterNames?.filter((n) => n !== MITRE_FILTER_KEY).includes(f.key)).length ?? 0) > 0 && ( - <FilterModeChip + <ClickableModeChip onClick={queryableHelpers.filterHelpers.handleSwitchMode} mode={searchPaginationInput.filterGroup.mode} /> diff --git a/openbas-front/src/components/nodes/NodeInject.tsx b/openbas-front/src/components/nodes/NodeInject.tsx index 48c38a65e3..46c23add77 100644 --- a/openbas-front/src/components/nodes/NodeInject.tsx +++ b/openbas-front/src/components/nodes/NodeInject.tsx @@ -1,5 +1,5 @@ import React, { memo } from 'react'; -import { Handle, NodeProps, Position, Node, OnConnect } from '@xyflow/react'; +import { Handle, NodeProps, Position, Node, OnConnect, XYPosition } from '@xyflow/react'; import { makeStyles, useTheme } from '@mui/styles'; import { Tooltip } from '@mui/material'; import moment from 'moment'; @@ -89,6 +89,10 @@ export type NodeInject = Node<{ fixedY?: number, startDate?: string, targets: string[], + boundingBox?: { + topLeft: XYPosition, + bottomRight: XYPosition + }, exerciseOrScenarioId: string, onSelectedInject(inject?: InjectOutputType): void, onCreate: (result: { result: string, entities: { injects: Record<string, InjectStore> } }) => void, diff --git a/openbas-front/src/utils/Localization.js b/openbas-front/src/utils/Localization.js index fe6f62b789..b055683651 100644 --- a/openbas-front/src/utils/Localization.js +++ b/openbas-front/src/utils/Localization.js @@ -1394,6 +1394,9 @@ const i18n = { 'The element has been successfully updated': 'L\'élément a été mis à jour avec succès', 'The element has been successfully deleted': 'L\'élément a été supprimé avec succès', 'No data to display': 'Aucune donnée à afficher', + 'Add condition': 'Ajouter une condition', + is: 'est', + undefined: 'non défini', }, zh: { 'Email address': 'email地址', @@ -2734,6 +2737,9 @@ const i18n = { 'The element has been successfully deleted': '元素已成功删除', 'Internal error': '内部错误 ', 'No data to display': '没有可显示的数据', + 'Add condition': '添加条件', + is: '是', + undefined: '未定义', }, en: { openbas_email: 'Email', diff --git a/openbas-front/src/utils/String.js b/openbas-front/src/utils/String.js index f1a589b5c5..a9ad73a527 100644 --- a/openbas-front/src/utils/String.js +++ b/openbas-front/src/utils/String.js @@ -68,6 +68,10 @@ export const computeLabel = (status) => { return 'Failed'; }; +export const capitalize = (text) => { + return text.charAt(0).toUpperCase() + text.slice(1).toLowerCase(); +}; + // compute color for status export const computeColorStyle = (status) => { if (status === 'PENDING') { diff --git a/openbas-front/src/utils/api-types.d.ts b/openbas-front/src/utils/api-types.d.ts index 2f7824239f..865bd00048 100644 --- a/openbas-front/src/utils/api-types.d.ts +++ b/openbas-front/src/utils/api-types.d.ts @@ -463,6 +463,12 @@ export interface Communication { listened?: boolean; } +export interface Condition { + key: string; + operator: "eq"; + value?: boolean; +} + export interface CreateUserInput { user_admin?: boolean; user_email: string; @@ -1073,7 +1079,7 @@ export interface Inject { * @min 0 */ inject_depends_duration: number; - inject_depends_on?: Inject; + inject_depends_on?: InjectDependency[]; inject_description?: string; inject_documents?: InjectDocument[]; inject_enabled?: boolean; @@ -1104,6 +1110,31 @@ export interface Inject { listened?: boolean; } +export interface InjectDependency { + dependency_condition?: InjectDependencyCondition; + /** @format date-time */ + dependency_created_at?: string; + dependency_relationship?: InjectDependencyId; + /** @format date-time */ + dependency_updated_at?: string; +} + +export interface InjectDependencyCondition { + conditions?: Condition[]; + mode: "and" | "or"; +} + +export interface InjectDependencyId { + inject_children_id?: string; + inject_parent_id?: string; +} + +export interface InjectDependencyInput { + dependency_conditions?: Condition[]; + dependency_mode?: "&&" | "||"; + dependency_parent?: string; +} + export interface InjectDocument { document_attached?: boolean; document_id?: Document; @@ -1221,7 +1252,7 @@ export interface InjectInput { inject_country?: string; /** @format int64 */ inject_depends_duration?: number; - inject_depends_on?: string; + inject_depends_on?: InjectDependencyInput[]; inject_description?: string; inject_documents?: InjectDocumentInput[]; inject_injector_contract?: string; @@ -1239,7 +1270,7 @@ export interface InjectOutput { * @min 0 */ inject_depends_duration: number; - inject_depends_on?: string; + inject_depends_on?: InjectDependency[]; inject_enabled?: boolean; inject_exercise?: string; inject_id: string; diff --git a/openbas-model/src/main/java/io/openbas/database/model/Inject.java b/openbas-model/src/main/java/io/openbas/database/model/Inject.java index e8b76fa11f..e3a93e53a6 100644 --- a/openbas-model/src/main/java/io/openbas/database/model/Inject.java +++ b/openbas-model/src/main/java/io/openbas/database/model/Inject.java @@ -123,11 +123,9 @@ public class Inject implements Base, Injection { private Scenario scenario; @Getter - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "inject_depends_from_another") - @JsonSerialize(using = MonoIdDeserializer.class) + @OneToMany(mappedBy = "compositeId.injectChildren", fetch = FetchType.EAGER, orphanRemoval = true, cascade = CascadeType.ALL) @JsonProperty("inject_depends_on") - private Inject dependsOn; + private List<InjectDependency> dependsOn = new ArrayList<>(); @Getter @Column(name = "inject_depends_duration") @@ -279,7 +277,7 @@ public boolean isReady() { @JsonIgnore public Instant computeInjectDate(Instant source, int speed) { - return InjectModelHelper.computeInjectDate(source, speed, getDependsOn(), getDependsDuration(), getExercise()); + return InjectModelHelper.computeInjectDate(source, speed, getDependsDuration(), getExercise()); } @JsonProperty("inject_date") @@ -291,7 +289,7 @@ public Optional<Instant> getDate() { return Optional.of(now().minusSeconds(60)); } } - return InjectModelHelper.getDate(getExercise(), getScenario(), getDependsOn(), getDependsDuration()); + return InjectModelHelper.getDate(getExercise(), getScenario(), getDependsDuration()); } @JsonIgnore diff --git a/openbas-model/src/main/java/io/openbas/database/model/InjectDependency.java b/openbas-model/src/main/java/io/openbas/database/model/InjectDependency.java new file mode 100644 index 0000000000..d7dd0419ec --- /dev/null +++ b/openbas-model/src/main/java/io/openbas/database/model/InjectDependency.java @@ -0,0 +1,55 @@ +package io.openbas.database.model; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import io.hypersistence.utils.hibernate.type.json.JsonType; +import io.openbas.helper.MonoIdDeserializer; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.Type; +import org.hibernate.annotations.UpdateTimestamp; + +import java.time.Instant; +import java.util.Objects; + +@Setter +@Getter +@Entity +@Table(name = "injects_dependencies") +public class InjectDependency { + + @EmbeddedId + @JsonProperty("dependency_relationship") + private InjectDependencyId compositeId = new InjectDependencyId(); + + @Column(name = "dependency_condition") + @JsonProperty("dependency_condition") + @Type(JsonType.class) + private InjectDependencyConditions.InjectDependencyCondition injectDependencyCondition; + + @CreationTimestamp + @Column(name = "dependency_created_at") + @JsonProperty("dependency_created_at") + private Instant creationDate; + + @UpdateTimestamp + @Column(name = "dependency_updated_at") + @JsonProperty("dependency_updated_at") + private Instant updateDate; + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + InjectDependency that = (InjectDependency) o; + return compositeId.equals(that.compositeId); + } + + @Override + public int hashCode() { + return Objects.hash(compositeId); + } +} diff --git a/openbas-model/src/main/java/io/openbas/database/model/InjectDependencyConditions.java b/openbas-model/src/main/java/io/openbas/database/model/InjectDependencyConditions.java new file mode 100644 index 0000000000..4652a74f5f --- /dev/null +++ b/openbas-model/src/main/java/io/openbas/database/model/InjectDependencyConditions.java @@ -0,0 +1,75 @@ +package io.openbas.database.model; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.util.Strings; + +import javax.annotation.Nullable; +import java.util.List; +import java.util.Optional; + +public class InjectDependencyConditions { + + public enum DependencyMode { + and{ + @Override + public String toString() { + return "&&"; + } + }, + or{ + @Override + public String toString() { + return "||"; + } + }; + } + + public enum DependencyOperator { + eq { + @Override + public String toString() { + return "=="; + } + }; + } + + @Data + public static class InjectDependencyCondition { + + @NotNull + private DependencyMode mode; // Between filters + private List<Condition> conditions; + + @Override + public String toString() { + StringBuilder result = new StringBuilder(Strings.EMPTY); + for (var i = 0 ; i < conditions.size() ; i++) { + if(i > 0) { + result.append(mode.toString()); + result.append(StringUtils.SPACE); + } + result.append(conditions.get(i).toString()); + result.append(StringUtils.SPACE); + } + return result.toString().trim(); + } + } + + @Data + public static class Condition { + + @NotNull + private String key; + private boolean value; + @NotNull + private DependencyOperator operator; + + @Override + public String toString() { + return String.format("%s %s %s", key, operator, value); + } + } +} diff --git a/openbas-model/src/main/java/io/openbas/database/model/InjectDependencyId.java b/openbas-model/src/main/java/io/openbas/database/model/InjectDependencyId.java new file mode 100644 index 0000000000..8391832777 --- /dev/null +++ b/openbas-model/src/main/java/io/openbas/database/model/InjectDependencyId.java @@ -0,0 +1,54 @@ +package io.openbas.database.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import io.swagger.v3.oas.annotations.media.Schema; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import io.openbas.helper.MonoIdDeserializer; +import jakarta.persistence.Embeddable; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Objects; + +@Embeddable +@Getter +@Setter +@NoArgsConstructor +public class InjectDependencyId implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @ManyToOne + @JsonProperty("inject_parent_id") + @JoinColumn(referencedColumnName="inject_id", name="inject_parent_id") + @JsonSerialize(using = MonoIdDeserializer.class) + @Schema(type = "string") + private Inject injectParent; + + @ManyToOne + @JsonProperty("inject_children_id") + @JoinColumn(referencedColumnName="inject_id", name="inject_children_id") + @JsonSerialize(using = MonoIdDeserializer.class) + @Schema(type = "string") + private Inject injectChildren; + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + InjectDependencyId that = (InjectDependencyId) o; + return injectParent.equals(that.injectParent) && injectChildren.equals(that.injectChildren); + } + + @Override + public int hashCode() { + return Objects.hash(injectParent, injectChildren); + } +} diff --git a/openbas-model/src/main/java/io/openbas/database/repository/InjectDependenciesRepository.java b/openbas-model/src/main/java/io/openbas/database/repository/InjectDependenciesRepository.java new file mode 100644 index 0000000000..87d5ed77bc --- /dev/null +++ b/openbas-model/src/main/java/io/openbas/database/repository/InjectDependenciesRepository.java @@ -0,0 +1,27 @@ +package io.openbas.database.repository; + +import io.openbas.database.model.InjectDependency; +import io.openbas.database.model.InjectDependencyId; +import io.openbas.database.model.Injector; +import jakarta.validation.constraints.NotNull; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.CrudRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface InjectDependenciesRepository extends CrudRepository<InjectDependency, InjectDependencyId>, JpaSpecificationExecutor<Injector> { + + @Query(value = "SELECT " + + "inject_parent_id, " + + "inject_children_id, " + + "dependency_condition, " + + "dependency_created_at, " + + "dependency_updated_at " + + "FROM injects_dependencies " + + "WHERE inject_children_id IN :childrens", nativeQuery = true) + List<InjectDependency> findParents(@NotNull List<String> childrens); +} diff --git a/openbas-model/src/main/java/io/openbas/helper/InjectModelHelper.java b/openbas-model/src/main/java/io/openbas/helper/InjectModelHelper.java index fb39b73f27..c0ef540741 100644 --- a/openbas-model/src/main/java/io/openbas/helper/InjectModelHelper.java +++ b/openbas-model/src/main/java/io/openbas/helper/InjectModelHelper.java @@ -64,15 +64,11 @@ public static boolean isReady( public static Instant computeInjectDate( Instant source, int speed, - Inject dependsOn, Long dependsDuration, Exercise exercise) { // Compute origin execution date - Optional<Inject> dependsOnInject = ofNullable(dependsOn); long duration = ofNullable(dependsDuration).orElse(0L) / speed; - Instant dependingStart = dependsOnInject - .map(inject -> inject.computeInjectDate(source, speed)) - .orElse(source); + Instant dependingStart = source; Instant standardExecutionDate = dependingStart.plusSeconds(duration); // Compute execution dates with previous terminated pauses long previousPauseDelay = 0L; @@ -99,7 +95,6 @@ public static Instant computeInjectDate( public static Optional<Instant> getDate( Exercise exercise, Scenario scenario, - Inject dependsOn, Long dependsDuration ) { if (exercise == null && scenario == null) { @@ -116,7 +111,7 @@ public static Optional<Instant> getDate( } return exercise .getStart() - .map(source -> computeInjectDate(source, SPEED_STANDARD, dependsOn, dependsDuration, exercise)); + .map(source -> computeInjectDate(source, SPEED_STANDARD, dependsDuration, exercise)); } return Optional.ofNullable(LocalDateTime.now().toInstant(ZoneOffset.UTC)); }