From 41915fbc069c55d8b735995e7c9308b24d541a29 Mon Sep 17 00:00:00 2001 From: Stephanya Casanova Date: Tue, 18 Jun 2024 10:43:46 +0200 Subject: [PATCH] [backend/frontend] Empower the injects timeline with new interactions (#877) --- .../io/openbas/rest/exercise/ExerciseApi.java | 2 +- .../io/openbas/rest/helper/TeamHelper.java | 27 ++- .../rest/inject/output/InjectOutput.java | 4 + .../io/openbas/rest/scenario/ScenarioApi.java | 8 +- .../java/io/openbas/rest/team/TeamApi.java | 8 +- openbas-front/src/actions/Schema.js | 10 - openbas-front/src/actions/injects/Inject.d.ts | 3 +- .../src/actions/injects/inject-helper.d.ts | 2 - .../components/common/articles/Articles.tsx | 2 +- .../components/common/injects/InjectIcon.js | 8 +- .../components/common/injects/Injects.js | 224 +++++++++--------- .../scenario/articles/ScenarioArticles.tsx | 2 +- .../simulation/ExerciseDatePopover.tsx | 8 +- .../simulation/injects/ExerciseInjects.tsx | 10 +- .../simulation/timeline/TimelineOverview.tsx | 14 +- openbas-front/src/components/Timeline.tsx | 213 ++++++++--------- openbas-front/src/utils/Localization.js | 1 + openbas-front/src/utils/api-types.d.ts | 5 +- .../database/repository/InjectRepository.java | 10 + 19 files changed, 289 insertions(+), 272 deletions(-) diff --git a/openbas-api/src/main/java/io/openbas/rest/exercise/ExerciseApi.java b/openbas-api/src/main/java/io/openbas/rest/exercise/ExerciseApi.java index c0afd71bec..50e55d14f5 100644 --- a/openbas-api/src/main/java/io/openbas/rest/exercise/ExerciseApi.java +++ b/openbas-api/src/main/java/io/openbas/rest/exercise/ExerciseApi.java @@ -220,7 +220,7 @@ public List comcheckStatuses(@PathVariable String exercise, @Pat @PreAuthorize("isExerciseObserver(#exerciseId)") public Iterable getExerciseTeams(@PathVariable String exerciseId) { return TeamHelper.rawTeamToSimplerTeam(teamRepository.rawTeamByExerciseId(exerciseId), - injectExpectationRepository,communicationRepository, exerciseTeamUserRepository, scenarioRepository); + injectExpectationRepository, injectRepository, communicationRepository, exerciseTeamUserRepository, scenarioRepository); } @Transactional(rollbackOn = Exception.class) diff --git a/openbas-api/src/main/java/io/openbas/rest/helper/TeamHelper.java b/openbas-api/src/main/java/io/openbas/rest/helper/TeamHelper.java index bb8f97c6dc..625d2798fb 100644 --- a/openbas-api/src/main/java/io/openbas/rest/helper/TeamHelper.java +++ b/openbas-api/src/main/java/io/openbas/rest/helper/TeamHelper.java @@ -5,6 +5,7 @@ import io.openbas.database.repository.CommunicationRepository; import io.openbas.database.repository.ExerciseTeamUserRepository; import io.openbas.database.repository.InjectExpectationRepository; +import io.openbas.database.repository.InjectRepository; import io.openbas.database.repository.ScenarioRepository; import java.util.List; @@ -17,6 +18,7 @@ public class TeamHelper { public static List rawTeamToSimplerTeam(List teams, InjectExpectationRepository injectExpectationRepository, + InjectRepository injectRepository, CommunicationRepository communicationRepository, ExerciseTeamUserRepository exerciseTeamUserRepository, ScenarioRepository scenarioRepository) { @@ -109,11 +111,30 @@ public static List rawTeamToSimplerTeam(List teams, } // We set the injects linked to the scenarios - teamSimple.setScenariosInjects(rawTeam.getTeam_scenarios().stream().flatMap( + teamSimple.setScenariosInjects( + getInjectTeamsIds(teamSimple.getId(), + rawTeam.getTeam_scenarios().stream().flatMap( scenario -> mapInjectsByScenarioIds.get(scenario).stream() - ).collect(Collectors.toSet())); + ).collect(Collectors.toSet()), + injectRepository) + ); + + // We set the injects linked to the exercises + teamSimple.setExercisesInjects( + getInjectTeamsIds(teamSimple.getId(), + rawTeam.getTeam_exercise_injects(), + injectRepository) + ); - return teamSimple; + return teamSimple; }).collect(Collectors.toList()); } + + private static Set getInjectTeamsIds(final String teamId, Set injectIds, final InjectRepository injectRepository) { + Set rawInjectTeams = injectRepository.findRawInjectTeams(injectIds, teamId); + return rawInjectTeams.stream() + .map(RawInject::getInject_id) + .collect(Collectors.toSet()); + } + } 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 dc2d62722a..574a01b64f 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 @@ -49,6 +49,9 @@ public class InjectOutput { @JsonProperty("inject_type") public String injectType; + @JsonProperty("inject_teams") + private Set teams; + public InjectOutput( String id, String title, @@ -81,5 +84,6 @@ public InjectOutput( assetGroups != null ? new HashSet<>(Arrays.asList(assetGroups)) : new HashSet<>() ); this.injectType = injectType; + this.teams = teams != null ? new HashSet<>(Arrays.asList(teams)) : new HashSet<>(); } } diff --git a/openbas-api/src/main/java/io/openbas/rest/scenario/ScenarioApi.java b/openbas-api/src/main/java/io/openbas/rest/scenario/ScenarioApi.java index 5b5e6556d1..f2284d073e 100644 --- a/openbas-api/src/main/java/io/openbas/rest/scenario/ScenarioApi.java +++ b/openbas-api/src/main/java/io/openbas/rest/scenario/ScenarioApi.java @@ -46,6 +46,7 @@ public class ScenarioApi { private TeamRepository teamRepository; private UserRepository userRepository; private InjectExpectationRepository injectExpectationRepository; + private InjectRepository injectRepository; private CommunicationRepository communicationRepository; private ExerciseTeamUserRepository exerciseTeamUserRepository; private ScenarioRepository scenarioRepository; @@ -65,6 +66,11 @@ public void setInjectExpectationRepository(InjectExpectationRepository injectExp this.injectExpectationRepository = injectExpectationRepository; } + @Autowired + public void setInjectRepository(InjectRepository injectRepository) { + this.injectRepository = injectRepository; + } + @Autowired public void setCommunicationRepository(CommunicationRepository communicationRepository) { this.communicationRepository = communicationRepository; @@ -190,7 +196,7 @@ public Iterable addScenarioTeams( @PreAuthorize("isScenarioObserver(#scenarioId)") public Iterable scenarioTeams(@PathVariable @NotBlank final String scenarioId) { return TeamHelper.rawTeamToSimplerTeam(teamRepository.rawTeamByScenarioId(scenarioId), - injectExpectationRepository,communicationRepository, exerciseTeamUserRepository, scenarioRepository); + injectExpectationRepository, injectRepository, communicationRepository, exerciseTeamUserRepository, scenarioRepository); } @Transactional(rollbackOn = Exception.class) diff --git a/openbas-api/src/main/java/io/openbas/rest/team/TeamApi.java b/openbas-api/src/main/java/io/openbas/rest/team/TeamApi.java index aca78e224e..5cbcdada30 100644 --- a/openbas-api/src/main/java/io/openbas/rest/team/TeamApi.java +++ b/openbas-api/src/main/java/io/openbas/rest/team/TeamApi.java @@ -50,6 +50,7 @@ public class TeamApi extends RestBehavior { private TeamRepository teamRepository; private CommunicationRepository communicationRepository; private InjectExpectationRepository injectExpectationRepository; + private InjectRepository injectRepository; private UserRepository userRepository; private OrganizationRepository organizationRepository; private TagRepository tagRepository; @@ -85,6 +86,11 @@ public void setInjectExpectationRepository(InjectExpectationRepository injectExp this.injectExpectationRepository = injectExpectationRepository; } + @Autowired + public void setInjectRepository(InjectRepository injectRepository) { + this.injectRepository = injectRepository; + } + @Autowired public void setCommunicationRepository(CommunicationRepository communicationRepository) { this.communicationRepository = communicationRepository; @@ -118,7 +124,7 @@ public Iterable getTeams() { teams = teamRepository.rawTeamsAccessibleFromOrganization(organizationIds); } - return TeamHelper.rawTeamToSimplerTeam(teams, injectExpectationRepository, communicationRepository, + return TeamHelper.rawTeamToSimplerTeam(teams, injectExpectationRepository, injectRepository, communicationRepository, exerciseTeamUserRepository, scenarioRepository); } diff --git a/openbas-front/src/actions/Schema.js b/openbas-front/src/actions/Schema.js index 057640b0f4..f9555f6c49 100644 --- a/openbas-front/src/actions/Schema.js +++ b/openbas-front/src/actions/Schema.js @@ -317,11 +317,6 @@ export const storeHelper = (state) => ({ getExerciseCommunications: (id) => entities('communications', state).filter( (i) => i.communication_exercise === id, ), - getExerciseTechnicalInjectsWithNoTeam: (id) => { - return getInjectsWithParsedInjectorContractContent( - entities('injects', state), - ).filter((i) => !(i.inject_injector_contract?.injector_contract_content_parsed.fields.filter((f) => f.name === 'teams').length > 0) && i.inject_exercise === id); - }, getExerciseObjectives: (id) => entities('objectives', state).filter((o) => o.objective_exercise === id), getExerciseLogs: (id) => entities('logs', state).filter((l) => l.log_exercise === id), getExerciseLessonsCategories: (id) => entities('lessonscategorys', state).filter( @@ -487,11 +482,6 @@ export const storeHelper = (state) => ({ getScenarioArticles: (id) => entities('articles', state).filter((i) => i.article_scenario === id), getScenarioChallenges: (id) => entities('challenges', state).filter((c) => c.challenge_scenarios.includes(id)), getScenarioInjects: (id) => getInjectsWithParsedInjectorContractContent(entities('injects', state).filter((i) => i.inject_scenario === id)), - getScenarioTechnicalInjectsWithNoTeam: (id) => { - return getInjectsWithParsedInjectorContractContent( - entities('injects', state), - ).filter((i) => !(i.inject_injector_contract?.injector_contract_content_parsed.fields.filter((f) => f.name === 'teams').length > 0) && i.inject_scenario === id); - }, getTeamScenarioInjects: (id) => entities('injects', state).filter((i) => (entity(id, 'teams', state) || {}).team_scenario_injects?.includes( i.inject_id, )), diff --git a/openbas-front/src/actions/injects/Inject.d.ts b/openbas-front/src/actions/injects/Inject.d.ts index cbf327d8fa..4201bdfd68 100644 --- a/openbas-front/src/actions/injects/Inject.d.ts +++ b/openbas-front/src/actions/injects/Inject.d.ts @@ -9,8 +9,9 @@ export type InjectInput = { inject_depends_duration_seconds: number; }; -export type InjectStore = Omit & { +export type InjectStore = Omit & { inject_tags: string[] | undefined; + inject_teams: string[] | undefined; inject_content: { expectationScore: number, challenges: string[] | undefined } inject_injector_contract: { // as we don't know the type of the content of a contract we need to put any here diff --git a/openbas-front/src/actions/injects/inject-helper.d.ts b/openbas-front/src/actions/injects/inject-helper.d.ts index db6b8540ec..397e1a2f5b 100644 --- a/openbas-front/src/actions/injects/inject-helper.d.ts +++ b/openbas-front/src/actions/injects/inject-helper.d.ts @@ -6,10 +6,8 @@ export interface InjectHelper { getExerciseInjects: (exerciseId: Exercise['exercise_id']) => Inject[]; getExerciseInjectExpectations: (scenarioId: Scenario['scenario_id']) => InjectExpectation[]; - getExerciseTechnicalInjectsWithNoTeam: (exerciseId: Exercise['exercise_id']) => Inject[]; getTeamExerciseInjects: (teamId: Team['team_id']) => Inject[]; getScenarioInjects: (scenarioId: Scenario['scenario_id']) => Inject[]; - getScenarioTechnicalInjectsWithNoTeam: (scenarioId: Scenario['scenario_id']) => Inject[]; getTeamScenarioInjects: (teamId: Team['team_id']) => Inject[]; } diff --git a/openbas-front/src/admin/components/common/articles/Articles.tsx b/openbas-front/src/admin/components/common/articles/Articles.tsx index 2f4f4a575e..f843f2362b 100644 --- a/openbas-front/src/admin/components/common/articles/Articles.tsx +++ b/openbas-front/src/admin/components/common/articles/Articles.tsx @@ -90,7 +90,7 @@ const Articles: FunctionComponent = ({ articles }) => { })); const sortedArticles: FullArticleStore[] = R.filter( (n: FullArticleStore) => channels.length === 0 - || channels.map((o) => o.id).includes(n.article_fullchannel.channel_id ?? ''), + || channels.map((o) => o.id).includes(n.article_fullchannel?.channel_id ?? ''), filtering.filterAndSort(fullArticles), ); diff --git a/openbas-front/src/admin/components/common/injects/InjectIcon.js b/openbas-front/src/admin/components/common/injects/InjectIcon.js index c812e4cc3b..4cad77c8cf 100644 --- a/openbas-front/src/admin/components/common/injects/InjectIcon.js +++ b/openbas-front/src/admin/components/common/injects/InjectIcon.js @@ -24,6 +24,7 @@ const iconSelector = (type, variant, fontSize, done, disabled) => { style={{ marginTop: variant === 'list' ? 5 : 0, padding: variant === 'timeline' ? 1 : 0, + cursor: 'pointer', width: fontSize === 'small' || variant === 'inline' ? 20 : 24, height: fontSize === 'small' || variant === 'inline' ? 20 : 24, borderRadius: 4, @@ -34,11 +35,11 @@ const iconSelector = (type, variant, fontSize, done, disabled) => { }; const InjectIcon = (props) => { - const { type, size, variant, tooltip, done, disabled } = props; + const { type, size, variant, tooltip, done, disabled, onClick } = props; const fontSize = size || 'medium'; if (tooltip) { return ( - + {iconSelector(type, variant, fontSize, done, disabled)} ); @@ -49,10 +50,11 @@ const InjectIcon = (props) => { InjectIcon.propTypes = { type: PropTypes.string, size: PropTypes.string, - tooltip: PropTypes.string, + tooltip: PropTypes.node, variant: PropTypes.string, done: PropTypes.bool, disabled: PropTypes.bool, + onClick: PropTypes.func, }; export default InjectIcon; diff --git a/openbas-front/src/admin/components/common/injects/Injects.js b/openbas-front/src/admin/components/common/injects/Injects.js index 42ef31f406..417411b7de 100644 --- a/openbas-front/src/admin/components/common/injects/Injects.js +++ b/openbas-front/src/admin/components/common/injects/Injects.js @@ -1,8 +1,8 @@ import React, { useContext, useState } from 'react'; import { makeStyles } from '@mui/styles'; -import { Checkbox, Chip, IconButton, List, ListItem, ListItemIcon, ListItemSecondaryAction, ListItemText, ToggleButton, ToggleButtonGroup, Tooltip } from '@mui/material'; +import { Checkbox, Chip, List, ListItem, ListItemIcon, ListItemSecondaryAction, ListItemText, Menu, MenuItem, ToggleButton, ToggleButtonGroup, Tooltip } from '@mui/material'; +import { BarChartOutlined, MoreVert, ReorderOutlined } from '@mui/icons-material'; import { CSVLink } from 'react-csv'; -import { BarChartOutlined, FileDownloadOutlined, ReorderOutlined } from '@mui/icons-material'; import { splitDuration } from '../../../../utils/Time'; import ItemTags from '../../../../components/ItemTags'; import SearchFilter from '../../../../components/SearchFilter'; @@ -175,7 +175,12 @@ const Injects = (props) => { const classes = useStyles(); const { t, tPick } = useFormatter(); const [selectedInjectId, setSelectedInjectId] = useState(null); - const [showTimeline, _setShowTimeline] = useState(true); + const [showTimeline, setShowTimeline] = useState( + () => { + const storedValue = localStorage.getItem(`${exerciseOrScenarioId}_show_injects_timeline`); + return storedValue === null ? true : storedValue === 'true'; + }, + ); const { permissions } = useContext(PermissionsContext); const injectContext = useContext(InjectContext); @@ -200,8 +205,34 @@ const Injects = (props) => { const onUpdateInject = async (data) => { await injectContext.onUpdateInject(selectedInjectId, data); }; + const sortedInjects = filtering.filterAndSort(injects); + // Menu + const [anchorEl, setAnchorEl] = useState(null); + + const exportInjects = exportData( + 'inject', + [ + 'inject_type', + 'inject_title', + 'inject_description', + 'inject_depends_duration', + 'inject_enabled', + 'inject_tags', + 'inject_content', + ], + sortedInjects, + tagsMap, + ); + const filename = `${t('Injects')}.xls`; + + const handleShowTimeline = () => { + setShowTimeline(!showTimeline); + localStorage.setItem(`${exerciseOrScenarioId}_show_injects_timeline`, !showTimeline); + setAnchorEl(null); + }; + // Rendering if (injects) { return ( @@ -221,7 +252,37 @@ const Injects = (props) => { currentTags={filtering.tags} /> -
+
+ {sortedInjects.length > 0 && ( +
+ { + ev.stopPropagation(); + setAnchorEl(ev.currentTarget); + }} + > + + + setAnchorEl(null)} + > + + + {`${t('Export this list')} (.xls)`} + + + + {showTimeline ? t('Hide timeline') : t('Show timeline')} + + +
+ )} {setViewMode ? ( { style={{ float: 'right' }} aria-label="Change view mode" > -
- {sortedInjects.length > 0 ? ( - - - - - - - - ) : ( - - - - )} -
- + @@ -285,53 +305,21 @@ const Injects = (props) => { onClick={() => setViewMode('distribution')} aria-label="Distribution view mode" > - +
- ) : ( -
- {sortedInjects.length > 0 ? ( - - - - - - - - ) : ( - - - - )} -
- )} + ) : null}
-
+
{showTimeline && (
- setSelectedInjectId(id)} teams={teams} > -
+
)} @@ -346,9 +334,9 @@ const Injects = (props) => { checked={selectAll} disableRipple onChange={ - typeof handleToggleSelectAll === 'function' - && handleToggleSelectAll.bind(this) - } + typeof handleToggleSelectAll === 'function' + && handleToggleSelectAll.bind(this) + } disabled={typeof handleToggleSelectAll !== 'function'} /> @@ -403,7 +391,7 @@ const Injects = (props) => { headerStyles, )} - } + } />   @@ -427,9 +415,9 @@ const Injects = (props) => { divider button disabled={ - !injectContract || isDisabled - || !inject.inject_enabled - } + !injectContract || isDisabled + || !inject.inject_enabled + } onClick={() => setSelectedInjectId(inject.inject_id)} > { onClick={(event) => (event.shiftKey ? onToggleShiftEntity(index, inject, event) : onToggleEntity(inject, event)) - } + } > @@ -455,9 +443,9 @@ const Injects = (props) => { tooltip={t(inject.inject_type)} type={inject.inject_type} disabled={ - !injectContract || isDisabled - || !inject.inject_enabled - } + !injectContract || isDisabled + || !inject.inject_enabled + } /> {
{ />
- } + } /> { {permissions.canWrite && ( <> {selectedInjectId !== null - && setSelectedInjectId(null)} - onUpdateInject={onUpdateInject} - injectId={selectedInjectId} - teamsFromExerciseOrScenario={teams} - articlesFromExerciseOrScenario={articles} - variablesFromExerciseOrScenario={variables} - uriVariable={uriVariable} - allUsersNumber={allUsersNumber} - usersNumber={usersNumber} - teamsUsers={teamsUsers} - /> - } + && setSelectedInjectId(null)} + onUpdateInject={onUpdateInject} + injectId={selectedInjectId} + teamsFromExerciseOrScenario={teams} + articlesFromExerciseOrScenario={articles} + variablesFromExerciseOrScenario={variables} + uriVariable={uriVariable} + allUsersNumber={allUsersNumber} + usersNumber={usersNumber} + teamsUsers={teamsUsers} + /> + } { } return (
- +
); }; diff --git a/openbas-front/src/admin/components/scenarios/scenario/articles/ScenarioArticles.tsx b/openbas-front/src/admin/components/scenarios/scenario/articles/ScenarioArticles.tsx index 9129dd2728..b686c6ffee 100644 --- a/openbas-front/src/admin/components/scenarios/scenario/articles/ScenarioArticles.tsx +++ b/openbas-front/src/admin/components/scenarios/scenario/articles/ScenarioArticles.tsx @@ -14,7 +14,7 @@ import { ArticleContext } from '../../../common/Context'; export const articleContextForScenario = (scenarioId: ScenarioStore['scenario_id']) => { const dispatch = useAppDispatch(); return { - previewArticleUrl: (article: FullArticleStore) => `/channels/${scenarioId}/${article.article_fullchannel.channel_id}?preview=true`, + previewArticleUrl: (article: FullArticleStore) => `/channels/${scenarioId}/${article.article_fullchannel?.channel_id}?preview=true`, onAddArticle: (data: ArticleCreateInput) => dispatch(addScenarioArticle(scenarioId, data)), onUpdateArticle: (article: ArticleStore, data: ArticleUpdateInput) => dispatch( updateScenarioArticle(scenarioId, article.article_id, data), diff --git a/openbas-front/src/admin/components/simulations/simulation/ExerciseDatePopover.tsx b/openbas-front/src/admin/components/simulations/simulation/ExerciseDatePopover.tsx index 48e029b663..85f6751dc9 100644 --- a/openbas-front/src/admin/components/simulations/simulation/ExerciseDatePopover.tsx +++ b/openbas-front/src/admin/components/simulations/simulation/ExerciseDatePopover.tsx @@ -24,9 +24,11 @@ const ExerciseDatePopover: React.FC = ({ exercise }) => { return ( <> - setOpenEdit(true)} style={{ marginRight: 5 }} disabled={exercise.exercise_status !== 'SCHEDULED'}> - - + + setOpenEdit(true)} style={{ marginRight: 5 }} disabled={exercise.exercise_status !== 'SCHEDULED'}> + + + ({ paperChart: { @@ -75,6 +75,8 @@ const ExerciseInjects: FunctionComponent = () => { const articleContext = articleContextForExercise(exerciseId); const teamContext = teamContextForExercise(exerciseId, []); + const injectContext = injectContextForExercise(exercise); + const [viewMode, setViewMode] = useState('list'); const { selectedElements, @@ -168,12 +170,12 @@ const ExerciseInjects: FunctionComponent = () => { injectToUpdate[`inject_${action.field}`] = R.uniq(action.values.map((n) => n.value)); } // eslint-disable-next-line no-await-in-loop - await dispatch(updateInjectForExercise(exercise.exercise_id, injectToUpdate.inject_id, R.pick(updateFields, injectToUpdate))); + await injectContext.onUpdateInject(injectToUpdate.inject_id, R.pick(updateFields, injectToUpdate)); break; case 'REPLACE': injectToUpdate[`inject_${action.field}`] = R.uniq(action.values.map((n) => n.value)); // eslint-disable-next-line no-await-in-loop - await dispatch(updateInjectForExercise(exercise.exercise_id, injectToUpdate.inject_id, R.pick(updateFields, injectToUpdate))); + await injectContext.onUpdateInject(injectToUpdate.inject_id, R.pick(updateFields, injectToUpdate)); break; case 'REMOVE': if (isNotEmptyField(injectToUpdate[`inject_${action.field}`])) { @@ -182,7 +184,7 @@ const ExerciseInjects: FunctionComponent = () => { injectToUpdate[`inject_${action.field}`] = []; } // eslint-disable-next-line no-await-in-loop - await dispatch(updateInjectForExercise(exercise.exercise_id, injectToUpdate.inject_id, R.pick(updateFields, injectToUpdate))); + await injectContext.onUpdateInject(injectToUpdate.inject_id, R.pick(updateFields, injectToUpdate)); break; default: return; 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 07e82a1575..5f48e30e9a 100644 --- a/openbas-front/src/admin/components/simulations/simulation/timeline/TimelineOverview.tsx +++ b/openbas-front/src/admin/components/simulations/simulation/timeline/TimelineOverview.tsx @@ -80,11 +80,10 @@ const TimelineOverview = () => { teams, tagsMap, } = useHelper((helper: InjectHelper & ExercisesHelper & TagHelper) => { - const exerciseTeams = helper.getExerciseTeams(exerciseId); return { exercise: helper.getExercise(exerciseId), injects: helper.getExerciseInjects(exerciseId), - teams: exerciseTeams, + teams: helper.getExerciseTeams(exerciseId), tagsMap: helper.getTagsMap(), }; }); @@ -103,7 +102,7 @@ const TimelineOverview = () => { searchColumns, ); - const sortedInjects = filtering.filterAndSort(injects); + const filteredInjects = filtering.filterAndSort(injects); const pendingInjects = filtering.filterAndSort(injects.filter((i: InjectStore) => i.inject_status === null)); @@ -111,7 +110,7 @@ const TimelineOverview = () => { const onUpdateInject = async (inject: Inject) => { if (selectedInjectId) { - dispatch(updateInjectForExercise(exerciseId, selectedInjectId, inject)); + await dispatch(updateInjectForExercise(exerciseId, selectedInjectId, inject)); } }; @@ -133,8 +132,10 @@ const TimelineOverview = () => { />
- setSelectedInjectId(id)} >
@@ -243,8 +244,9 @@ const TimelineOverview = () => { style={{ width: '20%' }} >
diff --git a/openbas-front/src/components/Timeline.tsx b/openbas-front/src/components/Timeline.tsx index 49e3b30a2b..c40dfb70a2 100644 --- a/openbas-front/src/components/Timeline.tsx +++ b/openbas-front/src/components/Timeline.tsx @@ -9,8 +9,6 @@ import { splitDuration } from '../utils/Time'; import type { Theme } from './Theme'; import { useFormatter } from './i18n'; import useSearchAnFilter from '../utils/SortingFiltering'; -import { useHelper } from '../store'; -import type { InjectHelper } from '../actions/injects/inject-helper'; import { truncate } from '../utils/String'; const useStyles = makeStyles(() => ({ @@ -86,70 +84,83 @@ const useStyles = makeStyles(() => ({ })); interface Props { - exerciseOrScenarioId: string, - injects: Inject[], + injects: InjectStore[], teams: Team[], + onSelectInject: (injectId: string) => void, } -const Timeline: FunctionComponent = ({ exerciseOrScenarioId, injects, teams }) => { +const Timeline: FunctionComponent = ({ injects, onSelectInject, teams }) => { // Standard hooks const classes = useStyles(); const theme = useTheme(); const { t } = useFormatter(); // Retrieve data - const { - injectsPerTeam, - technicalInjectsPerType, - } = useHelper((helper: InjectHelper) => { - const getTechnicalInjectsWithNoTeam = () => { - const exerciseInjects = helper.getExerciseTechnicalInjectsWithNoTeam(exerciseOrScenarioId); - return exerciseInjects.length > 0 ? exerciseInjects : helper.getScenarioTechnicalInjectsWithNoTeam(exerciseOrScenarioId); - }; + const getInjectsPerTeam = (teamId: string) => { + return injects.filter((i) => i.inject_teams?.includes(teamId)); + }; - const getInjectsPerTeam = (teamId: string) => { - const teamExerciseInjects = helper.getTeamExerciseInjects(teamId); - return teamExerciseInjects.length > 0 ? teamExerciseInjects : helper.getTeamScenarioInjects(teamId); - }; + const injectsPerTeam = R.mergeAll( + teams.map((a: Team) => ({ + [a.team_id]: getInjectsPerTeam(a.team_id), + })), + ); - const technicalInjectsWithNoTeam = getTechnicalInjectsWithNoTeam(); + const allTeamInjectIds = new Set(R.values(injectsPerTeam).flat().map((inj: Inject) => inj.inject_id)); - return { - injectsPerTeam: R.mergeAll( - teams.map((a) => ({ - [a.team_id]: getInjectsPerTeam(a.team_id), - })), - ), - technicalInjectsPerType: R.groupBy(R.prop('inject_type'))(technicalInjectsWithNoTeam), - }; - }); + // Build map of technical Injects or without team + /* eslint-disable @typescript-eslint/no-explicit-any */ + const injectsWithoutTeamMap = injects.reduce((acc: { [x: string]: any[]; }, inject: InjectStore) => { + let keys: any[] = []; - const injectsMap = { ...injectsPerTeam, ...technicalInjectsPerType }; - // SortedTeams - const technicalTeams: Team[] = R.pipe( - R.groupBy(R.prop('inject_type')), - R.toPairs, - R.filter( - (n: [string, InjectStore[]]) => !( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - n[1][0].inject_injector_contract?.injector_contract_content_parsed?.fields?.filter((f: any) => f.key === 'teams').length > 0 - ), - ), - R.map( - (n: [string, InjectStore[]]) => ({ - team_id: n[0], - team_name: n[0], - }), - ), - )(injects as Inject[]); + if (!allTeamInjectIds.has(inject.inject_id)) { + if ( + inject.inject_injector_contract?.convertedContent + && 'fields' in inject.inject_injector_contract.convertedContent + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + && inject.inject_injector_contract.convertedContent.fields.some( + (field: any) => field.key === 'teams', + ) + ) { + keys = ['No teams']; + } else { + keys = [inject.inject_type]; + } + } + + keys?.forEach((key) => { + if (!acc[key]) { + acc[key] = []; + } + acc[key].push(inject); + }); + + return acc; + }, {} as { [key: string]: Inject[] }); + + const injectsMap = { ...injectsPerTeam, ...injectsWithoutTeamMap }; + + // Sorted teams + const teamInjectNames = R.map((key: string) => ({ + team_id: key, + team_name: key, + }), R.keys(injectsMap)); const sortedNativeTeams = R.sortWith( [R.ascend(R.prop('team_name'))], teams, ); - const sortedTeams = [...technicalTeams, ...sortedNativeTeams]; - // Timeline + const filteredTeamInject = R.reject( + (teamInjectName: Team) => R.includes( + teamInjectName.team_id, + R.pluck('team_id', sortedNativeTeams), + ), + teamInjectNames, + ); + + const sortedTeams = [...filteredTeamInject, ...sortedNativeTeams]; // Re utilisation of filter and sort hook const searchColumns = ['title', 'description', 'content']; @@ -159,6 +170,10 @@ const Timeline: FunctionComponent = ({ exerciseOrScenarioId, injects, tea searchColumns, ); + const handleSelectInject = (id: string) => { + onSelectInject(id); + }; + const lastInject = R.pipe( R.sortWith([R.descend(R.prop('inject_depends_duration'))]), R.head, @@ -191,7 +206,7 @@ const Timeline: FunctionComponent = ({ exerciseOrScenarioId, injects, tea return ( <> - {sortedTeams.length > 0 ? ( + {injects.length > 0 ? (
{sortedTeams.map((team) => ( @@ -213,7 +228,7 @@ const Timeline: FunctionComponent = ({ exerciseOrScenarioId, injects, tea
{sortedTeams.map((team, index) => { const injectsGroupedByTick = byTick( - filtering.filterAndSort(injectsMap[team.team_id]), + filtering.filterAndSort(injectsMap[team.team_id] ?? []), ); return (
= ({ exerciseOrScenarioId, injects, tea className={classes.injectGroup} style={{ left: `${injectGroupPosition}%` }} > - {injectsGroupedByTick[key].map((inject: InjectStore) => ( - - ))} + {injectsGroupedByTick[key].map((inject: InjectStore) => { + const duration = splitDuration(inject.inject_depends_duration || 0); + const tooltipContent = ( + + {inject.inject_title} +
+ + {`${duration.days} ${t('d')}, ${duration.hours} ${t('h')}, ${duration.minutes} ${t('m')}`} + +
+ ); + return ( + handleSelectInject(inject.inject_id)} + done={inject.inject_status !== null} + disabled={!inject.inject_enabled} + size="small" + variant={'timeline'} + /> + ); + }) + }
); })} @@ -263,66 +292,17 @@ const Timeline: FunctionComponent = ({ exerciseOrScenarioId, injects, tea
{index % 5 === 0 ? `${duration.days} - ${t('d')}, ${duration.hours} - ${t('h')}, ${duration.minutes} - ${t('m')}` - : ''} -
-
- {index % 5 === 0 - ? `${duration.days} - ${t('d')}, ${duration.hours} - ${t('h')}, ${duration.minutes} - ${t('m')}` - : ''} -
-
- ); - })} -
-
-
- ) : ( -
-
-
-
- -    - {t('No team')} -
-
-
-
-
 
-
- {ticks.map((tick, index) => { - const duration = splitDuration(tick); - return ( -
-
- {index % 5 === 0 - ? `${duration.days} - ${t('d')}, ${duration.hours} - ${t('h')}, ${duration.minutes} - ${t('m')}` + ${t('d')}, ${duration.hours} + ${t('h')}, ${duration.minutes} + ${t('m')}` : ''}
{index % 5 === 0 ? `${duration.days} - ${t('d')}, ${duration.hours} - ${t('h')}, ${duration.minutes} - ${t('m')}` + ${t('d')}, ${duration.hours} + ${t('h')}, ${duration.minutes} + ${t('m')}` : ''}
@@ -331,7 +311,8 @@ const Timeline: FunctionComponent = ({ exerciseOrScenarioId, injects, tea
- )} + ) : null + } ); }; diff --git a/openbas-front/src/utils/Localization.js b/openbas-front/src/utils/Localization.js index 06765e9f5d..d534c6c12c 100644 --- a/openbas-front/src/utils/Localization.js +++ b/openbas-front/src/utils/Localization.js @@ -1111,6 +1111,7 @@ const i18n = { 'Platform consent confirm text': 'Texte de confirmation du consentement à la plate-forme', Write: 'Ecriture', // -- Timeline + 'Hide timeline': 'Cacher la chronologie', 'Show Timeline': 'Afficher la chronologie', }, en: { diff --git a/openbas-front/src/utils/api-types.d.ts b/openbas-front/src/utils/api-types.d.ts index 1274e62bbd..cbc53ef4e2 100644 --- a/openbas-front/src/utils/api-types.d.ts +++ b/openbas-front/src/utils/api-types.d.ts @@ -1112,6 +1112,8 @@ export interface InjectOutput { inject_scenario?: string; /** @uniqueItems true */ inject_tags?: string[]; + /** @uniqueItems true */ + inject_teams?: string[]; inject_title?: string; inject_type?: string; } @@ -2100,6 +2102,7 @@ export interface PlatformSettings { platform_saml2_providers?: OAuthProvider[]; auth_local_enable?: boolean; auth_openid_enable?: boolean; + disabled_dev_features?: string[]; executor_caldera_enable?: boolean; executor_caldera_public_url?: string; executor_tanium_enable?: boolean; @@ -2125,7 +2128,6 @@ export interface PlatformSettings { rabbitmq_version?: string; xtm_opencti_enable?: boolean; xtm_opencti_url?: string; - disabled_dev_features: string[]; } export interface PlatformStatistic { @@ -2268,6 +2270,7 @@ export interface RawPaginationTeam { team_description?: string; team_id?: string; team_name?: string; + team_organization?: string; team_tags?: string[]; /** @format date-time */ team_updated_at?: string; diff --git a/openbas-model/src/main/java/io/openbas/database/repository/InjectRepository.java b/openbas-model/src/main/java/io/openbas/database/repository/InjectRepository.java index a42717faa0..9468d71227 100644 --- a/openbas-model/src/main/java/io/openbas/database/repository/InjectRepository.java +++ b/openbas-model/src/main/java/io/openbas/database/repository/InjectRepository.java @@ -2,6 +2,8 @@ import io.openbas.database.model.Inject; import io.openbas.database.raw.RawInject; +import java.util.Collection; +import java.util.Set; import org.jetbrains.annotations.NotNull; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.Modifying; @@ -120,6 +122,14 @@ void importSaveForScenario(@Param("id") String id, "GROUP BY injects.inject_id, ins.status_name;", nativeQuery = true) List findRawByIds(@Param("ids")List ids); + @Query(value = " SELECT injects.inject_id, " + + "coalesce(array_agg(it.team_id) FILTER ( WHERE it.team_id IS NOT NULL ), '{}') as inject_teams " + + "FROM injects " + + "LEFT JOIN injects_teams it ON injects.inject_id = it.inject_id " + + "WHERE injects.inject_id IN :ids AND it.team_id = :teamId " + + "GROUP BY injects.inject_id", nativeQuery = true) + Set findRawInjectTeams(@Param("ids") Collection ids, @Param("teamId") String teamId); + @Query(value = "SELECT org.*, " + "array_agg(DISTINCT org_tags.tag_id) FILTER (WHERE org_tags.tag_id IS NOT NULL) AS organization_tags, " +