From 250b2f89ba49724e30fb13b2f247eac96dbe97c6 Mon Sep 17 00:00:00 2001 From: Gael Leblan Date: Fri, 13 Sep 2024 15:20:07 +0200 Subject: [PATCH] [frontend/backend] Chaining injects logically (#1194) --- ..._34__Remove_cascade_delete_dependency.java | 19 ++ .../openbas/rest/inject/form/InjectInput.java | 2 +- .../scheduler/jobs/InjectsExecutionJob.java | 171 +++++------ .../service/ScenarioToExerciseService.java | 12 + openbas-front/package.json | 2 +- .../atomic_testing/TargetResultsDetail.tsx | 1 + .../types/nodes/NodeResultStep.tsx | 16 +- .../src/admin/components/common/Context.ts | 1 + .../common/injects/InjectChainsForm.js | 280 ++++++++++++++++++ .../components/common/injects/Injects.tsx | 72 +++-- .../common/injects/InjectsListButtons.tsx | 38 ++- .../common/injects/UpdateInject.tsx | 59 +++- .../common/injects/UpdateInjectDetails.js | 1 + .../injects/UpdateInjectLogicalChains.js | 149 ++++++++++ .../scenario/injects/ScenarioInjects.tsx | 65 ++-- .../simulation/injects/ExerciseInjects.tsx | 44 ++- .../simulation/timeline/TimelineOverview.tsx | 1 + .../src/components/ChainedTimeline.tsx | 247 ++++++++++++--- .../components/CustomTimelineBackground.tsx | 26 +- .../src/components/CustomTimelinePanel.tsx | 16 +- openbas-front/src/components/i18n.js | 26 ++ .../src/components/nodes/NodeInject.tsx | 37 ++- openbas-front/src/static/css/index.css | 29 +- openbas-front/src/utils/Localization.js | 10 + openbas-front/yarn.lock | 20 +- 25 files changed, 1057 insertions(+), 287 deletions(-) create mode 100644 openbas-api/src/main/java/io/openbas/migration/V3_34__Remove_cascade_delete_dependency.java create mode 100644 openbas-front/src/admin/components/common/injects/InjectChainsForm.js create mode 100644 openbas-front/src/admin/components/common/injects/UpdateInjectLogicalChains.js diff --git a/openbas-api/src/main/java/io/openbas/migration/V3_34__Remove_cascade_delete_dependency.java b/openbas-api/src/main/java/io/openbas/migration/V3_34__Remove_cascade_delete_dependency.java new file mode 100644 index 0000000000..0326b164c6 --- /dev/null +++ b/openbas-api/src/main/java/io/openbas/migration/V3_34__Remove_cascade_delete_dependency.java @@ -0,0 +1,19 @@ +package io.openbas.migration; + +import org.flywaydb.core.api.migration.BaseJavaMigration; +import org.flywaydb.core.api.migration.Context; +import org.springframework.stereotype.Component; + +import java.sql.Statement; + +@Component +public class V3_34__Remove_cascade_delete_dependency extends BaseJavaMigration { + + @Override + public void migrate(Context context) throws Exception { + Statement select = context.getConnection().createStatement(); + select.execute("ALTER TABLE injects DROP CONSTRAINT fk_depends_from_another;"); + select.execute("ALTER TABLE injects ADD CONSTRAINT fk_depends_from_another " + + "FOREIGN KEY (inject_depends_from_another) REFERENCES injects ON DELETE SET NULL;"); + } +} 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 1528d50d07..8571fc4003 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 @@ -27,7 +27,7 @@ public class InjectInput { @JsonProperty("inject_content") private ObjectNode content; - @JsonProperty("inject_depends_from_another") + @JsonProperty("inject_depends_on") private String dependsOn; @JsonProperty("inject_depends_duration") 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 df2d666ea7..3e62453bc6 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 @@ -10,6 +10,7 @@ import jakarta.annotation.Resource; import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; +import lombok.RequiredArgsConstructor; import org.quartz.DisallowConcurrentExecution; import org.quartz.Job; import org.quartz.JobExecutionContext; @@ -29,20 +30,24 @@ @Component @DisallowConcurrentExecution +@RequiredArgsConstructor public class InjectsExecutionJob implements Job { private static final Logger LOGGER = Logger.getLogger(InjectsExecutionJob.class.getName()); - private ApplicationContext context; - private InjectHelper injectHelper; - private DryInjectRepository dryInjectRepository; - private InjectRepository injectRepository; - private InjectorRepository injectorRepository; - private InjectStatusRepository injectStatusRepository; - private DryInjectStatusRepository dryInjectStatusRepository; - private ExerciseRepository exerciseRepository; - private QueueService queueService; - private ExecutionExecutorService executionExecutorService; + private final ApplicationContext context; + private final InjectHelper injectHelper; + private final DryInjectRepository dryInjectRepository; + private final InjectRepository injectRepository; + private final InjectorRepository injectorRepository; + private final InjectStatusRepository injectStatusRepository; + private final DryInjectStatusRepository dryInjectStatusRepository; + private final ExerciseRepository exerciseRepository; + private final QueueService queueService; + private final ExecutionExecutorService executionExecutorService; + + private final List executionStatusesNotReady = + List.of(ExecutionStatus.QUEUING, ExecutionStatus.DRAFT, ExecutionStatus.EXECUTING, ExecutionStatus.PENDING); @Resource protected ObjectMapper mapper; @@ -55,56 +60,6 @@ public void setEntityManager(EntityManager entityManager) { this.entityManager = entityManager; } - @Autowired - public void setQueueService(QueueService queueService) { - this.queueService = queueService; - } - - @Autowired - public void setExecutionExecutorService(ExecutionExecutorService executionExecutorService) { - this.executionExecutorService = executionExecutorService; - } - - @Autowired - public void setDryInjectStatusRepository(DryInjectStatusRepository dryInjectStatusRepository) { - this.dryInjectStatusRepository = dryInjectStatusRepository; - } - - @Autowired - public void setInjectStatusRepository(InjectStatusRepository injectStatusRepository) { - this.injectStatusRepository = injectStatusRepository; - } - - @Autowired - public void setInjectorRepository(InjectorRepository injectorRepository) { - this.injectorRepository = injectorRepository; - } - - @Autowired - public void setInjectRepository(InjectRepository injectRepository) { - this.injectRepository = injectRepository; - } - - @Autowired - public void setDryInjectRepository(DryInjectRepository dryInjectRepository) { - this.dryInjectRepository = dryInjectRepository; - } - - @Autowired - public void setExerciseRepository(ExerciseRepository exerciseRepository) { - this.exerciseRepository = exerciseRepository; - } - - @Autowired - public void setContext(ApplicationContext context) { - this.context = context; - } - - @Autowired - public void setInjectHelper(InjectHelper injectHelper) { - this.injectHelper = injectHelper; - } - public void handleAutoStartExercises() { List exercises = exerciseRepository.findAllShouldBeInRunningState(now()); exerciseRepository.saveAll(exercises.stream() @@ -190,57 +145,77 @@ private void executeInject(ExecutableInject executableInject) { // Depending on injector type (internal or external) execution must be done differently Inject inject = executableInject.getInjection().getInject(); - inject.getInjectorContract().ifPresent(injectorContract -> { - - if (!inject.isReady()) { - // Status - if (inject.getStatus().isEmpty()) { - InjectStatus status = new InjectStatus(); - status.getTraces().add(InjectStatusExecution.traceError("The inject is not ready to be executed (missing mandatory fields)")); - status.setName(ExecutionStatus.ERROR); - status.setTrackingSentDate(Instant.now()); - status.setInject(inject); - injectStatusRepository.save(status); - } else { - InjectStatus status = inject.getStatus().get(); - status.getTraces().add(InjectStatusExecution.traceError("The inject is not ready to be executed (missing mandatory fields)")); - status.setName(ExecutionStatus.ERROR); - status.setTrackingSentDate(Instant.now()); - injectStatusRepository.save(status); - } - return; + // 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()))) { + InjectStatus status = new InjectStatus(); + if (inject.getStatus().isEmpty()) { + status.setInject(inject); + } else { + status = inject.getStatus().get(); } - - Injector externalInjector = injectorRepository.findByType(injectorContract.getInjector().getType()).orElseThrow(); - LOGGER.log(Level.INFO, "Executing inject " + inject.getInject().getTitle()); - // Executor logics - ExecutableInject newExecutableInject = executableInject; - if (Boolean.TRUE.equals(injectorContract.getNeedsExecutor())) { - try { + 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()); + injectStatusRepository.save(status); + } else { + inject.getInjectorContract().ifPresent(injectorContract -> { + + if (!inject.isReady()) { // Status if (inject.getStatus().isEmpty()) { InjectStatus status = new InjectStatus(); - status.setName(ExecutionStatus.EXECUTING); + status.getTraces().add(InjectStatusExecution.traceError("The inject is not ready to be executed (missing mandatory fields)")); + status.setName(ExecutionStatus.ERROR); status.setTrackingSentDate(Instant.now()); status.setInject(inject); injectStatusRepository.save(status); } else { InjectStatus status = inject.getStatus().get(); - status.setName(ExecutionStatus.EXECUTING); + status.getTraces().add(InjectStatusExecution.traceError("The inject is not ready to be executed (missing mandatory fields)")); + status.setName(ExecutionStatus.ERROR); status.setTrackingSentDate(Instant.now()); injectStatusRepository.save(status); } - newExecutableInject = this.executionExecutorService.launchExecutorContext(executableInject, inject); - } catch (InterruptedException e) { - throw new RuntimeException(e); + return; } - } - if (externalInjector.isExternal()) { - executeExternal(newExecutableInject); - } else { - executeInternal(newExecutableInject); - } - }); + + Injector externalInjector = injectorRepository.findByType(injectorContract.getInjector().getType()).orElseThrow(); + LOGGER.log(Level.INFO, "Executing inject " + inject.getInject().getTitle()); + // Executor logics + ExecutableInject newExecutableInject = executableInject; + if (Boolean.TRUE.equals(injectorContract.getNeedsExecutor())) { + try { + // Status + if (inject.getStatus().isEmpty()) { + InjectStatus status = new InjectStatus(); + status.setName(ExecutionStatus.EXECUTING); + status.setTrackingSentDate(Instant.now()); + status.setInject(inject); + injectStatusRepository.save(status); + } else { + InjectStatus status = inject.getStatus().get(); + status.setName(ExecutionStatus.EXECUTING); + status.setTrackingSentDate(Instant.now()); + injectStatusRepository.save(status); + } + newExecutableInject = this.executionExecutorService.launchExecutorContext(executableInject, inject); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + if (externalInjector.isExternal()) { + executeExternal(newExecutableInject); + } else { + executeInternal(newExecutableInject); + } + }); + } } public void updateExercise(String exerciseId) { 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 78045f6cdb..7669482a2c 100644 --- a/openbas-api/src/main/java/io/openbas/service/ScenarioToExerciseService.java +++ b/openbas-api/src/main/java/io/openbas/service/ScenarioToExerciseService.java @@ -186,6 +186,7 @@ public Exercise toExercise( // Injects List scenarioInjects = scenario.getInjects(); + Map mapExerciseInjectsByScenarioInject = new HashMap<>(); scenarioInjects.forEach(scenarioInject -> { Inject exerciseInject = new Inject(); exerciseInject.setTitle(scenarioInject.getTitle()); @@ -228,6 +229,8 @@ public Exercise toExercise( exerciseInject.setAssetGroups(CopyObjectListUtils.copy(scenarioInject.getAssetGroups(), AssetGroup.class)); Inject injectSaved = this.injectRepository.save(exerciseInject); + mapExerciseInjectsByScenarioInject.put(scenarioInject.getId(), injectSaved); + // Documents List exerciseInjectDocuments = new ArrayList<>(); scenarioInject.getDocuments().forEach(injectDocument -> { @@ -240,6 +243,15 @@ public Exercise toExercise( this.injectDocumentRepository.saveAll(exerciseInjectDocuments); }); + // Second pass to add the correct links + scenarioInjects.forEach(scenarioInject -> { + if(scenarioInject.getDependsOn() != null) { + Inject injectToUpdate = mapExerciseInjectsByScenarioInject.get(scenarioInject.getId()); + injectToUpdate.setDependsOn(mapExerciseInjectsByScenarioInject.get(scenarioInject.getDependsOn().getId())); + this.injectRepository.save(injectToUpdate); + } + }); + // Variables List scenarioVariables = this.variableService.variablesFromScenario(scenario.getId()); List exerciseVariables = scenarioVariables.stream() diff --git a/openbas-front/package.json b/openbas-front/package.json index 3bdf587087..569c657bff 100644 --- a/openbas-front/package.json +++ b/openbas-front/package.json @@ -22,7 +22,7 @@ "@mui/x-date-pickers": "7.12.0", "@redux-devtools/extension": "3.3.0", "@uiw/react-md-editor": "4.0.4", - "@xyflow/react": "^12.0.3", + "@xyflow/react": "12.2.1", "apexcharts": "3.51.0", "axios": "1.7.4", "ckeditor5-custom-build": "link:packages/ckeditor5-custom-build", diff --git a/openbas-front/src/admin/components/atomic_testings/atomic_testing/TargetResultsDetail.tsx b/openbas-front/src/admin/components/atomic_testings/atomic_testing/TargetResultsDetail.tsx index d7ff1f0382..e212de1d82 100644 --- a/openbas-front/src/admin/components/atomic_testings/atomic_testing/TargetResultsDetail.tsx +++ b/openbas-front/src/admin/components/atomic_testings/atomic_testing/TargetResultsDetail.tsx @@ -450,6 +450,7 @@ const TargetResultsDetailFlow: FunctionComponent = ({
) => {
{data.description}
- {(data.end || data.middle) && ()} - {(data.start || data.middle) && ()} + {(data.end || data.middle) && ( + + )} + {(data.start || data.middle) && ( + + )}
); }; diff --git a/openbas-front/src/admin/components/common/Context.ts b/openbas-front/src/admin/components/common/Context.ts index 2bf8d2c033..ea09ae935b 100644 --- a/openbas-front/src/admin/components/common/Context.ts +++ b/openbas-front/src/admin/components/common/Context.ts @@ -277,3 +277,4 @@ export const ViewLessonContext = createContext({ }); }, }); +export const ViewModeContext = createContext('list'); diff --git a/openbas-front/src/admin/components/common/injects/InjectChainsForm.js b/openbas-front/src/admin/components/common/injects/InjectChainsForm.js new file mode 100644 index 0000000000..f0f981aa38 --- /dev/null +++ b/openbas-front/src/admin/components/common/injects/InjectChainsForm.js @@ -0,0 +1,280 @@ +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 ( + <> +
+ + {t('Parent')} + + 0} + onClick={addParent} + > + + +
+ + {parents.map((parent, index) => { + return ( + + } + > +
+ + #{index + 1} {parent.inject?.inject_title} + + + { deleteParent(parent); }} + > + + + +
+
+ + + {t('Inject')} + + + + {t('Condition')} + + + +
+ ); + })} + +
+ + {t('Childrens')} + + + + +
+ {childrens.map((children, index) => { + return ( + + } + > +
+ + #{index + 1} {children.inject?.inject_title} + + + { deleteChildren(children); }} + > + + + +
+
+ + + {t('Inject')} + + + + {t('Condition')} + + + +
+ ); + })} + + ); +}; + +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 7872850bda..29f393234a 100644 --- a/openbas-front/src/admin/components/common/injects/Injects.tsx +++ b/openbas-front/src/admin/components/common/injects/Injects.tsx @@ -1,7 +1,6 @@ import React, { CSSProperties, FunctionComponent, useContext, useMemo, useState } from 'react'; import { Checkbox, Chip, List, ListItem, ListItemIcon, ListItemSecondaryAction, ListItemText } from '@mui/material'; import { makeStyles } from '@mui/styles'; -import { Connection } from '@xyflow/react'; import { Link } from 'react-router-dom'; import * as R from 'ramda'; import { splitDuration } from '../../../../utils/Time'; @@ -15,7 +14,7 @@ import { isNotEmptyField } from '../../../../utils/utils'; import { useFormatter } from '../../../../components/i18n'; import type { FilterGroup, Inject, InjectTestStatus, Variable } from '../../../../utils/api-types'; import type { InjectorContractConvertedContent, InjectOutputType, InjectStore } from '../../../../actions/injects/Inject'; -import { InjectContext, PermissionsContext } from '../Context'; +import { InjectContext, PermissionsContext, ViewModeContext } from '../Context'; import PaginationComponentV2 from '../../../../components/common/queryable/pagination/PaginationComponentV2'; import { buildSearchPagination } from '../../../../components/common/queryable/QueryableUtils'; import SortHeadersComponentV2 from '../../../../components/common/queryable/sort/SortHeadersComponentV2'; @@ -95,7 +94,8 @@ interface Props { exerciseOrScenarioId: string setViewMode?: (mode: string) => void - onConnectInjects: (connection: Connection) => void + + availableButtons: string[] teams: TeamStore[] articles: ArticleStore[] @@ -109,7 +109,7 @@ interface Props { const Injects: FunctionComponent = ({ exerciseOrScenarioId, setViewMode, - onConnectInjects, + availableButtons, teams, articles, variables, @@ -122,6 +122,7 @@ const Injects: FunctionComponent = ({ const classes = useStyles(); const { t, tPick } = useFormatter(); const injectContext = useContext(InjectContext); + const viewModeContext = useContext(ViewModeContext); const { permissions } = useContext(PermissionsContext); // Headers @@ -244,6 +245,29 @@ const Injects: FunctionComponent = ({ } }; + const massUpdateInject = async (data: Inject[]) => { + const promises: Promise[] = []; + data.forEach((inject) => { + promises.push(injectContext.onUpdateInject(inject.inject_id, inject).then((result: { result: string, entities: { injects: Record } }) => { + if (result.entities) { + const updated = result.entities.injects[result.result]; + return updated; + } + return undefined; + })); + }); + + Promise.all(promises).then((values) => { + if (values !== undefined) { + const updatedInjects = injects + .map((inject) => (values.find((value) => value !== undefined && value.inject_id === inject.inject_id) + ? (values.find((value) => value !== undefined && value?.inject_id === inject.inject_id) as InjectOutputType) + : inject as InjectOutputType)); + setInjects(updatedInjects); + } + }); + }; + const [openCreateDrawer, setOpenCreateDrawer] = useState(false); const [presetCreationValues, setPresetCreationValues] = useState<{ inject_depends_duration_days?: number, @@ -260,18 +284,6 @@ const Injects: FunctionComponent = ({ setPresetCreationValues(data); }; - // Timeline - const [showTimeline, setShowTimeline] = useState( - () => { - const storedValue = localStorage.getItem(`${exerciseOrScenarioId}_show_injects_timeline`); - return storedValue === null ? true : storedValue === 'true'; - }, - ); - const handleShowTimeline = () => { - setShowTimeline(!showTimeline); - localStorage.setItem(`${exerciseOrScenarioId}_show_injects_timeline`, String(!showTimeline)); - }; - // Filters const availableFilterNames = [ 'inject_platforms', @@ -462,16 +474,20 @@ const Injects: FunctionComponent = ({ queryableHelpers={queryableHelpers} disablePagination topBarButtons={ - + } /> - {showTimeline && ( -
+ {viewModeContext === 'chain' && ( +
{ const injectContract = inject?.inject_injector_contract.convertedContent; @@ -480,11 +496,15 @@ const Injects: FunctionComponent = ({ setSelectedInjectId(inject?.inject_id); } }} + onCreate={onCreate} + onUpdate={onUpdate} + onDelete={onDelete} />
)} + {viewModeContext === 'list' && ( = ({ = ({ ); })} + )} {permissions.canWrite && ( <> {selectedInjectId !== null @@ -598,15 +619,18 @@ const Injects: FunctionComponent = ({ open handleClose={() => setSelectedInjectId(null)} onUpdateInject={onUpdateInject} + massUpdateInject={massUpdateInject} injectId={selectedInjectId} teamsFromExerciseOrScenario={teams} // @ts-expect-error typing articlesFromExerciseOrScenario={articles} variablesFromExerciseOrScenario={variables} + exerciseOrScenarioId={exerciseOrScenarioId} uriVariable={uriVariable} allUsersNumber={allUsersNumber} usersNumber={usersNumber} teamsUsers={teamsUsers} + injects={injects} /> } { diff --git a/openbas-front/src/admin/components/common/injects/InjectsListButtons.tsx b/openbas-front/src/admin/components/common/injects/InjectsListButtons.tsx index db8bbf9dc5..7a7850ab77 100644 --- a/openbas-front/src/admin/components/common/injects/InjectsListButtons.tsx +++ b/openbas-front/src/admin/components/common/injects/InjectsListButtons.tsx @@ -1,11 +1,11 @@ import React, { FunctionComponent, useContext } from 'react'; import { ToggleButton, ToggleButtonGroup, Tooltip } from '@mui/material'; -import { BarChartOutlined, ReorderOutlined } from '@mui/icons-material'; +import { BarChartOutlined, ReorderOutlined, ViewTimelineOutlined } from '@mui/icons-material'; import { makeStyles } from '@mui/styles'; import type { InjectOutputType } from '../../../../actions/injects/Inject'; import { exportData } from '../../../../utils/Environment'; import { useFormatter } from '../../../../components/i18n'; -import { InjectContext } from '../Context'; +import { InjectContext, ViewModeContext } from '../Context'; import ImportUploaderInjectFromXls from './ImportUploaderInjectFromXls'; import useExportToXLS from '../../../../utils/hooks/useExportToXLS'; import { useHelper } from '../../../../store'; @@ -24,21 +24,21 @@ const useStyles = makeStyles(() => ({ interface Props { injects: InjectOutputType[]; setViewMode?: (mode: string) => void; - showTimeline: boolean; - handleShowTimeline: () => void; + availableButtons: string[]; } const InjectsListButtons: FunctionComponent = ({ injects, setViewMode, - showTimeline, - handleShowTimeline, + availableButtons, }) => { // Standard hooks const classes = useStyles(); const { t } = useFormatter(); const injectContext = useContext(InjectContext); + const viewModeContext = useContext(ViewModeContext); + // Fetching data const { tagsMap } = useHelper((helper: TagHelper) => ({ tagsMap: helper.getTagsMap(), @@ -62,13 +62,8 @@ const InjectsListButtons: FunctionComponent = ({ ); const exportInjectsToXLS = useExportToXLS({ data: exportInjects, fileName: `${t('Injects')}` }); - const onShowTimeline = () => { - handleShowTimeline(); - }; - const entries = [ { label: 'Export injects', action: exportInjectsToXLS }, - { label: showTimeline ? t('Hide timeline') : t('Show timeline'), action: onShowTimeline }, ]; return ( @@ -86,18 +81,31 @@ const InjectsListButtons: FunctionComponent = ({ > {injectContext.onImportInjectFromXls && } - {!!setViewMode + {(!!setViewMode && availableButtons.includes('list')) && setViewMode('list')} + selected={viewModeContext === 'list'} aria-label="List view mode" > - + + + + } + {(!!setViewMode && availableButtons.includes('chain')) + && + setViewMode('chain')} + selected={viewModeContext === 'chain'} + aria-label="Interactive view mode" + > + } - {!!setViewMode + {(!!setViewMode && availableButtons.includes('distribution')) && void; onUpdateInject: (data: Inject) => Promise; + massUpdateInject?: (data: Inject[]) => Promise; injectId: string; isAtomic?: boolean; teamsFromExerciseOrScenario: TeamStore[]; + injects?: InjectOutputType[]; } -const UpdateInject: React.FC = ({ open, handleClose, onUpdateInject, injectId, isAtomic = false, teamsFromExerciseOrScenario, ...props }) => { +const UpdateInject: React.FC = ({ open, handleClose, onUpdateInject, massUpdateInject, injectId, isAtomic = false, teamsFromExerciseOrScenario, injects, ...props }) => { const { t } = useFormatter(); const dispatch = useAppDispatch(); const drawerRef = useRef(null); + const [availableTabs] = useState(['Inject details', 'Logical chains']); + const [activeTab, setActiveTab] = useState(availableTabs[0]); // Fetching data const { inject } = useHelper((helper: InjectHelper) => ({ inject: helper.getInject(injectId), })); + useDataLoader(() => { dispatch(fetchInject(injectId)); }); + // Selection + const handleTabChange = (_: React.SyntheticEvent, newValue: string) => { + setActiveTab(newValue); + }; + const [injectorContract, setInjectorContract] = useState(null); useEffect(() => { if (inject?.inject_injector_contract?.injector_contract_content) { @@ -47,17 +60,39 @@ const UpdateInject: React.FC = ({ open, handleClose, onUpdateInject, inje }} disableEnforceFocus > - + <> + {!isAtomic && ( + + {availableTabs.map((tab) => { + return ( + + ); + })} + + )} + {(isAtomic || activeTab === 'Inject details') && ( + + )} + {(!isAtomic && activeTab === 'Logical chains') && ( + + )} + ); }; diff --git a/openbas-front/src/admin/components/common/injects/UpdateInjectDetails.js b/openbas-front/src/admin/components/common/injects/UpdateInjectDetails.js index 0a2a78b683..8d6656ad9e 100644 --- a/openbas-front/src/admin/components/common/injects/UpdateInjectDetails.js +++ b/openbas-front/src/admin/components/common/injects/UpdateInjectDetails.js @@ -215,6 +215,7 @@ const UpdateInjectDetails = ({ inject_asset_groups: assetGroupIds, inject_documents: documents, inject_depends_duration, + inject_depends_on: data.inject_depends_on, }; await onUpdateInject(values); } diff --git a/openbas-front/src/admin/components/common/injects/UpdateInjectLogicalChains.js b/openbas-front/src/admin/components/common/injects/UpdateInjectLogicalChains.js new file mode 100644 index 0000000000..43ac86e834 --- /dev/null +++ b/openbas-front/src/admin/components/common/injects/UpdateInjectLogicalChains.js @@ -0,0 +1,149 @@ +import React from 'react'; +import arrayMutators from 'final-form-arrays'; +import { Form } from 'react-final-form'; +import { makeStyles } from '@mui/styles'; +import { Avatar, Button, Card, CardContent, CardHeader } from '@mui/material'; +import { HelpOutlined } from '@mui/icons-material'; +import { useFormatter } from '../../../../components/i18n'; +import PlatformIcon from '../../../../components/PlatformIcon'; +import InjectChainsForm from './InjectChainsForm'; + +const useStyles = makeStyles((theme) => ({ + injectorContract: { + margin: '10px 0 20px 0', + width: '100%', + border: `1px solid ${theme.palette.divider}`, + borderRadius: 4, + }, + injectorContractContent: { + fontSize: 18, + textAlign: 'center', + }, + injectorContractHeader: { + backgroundColor: theme.palette.background.default, + }, +})); + +const UpdateInjectLogicalChains = ({ + inject, + handleClose, + onUpdateInject, + injects, +}) => { + const { t, tPick } = useFormatter(); + const classes = useStyles(); + + const initialValues = { + ...inject, + inject_depends_to: injects + .filter((currentInject) => currentInject.inject_depends_on === inject.inject_id) + .map((currentInject) => currentInject.inject_id), + }; + + const onSubmit = async (data) => { + const injectUpdate = { + ...data, + inject_id: data.inject_id, + inject_injector_contract: data.inject_injector_contract.injector_contract_id, + inject_depends_on: data.inject_depends_on, + }; + + const injectsToUpdate = []; + + const childrenIds = data.inject_depends_to; + + const injectsWithoutDependencies = injects + .filter((currentInject) => currentInject.inject_depends_on === data.inject_id + && !childrenIds.includes(currentInject.inject_id)) + .map((currentInject) => { + return { + ...currentInject, + inject_id: currentInject.inject_id, + inject_injector_contract: currentInject.inject_injector_contract.injector_contract_id, + inject_depends_on: undefined, + }; + }); + + injectsToUpdate.push(...injectsWithoutDependencies); + + childrenIds.forEach((childrenId) => { + const children = injects.find((currentInject) => currentInject.inject_id === childrenId); + if (children !== undefined) { + const injectChildrenUpdate = { + ...children, + inject_id: children.inject_id, + inject_injector_contract: children.inject_injector_contract.injector_contract_id, + inject_depends_on: inject.inject_id, + }; + injectsToUpdate.push(injectChildrenUpdate); + } + }); + + await onUpdateInject([injectUpdate, ...injectsToUpdate]); + + handleClose(); + }; + const injectorContractContent = JSON.parse(inject.inject_injector_contract.injector_contract_content); + return ( + <> + + + : } + title={inject?.contract_attack_patterns_external_ids?.join(', ')} + action={
+ {inject?.inject_injector_contract?.injector_contract_platforms?.map( + (platform) => , + )} +
} + /> + + {tPick(inject?.inject_injector_contract?.injector_contract_labels)} + +
+
{ + changeValue(state, field, () => value); + }, + }} + > + {({ form, handleSubmit, values, errors }) => { + return ( + + +
+ + +
+ + ); + }} + + + ); +}; + +export default UpdateInjectLogicalChains; diff --git a/openbas-front/src/admin/components/scenarios/scenario/injects/ScenarioInjects.tsx b/openbas-front/src/admin/components/scenarios/scenario/injects/ScenarioInjects.tsx index 09e9e84f48..9eae25a81d 100644 --- a/openbas-front/src/admin/components/scenarios/scenario/injects/ScenarioInjects.tsx +++ b/openbas-front/src/admin/components/scenarios/scenario/injects/ScenarioInjects.tsx @@ -1,8 +1,6 @@ -import React, { FunctionComponent } from 'react'; +import React, { FunctionComponent, useState } from 'react'; import { useParams } from 'react-router-dom'; -import * as R from 'ramda'; -import { Connection } from '@xyflow/react'; -import { ArticleContext, TeamContext } from '../../../common/Context'; +import { ArticleContext, TeamContext, ViewModeContext } from '../../../common/Context'; import { useAppDispatch } from '../../../../../utils/hooks'; import { useHelper } from '../../../../../store'; import type { InjectHelper } from '../../../../../actions/injects/inject-helper'; @@ -13,10 +11,9 @@ import type { ScenariosHelper } from '../../../../../actions/scenarios/scenario- import useDataLoader from '../../../../../utils/hooks/useDataLoader'; import { fetchVariablesForScenario } from '../../../../../actions/variables/variable-actions'; import { fetchScenarioTeams } from '../../../../../actions/scenarios/scenario-actions'; -import type { Inject, Scenario } from '../../../../../utils/api-types'; +import type { Scenario } from '../../../../../utils/api-types'; import { articleContextForScenario } from '../articles/ScenarioArticles'; import { teamContextForScenario } from '../teams/ScenarioTeams'; -import injectContextForScenario from '../ScenarioContext'; import { fetchScenarioInjectsSimple } from '../../../../../actions/injects/inject-action'; import Injects from '../../../common/injects/Injects'; @@ -29,7 +26,9 @@ const ScenarioInjects: FunctionComponent = () => { const dispatch = useAppDispatch(); const { scenarioId } = useParams() as { scenarioId: Scenario['scenario_id'] }; - const { injects, scenario, teams, articles, variables } = useHelper( + const availableButtons = ['chain', 'list']; + + const { scenario, teams, articles, variables } = useHelper( (helper: InjectHelper & ScenariosHelper & ArticlesHelper & ChallengeHelper & VariablesHelper) => { return { injects: helper.getScenarioInjects(scenarioId), @@ -49,37 +48,37 @@ const ScenarioInjects: FunctionComponent = () => { const articleContext = articleContextForScenario(scenarioId); const teamContext = teamContextForScenario(scenarioId, []); - const injectContext = injectContextForScenario(scenario); + const [viewMode, setViewMode] = useState(() => { + const storedValue = localStorage.getItem('scenario_or_exercise_view_mode'); + return storedValue === null || !availableButtons.includes(storedValue) ? 'list' : storedValue; + }); - const handleConnectInjects = async (connection: Connection) => { - const updateFields = [ - 'inject_title', - 'inject_depends_from_another', - 'inject_depends_duration', - ]; - const sourceInject = injects.find((inject: Inject) => inject.inject_id === connection.source); - sourceInject.inject_depends_from_another = connection.target; - await injectContext.onUpdateInject(sourceInject.inject_id, R.pick(updateFields, sourceInject)); + const handleViewMode = (mode: string) => { + setViewMode(mode); + localStorage.setItem('scenario_or_exercise_view_mode', mode); }; return ( <> - - - - - + + + + + + + ); diff --git a/openbas-front/src/admin/components/simulations/simulation/injects/ExerciseInjects.tsx b/openbas-front/src/admin/components/simulations/simulation/injects/ExerciseInjects.tsx index c0dbee4202..34c225dcc5 100644 --- a/openbas-front/src/admin/components/simulations/simulation/injects/ExerciseInjects.tsx +++ b/openbas-front/src/admin/components/simulations/simulation/injects/ExerciseInjects.tsx @@ -1,10 +1,10 @@ import React, { FunctionComponent, useState } from 'react'; import { useParams } from 'react-router-dom'; -import { BarChartOutlined, ReorderOutlined } from '@mui/icons-material'; +import { BarChartOutlined, ReorderOutlined, ViewTimelineOutlined } from '@mui/icons-material'; import { Grid, Paper, ToggleButton, ToggleButtonGroup, Tooltip, Typography } from '@mui/material'; import { makeStyles } from '@mui/styles'; import type { Exercise } from '../../../../../utils/api-types'; -import { ArticleContext, TeamContext } from '../../../common/Context'; +import { ArticleContext, TeamContext, ViewModeContext } from '../../../common/Context'; import { useAppDispatch } from '../../../../../utils/hooks'; import { useHelper } from '../../../../../store'; import useDataLoader from '../../../../../utils/hooks/useDataLoader'; @@ -46,8 +46,19 @@ const ExerciseInjects: FunctionComponent = () => { const { t } = useFormatter(); const classes = useStyles(); const dispatch = useAppDispatch(); + const availableButtons = ['chain', 'list', 'distribution']; const { exerciseId } = useParams() as { exerciseId: Exercise['exercise_id'] }; + const [viewMode, setViewMode] = useState(() => { + const storedValue = localStorage.getItem('scenario_or_exercise_view_mode'); + return storedValue === null || !availableButtons.includes(storedValue) ? 'list' : storedValue; + }); + + const handleViewMode = (mode: string) => { + localStorage.setItem('scenario_or_exercise_view_mode', mode); + setViewMode(mode); + }; + const { injects, exercise, teams, articles, variables } = useHelper( (helper: InjectHelper & ExercisesHelper & ArticlesHelper & ChallengeHelper & VariablesHelper) => { return { @@ -70,11 +81,10 @@ const ExerciseInjects: FunctionComponent = () => { const articleContext = articleContextForExercise(exerciseId); const teamContext = teamContextForExercise(exerciseId, []); - const [viewMode, setViewMode] = useState('list'); - return ( <> - {viewMode === 'list' && ( + + {(viewMode === 'list' || viewMode === 'chain') && ( = () => { usersNumber={exercise.exercise_users_number} // @ts-expect-error typing teamsUsers={exercise.exercise_teams_users} - setViewMode={setViewMode} + setViewMode={handleViewMode} + availableButtons={availableButtons} /> - )} - {viewMode === 'distribution' && ( + )} + {viewMode === 'distribution' && (
= () => { setViewMode('list')} + onClick={() => handleViewMode('list')} selected={false} aria-label="List view mode" > + + handleViewMode('chain')} + selected={false} + aria-label="Interactive view mode" + > + + + setViewMode('distribution')} + onClick={() => handleViewMode('distribution')} selected={true} aria-label="Distribution view mode" > @@ -176,7 +197,8 @@ const ExerciseInjects: FunctionComponent = () => {
- )} + )} +
); }; diff --git a/openbas-front/src/admin/components/simulations/simulation/timeline/TimelineOverview.tsx b/openbas-front/src/admin/components/simulations/simulation/timeline/TimelineOverview.tsx index 8674c74b22..c4ea19f9a4 100644 --- a/openbas-front/src/admin/components/simulations/simulation/timeline/TimelineOverview.tsx +++ b/openbas-front/src/admin/components/simulations/simulation/timeline/TimelineOverview.tsx @@ -316,6 +316,7 @@ const TimelineOverview = () => { injectId={selectedInjectId} teamsFromExerciseOrScenario={teams} isAtomic={false} + injects={injects} /> )}
diff --git a/openbas-front/src/components/ChainedTimeline.tsx b/openbas-front/src/components/ChainedTimeline.tsx index c26aaf88bd..cc9252df97 100644 --- a/openbas-front/src/components/ChainedTimeline.tsx +++ b/openbas-front/src/components/ChainedTimeline.tsx @@ -1,29 +1,31 @@ -import React, { FunctionComponent, useContext, useEffect, useState } from 'react'; +import React, { FunctionComponent, useEffect, useState } from 'react'; import { makeStyles, useTheme } from '@mui/styles'; import { + Connection, + ConnectionLineType, + ControlButton, + Controls, + Edge, MarkerType, ReactFlow, ReactFlowProvider, useEdgesState, useNodesState, - Connection, - Edge, useReactFlow, Viewport, XYPosition, - Controls, - ControlButton, + MiniMap, + ConnectionState, } from '@xyflow/react'; import { Tooltip } from '@mui/material'; import moment from 'moment-timezone'; import { UnfoldLess, UnfoldMore, CropFree } from '@mui/icons-material'; -import type { InjectOutputType } from '../actions/injects/Inject'; +import type { InjectOutputType, InjectStore } from '../actions/injects/Inject'; import type { Theme } from './Theme'; import nodeTypes from './nodes'; import CustomTimelineBackground from './CustomTimelineBackground'; import { NodeInject } from './nodes/NodeInject'; import CustomTimelinePanel from './CustomTimelinePanel'; -import { InjectContext } from '../admin/components/common/Context'; import { useHelper } from '../store'; import type { InjectHelper } from '../actions/injects/inject-helper'; import type { ScenariosHelper } from '../actions/scenarios/scenario-helper'; @@ -34,6 +36,7 @@ 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'; const useStyles = makeStyles(() => ({ container: { @@ -54,16 +57,27 @@ const useStyles = makeStyles(() => ({ interface Props { injects: InjectOutputType[], exerciseOrScenarioId: string, - onConnectInjects(connection: Connection): void, onSelectedInject(inject?: InjectOutputType): void, openCreateInjectDrawer(data: { inject_depends_duration_days: number, inject_depends_duration_minutes: number, inject_depends_duration_hours: number }): void, + onUpdateInject: (data: Inject[]) => void + onCreate: (result: { result: string, entities: { injects: Record } }) => void, + onUpdate: (result: { result: string, entities: { injects: Record } }) => void, + onDelete: (result: string) => void, } -const ChainedTimelineFlow: FunctionComponent = ({ injects, exerciseOrScenarioId, onConnectInjects, onSelectedInject, openCreateInjectDrawer }) => { +const ChainedTimelineFlow: FunctionComponent = ({ + injects, + exerciseOrScenarioId, + onSelectedInject, + openCreateInjectDrawer, + onUpdateInject, + onCreate, + onUpdate, + onDelete }) => { // Standard hooks const classes = useStyles(); const theme = useTheme(); @@ -77,8 +91,8 @@ const ChainedTimelineFlow: FunctionComponent = ({ injects, exerciseOrScen const [newNodeCursorVisibility, setNewNodeCursorVisibility] = useState<'visible' | 'hidden'>('hidden'); const [newNodeCursorClickable, setNewNodeCursorClickable] = useState(true); const [currentMouseTime, setCurrentMouseTime] = useState(''); + const [connectOnGoing, setConnectOnGoing] = useState(false); - const injectContext = useContext(InjectContext); const reactFlow = useReactFlow(); const injectsMap = useHelper((injectHelper: InjectHelper) => injectHelper.getInjectsMap()); @@ -92,8 +106,11 @@ const ChainedTimelineFlow: FunctionComponent = ({ injects, exerciseOrScen const proOptions = { account: 'paid-pro', hideAttribution: true }; const defaultEdgeOptions = { - type: 'straight', - markerEnd: { type: MarkerType.ArrowClosed }, + type: 'smoothstep', + markerEnd: { type: MarkerType.ArrowClosed, + width: 30, + height: 30, + }, }; const minutesPerGapAllowed = [ @@ -107,6 +124,7 @@ const ChainedTimelineFlow: FunctionComponent = ({ injects, exerciseOrScen let startDate: string | undefined; + // If we have a scenario, we find the startdate using the cron info if (scenario !== undefined) { const parsedCron = scenario.scenario_recurrence ? parseCron(scenario.scenario_recurrence) : null; startDate = scenario?.scenario_recurrence_start ? scenario?.scenario_recurrence_start : exercise?.exercise_start_date; @@ -117,6 +135,7 @@ const ChainedTimelineFlow: FunctionComponent = ({ injects, exerciseOrScen : moment(startDate).utc().format(); } } else if (exercise !== undefined) { + // Otherwise, we're in a simulation and we use the start_date startDate = exercise.exercise_start_date != null ? exercise.exercise_start_date : undefined; } @@ -140,7 +159,7 @@ const ChainedTimelineFlow: FunctionComponent = ({ injects, exerciseOrScen 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 + 240); + .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]; @@ -157,6 +176,22 @@ const ChainedTimelineFlow: FunctionComponent = ({ injects, exerciseOrScen }); }; + 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 }, + }); + }); + setEdges(newEdges); + }; + /** * Update all nodes */ @@ -171,10 +206,12 @@ const ChainedTimelineFlow: FunctionComponent = ({ injects, exerciseOrScen key: inject.inject_id, label: inject.inject_title, color: 'green', - background: '#09101e', - onConnectInjects, - isTargeted: false, - isTargeting: false, + background: + theme.palette.mode === 'dark' + ? '#09101e' + : '#e5e5e5', + isTargeted: injects.find((anyInject) => anyInject.inject_id === inject.inject_id) !== undefined, + isTargeting: inject.inject_depends_on !== undefined, inject, fixedY: 0, startDate, @@ -183,6 +220,9 @@ const ChainedTimelineFlow: FunctionComponent = ({ injects, exerciseOrScen .concat(inject.inject_asset_groups!.map((assetGroup) => assetGroups[assetGroup]?.asset_group_name)) .concat(inject.inject_teams!.map((team) => teams[team]?.team_name)), exerciseOrScenarioId, + onCreate, + onUpdate, + onDelete, }, position: { x: (inject.inject_depends_duration / 60) * (gapSize / minutesPerGapAllowed[minutesPerGapIndex]), @@ -198,18 +238,7 @@ const ChainedTimelineFlow: FunctionComponent = ({ injects, exerciseOrScen setDraggingOnGoing(false); calculateInjectPosition(injectsNodes); setNodes(injectsNodes); - setEdges(injects.filter((inject) => inject.inject_depends_on != null).map((inject) => { - return ({ - id: `${inject.inject_id}->${inject.inject_depends_on}`, - source: `${inject.inject_id}`, - sourceHandle: `source-${inject.inject_id}`, - target: `${inject.inject_depends_on}`, - targetHandle: `target-${inject.inject_depends_on}`, - label: '', - labelShowBg: false, - labelStyle: { fill: theme.palette.text?.primary, fontSize: 9 }, - }); - })); + updateEdges(); } }; @@ -217,6 +246,26 @@ const ChainedTimelineFlow: FunctionComponent = ({ injects, exerciseOrScen updateNodes(); }, [injects, minutesPerGapIndex]); + /** + * Actions to hide the new node 'button' + */ + const hideNewNode = () => { + if (!connectOnGoing) { + setNewNodeCursorVisibility('hidden'); + setNewNodeCursorClickable(false); + } + }; + + /** + * Actions to show the new node 'button' + */ + const showNewNode = () => { + if (!connectOnGoing) { + setNewNodeCursorVisibility('visible'); + setNewNodeCursorClickable(true); + } + }; + /** * Take care of updates when the node drag is starting * @param _event the mouse event (unused for now) @@ -231,8 +280,9 @@ const ChainedTimelineFlow: FunctionComponent = ({ injects, exerciseOrScen inject_id: node.id, inject_depends_duration: convertCoordinatesToTime(node.position), }; - injectContext.onUpdateInject(node.id, inject); + onUpdateInject([inject]); setCurrentUpdatedNode(node); + setDraggingOnGoing(false); } }; @@ -244,6 +294,38 @@ const ChainedTimelineFlow: FunctionComponent = ({ injects, exerciseOrScen setNodes(nodesList); }; + /** + * Small function to do some stuff when draggind is starting + */ + const connectStart = () => { + setConnectOnGoing(true); + hideNewNode(); + }; + + /** + * Small function to do some stuff when draggind is starting + */ + const connectEnd = () => { + setTimeout(() => { + setConnectOnGoing(false); + showNewNode(); + }, 100); + }; + + const connect = (connection: Connection) => { + 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 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, + }; + onUpdateInject([injectToUpdate]); + } + }; + /** * Actions to do during node drag, especially keeping it horizontal * @param _event the mouse event @@ -253,6 +335,17 @@ const ChainedTimelineFlow: FunctionComponent = ({ injects, exerciseOrScen 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)) + .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) { + position.x = dependsOn.position.x + aSecond; + } + + if (dependsTo?.position && position.x >= dependsTo?.position.x) { + position.x = dependsTo.position.x - aSecond; + } if (node.data.fixedY !== undefined) { position.y = node.data.fixedY; @@ -327,22 +420,6 @@ const ChainedTimelineFlow: FunctionComponent = ({ injects, exerciseOrScen setViewportData(viewport); }; - /** - * Actions to hide the new node 'button' - */ - const hideNewNode = () => { - setNewNodeCursorVisibility('hidden'); - setNewNodeCursorClickable(false); - }; - - /** - * Actions to show the new node 'button' - */ - const showNewNode = () => { - setNewNodeCursorVisibility('visible'); - setNewNodeCursorClickable(true); - }; - /** * Updating the time between each gap * @param incrementIndex increment or decrement the index to get the current minutesPerGap @@ -355,18 +432,73 @@ const ChainedTimelineFlow: FunctionComponent = ({ injects, exerciseOrScen setDraggingOnGoing(false); }; + const onReconnectEnd = (event: React.MouseEvent, edge: Edge, handleType: 'source' | 'target', connectionState: Omit) => { + if (!connectionState.isValid) { + const inject = injects.find((currentInject) => currentInject.inject_id === edge.target); + if (inject !== undefined) { + const injectToUpdate = { + ...injectsMap[inject.inject_id], + inject_injector_contract: inject.inject_injector_contract.injector_contract_id, + inject_id: inject.inject_id, + inject_depends_on: undefined, + }; + onUpdateInject([injectToUpdate]); + } + } else if (handleType === 'source') { + const updates = []; + const injectToRemove = injects.find((currentInject) => currentInject.inject_id === edge.target); + const injectToUpdate = injects.find((currentInject) => currentInject.inject_id === connectionState.toNode?.id); + + const parent = injects.find((currentInject) => currentInject.inject_id === connectionState.fromNode?.id); + + if (parent !== undefined + && injectToUpdate !== undefined + && injectToRemove !== undefined + && parent.inject_depends_duration < injectToUpdate.inject_depends_duration) { + const injectToRemoveEdge = { + ...injectsMap[injectToRemove.inject_id], + inject_injector_contract: injectToRemove.inject_injector_contract.injector_contract_id, + inject_id: injectToRemove.inject_id, + inject_depends_on: undefined, + }; + updates.push(injectToRemoveEdge); + 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, + }; + updates.push(injectToUpdateEdge); + onUpdateInject(updates); + } + } else { + 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 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, + }; + onUpdateInject([injectToUpdate]); + } + } + updateNodes(); + }; return ( <> {injects.length > 0 ? ( -
+
= ({ injects, exerciseOrScen onNodeDragStart={nodeDragStart} onNodeMouseEnter={hideNewNode} onNodeMouseLeave={showNewNode} + onConnectStart={connectStart} + onConnectEnd={connectEnd} + onConnect={connect} + onEdgeMouseEnter={hideNewNode} + onEdgeMouseLeave={showNewNode} defaultEdgeOptions={defaultEdgeOptions} + connectionLineType={ConnectionLineType.SmoothStep} onMouseMove={onMouseMove} onMove={panTimeline} proOptions={proOptions} @@ -385,8 +523,12 @@ const ChainedTimelineFlow: FunctionComponent = ({ injects, exerciseOrScen onClick={onNewNodeClick} onMouseEnter={showNewNode} onMouseLeave={hideNewNode} + onReconnect={() => {}} + // @ts-expect-error for some reason, the signature here is not well defined + onReconnectEnd={onReconnectEnd} + edgesReconnectable={true} > -
= ({ injects, exerciseOrScen viewportData={viewportData} startDate={startDate} /> + +
) : null diff --git a/openbas-front/src/components/CustomTimelineBackground.tsx b/openbas-front/src/components/CustomTimelineBackground.tsx index 55aab8467f..098d03c411 100644 --- a/openbas-front/src/components/CustomTimelineBackground.tsx +++ b/openbas-front/src/components/CustomTimelineBackground.tsx @@ -3,6 +3,8 @@ import cc from 'classcat'; import { shallow } from 'zustand/shallow'; import { useStore, type ReactFlowState, type BackgroundProps } from '@xyflow/react'; +import { useTheme } from '@mui/styles'; +import type { Theme } from './Theme'; interface Props extends BackgroundProps { minutesPerGap: number, @@ -28,28 +30,30 @@ function BackgroundComponent({ style, className, }: Props) { + const theme: Theme = useTheme(); const ref = useRef(null); const { transform, patternId } = useStore(selector, shallow); const gapXY: [number, number] = Array.isArray(gap) ? gap : [gap, gap * 2]; const scaledGap: [number, number] = [gapXY[0] * transform[2] || 1, gapXY[1] * transform[2] || 1]; const scaledSize = size * transform[2]; - const patternOffset = [scaledSize / offset, scaledSize / offset]; + const computedOffset = Array.isArray(offset) ? offset : [offset, offset]; + const patternOffset = offset ? [scaledSize / computedOffset[0], scaledSize / computedOffset[1]] : [scaledSize, scaledSize]; const modifiedPatternId = `${patternId}${id}`; return ( @@ -68,7 +72,7 @@ function BackgroundComponent({ > diff --git a/openbas-front/src/components/CustomTimelinePanel.tsx b/openbas-front/src/components/CustomTimelinePanel.tsx index b3a59b646e..28f5ab0e16 100644 --- a/openbas-front/src/components/CustomTimelinePanel.tsx +++ b/openbas-front/src/components/CustomTimelinePanel.tsx @@ -1,8 +1,10 @@ import React, { CSSProperties, memo, useEffect, useState } from 'react'; import { shallow } from 'zustand/shallow'; import { useStore, type ReactFlowState, type BackgroundProps, Panel, Viewport } from '@xyflow/react'; -import { makeStyles } from '@mui/styles'; +import { makeStyles, useTheme } from '@mui/styles'; import moment from 'moment-timezone'; +import type { Theme } from './Theme'; +import { useFormatter } from './i18n'; const selector = (s: ReactFlowState) => ({ transform: s.transform, patternId: `pattern-${s.rfId}` }); @@ -47,7 +49,9 @@ function BackgroundComponent({ viewportData, startDate = undefined, }: Props) { + const theme: Theme = useTheme(); const classes = useStyles(); + const { ft, fld, vnsdt } = useFormatter(); const { transform } = useStore(selector, shallow); const [parsedDates, setParsedDates] = useState([]); @@ -70,13 +74,13 @@ function BackgroundComponent({ dateIndex: Math.round(date.unix() / (minutesPerGap * 3 * 60)), }); } else { - const beginningDate = moment.utc(startDate) - .add(-new Date().getTimezoneOffset() / 60, 'h'); + const beginningDate = moment.utc(startDate); const date = moment.utc(beginningDate) .add((minutesPerGap * 3 * i) + offset, 'm'); + newParsedDates.push({ - parsedDate: viewportData !== undefined && viewportData?.zoom > 0.45 - ? date.format('MMMM Do, YYYY - h:mmA') : date.format('l-h:mmA'), + parsedDate: viewportData === undefined || viewportData?.zoom > 0.5 + ? `${fld(date.toDate())} - ${ft(date.toDate())}` : `${vnsdt(date.toDate())}`, dateIndex: Math.round((date.unix() - beginningDate.unix()) / (minutesPerGap * 3 * 60)), }); } @@ -98,7 +102,7 @@ function BackgroundComponent({ } > {parsedDates.map((parsedDate, index) => ( - + {parsedDate.parsedDate} ))} diff --git a/openbas-front/src/components/i18n.js b/openbas-front/src/components/i18n.js index b0b23ebb26..8ba8a2ad42 100644 --- a/openbas-front/src/components/i18n.js +++ b/openbas-front/src/components/i18n.js @@ -86,6 +86,18 @@ const inject18n = (WrappedComponent) => { year: 'numeric', }); }; + const veryShortNumericDateTime = (date) => { + if (isNone(date)) { + return translate('None'); + } + return this.props.intl.formatDate(date, { + minute: 'numeric', + hour: 'numeric', + day: 'numeric', + month: 'numeric', + year: 'numeric', + }); + }; const fullNumericDateTime = (date) => { if (isNone(date)) { return translate('None'); @@ -145,6 +157,7 @@ const inject18n = (WrappedComponent) => { {...{ fsd: shortDate }} {...{ nsd: shortNumericDate }} {...{ nsdt: shortNumericDateTime }} + {...{ vnsdt: veryShortNumericDateTime }} {...{ fndt: fullNumericDateTime }} {...{ fd: standardDate }} {...{ md: monthDate }} @@ -230,6 +243,18 @@ export const useFormatter = () => { year: 'numeric', }); }; + const veryShortNumericDateTime = (date) => { + if (isNone(date)) { + return translate('None'); + } + return intl.formatDate(date, { + minute: 'numeric', + hour: 'numeric', + day: 'numeric', + month: 'numeric', + year: 'numeric', + }); + }; const fullNumericDateTime = (date) => { if (isNone(date)) { return translate('None'); @@ -292,6 +317,7 @@ export const useFormatter = () => { fsd: shortDate, nsd: shortNumericDate, nsdt: shortNumericDateTime, + vnsdt: veryShortNumericDateTime, fndt: fullNumericDateTime, ft: time, fd: standardDate, diff --git a/openbas-front/src/components/nodes/NodeInject.tsx b/openbas-front/src/components/nodes/NodeInject.tsx index fec6161c3e..48c38a65e3 100644 --- a/openbas-front/src/components/nodes/NodeInject.tsx +++ b/openbas-front/src/components/nodes/NodeInject.tsx @@ -7,7 +7,8 @@ import type { Theme } from '../Theme'; import { isNotEmptyField } from '../../utils/utils'; import InjectIcon from '../../admin/components/common/injects/InjectIcon'; import InjectPopover from '../../admin/components/common/injects/InjectPopover'; -import type { InjectOutputType } from '../../actions/injects/Inject'; +import type { InjectOutputType, InjectStore } from '../../actions/injects/Inject'; +import { useFormatter } from '../i18n'; // Deprecated - https://mui.com/system/styles/basics/ // Do not use it for new code. @@ -19,7 +20,7 @@ const useStyles = makeStyles((theme) => ({ ? '1px solid rgba(255, 255, 255, 0.12)' : '1px solid rgba(0, 0, 0, 0.12)', borderRadius: 4, - width: 240, + width: 250, minHeight: '100px', height: 'auto', padding: '8px 5px 5px 5px', @@ -52,7 +53,9 @@ const useStyles = makeStyles((theme) => ({ WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', height: '40px', - + color: theme.palette.mode === 'dark' + ? 'white' + : 'black', }, targets: { display: 'flex', @@ -88,6 +91,9 @@ export type NodeInject = Node<{ targets: string[], exerciseOrScenarioId: string, onSelectedInject(inject?: InjectOutputType): void, + onCreate: (result: { result: string, entities: { injects: Record } }) => void, + onUpdate: (result: { result: string, entities: { injects: Record } }) => void, + onDelete: (result: string) => void, } >; @@ -100,6 +106,7 @@ export type NodeInject = Node<{ const NodeInjectComponent = ({ data }: NodeProps) => { const classes = useStyles(); const theme: Theme = useTheme(); + const { ft, fld } = useFormatter(); /** * Converts the duration in second to a string representing the relative time @@ -116,10 +123,11 @@ const NodeInjectComponent = ({ data }: NodeProps) => { * @param durationInSeconds the duration in seconds */ const convertToAbsoluteTime = (startDate: string, durationInSeconds: number) => { - return moment.utc(startDate) + const date = moment.utc(startDate) .add(durationInSeconds, 's') - .add(-new Date().getTimezoneOffset() / 60, 'h') - .format('MMMM Do, YYYY - h:mmA'); + .toDate(); + + return `${fld(date)} - ${ft(date)}`; }; /** @@ -144,7 +152,9 @@ const NodeInjectComponent = ({ data }: NodeProps) => { if (data.inject) data.onSelectedInject(data.inject); }; - const isDisabled = !data.inject?.inject_injector_contract.injector_contract_content_parsed?.config.expose; + const isDisabled = !data.inject?.inject_injector_contract?.convertedContent?.config.expose; + + const dimNode = !data.inject?.inject_enabled || !data.inject?.inject_injector_contract?.convertedContent?.config.expose; let borderLeftColor = theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.7)' : 'rgba(0, 0, 0, 0.7)'; if (!data.inject?.inject_enabled) { @@ -158,7 +168,7 @@ const NodeInjectComponent = ({ data }: NodeProps) => { borderLeftColor, }} onClick={onClick} > -
+
) => { { data.startDate !== undefined ? (
{convertToAbsoluteTime(data.startDate, data.inject!.inject_depends_duration)}
) : (
{convertToRelativeTime(data.inject!.inject_depends_duration)}
)} - +
{data.label}
-
+
{`${data.targets.slice(0, 3).join(', ')}${data.targets.length > 3 ? ', ...' : ''}`}
@@ -199,6 +209,9 @@ const NodeInjectComponent = ({ data }: NodeProps) => { canBeTested={data.inject?.inject_testable} isDisabled={isDisabled} exerciseOrScenarioId={data.exerciseOrScenarioId} + onDelete={data.onDelete} + onUpdate={data.onUpdate} + onCreate={data.onCreate} />
diff --git a/openbas-front/src/static/css/index.css b/openbas-front/src/static/css/index.css index 45d1ad16e8..df8398c674 100644 --- a/openbas-front/src/static/css/index.css +++ b/openbas-front/src/static/css/index.css @@ -146,7 +146,6 @@ input[type="file"] { } .react-flow__controls-button { - background: #070d19 !important; border-bottom: none !important; } .react-flow__controls-button:hover { @@ -157,6 +156,32 @@ input[type="file"] { border: 1px solid #b5b7ba; } -.react-flow__pane.draggable:not(.react-flow__pane.draggable.dragging) { +.react-flow__pane.draggable:not(.react-flow__pane.draggable.dragging, :has(.react-flow__connection)) { cursor: none !important; +} + +.react-flow__handle { + width: 12px; + height: 12px; +} + +.dark.react-flow { + --xy-minimap-background-color-props: #070d19; + --xy-background-color: #070d19; + --xy-background-color-props: #070d19; + --xy-background-color-default:#070d19; + --xy-minimap-mask-background-color-default: rgba(80, 80, 80, 0.8); + --xy-controls-button-background-color-default: #070d19; +} + +.light.react-flow { + --xy-minimap-background-color-props: #f8f8f8; + --xy-background-color: #f8f8f8; + --xy-background-color-props: #f8f8f8; + --xy-background-color-default:#f8f8f8; + --xy-minimap-mask-background-color-default: rgba(255, 255, 255, 0.8); +} + +.react-flow__minimap { + margin: 15px 40px 15px 15px; } \ No newline at end of file diff --git a/openbas-front/src/utils/Localization.js b/openbas-front/src/utils/Localization.js index e3567c9b81..a161e124a6 100644 --- a/openbas-front/src/utils/Localization.js +++ b/openbas-front/src/utils/Localization.js @@ -1347,6 +1347,11 @@ const i18n = { 'Launch simulation now': 'Lancer la simulation maintenant', 'Launch now': 'Lancer maintenant', 'All teams value': 'Valeur de toutes les équipes', + Parent: 'Parent', + Inject: 'Stimuli', + Condition: 'Condition', + Childrens: 'Enfants', + 'Interactive view': 'Vue interactive', }, zh: { 'Email address': 'email地址', @@ -2601,6 +2606,11 @@ const i18n = { 'All teams value': '所有团队的值', 'The score set for the team will also be applied to all players in the team': '为团队设置的分数也将应用于团队中的所有玩家', Player: '玩家', + Parent: '父母', + Inject: '父母', + Condition: '条件', + Childrens: '儿童', + 'Interactive view': '交互式视图', }, en: { openbas_email: 'Email', diff --git a/openbas-front/yarn.lock b/openbas-front/yarn.lock index 9be0a117e0..32ceb2693b 100644 --- a/openbas-front/yarn.lock +++ b/openbas-front/yarn.lock @@ -5169,23 +5169,23 @@ __metadata: languageName: node linkType: hard -"@xyflow/react@npm:^12.0.3": - version: 12.0.3 - resolution: "@xyflow/react@npm:12.0.3" +"@xyflow/react@npm:12.2.1": + version: 12.2.1 + resolution: "@xyflow/react@npm:12.2.1" dependencies: - "@xyflow/system": "npm:0.0.37" + "@xyflow/system": "npm:0.0.41" classcat: "npm:^5.0.3" zustand: "npm:^4.4.0" peerDependencies: react: ">=17" react-dom: ">=17" - checksum: 10c0/148fcbb75dc17b22b3912c7955f0f931120f80becf87e87b0a24f885474962ddf65af370946f3e63eb3bc5d799036154e8d567d45d7960112bfc11327acb19b7 + checksum: 10c0/b388fb2550a8c9073bf121c72d3854a0fc52503755874ba2f33e787755297088153164f3c960c4e119f7637e49d2f33639cd994f57e696c33b10344c591eb06b languageName: node linkType: hard -"@xyflow/system@npm:0.0.37": - version: 0.0.37 - resolution: "@xyflow/system@npm:0.0.37" +"@xyflow/system@npm:0.0.41": + version: 0.0.41 + resolution: "@xyflow/system@npm:0.0.41" dependencies: "@types/d3-drag": "npm:^3.0.7" "@types/d3-selection": "npm:^3.0.10" @@ -5194,7 +5194,7 @@ __metadata: d3-drag: "npm:^3.0.0" d3-selection: "npm:^3.0.0" d3-zoom: "npm:^3.0.0" - checksum: 10c0/60b2de70a53dc3f2b691d837f2adcd2324f2e3e19258d6928e58578ad896a7f9fa7dd20938b224e7054284542135e0d7519ab34c012d69a8ed0e15ecf452d1ee + checksum: 10c0/1acb4cd056c7f0d1e5a9c0c8d6bd4f1b87c186653d4f255ef1d70dc9646cd275a108d485ca8fe01668d63858b60cfa99f38c3af71fae1cee2534d140cfd482a0 languageName: node linkType: hard @@ -12068,7 +12068,7 @@ __metadata: "@typescript-eslint/parser": "npm:7.18.0" "@uiw/react-md-editor": "npm:4.0.4" "@vitejs/plugin-react": "npm:4.3.0" - "@xyflow/react": "npm:^12.0.3" + "@xyflow/react": "npm:12.2.1" apexcharts: "npm:3.51.0" axios: "npm:1.7.4" chokidar: "npm:3.6.0"