From 39f75b1abd885f31041033b545dba8e3a503c955 Mon Sep 17 00:00:00 2001 From: Issel Parra <14161286+isselparra@users.noreply.github.com> Date: Fri, 18 Oct 2024 09:08:27 +0200 Subject: [PATCH] [backend/frontend] handle endpoint filtering --- .../rest/asset/endpoint/EndpointApi.java | 41 ++--- .../EndpointCriteriaBuilderService.java | 105 +++++++++++++ .../asset/endpoint/EndpointQueryHelper.java | 71 +++++++++ .../asset/endpoint/form/EndpointOutput.java | 22 +++ .../openbas/rest/asset/form/AssetOutput.java | 29 ++++ .../rest/asset_group/AssetGroupApi.java | 4 +- .../io/openbas/rest/executor/ExecutorApi.java | 30 +++- .../openbas/integrations/ExecutorService.java | 21 +++ .../src/actions/assets/endpoint-actions.ts | 8 +- .../src/actions/executors/executor-actions.ts | 12 ++ .../assets/endpoints/EndpointPopover.tsx | 4 +- .../components/assets/endpoints/Endpoints.tsx | 144 +++++++++++------- .../endpoints/EndpointsDialogAdding.tsx | 40 ++--- openbas-front/src/components/ItemExecutor.tsx | 33 ++++ .../queryable/filter/useRetrieveOptions.tsx | 6 + .../queryable/filter/useSearchOptions.tsx | 6 + openbas-front/src/utils/Localization.js | 6 + openbas-front/src/utils/api-types.d.ts | 31 ++++ .../java/io/openbas/database/model/Asset.java | 7 +- .../io/openbas/database/model/Endpoint.java | 4 +- .../repository/ExecutorRepository.java | 3 +- .../specification/EndpointSpecification.java | 7 + .../specification/ExecutorSpecification.java | 11 ++ 23 files changed, 528 insertions(+), 117 deletions(-) create mode 100644 openbas-api/src/main/java/io/openbas/rest/asset/endpoint/EndpointCriteriaBuilderService.java create mode 100644 openbas-api/src/main/java/io/openbas/rest/asset/endpoint/EndpointQueryHelper.java create mode 100644 openbas-api/src/main/java/io/openbas/rest/asset/endpoint/form/EndpointOutput.java create mode 100644 openbas-api/src/main/java/io/openbas/rest/asset/form/AssetOutput.java create mode 100644 openbas-front/src/actions/executors/executor-actions.ts create mode 100644 openbas-front/src/components/ItemExecutor.tsx create mode 100644 openbas-model/src/main/java/io/openbas/database/specification/ExecutorSpecification.java diff --git a/openbas-api/src/main/java/io/openbas/rest/asset/endpoint/EndpointApi.java b/openbas-api/src/main/java/io/openbas/rest/asset/endpoint/EndpointApi.java index 06bb4d5778..edf871a91a 100644 --- a/openbas-api/src/main/java/io/openbas/rest/asset/endpoint/EndpointApi.java +++ b/openbas-api/src/main/java/io/openbas/rest/asset/endpoint/EndpointApi.java @@ -1,5 +1,6 @@ package io.openbas.rest.asset.endpoint; +import io.openbas.aop.LogExecutionTime; import io.openbas.asset.EndpointService; import io.openbas.database.model.AssetAgentJob; import io.openbas.database.model.Endpoint; @@ -10,18 +11,18 @@ import io.openbas.database.specification.AssetAgentJobSpecification; import io.openbas.database.specification.EndpointSpecification; import io.openbas.rest.asset.endpoint.form.EndpointInput; +import io.openbas.rest.asset.endpoint.form.EndpointOutput; import io.openbas.rest.asset.endpoint.form.EndpointRegisterInput; import io.openbas.utils.pagination.SearchPaginationInput; -import jakarta.transaction.Transactional; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.jpa.domain.Specification; import org.springframework.security.access.annotation.Secured; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; import java.io.IOException; @@ -31,9 +32,9 @@ import static io.openbas.database.model.User.ROLE_ADMIN; import static io.openbas.database.model.User.ROLE_USER; +import static io.openbas.database.specification.EndpointSpecification.fromIds; import static io.openbas.executors.openbas.OpenBASExecutor.OPENBAS_EXECUTOR_ID; import static io.openbas.helper.StreamHelper.iterableToSet; -import static io.openbas.utils.pagination.PaginationUtils.buildPaginationJPA; @RequiredArgsConstructor @RestController @@ -44,6 +45,7 @@ public class EndpointApi { @Value("${info.app.version:unknown}") String version; private final EndpointService endpointService; + private final EndpointCriteriaBuilderService endpointCriteriaBuilderService; private final EndpointRepository endpointRepository; private final ExecutorRepository executorRepository; private final TagRepository tagRepository; @@ -51,7 +53,7 @@ public class EndpointApi { @PostMapping(ENDPOINT_URI) @PreAuthorize("isPlanner()") - @Transactional(rollbackOn = Exception.class) + @Transactional(rollbackFor = Exception.class) public Endpoint createEndpoint(@Valid @RequestBody final EndpointInput input) { Endpoint endpoint = new Endpoint(); endpoint.setUpdateAttributes(input); @@ -63,7 +65,7 @@ public Endpoint createEndpoint(@Valid @RequestBody final EndpointInput input) { @Secured(ROLE_ADMIN) @PostMapping(ENDPOINT_URI + "/register") - @Transactional(rollbackOn = Exception.class) + @Transactional(rollbackFor = Exception.class) public Endpoint upsertEndpoint(@Valid @RequestBody final EndpointRegisterInput input) throws IOException { Optional optionalEndpoint = this.endpointService.findByExternalReference(input.getExternalReference()); Endpoint endpoint; @@ -99,14 +101,14 @@ public Endpoint upsertEndpoint(@Valid @RequestBody final EndpointRegisterInput i @GetMapping(ENDPOINT_URI + "/jobs/{endpointExternalReference}") @PreAuthorize("isPlanner()") - @Transactional(rollbackOn = Exception.class) + @Transactional(rollbackFor = Exception.class) public List getEndpointJobs(@PathVariable @NotBlank final String endpointExternalReference) { return this.assetAgentJobRepository.findAll(AssetAgentJobSpecification.forEndpoint(endpointExternalReference)); } @PostMapping(ENDPOINT_URI + "/jobs/{assetAgentJobId}") @PreAuthorize("isPlanner()") - @Transactional(rollbackOn = Exception.class) + @Transactional(rollbackFor = Exception.class) public void cleanupAssetAgentJob(@PathVariable @NotBlank final String assetAgentJobId) { this.assetAgentJobRepository.deleteById(assetAgentJobId); } @@ -123,21 +125,22 @@ public Endpoint endpoint(@PathVariable @NotBlank final String endpointId) { return this.endpointService.endpoint(endpointId); } + @LogExecutionTime @PostMapping(ENDPOINT_URI + "/search") - public Page endpoints(@RequestBody @Valid SearchPaginationInput searchPaginationInput) { - return buildPaginationJPA( - (Specification specification, Pageable pageable) -> this.endpointRepository.findAll( - EndpointSpecification.findEndpointsForInjection().and(specification), - pageable - ), - searchPaginationInput, - Endpoint.class - ); + public Page endpoints(@RequestBody @Valid SearchPaginationInput searchPaginationInput) { + return this.endpointCriteriaBuilderService.endpointPagination(searchPaginationInput); + } + + @PostMapping(ENDPOINT_URI + "/find") + @PreAuthorize("isPlanner()") + @Transactional(readOnly = true) + public List findEndpoints(@RequestBody @Valid @NotNull final List endpointIds) { + return this.endpointCriteriaBuilderService.find(fromIds(endpointIds)); } @PutMapping(ENDPOINT_URI + "/{endpointId}") @PreAuthorize("isPlanner()") - @Transactional(rollbackOn = Exception.class) + @Transactional(rollbackFor = Exception.class) public Endpoint updateEndpoint( @PathVariable @NotBlank final String endpointId, @Valid @RequestBody final EndpointInput input) { @@ -151,7 +154,7 @@ public Endpoint updateEndpoint( @DeleteMapping(ENDPOINT_URI + "/{endpointId}") @PreAuthorize("isPlanner()") - @Transactional(rollbackOn = Exception.class) + @Transactional(rollbackFor = Exception.class) public void deleteEndpoint(@PathVariable @NotBlank final String endpointId) { this.endpointService.deleteEndpoint(endpointId); } diff --git a/openbas-api/src/main/java/io/openbas/rest/asset/endpoint/EndpointCriteriaBuilderService.java b/openbas-api/src/main/java/io/openbas/rest/asset/endpoint/EndpointCriteriaBuilderService.java new file mode 100644 index 0000000000..e7553b6292 --- /dev/null +++ b/openbas-api/src/main/java/io/openbas/rest/asset/endpoint/EndpointCriteriaBuilderService.java @@ -0,0 +1,105 @@ +package io.openbas.rest.asset.endpoint; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.openbas.database.model.Endpoint; +import io.openbas.rest.asset.endpoint.form.EndpointOutput; +import io.openbas.utils.pagination.SearchPaginationInput; +import jakarta.annotation.Resource; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.persistence.Tuple; +import jakarta.persistence.TypedQuery; +import jakarta.persistence.criteria.*; +import lombok.RequiredArgsConstructor; +import org.jetbrains.annotations.NotNull; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; + +import java.util.List; + +import static io.openbas.database.criteria.GenericCriteria.countQuery; +import static io.openbas.rest.asset.endpoint.EndpointQueryHelper.execution; +import static io.openbas.rest.asset.endpoint.EndpointQueryHelper.select; +import static io.openbas.utils.pagination.PaginationUtils.buildPaginationCriteriaBuilder; +import static io.openbas.utils.pagination.SortUtilsCriteriaBuilder.toSortCriteriaBuilder; + +@RequiredArgsConstructor +@Service +public class EndpointCriteriaBuilderService { + + @Resource + protected ObjectMapper mapper; + + @PersistenceContext + private EntityManager entityManager; + + public Page endpointPagination( + @NotNull SearchPaginationInput searchPaginationInput) { + return buildPaginationCriteriaBuilder( + this::paginate, + searchPaginationInput, + Endpoint.class + ); + } + + public List find(Specification specification) { + CriteriaBuilder cb = this.entityManager.getCriteriaBuilder(); + + CriteriaQuery cq = cb.createTupleQuery(); + Root root = cq.from(Endpoint.class); + select(cb, cq, root); + + if (specification != null) { + Predicate predicate = specification.toPredicate(root, cq, cb); + if (predicate != null) { + cq.where(predicate); + } + } + + TypedQuery query = entityManager.createQuery(cq); + return execution(query, this.mapper); + } + + // -- PRIVATE -- + + private Page paginate( + Specification specification, + Specification specificationCount, + Pageable pageable) { + CriteriaBuilder cb = this.entityManager.getCriteriaBuilder(); + + CriteriaQuery cq = cb.createTupleQuery(); + Root endpointRoot = cq.from(Endpoint.class); + select(cb, cq, endpointRoot); + + // -- Specification -- + if (specification != null) { + Predicate predicate = specification.toPredicate(endpointRoot, cq, cb); + if (predicate != null) { + cq.where(predicate); + } + } + + // -- Sorting -- + List orders = toSortCriteriaBuilder(cb, endpointRoot, pageable.getSort()); + cq.orderBy(orders); + + // Type Query + TypedQuery query = entityManager.createQuery(cq); + + // -- Pagination -- + query.setFirstResult((int) pageable.getOffset()); + query.setMaxResults(pageable.getPageSize()); + + // -- EXECUTION -- + List endpoints = execution(query, this.mapper); + + // -- Count Query -- + Long total = countQuery(cb, this.entityManager, Endpoint.class, specificationCount); + + return new PageImpl<>(endpoints, pageable, total); + } +} diff --git a/openbas-api/src/main/java/io/openbas/rest/asset/endpoint/EndpointQueryHelper.java b/openbas-api/src/main/java/io/openbas/rest/asset/endpoint/EndpointQueryHelper.java new file mode 100644 index 0000000000..c545ab8240 --- /dev/null +++ b/openbas-api/src/main/java/io/openbas/rest/asset/endpoint/EndpointQueryHelper.java @@ -0,0 +1,71 @@ +package io.openbas.rest.asset.endpoint; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.openbas.database.model.*; +import io.openbas.rest.asset.endpoint.form.EndpointOutput; +import jakarta.persistence.Tuple; +import jakarta.persistence.TypedQuery; +import jakarta.persistence.criteria.*; + +import java.time.Instant; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import static io.openbas.database.model.Asset.ACTIVE_THRESHOLD; +import static io.openbas.utils.JpaUtils.createJoinArrayAggOnId; +import static io.openbas.utils.JpaUtils.createLeftJoin; +import static java.time.Instant.now; + +public class EndpointQueryHelper { + + private EndpointQueryHelper() {} + + // -- SELECT -- + + public static void select(CriteriaBuilder cb, CriteriaQuery cq, Root endpointRoot) { + // Array aggregations + Join endpointExecutorJoin = createLeftJoin(endpointRoot, "executor"); + Expression tagIdsExpression = createJoinArrayAggOnId(cb, endpointRoot, "tags"); + + // Multiselect + cq.multiselect( + endpointRoot.get("id").alias("asset_id"), + endpointRoot.get("name").alias("asset_name"), + endpointExecutorJoin.get("id").alias("asset_executor"), + endpointRoot.get("lastSeen").alias("asset_last_seen"), + endpointRoot.get("platform").alias("endpoint_platform"), + endpointRoot.get("arch").alias("endpoint_arch"), + tagIdsExpression.alias("asset_tags") + ).distinct(true); + + // Group by + cq.groupBy(Collections.singletonList( + endpointRoot.get("id") + )); + } + + // -- EXECUTION -- + + public static List execution(TypedQuery query, ObjectMapper mapper) { + return query.getResultList() + .stream() + .map(tuple -> (EndpointOutput) EndpointOutput.builder() + .id(tuple.get("asset_id", String.class)) + .name(tuple.get("asset_name", String.class)) + .executor(tuple.get("asset_executor", String.class)) + .active(isActive(tuple.get("asset_last_seen", Instant.class))) + .tags(Arrays.stream(tuple.get("asset_tags", String[].class)).collect(Collectors.toSet())) + .platform(tuple.get("endpoint_platform", Endpoint.PLATFORM_TYPE.class)) + .arch(tuple.get("endpoint_arch", Endpoint.PLATFORM_ARCH.class)) + .build()) + .toList(); + } + + private static boolean isActive(Instant lastSeen) { + return Optional.ofNullable(lastSeen) + .map(last -> (now().toEpochMilli() - last.toEpochMilli()) < ACTIVE_THRESHOLD).orElse(false); + } +} diff --git a/openbas-api/src/main/java/io/openbas/rest/asset/endpoint/form/EndpointOutput.java b/openbas-api/src/main/java/io/openbas/rest/asset/endpoint/form/EndpointOutput.java new file mode 100644 index 0000000000..96fb138cb5 --- /dev/null +++ b/openbas-api/src/main/java/io/openbas/rest/asset/endpoint/form/EndpointOutput.java @@ -0,0 +1,22 @@ +package io.openbas.rest.asset.endpoint.form; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.openbas.database.model.Endpoint; +import io.openbas.rest.asset.form.AssetOutput; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.experimental.SuperBuilder; + +@EqualsAndHashCode(callSuper = true) +@Data +@SuperBuilder +public class EndpointOutput extends AssetOutput { + @NotNull + @JsonProperty("endpoint_platform") + private Endpoint.PLATFORM_TYPE platform; + + @NotNull + @JsonProperty("endpoint_arch") + private Endpoint.PLATFORM_ARCH arch; +} diff --git a/openbas-api/src/main/java/io/openbas/rest/asset/form/AssetOutput.java b/openbas-api/src/main/java/io/openbas/rest/asset/form/AssetOutput.java new file mode 100644 index 0000000000..b700f7f096 --- /dev/null +++ b/openbas-api/src/main/java/io/openbas/rest/asset/form/AssetOutput.java @@ -0,0 +1,29 @@ +package io.openbas.rest.asset.form; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; +import lombok.experimental.SuperBuilder; + +import java.util.Set; + +@SuperBuilder +@Data +public abstract class AssetOutput { + @NotBlank + @JsonProperty("asset_id") + private String id; + + @NotBlank + @JsonProperty("asset_name") + private String name; + + @JsonProperty("asset_executor") + private String executor; + + @JsonProperty("asset_tags") + private Set tags; + + @JsonProperty("asset_active") + private boolean active; +} diff --git a/openbas-api/src/main/java/io/openbas/rest/asset_group/AssetGroupApi.java b/openbas-api/src/main/java/io/openbas/rest/asset_group/AssetGroupApi.java index 700adf1bad..833bf3c7c0 100644 --- a/openbas-api/src/main/java/io/openbas/rest/asset_group/AssetGroupApi.java +++ b/openbas-api/src/main/java/io/openbas/rest/asset_group/AssetGroupApi.java @@ -62,8 +62,8 @@ public Page assetGroups(@RequestBody @Valid SearchPaginationIn @PostMapping(ASSET_GROUP_URI + "/find") @PreAuthorize("isObserver()") @Transactional(readOnly = true) - @Tracing(name = "Find teams", layer = "api", operation = "POST") - public List findTeams(@RequestBody @Valid @NotNull final List assetGroupIds) { + @Tracing(name = "Find asset groups", layer = "api", operation = "POST") + public List findAssetGroups(@RequestBody @Valid @NotNull final List assetGroupIds) { return this.assetGroupCriteriaBuilderService.find(fromIds(assetGroupIds)); } diff --git a/openbas-api/src/main/java/io/openbas/rest/executor/ExecutorApi.java b/openbas-api/src/main/java/io/openbas/rest/executor/ExecutorApi.java index 7a8c1d5d40..542cec4d4c 100644 --- a/openbas-api/src/main/java/io/openbas/rest/executor/ExecutorApi.java +++ b/openbas-api/src/main/java/io/openbas/rest/executor/ExecutorApi.java @@ -6,11 +6,13 @@ import io.openbas.database.model.Token; import io.openbas.database.repository.ExecutorRepository; import io.openbas.database.repository.TokenRepository; +import io.openbas.integrations.ExecutorService; import io.openbas.rest.exception.ElementNotFoundException; import io.openbas.rest.executor.form.ExecutorCreateInput; import io.openbas.rest.executor.form.ExecutorUpdateInput; import io.openbas.rest.helper.RestBehavior; import io.openbas.service.FileService; +import io.openbas.utils.FilterUtilsJpa; import jakarta.annotation.Resource; import jakarta.transaction.Transactional; import jakarta.validation.Valid; @@ -21,6 +23,7 @@ import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.access.annotation.Secured; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -29,6 +32,7 @@ import java.io.InputStream; import java.net.URL; import java.time.Instant; +import java.util.List; import java.util.Optional; import static io.openbas.asset.EndpointService.JFROG_BASE; @@ -37,12 +41,15 @@ @RestController public class ExecutorApi extends RestBehavior { + public static final String EXECUTOR_URI = "/api/executors"; + @Value("${info.app.version:unknown}") String version; private ExecutorRepository executorRepository; private EndpointService endpointService; private FileService fileService; private TokenRepository tokenRepository; + private ExecutorService executorService; @Resource protected ObjectMapper mapper; @@ -67,7 +74,12 @@ public void setExecutorRepository(ExecutorRepository executorRepository) { this.executorRepository = executorRepository; } - @GetMapping("/api/executors") + @Autowired + public void setExecutorService(ExecutorService executorService) { + this.executorService = executorService; + } + + @GetMapping(EXECUTOR_URI) public Iterable executors() { return executorRepository.findAll(); } @@ -81,14 +93,14 @@ private Executor updateExecutor(Executor executor, String type, String name, Str } @Secured(ROLE_ADMIN) - @PutMapping("/api/executors/{executorId}") + @PutMapping(EXECUTOR_URI + "/{executorId}") public Executor updateExecutor(@PathVariable String executorId, @Valid @RequestBody ExecutorUpdateInput input) { Executor executor = executorRepository.findById(executorId).orElseThrow(ElementNotFoundException::new); return updateExecutor(executor, executor.getType(), executor.getName(), executor.getPlatforms()); } @Secured(ROLE_ADMIN) - @PostMapping(value = "/api/executors", + @PostMapping(value = EXECUTOR_URI, produces = {MediaType.APPLICATION_JSON_VALUE}, consumes = {MediaType.APPLICATION_JSON_VALUE, MediaType.MULTIPART_FORM_DATA_VALUE}) @Transactional(rollbackOn = Exception.class) @@ -198,4 +210,16 @@ public Executor registerExecutor(@Valid @RequestPart("input") ExecutorCreateInpu .contentType(MediaType.TEXT_PLAIN) .body(installCommand); } + + @GetMapping(EXECUTOR_URI + "/options") + @PreAuthorize("isPlanner()") + public List optionsByName(@RequestParam(required = false) final String searchText) { + return this.executorService.optionsByName(searchText); + } + + @PostMapping(EXECUTOR_URI + "/options") + @PreAuthorize("isPlanner()") + public List optionsByIds(@RequestBody final List ids) { + return this.executorService.optionsByIds(ids); + } } diff --git a/openbas-framework/src/main/java/io/openbas/integrations/ExecutorService.java b/openbas-framework/src/main/java/io/openbas/integrations/ExecutorService.java index 741d0cedc2..f7d25a93d0 100644 --- a/openbas-framework/src/main/java/io/openbas/integrations/ExecutorService.java +++ b/openbas-framework/src/main/java/io/openbas/integrations/ExecutorService.java @@ -4,13 +4,18 @@ import io.openbas.database.model.Executor; import io.openbas.database.repository.ExecutorRepository; import io.openbas.service.FileService; +import io.openbas.utils.FilterUtilsJpa; import jakarta.annotation.Resource; import jakarta.transaction.Transactional; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import java.io.InputStream; +import java.util.List; +import static io.openbas.database.specification.ExecutorSpecification.byName; +import static io.openbas.helper.StreamHelper.fromIterable; import static io.openbas.service.FileService.EXECUTORS_IMAGES_BASE_PATH; @Service @@ -70,4 +75,20 @@ public void remove(String id) { public void removeFromType(String type) { executorRepository.findByType(type).ifPresent(executor -> executorRepository.deleteById(executor.getId())); } + + @Transactional + public List optionsByName(final String searchText) { + return fromIterable(this.executorRepository.findAll(byName(searchText), Sort.by(Sort.Direction.ASC, "name"))) + .stream() + .map(i -> new FilterUtilsJpa.Option(i.getId(), i.getName())) + .toList(); + } + + @Transactional + public List optionsByIds(final List ids) { + return fromIterable(this.executorRepository.findAllById(ids)) + .stream() + .map(i -> new FilterUtilsJpa.Option(i.getId(), i.getName())) + .toList(); + } } diff --git a/openbas-front/src/actions/assets/endpoint-actions.ts b/openbas-front/src/actions/assets/endpoint-actions.ts index 50c8a3565b..83ba8ca3e0 100644 --- a/openbas-front/src/actions/assets/endpoint-actions.ts +++ b/openbas-front/src/actions/assets/endpoint-actions.ts @@ -28,6 +28,12 @@ export const fetchEndpoints = () => (dispatch: Dispatch) => { export const searchEndpoints = (searchPaginationInput: SearchPaginationInput) => { const data = searchPaginationInput; - const uri = '/api/endpoints/search'; + const uri = `${ENDPOINT_URI}/search`; + return simplePostCall(uri, data); +}; + +export const findEndpoints = (endpointIds: string[]) => { + const data = endpointIds; + const uri = `${ENDPOINT_URI}/find`; return simplePostCall(uri, data); }; diff --git a/openbas-front/src/actions/executors/executor-actions.ts b/openbas-front/src/actions/executors/executor-actions.ts new file mode 100644 index 0000000000..91ab314e96 --- /dev/null +++ b/openbas-front/src/actions/executors/executor-actions.ts @@ -0,0 +1,12 @@ +import { simpleCall, simplePostCall } from '../../utils/Action'; + +export const EXECUTOR_URI = '/api/executors'; + +export const searchExecutorAsOption = (searchText: string = '') => { + const params = { searchText }; + return simpleCall(`${EXECUTOR_URI}/options`, params); +}; + +export const searchExecutorByIdAsOption = (ids: string[]) => { + return simplePostCall(`${EXECUTOR_URI}/options`, ids); +}; diff --git a/openbas-front/src/admin/components/assets/endpoints/EndpointPopover.tsx b/openbas-front/src/admin/components/assets/endpoints/EndpointPopover.tsx index e79c63f005..8c203deae6 100644 --- a/openbas-front/src/admin/components/assets/endpoints/EndpointPopover.tsx +++ b/openbas-front/src/admin/components/assets/endpoints/EndpointPopover.tsx @@ -2,7 +2,7 @@ import React, { useState } from 'react'; import { IconButton, Menu, MenuItem } from '@mui/material'; import { MoreVert } from '@mui/icons-material'; import { useFormatter } from '../../../../components/i18n'; -import type { EndpointInput } from '../../../../utils/api-types'; +import type { EndpointInput, EndpointOutput } from '../../../../utils/api-types'; import EndpointForm from './EndpointForm'; import { useAppDispatch } from '../../../../utils/hooks'; import { deleteEndpoint, updateEndpoint } from '../../../../actions/assets/endpoint-actions'; @@ -15,7 +15,7 @@ import type { EndpointStore } from './Endpoint'; interface Props { inline?: boolean; - endpoint: EndpointStoreWithType; + endpoint: EndpointStoreWithType | EndpointOutput; assetGroupId?: string; assetGroupEndpointIds?: string[]; onRemoveEndpointFromInject?: (assetId: string) => void; diff --git a/openbas-front/src/admin/components/assets/endpoints/Endpoints.tsx b/openbas-front/src/admin/components/assets/endpoints/Endpoints.tsx index 00b6c7a2ce..abe7414cb9 100644 --- a/openbas-front/src/admin/components/assets/endpoints/Endpoints.tsx +++ b/openbas-front/src/admin/components/assets/endpoints/Endpoints.tsx @@ -1,4 +1,4 @@ -import React, { CSSProperties, useState } from 'react'; +import React, { CSSProperties, useMemo, useState } from 'react'; import { makeStyles } from '@mui/styles'; import { List, ListItem, ListItemIcon, ListItemSecondaryAction, ListItemText } from '@mui/material'; import { DevicesOtherOutlined } from '@mui/icons-material'; @@ -8,43 +8,42 @@ import EndpointCreation from './EndpointCreation'; import EndpointPopover from './EndpointPopover'; import { useHelper } from '../../../../store'; import { useFormatter } from '../../../../components/i18n'; -import type { TagHelper, UserHelper } from '../../../../actions/helper'; +import type { UserHelper } from '../../../../actions/helper'; import type { EndpointStore } from './Endpoint'; import ItemTags from '../../../../components/ItemTags'; -import AssetStatus from '../AssetStatus'; import Breadcrumbs from '../../../../components/Breadcrumbs'; -import PaginationComponent from '../../../../components/common/pagination/PaginationComponent'; -import SortHeadersComponent from '../../../../components/common/pagination/SortHeadersComponent'; import { initSorting } from '../../../../components/common/queryable/Page'; -import type { SearchPaginationInput } from '../../../../utils/api-types'; +import type { EndpointOutput } from '../../../../utils/api-types'; import { searchEndpoints } from '../../../../actions/assets/endpoint-actions'; -import PlatformIcon from '../../../../components/PlatformIcon'; -import type { ExecutorHelper } from '../../../../actions/executors/executor-helper'; import useDataLoader from '../../../../utils/hooks/useDataLoader'; -import { fetchExecutors } from '../../../../actions/Executor'; import { fetchTags } from '../../../../actions/Tag'; import { buildSearchPagination } from '../../../../components/common/queryable/QueryableUtils'; +import { useQueryableWithLocalStorage } from '../../../../components/common/queryable/useQueryableWithLocalStorage'; +import PaginationComponentV2 from '../../../../components/common/queryable/pagination/PaginationComponentV2'; +import ExportButton from '../../../../components/common/ExportButton'; +import SortHeadersComponentV2 from '../../../../components/common/queryable/sort/SortHeadersComponentV2'; +import { Header } from '../../../../components/common/SortHeadersList'; +import { fetchExecutors } from '../../../../actions/Executor'; +import ItemExecutor from '../../../../components/ItemExecutor'; +import AssetStatus from '../AssetStatus'; +import PlatformIcon from '../../../../components/PlatformIcon'; const useStyles = makeStyles(() => ({ itemHead: { - paddingLeft: 10, textTransform: 'uppercase', - cursor: 'pointer', }, item: { - paddingLeft: 10, height: 50, }, bodyItems: { display: 'flex', - alignItems: 'center', }, bodyItem: { fontSize: 13, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', - paddingRight: 10, + boxSizing: 'content-box', }, })); @@ -70,7 +69,7 @@ const inlineStyles: Record = { asset_tags: { width: '20%', }, - asset_status: { + asset_active: { width: '15%', cursor: 'default', }, @@ -88,10 +87,8 @@ const Endpoints = () => { const [searchId] = searchParams.getAll('id'); // Fetching data - const { userAdmin, executorsMap } = useHelper((helper: ExecutorHelper & UserHelper & TagHelper) => ({ + const { userAdmin } = useHelper((helper: UserHelper) => ({ userAdmin: helper.getMe()?.user_admin ?? false, - executorsMap: helper.getExecutorsMap(), - tagsMap: helper.getTagsMap(), })); useDataLoader(() => { dispatch(fetchExecutors()); @@ -99,17 +96,60 @@ const Endpoints = () => { }); // Headers - const headers = [ - { field: 'asset_name', label: 'Name', isSortable: true }, - { field: 'endpoint_platform', label: 'Platform', isSortable: true }, - { field: 'endpoint_arch', label: 'Architecture', isSortable: true }, - { field: 'asset_executor', label: 'Executor', isSortable: true }, - { field: 'asset_tags', label: 'Tags', isSortable: true }, - { field: 'asset_status', label: 'Status', isSortable: false }, + const headers: Header[] = useMemo(() => [ + { + field: 'asset_name', + label: t('Name'), + isSortable: true, + value: (endpoint: EndpointOutput) => endpoint.asset_name, + }, + { + field: 'endpoint_platform', + label: 'Platform', + isSortable: true, + value: (endpoint: EndpointOutput) => ( + <> + {endpoint.endpoint_platform} + + ), + }, + { + field: 'endpoint_arch', + label: t('Architecture'), + isSortable: true, + value: (endpoint: EndpointOutput) => endpoint.endpoint_arch, + }, + { + field: 'asset_executor', + label: t('Executor'), + isSortable: true, + value: (endpoint: EndpointOutput) => , + }, + { + field: 'asset_tags', + label: t('Tags'), + isSortable: false, + value: (endpoint: EndpointOutput) => , + }, + { + field: 'asset_active', + label: t('Status'), + isSortable: false, + value: (endpoint: EndpointOutput) => , + }, + ], []); + + const availableFilterNames = [ + 'asset_name', + 'endpoint_platform', + 'endpoint_arch', + 'asset_executor', + 'asset_tags', + 'asset_last_seen', ]; const [endpoints, setEndpoints] = useState([]); - const [searchPaginationInput, setSearchPaginationInput] = useState(buildSearchPagination({ + const { queryableHelpers, searchPaginationInput } = useQueryableWithLocalStorage('endpoints', buildSearchPagination({ sorts: initSorting('asset_name'), textSearch: search, })); @@ -134,33 +174,37 @@ const Endpoints = () => { return ( <> - + } />  } > } /> - {endpoints.map((endpoint: EndpointStore) => { - const executor = executorsMap[endpoint.asset_executor ?? 'Unknown']; + {endpoints.map((endpoint: EndpointOutput) => { return ( { -
- {endpoint.asset_name} -
-
- {endpoint.endpoint_platform} -
-
- {endpoint.endpoint_arch} -
-
- {executor && ( - {executor.executor_type} - )} - {executor?.executor_name ?? t('Unknown')} -
-
- -
-
- -
+ {headers.map((header) => ( +
+ {header.value?.(endpoint)} +
+ ))} } /> diff --git a/openbas-front/src/admin/components/assets/endpoints/EndpointsDialogAdding.tsx b/openbas-front/src/admin/components/assets/endpoints/EndpointsDialogAdding.tsx index 1f4abc8944..fc9f2c8889 100644 --- a/openbas-front/src/admin/components/assets/endpoints/EndpointsDialogAdding.tsx +++ b/openbas-front/src/admin/components/assets/endpoints/EndpointsDialogAdding.tsx @@ -3,19 +3,14 @@ import { Box, Button, Dialog, DialogActions, DialogContent, DialogTitle } from ' import { DevicesOtherOutlined } from '@mui/icons-material'; import Transition from '../../../../components/common/Transition'; import ItemTags from '../../../../components/ItemTags'; -import type { EndpointStore } from './Endpoint'; -import { useAppDispatch } from '../../../../utils/hooks'; import { useFormatter } from '../../../../components/i18n'; -import { useHelper } from '../../../../store'; -import type { EndpointHelper } from '../../../../actions/assets/asset-helper'; -import useDataLoader from '../../../../utils/hooks/useDataLoader'; -import { fetchEndpoints, searchEndpoints } from '../../../../actions/assets/endpoint-actions'; +import { findEndpoints, searchEndpoints } from '../../../../actions/assets/endpoint-actions'; import PlatformIcon from '../../../../components/PlatformIcon'; import SelectList, { SelectListElements } from '../../../../components/common/SelectList'; import PaginationComponentV2 from '../../../../components/common/queryable/pagination/PaginationComponentV2'; import { useQueryable } from '../../../../components/common/queryable/useQueryableWithLocalStorage'; import { buildSearchPagination } from '../../../../components/common/queryable/QueryableUtils'; -import type { FilterGroup } from '../../../../utils/api-types'; +import type { EndpointOutput, FilterGroup } from '../../../../utils/api-types'; import { buildFilter } from '../../../../components/common/queryable/filter/FilterUtils'; interface Props { @@ -40,24 +35,17 @@ const EndpointsDialogAdding: FunctionComponent = ({ payloadArch, }) => { // Standard hooks - const dispatch = useAppDispatch(); const { t } = useFormatter(); - // Fetching data - const { endpointsMap } = useHelper((helper: EndpointHelper) => ({ - endpointsMap: helper.getEndpointsMap(), - })); - useDataLoader(() => { - dispatch(fetchEndpoints()); - }); - - const [endpointValues, setEndpointValues] = useState(initialState.map((id) => endpointsMap[id])); + const [endpointValues, setEndpointValues] = useState([]); useEffect(() => { - setEndpointValues(initialState.map((id) => endpointsMap[id])); + if (open) { + findEndpoints(initialState).then((result) => setEndpointValues(result.data)); + } }, [open, initialState]); - const addEndpoint = (endpointId: string) => { - setEndpointValues([...endpointValues, endpointsMap[endpointId]]); + const addEndpoint = (_endpointId: string, endpoint: EndpointOutput) => { + setEndpointValues([...endpointValues, endpoint]); }; const removeEndpoint = (endpointId: string) => { setEndpointValues(endpointValues.filter((v) => v.asset_id !== endpointId)); @@ -75,19 +63,19 @@ const EndpointsDialogAdding: FunctionComponent = ({ }; // Headers - const elements: SelectListElements = useMemo(() => ({ + const elements: SelectListElements = useMemo(() => ({ icon: { value: () => , }, headers: [ { field: 'asset_name', - value: (endpoint: EndpointStore) => endpoint.asset_name, + value: (endpoint: EndpointOutput) => endpoint.asset_name, width: 50, }, { field: 'endpoint_platform', - value: (endpoint: EndpointStore) =>
+ value: (endpoint: EndpointOutput) =>
{endpoint.endpoint_platform}
, @@ -95,19 +83,19 @@ const EndpointsDialogAdding: FunctionComponent = ({ }, { field: 'endpoint_arch', - value: (endpoint: EndpointStore) => endpoint.endpoint_arch, + value: (endpoint: EndpointOutput) => endpoint.endpoint_arch, width: 20, }, { field: 'asset_tags', - value: (endpoint: EndpointStore) => , + value: (endpoint: EndpointOutput) => , width: 30, }, ], }), []); // Pagination - const [endpoints, setEndpoints] = useState([]); + const [endpoints, setEndpoints] = useState([]); const availableFilterNames = [ 'asset_tags', diff --git a/openbas-front/src/components/ItemExecutor.tsx b/openbas-front/src/components/ItemExecutor.tsx new file mode 100644 index 0000000000..7de5685602 --- /dev/null +++ b/openbas-front/src/components/ItemExecutor.tsx @@ -0,0 +1,33 @@ +import React, { FunctionComponent } from 'react'; +import type { Executor } from '../utils/api-types'; +import { useFormatter } from './i18n'; +import { useHelper } from '../store'; +import type { ExecutorHelper } from '../actions/executors/executor-helper'; + +interface ItemExecutorProps { + executorId: string | null; +} + +const ItemExecutor: FunctionComponent = ({ + executorId, +}) => { + const { t } = useFormatter(); + const { executorsMap }: { executorsMap: Record } = useHelper((helper: ExecutorHelper) => ({ + executorsMap: helper.getExecutorsMap(), + })); + const executor = executorId ? executorsMap[executorId] : null; + return ( + <> + {executor && ( + {executor.executor_type} + )} + {executor?.executor_name ?? t('Unknown')} + + ); +}; + +export default ItemExecutor; diff --git a/openbas-front/src/components/common/queryable/filter/useRetrieveOptions.tsx b/openbas-front/src/components/common/queryable/filter/useRetrieveOptions.tsx index 598700cf35..3e28b29112 100644 --- a/openbas-front/src/components/common/queryable/filter/useRetrieveOptions.tsx +++ b/openbas-front/src/components/common/queryable/filter/useRetrieveOptions.tsx @@ -6,6 +6,7 @@ import { searchTagByIdAsOption } from '../../../../actions/tags/tag-action'; import { searchScenarioByIdAsOption } from '../../../../actions/scenarios/scenario-actions'; import { searchAttackPatternsByIdAsOption } from '../../../../actions/AttackPattern'; import { searchOrganizationByIdAsOptions } from '../../../../actions/organizations/organization-actions'; +import { searchExecutorByIdAsOption } from '../../../../actions/executors/executor-actions'; const useRetrieveOptions = () => { const [options, setOptions] = useState([]); @@ -53,6 +54,11 @@ const useRetrieveOptions = () => { setOptions(response.data); }); break; + case 'asset_executor': + searchExecutorByIdAsOption(ids).then((response) => { + setOptions(response.data); + }); + break; default: setOptions(ids.map((id) => ({ id, label: id }))); break; diff --git a/openbas-front/src/components/common/queryable/filter/useSearchOptions.tsx b/openbas-front/src/components/common/queryable/filter/useSearchOptions.tsx index bdbee8aaa5..6acdaff19c 100644 --- a/openbas-front/src/components/common/queryable/filter/useSearchOptions.tsx +++ b/openbas-front/src/components/common/queryable/filter/useSearchOptions.tsx @@ -7,6 +7,7 @@ import { searchScenarioAsOption, searchScenarioCategoryAsOption } from '../../.. import { searchAttackPatternsByNameAsOption } from '../../../../actions/AttackPattern'; import { useFormatter } from '../../../i18n'; import { searchOrganizationsByNameAsOption } from '../../../../actions/organizations/organization-actions'; +import { searchExecutorAsOption } from '../../../../actions/executors/executor-actions'; const useSearchOptions = () => { // Standard hooks @@ -62,6 +63,11 @@ const useSearchOptions = () => { setOptions(response.data.map((d) => ({ id: d.id, label: t(d.label) }))); }); break; + case 'asset_executor': + searchExecutorAsOption(search).then((response) => { + setOptions(response.data); + }); + break; default: } }; diff --git a/openbas-front/src/utils/Localization.js b/openbas-front/src/utils/Localization.js index 8789eb0e53..ceb6b12bd4 100644 --- a/openbas-front/src/utils/Localization.js +++ b/openbas-front/src/utils/Localization.js @@ -871,6 +871,8 @@ const i18n = { OR: 'OU', // Asset asset_tags: 'Tags', + asset_name: 'Nom', + asset_executor: 'Exécuteur', // Asset Group asset_group_name: 'Nom', asset_group_description: 'Description', @@ -1118,6 +1120,7 @@ const i18n = { 'Last simulations': 'Dernières simulations', Collectors: 'Collecteurs', 'Updated at': 'Mis à jour à', + Executor: 'Exécuteur', Executors: 'Exécuteurs', Injectors: 'Injecteurs', InjectorContracts: "Contrats d'injecteur", @@ -2474,6 +2477,7 @@ const i18n = { 'Last simulations': '最后模拟', Collectors: '收集器', 'Updated at': '更新于', + Executor: '执行者', Executors: '执行者', Injectors: '注入者', InjectorContracts: '注入者合约', @@ -2784,6 +2788,8 @@ const i18n = { // -- FILTERS -- // Asset asset_tags: 'Tags', + asset_name: 'Name', + asset_executor: 'Executor', // Asset Group asset_group_name: 'Name', asset_group_description: 'Description', diff --git a/openbas-front/src/utils/api-types.d.ts b/openbas-front/src/utils/api-types.d.ts index 2f7824239f..84e562202c 100644 --- a/openbas-front/src/utils/api-types.d.ts +++ b/openbas-front/src/utils/api-types.d.ts @@ -612,6 +612,18 @@ export interface Endpoint { listened?: boolean; } + +export interface EndpointOutput { + asset_id: string; + asset_name: string; + asset_executor?: string; + asset_active?: boolean; + /** @uniqueItems true */ + asset_tags?: Tag[]; + endpoint_platform: "Linux" | "Windows" | "MacOS" | "Container" | "Service" | "Generic" | "Internal" | "Unknown"; + endpoint_arch: "x86_64" | "arm64" | "Unknown"; +} + export interface EndpointInput { asset_description?: string; /** @format date-time */ @@ -630,6 +642,25 @@ export interface EndpointInput { endpoint_platform: "Linux" | "Windows" | "MacOS" | "Container" | "Service" | "Generic" | "Internal" | "Unknown"; } +export interface PageEndpointOutput { + content?: EndpointOutput[]; + empty?: boolean; + first?: boolean; + last?: boolean; + /** @format int32 */ + number?: number; + /** @format int32 */ + numberOfElements?: number; + pageable?: PageableObject; + /** @format int32 */ + size?: number; + sort?: SortObject[]; + /** @format int64 */ + totalElements?: number; + /** @format int32 */ + totalPages?: number; +} + export interface EndpointRegisterInput { asset_description?: string; asset_external_reference: string; diff --git a/openbas-model/src/main/java/io/openbas/database/model/Asset.java b/openbas-model/src/main/java/io/openbas/database/model/Asset.java index a6edd7dbfa..c6b0f559a6 100644 --- a/openbas-model/src/main/java/io/openbas/database/model/Asset.java +++ b/openbas-model/src/main/java/io/openbas/database/model/Asset.java @@ -47,7 +47,7 @@ public class Asset implements Base { @Setter(NONE) private String type; - @Queryable(searchable = true, sortable = true) + @Queryable(searchable = true, filterable = true, sortable = true) @Column(name = "asset_name") @JsonProperty("asset_name") @NotBlank @@ -58,6 +58,7 @@ public class Asset implements Base { @JsonProperty("asset_description") private String description; + @Queryable(filterable = true) @Column(name = "asset_last_seen") @JsonProperty("asset_last_seen") private Instant lastSeen; @@ -76,7 +77,7 @@ public class Asset implements Base { // -- TAG -- - @Queryable(filterable = true, sortable = true, dynamicValues = true, path = "tags.id") + @Queryable(searchable = true, filterable = true, sortable = true, dynamicValues = true, path = "tags.id") @ManyToMany(fetch = FetchType.LAZY) @JoinTable(name = "assets_tags", joinColumns = @JoinColumn(name = "asset_id"), @@ -85,7 +86,7 @@ public class Asset implements Base { @JsonProperty("asset_tags") private Set tags = new HashSet<>(); - @Queryable(sortable = true) + @Queryable(searchable = true, filterable = true, sortable = true, dynamicValues = true) @ManyToOne(fetch = FetchType.EAGER) @JoinColumn(name = "asset_executor") @JsonSerialize(using = MonoIdDeserializer.class) diff --git a/openbas-model/src/main/java/io/openbas/database/model/Endpoint.java b/openbas-model/src/main/java/io/openbas/database/model/Endpoint.java index 6fb96e738f..a94adcd0b2 100644 --- a/openbas-model/src/main/java/io/openbas/database/model/Endpoint.java +++ b/openbas-model/src/main/java/io/openbas/database/model/Endpoint.java @@ -67,14 +67,14 @@ public enum PLATFORM_TYPE { @JsonProperty("endpoint_agent_version") private String agentVersion; - @Queryable(filterable = true, sortable = true) + @Queryable(filterable = true, sortable = true, searchable = true) @Column(name = "endpoint_platform") @JsonProperty("endpoint_platform") @Enumerated(EnumType.STRING) @NotNull private PLATFORM_TYPE platform; - @Queryable(filterable = true, sortable = true) + @Queryable(filterable = true, sortable = true, searchable = true) @Column(name = "endpoint_arch") @JsonProperty("endpoint_arch") @Enumerated(EnumType.STRING) diff --git a/openbas-model/src/main/java/io/openbas/database/repository/ExecutorRepository.java b/openbas-model/src/main/java/io/openbas/database/repository/ExecutorRepository.java index feba5ad329..72081bf70e 100644 --- a/openbas-model/src/main/java/io/openbas/database/repository/ExecutorRepository.java +++ b/openbas-model/src/main/java/io/openbas/database/repository/ExecutorRepository.java @@ -2,13 +2,14 @@ import io.openbas.database.model.Executor; import jakarta.validation.constraints.NotNull; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.repository.CrudRepository; import org.springframework.stereotype.Repository; import java.util.Optional; @Repository -public interface ExecutorRepository extends CrudRepository { +public interface ExecutorRepository extends CrudRepository, JpaSpecificationExecutor { @NotNull Optional findById(@NotNull String id); diff --git a/openbas-model/src/main/java/io/openbas/database/specification/EndpointSpecification.java b/openbas-model/src/main/java/io/openbas/database/specification/EndpointSpecification.java index 292880d045..1e8ff697a6 100644 --- a/openbas-model/src/main/java/io/openbas/database/specification/EndpointSpecification.java +++ b/openbas-model/src/main/java/io/openbas/database/specification/EndpointSpecification.java @@ -1,8 +1,11 @@ package io.openbas.database.specification; import io.openbas.database.model.Endpoint; +import org.jetbrains.annotations.NotNull; import org.springframework.data.jpa.domain.Specification; +import java.util.List; + public class EndpointSpecification { public static Specification findEndpointsForInjection() { return (root, query, criteriaBuilder) -> criteriaBuilder.and(criteriaBuilder.isNull(root.get("parent")), criteriaBuilder.isNull(root.get("inject"))); @@ -11,4 +14,8 @@ public static Specification findEndpointsForInjection() { public static Specification findEndpointsForExecution() { return (root, query, criteriaBuilder) -> criteriaBuilder.or(criteriaBuilder.isNotNull(root.get("parent")), criteriaBuilder.isNotNull(root.get("inject"))); } + + public static Specification fromIds(@NotNull final List ids) { + return (root, query, builder) -> root.get("id").in(ids); + } } diff --git a/openbas-model/src/main/java/io/openbas/database/specification/ExecutorSpecification.java b/openbas-model/src/main/java/io/openbas/database/specification/ExecutorSpecification.java new file mode 100644 index 0000000000..e5f723f2e3 --- /dev/null +++ b/openbas-model/src/main/java/io/openbas/database/specification/ExecutorSpecification.java @@ -0,0 +1,11 @@ +package io.openbas.database.specification; + +import io.openbas.database.model.Executor; +import jakarta.annotation.Nullable; +import org.springframework.data.jpa.domain.Specification; + +public class ExecutorSpecification { + public static Specification byName(@Nullable final String searchText) { + return UtilsSpecification.byName(searchText, "name"); + } +}