Introduce target grouping (#2538)

* Introduce target grouping

Signed-off-by: strailov <Stanislav.Trailov@bosch.io>

* minor refactor

Signed-off-by: strailov <Stanislav.Trailov@bosch.io>

* throw validation exception instead direct returning bad request response

Signed-off-by: strailov <Stanislav.Trailov@bosch.io>

* fix group query parameter

Signed-off-by: strailov <Stanislav.Trailov@bosch.io>

* remove wrongly added import

Signed-off-by: strailov <Stanislav.Trailov@bosch.io>

* add review fixes

Signed-off-by: strailov <Stanislav.Trailov@bosch.io>

* apply latest review changes

Signed-off-by: strailov <Stanislav.Trailov@bosch.io>

* apply latest changes after sybnc/review

Signed-off-by: strailov <Stanislav.Trailov@bosch.io>

* fix after review

Signed-off-by: strailov <Stanislav.Trailov@bosch.io>

---------

Signed-off-by: strailov <Stanislav.Trailov@bosch.io>
This commit is contained in:
Stanislav Trailov
2025-07-14 15:48:37 +03:00
committed by GitHub
parent e7373275bf
commit b4793fcce1
23 changed files with 888 additions and 2 deletions

View File

@@ -29,6 +29,7 @@ public enum TargetFields implements RsqlQueryField {
UPDATESTATUS("updateStatus"),
IPADDRESS("address"),
ATTRIBUTE("controllerAttributes"),
GROUP("group"),
ASSIGNEDDS(
"assignedDistributionSet",
DistributionSetFields.NAME.getJpaEntityFieldName(), DistributionSetFields.VERSION.getJpaEntityFieldName()),

View File

@@ -90,6 +90,9 @@ public class MgmtTarget extends MgmtNamedEntity {
@Schema(description = "Controller ID", example = "123")
private String controllerId;
@Schema(description = "Target group", example = "Europe/East")
private String group;
@Schema(description = "If the target is in sync", example = "in_sync")
private String updateStatus;

View File

@@ -42,4 +42,7 @@ public class MgmtTargetRequestBody {
@Schema(description = "ID of the target type", example = "10")
private Long targetType;
@Schema(description = "Target group", example = "Asia")
private String group;
}

View File

@@ -46,6 +46,10 @@ public final class MgmtRestConstants {
* The target type URL mapping rest resource.
*/
public static final String TARGETTYPE_V1_REQUEST_MAPPING = BASE_V1_REQUEST_MAPPING + "/targettypes";
/**
* The target group URL mapping rest resource.
*/
public static final String TARGET_GROUP_V1_REQUEST_MAPPING = BASE_V1_REQUEST_MAPPING + "/targetgroups";
/**
* The tag URL mapping rest resource.
*/
@@ -162,6 +166,10 @@ public final class MgmtRestConstants {
* The tag URL mapping rest resource.
*/
public static final String DISTRIBUTIONSET_TAG_DISTRIBUTIONSETS_REQUEST_MAPPING = "/{distributionsetTagId}/assigned";
/**
* Target group URL mapping rest resource
*/
public static final String TARGET_GROUP_TARGETS_REQUEST_MAPPING = "/{group}/assigned";
/**
* The default offset parameter in case the offset parameter is not present in the request.
*
@@ -233,6 +241,7 @@ public final class MgmtRestConstants {
public static final String TARGET_TAG_ORDER = "2000";
public static final String TARGET_TYPE_ORDER = "3000";
public static final String TARGET_FILTER_ORDER = "4000";
public static final String TARGET_GROUP_ORDER = "4500";
public static final String ACTION_ORDER = "5000";
public static final String ROLLOUT_ORDER = "6000";
public static final String DISTRIBUTION_SET_ORDER = "7000";

View File

@@ -0,0 +1,370 @@
/**
* Copyright (c) 2025 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.mgmt.rest.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.extensions.Extension;
import io.swagger.v3.oas.annotations.extensions.ExtensionProperty;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.eclipse.hawkbit.mgmt.json.model.PagedList;
import org.eclipse.hawkbit.mgmt.json.model.target.MgmtTarget;
import org.eclipse.hawkbit.rest.OpenApi;
import org.eclipse.hawkbit.rest.json.model.ExceptionInfo;
import org.springframework.hateoas.MediaTypes;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import java.util.List;
import static org.eclipse.hawkbit.mgmt.rest.api.MgmtRestConstants.TARGET_GROUP_ORDER;
@Tag(
name = "Target Groups", description = "REST API for Target Groups operations.",
extensions = @Extension(name = OpenApi.X_HAWKBIT, properties = @ExtensionProperty(name = "order", value = TARGET_GROUP_ORDER)))
public interface MgmtTargetGroupRestApi {
/**
* Handles the GET request of retrieving a list of assigned targets for a specific group. Complex grouping (subgroups) not supported here.
* For complex grouping use the analogical resource with query parameter for target group.
*
* @param group - target group
* @param pagingOffsetParam the offset of list of targets for pagination, might not be present in the rest request then default value will
* be applied
* @param pagingLimitParam the limit of the paged request, might not be present in the rest request then default value will be applied
* @param sortParam the sorting parameter in the request URL, syntax {@code field:direction, field:direction}
* @return a list of targets matching the provided group for a defined or default page request with status OK. The response is always paged. In any failure the
* JsonResponseExceptionHandler is handling the response.
*/
@Operation(summary = "Return assigned targets for group",
description = "Handles the GET request of retrieving a list of assigned targets for a specific group. Complex grouping (subgroups) not supported here." +
"For complex grouping use the analogical resource with query parameter for target group.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Successfully retrieved"),
@ApiResponse(responseCode = "400", description = "Bad Request - e.g. invalid parameters",
content = @Content(mediaType = "application/json", schema = @Schema(implementation = ExceptionInfo.class))),
@ApiResponse(responseCode = "401", description = "The request requires user authentication."),
@ApiResponse(responseCode = "403", description = "Insufficient permissions, entity is not allowed to be " +
"changed (i.e. read-only) or data volume restriction applies."),
@ApiResponse(responseCode = "405", description = "The http request method is not allowed on the resource."),
@ApiResponse(responseCode = "406", description = "In case accept header is specified and not application/json."),
@ApiResponse(responseCode = "429", description = "Too many requests. The server will refuse further attempts " +
"and the client has to wait another second.")
})
@GetMapping(value = MgmtRestConstants.TARGET_GROUP_V1_REQUEST_MAPPING + MgmtRestConstants.TARGET_GROUP_TARGETS_REQUEST_MAPPING,
produces = { MediaTypes.HAL_JSON_VALUE, MediaType.APPLICATION_JSON_VALUE })
ResponseEntity<PagedList<MgmtTarget>> getAssignedTargets(
@PathVariable
@Schema(description = "The target group of the targets")
String group,
@RequestParam(
value = MgmtRestConstants.REQUEST_PARAMETER_PAGING_OFFSET,
defaultValue = MgmtRestConstants.REQUEST_PARAMETER_PAGING_DEFAULT_OFFSET)
@Schema(description = "The paging offset (default is 0)")
int pagingOffsetParam,
@RequestParam(
value = MgmtRestConstants.REQUEST_PARAMETER_PAGING_LIMIT,
defaultValue = MgmtRestConstants.REQUEST_PARAMETER_PAGING_DEFAULT_LIMIT)
@Schema(description = "The maximum number of entries in a page (default is 50)")
int pagingLimitParam,
@RequestParam(value = MgmtRestConstants.REQUEST_PARAMETER_SORTING, required = false)
@Schema(description = """
The query parameter sort allows to define the sort order for the result of a query. A sort criteria
consists of the name of a field and the sort direction (ASC for ascending and DESC descending).
The sequence of the sort criteria (multiple can be used) defines the sort order of the entities
in the result.""")
String sortParam
);
/**
* Handles the GET request of retrieving a list of assigned targets for a specific group. Complex grouping (subgroups) is supported here.
* Search could be for specific group, complex group e.g Parent/Child or also for groups including its subgroups
*
* @param groupFilter - An Actual group - Parent/Child or Parent
* @param subgroups - If set to {@code true} enables the search in subgroups
* @param pagingOffsetParam the offset of list of targets for pagination, might not be present in the rest request then default value will
* be applied
* @param pagingLimitParam the limit of the paged request, might not be present in the rest request then default value will be applied
* @param sortParam the sorting parameter in the request URL, syntax {@code field:direction, field:direction}
* @return a list of targets matching the provided group for a defined or default page request with status OK. The response is always paged. In any failure the
* JsonResponseExceptionHandler is handling the response.
*/
@Operation(summary = "Return assigned targets for group",
description = "Handles the GET request of retrieving a list of assigned targets for a specific group. Complex grouping (subgroups) is supported here." +
"Search could be for specific group, complex group e.g Parent/Child or also for groups including its subgroups")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Successfully retrieved"),
@ApiResponse(responseCode = "400", description = "Bad Request - e.g. invalid parameters",
content = @Content(mediaType = "application/json", schema = @Schema(implementation = ExceptionInfo.class))),
@ApiResponse(responseCode = "401", description = "The request requires user authentication."),
@ApiResponse(responseCode = "403", description = "Insufficient permissions, entity is not allowed to be " +
"changed (i.e. read-only) or data volume restriction applies."),
@ApiResponse(responseCode = "405", description = "The http request method is not allowed on the resource."),
@ApiResponse(responseCode = "406", description = "In case accept header is specified and not application/json."),
@ApiResponse(responseCode = "429", description = "Too many requests. The server will refuse further attempts " +
"and the client has to wait another second.")
})
@GetMapping(value = MgmtRestConstants.TARGET_GROUP_V1_REQUEST_MAPPING + "/assigned")
ResponseEntity<PagedList<MgmtTarget>> getAssignedTargetsWithSubgroups(
@RequestParam(value = "group")
@Schema(description = "Target Group or Filter based on target groups. ")
String groupFilter,
@RequestParam(value = "subgroups", defaultValue = "false")
@Schema(description = " Possibility to search for subgroups with wildcard or not - e.g. ParentGroup/*")
boolean subgroups,
@RequestParam(
value = MgmtRestConstants.REQUEST_PARAMETER_PAGING_OFFSET,
defaultValue = MgmtRestConstants.REQUEST_PARAMETER_PAGING_DEFAULT_OFFSET)
@Schema(description = "The paging offset (default is 0)")
int pagingOffsetParam,
@RequestParam(
value = MgmtRestConstants.REQUEST_PARAMETER_PAGING_LIMIT,
defaultValue = MgmtRestConstants.REQUEST_PARAMETER_PAGING_DEFAULT_LIMIT)
@Schema(description = "The maximum number of entries in a page (default is 50)")
int pagingLimitParam,
@RequestParam(value = MgmtRestConstants.REQUEST_PARAMETER_SORTING, required = false)
@Schema(description = """
The query parameter sort allows to define the sort order for the result of a query. A sort criteria
consists of the name of a field and the sort direction (ASC for ascending and DESC descending).
The sequence of the sort criteria (multiple can be used) defines the sort order of the entities
in the result.""")
String sortParam
);
/**
* Assigns targets to a given group.
* For complex groups use analogical method with query parameters.
*
* @param group - target group to be assigned
* @param controllerIds - list of controllerIds for targets to be assigned
*
*/
@Operation(summary = "Assign target(s) to given group",
description = "Handles the POST request of target assignment. Already assigned target will be ignored. " +
"For complex groups use analogical method with query parameters.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Successfully assigned"),
@ApiResponse(responseCode = "400", description = "Bad Request - e.g. invalid parameters",
content = @Content(mediaType = "application/json", schema = @Schema(implementation = ExceptionInfo.class))),
@ApiResponse(responseCode = "401", description = "The request requires user authentication."),
@ApiResponse(responseCode = "403", description = "Insufficient permissions, entity is not allowed to be " +
"changed (i.e. read-only) or data volume restriction applies."),
@ApiResponse(responseCode = "405", description = "The http request method is not allowed on the resource."),
@ApiResponse(responseCode = "406", description = "In case accept header is specified and not application/json."),
@ApiResponse(responseCode = "409", description = "E.g. in case an entity is created or modified by another " +
"user in another request at the same time. You may retry your modification request."),
@ApiResponse(responseCode = "415", description = "The request was attempt with a media-type which is not " +
"supported by the server for this resource."),
@ApiResponse(responseCode = "429", description = "Too many requests. The server will refuse further attempts " +
"and the client has to wait another second.")
})
@PutMapping(value = MgmtRestConstants.TARGET_GROUP_V1_REQUEST_MAPPING + MgmtRestConstants.TARGET_GROUP_TARGETS_REQUEST_MAPPING)
ResponseEntity<Void> assignTargetsToGroup(
@PathVariable(value = "group")
@Schema(description = "The target group to be set. Sub-grouping not allowed here, for sub-grouping use the analogical method with query parameter.")
String group,
@Schema(description = "List of controller ids to be assigned", example = "[\"controllerId1\", \"controllerId2\"]")
@RequestBody List<String> controllerIds
);
/**
* Assigns targets to a given group.
* Complex (subgroups) are allowed - e.g. Parent/Child
*
* @param group - target group to be assigned
* @param controllerIds - list of controllerIds for targets to be assigned
*
*/
@Operation(summary = "Assign target(s) to given group",
description = "Handles the PUT request of assign target group." +
"Subgroups are allowed - e.g. Parent/Child")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Successfully assigned"),
@ApiResponse(responseCode = "400", description = "Bad Request - e.g. invalid parameters",
content = @Content(mediaType = "application/json", schema = @Schema(implementation = ExceptionInfo.class))),
@ApiResponse(responseCode = "401", description = "The request requires user authentication."),
@ApiResponse(responseCode = "403", description = "Insufficient permissions, entity is not allowed to be " +
"changed (i.e. read-only) or data volume restriction applies."),
@ApiResponse(responseCode = "405", description = "The http request method is not allowed on the resource."),
@ApiResponse(responseCode = "406", description = "In case accept header is specified and not application/json."),
@ApiResponse(responseCode = "409", description = "E.g. in case an entity is created or modified by another " +
"user in another request at the same time. You may retry your modification request."),
@ApiResponse(responseCode = "415", description = "The request was attempt with a media-type which is not " +
"supported by the server for this resource."),
@ApiResponse(responseCode = "429", description = "Too many requests. The server will refuse further attempts " +
"and the client has to wait another second.")
})
@PutMapping(value = MgmtRestConstants.TARGET_GROUP_V1_REQUEST_MAPPING + "/assigned")
ResponseEntity<Void> assignTargetsToGroupWithSubgroups(
@RequestParam("group")
@Schema(description = "The target group to be set. Sub-grouping is allowed here - '/' could be used for subgroups")
final String group,
@Schema(description = "List of controller ids to be assigned", example = "[\"controllerId1\", \"controllerId2\"]")
@RequestBody List<String> controllerIds
);
/**
* Assigns targets to a given group.
* Complex (subgroups) are allowed - e.g. Parent/Child
*
* @param group - target group to be assigned
* @param rsql - filter to match desired targets
*
*/
@Operation(summary = "Assign target(s) to given group by rsql",
description = "Handles the PUT request of target group assignment." +
"Subgroups are NOT allowed here - e.g. Parent/Child")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Successfully assigned"),
@ApiResponse(responseCode = "400", description = "Bad Request - e.g. invalid parameters",
content = @Content(mediaType = "application/json", schema = @Schema(implementation = ExceptionInfo.class))),
@ApiResponse(responseCode = "401", description = "The request requires user authentication."),
@ApiResponse(responseCode = "403", description = "Insufficient permissions, entity is not allowed to be " +
"changed (i.e. read-only) or data volume restriction applies."),
@ApiResponse(responseCode = "405", description = "The http request method is not allowed on the resource."),
@ApiResponse(responseCode = "406", description = "In case accept header is specified and not application/json."),
@ApiResponse(responseCode = "409", description = "E.g. in case an entity is created or modified by another " +
"user in another request at the same time. You may retry your modification request."),
@ApiResponse(responseCode = "415", description = "The request was attempt with a media-type which is not " +
"supported by the server for this resource."),
@ApiResponse(responseCode = "429", description = "Too many requests. The server will refuse further attempts " +
"and the client has to wait another second.")
})
@PutMapping(value = MgmtRestConstants.TARGET_GROUP_V1_REQUEST_MAPPING + "/{group}")
ResponseEntity<Void> assignTargetsToGroupWithRsql(
@PathVariable final String group,
@RequestParam(value = MgmtRestConstants.REQUEST_PARAMETER_SEARCH)
@Schema(description = """
Query fields based on the Feed Item Query Language (FIQL). See Entity Definitions for
available fields.""")
final String rsql
);
/**
* Unassigns targets from their groups
*
* @param controllerIds - list of targets to be unassigned.
*/
@Operation(summary = "Unassign targets from their target groups",
description = "Handles the DELETE request to unassign the given target(s).")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Successfully retrieved"),
@ApiResponse(responseCode = "400", description = "Bad Request - e.g. invalid parameters",
content = @Content(mediaType = "application/json", schema = @Schema(implementation = ExceptionInfo.class))),
@ApiResponse(responseCode = "401", description = "The request requires user authentication."),
@ApiResponse(responseCode = "403", description = "Insufficient permissions, entity is not allowed to be " +
"changed (i.e. read-only) or data volume restriction applies."),
@ApiResponse(responseCode = "405", description = "The http request method is not allowed on the resource."),
@ApiResponse(responseCode = "406", description = "In case accept header is specified and not application/json."),
@ApiResponse(responseCode = "429", description = "Too many requests. The server will refuse further attempts " +
"and the client has to wait another second.")
})
@DeleteMapping(value = MgmtRestConstants.TARGET_GROUP_V1_REQUEST_MAPPING + "/assigned")
ResponseEntity<Void> unassignTargetsFromGroup(
@RequestBody(required = false)
@Schema(description = "List of controller ids to be unassigned from their groups", example = "[\"controllerId1\", \"controllerId2\"]")
List<String> controllerIds
);
/**
* Unassigns targets from their groups
*
* @param rsql - filter for the matching targets to be unassigned
*/
@Operation(summary = "Unassign targets from their target groups",
description = "Handles the DELETE request to unassign the given target(s).")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Successfully retrieved"),
@ApiResponse(responseCode = "400", description = "Bad Request - e.g. invalid parameters",
content = @Content(mediaType = "application/json", schema = @Schema(implementation = ExceptionInfo.class))),
@ApiResponse(responseCode = "401", description = "The request requires user authentication."),
@ApiResponse(responseCode = "403", description = "Insufficient permissions, entity is not allowed to be " +
"changed (i.e. read-only) or data volume restriction applies."),
@ApiResponse(responseCode = "405", description = "The http request method is not allowed on the resource."),
@ApiResponse(responseCode = "406", description = "In case accept header is specified and not application/json."),
@ApiResponse(responseCode = "429", description = "Too many requests. The server will refuse further attempts " +
"and the client has to wait another second.")
})
@DeleteMapping(value = MgmtRestConstants.TARGET_GROUP_V1_REQUEST_MAPPING)
ResponseEntity<Void> unassignTargetsFromGroupByRsql(
@RequestParam(value = MgmtRestConstants.REQUEST_PARAMETER_SEARCH, required = false)
@Schema(description = """
Query fields based on the Feed Item Query Language (FIQL). See Entity Definitions for
available fields.""")
final String rsql
);
/**
*
* @return list of all assigned target groups
*/
@Operation(summary = "Return all assigned target groups",
description = "Handles the GET request of retrieving a list of all target groups.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Successfully retrieved"),
@ApiResponse(responseCode = "401", description = "The request requires user authentication."),
@ApiResponse(responseCode = "403", description = "Insufficient permissions, entity is not allowed to be " +
"changed (i.e. read-only) or data volume restriction applies."),
@ApiResponse(responseCode = "405", description = "The http request method is not allowed on the resource."),
@ApiResponse(responseCode = "406", description = "In case accept header is specified and not application/json."),
@ApiResponse(responseCode = "429", description = "Too many requests. The server will refuse further attempts " +
"and the client has to wait another second.")
})
@GetMapping(value = MgmtRestConstants.TARGET_GROUP_V1_REQUEST_MAPPING,
produces = { MediaTypes.HAL_JSON_VALUE, MediaType.APPLICATION_JSON_VALUE })
ResponseEntity<List<String>> getTargetGroups();
/**
* Assign targets matching a rsql filter to a provided target group
* @param group - target group to be assigned
* @param rsqlParam - rsql filter based on Target fields
*/
@Operation(summary = "Assign targets matching a rsql filter to provided target group",
description = "Handles the GET request of assigning targets matching a rsql filter to a provided target group" +
"Subgroups are allowed - e.g. Parent/Child")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Successfully assigned"),
@ApiResponse(responseCode = "400", description = "Bad Request - e.g. invalid parameters",
content = @Content(mediaType = "application/json", schema = @Schema(implementation = ExceptionInfo.class))),
@ApiResponse(responseCode = "401", description = "The request requires user authentication."),
@ApiResponse(responseCode = "403", description = "Insufficient permissions, entity is not allowed to be " +
"changed (i.e. read-only) or data volume restriction applies."),
@ApiResponse(responseCode = "405", description = "The http request method is not allowed on the resource."),
@ApiResponse(responseCode = "406", description = "In case accept header is specified and not application/json."),
@ApiResponse(responseCode = "409", description = "E.g. in case an entity is created or modified by another " +
"user in another request at the same time. You may retry your modification request."),
@ApiResponse(responseCode = "415", description = "The request was attempt with a media-type which is not " +
"supported by the server for this resource."),
@ApiResponse(responseCode = "429", description = "Too many requests. The server will refuse further attempts " +
"and the client has to wait another second.")
})
@PutMapping(value = MgmtRestConstants.TARGET_GROUP_V1_REQUEST_MAPPING)
ResponseEntity<Void> assignTargetsToGroup(
@RequestParam(name = "group")
@Schema(description = "The target group to be set. Sub-grouping is allowed here - '/' could be used for subgroups")
final String group,
@RequestParam(value = MgmtRestConstants.REQUEST_PARAMETER_SEARCH)
@Schema(description = """
Query fields based on the Feed Item Query Language (FIQL). See Entity Definitions for
available fields.""")
String rsqlParam);
}

View File

@@ -0,0 +1,109 @@
/**
* Copyright (c) 2025 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.mgmt.rest.resource;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.hawkbit.mgmt.json.model.PagedList;
import org.eclipse.hawkbit.mgmt.json.model.target.MgmtTarget;
import org.eclipse.hawkbit.mgmt.rest.api.MgmtTargetGroupRestApi;
import org.eclipse.hawkbit.mgmt.rest.resource.mapper.MgmtTargetMapper;
import org.eclipse.hawkbit.mgmt.rest.resource.util.PagingUtility;
import org.eclipse.hawkbit.repository.TargetManagement;
import org.eclipse.hawkbit.repository.TenantConfigurationManagement;
import org.eclipse.hawkbit.repository.model.Target;
import org.eclipse.hawkbit.security.SystemSecurityContext;
import org.eclipse.hawkbit.utils.TenantConfigHelper;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import static org.eclipse.hawkbit.mgmt.rest.resource.util.PagingUtility.sanitizeTargetSortParam;
@Slf4j
@RestController
public class MgmtTargetGroupResource implements MgmtTargetGroupRestApi {
private final TargetManagement targetManagement;
private final TenantConfigHelper tenantConfigHelper;
public MgmtTargetGroupResource(final TargetManagement targetManagement, final SystemSecurityContext systemSecurityContext,
final TenantConfigurationManagement tenantConfigurationManagement) {
this.targetManagement = targetManagement;
this.tenantConfigHelper = TenantConfigHelper.usingContext(systemSecurityContext, tenantConfigurationManagement);
}
@Override
public ResponseEntity<PagedList<MgmtTarget>> getAssignedTargets(String group, int pagingOffsetParam, int pagingLimitParam, String sortParam) {
final Pageable pageable = PagingUtility.toPageable(pagingOffsetParam, pagingLimitParam, sanitizeTargetSortParam(sortParam));
final Page<Target> targets = targetManagement.findTargetsByGroup(group, false, pageable);
final List<MgmtTarget> rest = MgmtTargetMapper.toResponse(targets.getContent(), tenantConfigHelper);
return ResponseEntity.ok(new PagedList<>(rest, targets.getTotalElements()));
}
@Override
public ResponseEntity<PagedList<MgmtTarget>> getAssignedTargetsWithSubgroups(String groupFilter, boolean subgroups, int pagingOffsetParam, int pagingLimitParam, String sortParam) {
final Pageable pageable = PagingUtility.toPageable(pagingOffsetParam, pagingLimitParam, sanitizeTargetSortParam(sortParam));
final Page<Target> targets = targetManagement.findTargetsByGroup(groupFilter, subgroups, pageable);
final List<MgmtTarget> rest = MgmtTargetMapper.toResponse(targets.getContent(), tenantConfigHelper);
return ResponseEntity.ok(new PagedList<>(rest, targets.getTotalElements()));
}
@Override
public ResponseEntity<Void> assignTargetsToGroup(String group, List<String> controllerIds) {
return assignTargets(group, controllerIds);
}
@Override
public ResponseEntity<Void> assignTargetsToGroupWithSubgroups(String group, List<String> controllerIds) {
return assignTargets(group, controllerIds);
}
@Override
public ResponseEntity<Void> assignTargetsToGroupWithRsql(String group, String rsql) {
targetManagement.assignTargetGroupWithRsql(group, rsql);
return ResponseEntity.ok().build();
}
@Override
public ResponseEntity<Void> unassignTargetsFromGroup(List<String> controllerIds) {
targetManagement.assignTargetsWithGroup(null, controllerIds);
return ResponseEntity.ok().build();
}
@Override
public ResponseEntity<Void> unassignTargetsFromGroupByRsql(String rsql) {
targetManagement.assignTargetGroupWithRsql(null, rsql);
return ResponseEntity.ok().build();
}
@Override
public ResponseEntity<List<String>> getTargetGroups() {
final List<String> groups = targetManagement.findGroups();
return ResponseEntity.ok(groups);
}
@Override
public ResponseEntity<Void> assignTargetsToGroup(final String group, final String rsql) {
targetManagement.assignTargetGroupWithRsql(group, rsql);
return ResponseEntity.ok().build();
}
private ResponseEntity<Void> assignTargets(final String group, final List<String> controllerIds) {
targetManagement.assignTargetsWithGroup(group, controllerIds);
return ResponseEntity.ok().build();
}
}

View File

@@ -140,6 +140,7 @@ public final class MgmtTargetMapper {
targetRest.setDescription(target.getDescription());
targetRest.setName(target.getName());
targetRest.setUpdateStatus(target.getUpdateStatus().name().toLowerCase());
targetRest.setGroup(target.getGroup());
final URI address = target.getAddress();
if (address != null) {
@@ -325,7 +326,7 @@ public final class MgmtTargetMapper {
private static TargetCreate fromRequest(final EntityFactory entityFactory, final MgmtTargetRequestBody targetRest) {
return entityFactory.target().create().controllerId(targetRest.getControllerId()).name(targetRest.getName())
.description(targetRest.getDescription()).securityToken(targetRest.getSecurityToken())
.address(targetRest.getAddress()).targetType(targetRest.getTargetType());
.address(targetRest.getAddress()).targetType(targetRest.getTargetType()).group(targetRest.getGroup());
}
private static String getType(final Action action) {

View File

@@ -0,0 +1,236 @@
/**
* Copyright (c) 2025 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.mgmt.rest.resource;
import org.eclipse.hawkbit.mgmt.rest.api.MgmtRestConstants;
import org.eclipse.hawkbit.rest.util.JsonBuilder;
import org.hamcrest.Matchers;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
public class MgmtTargetGroupResourceTest extends AbstractManagementApiIntegrationTest {
@Test
void shouldRetrieveDistinctTargetGroups() throws Exception {
final List<String> expectedGroups = List.of("Europe", "Asia");
targetManagement.create(entityFactory.target().create().controllerId("target1").group("Europe"));
targetManagement.create(entityFactory.target().create().controllerId("target2").group("Asia"));
targetManagement.create(entityFactory.target().create().controllerId("target3").group("Europe"));
mvc.perform(get(MgmtRestConstants.TARGET_GROUP_V1_REQUEST_MAPPING)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.[0]", Matchers.in(expectedGroups)))
.andExpect(jsonPath("$.[1]", Matchers.in(expectedGroups)));
}
@Test
void shouldRetrieveTargetsFilteredByGroupAndParentGroupCorrectly() throws Exception {
targetManagement.create(entityFactory.target().create().controllerId("target1").group("Europe/West"));
targetManagement.create(entityFactory.target().create().controllerId("target2").group("Europe/East"));
targetManagement.create(entityFactory.target().create().controllerId("target3").group("Europe"));
mvc.perform(get(MgmtRestConstants.TARGET_GROUP_V1_REQUEST_MAPPING + "/assigned")
.param("group", "Europe/East")
.param("subgroups", "false"))
.andExpect(status().isOk())
.andExpect(jsonPath("content", Matchers.hasSize(1)))
.andExpect(jsonPath("content.[0].controllerId", Matchers.equalTo("target2")));
mvc.perform(get(MgmtRestConstants.TARGET_GROUP_V1_REQUEST_MAPPING + "/assigned")
.param("group", "Europe")
.param("subgroups", "true")
.param(MgmtRestConstants.REQUEST_PARAMETER_SORTING, "ID:ASC"))
.andExpect(status().isOk())
.andExpect(jsonPath("content", Matchers.hasSize(3)))
.andExpect(jsonPath("content.[0].controllerId", Matchers.equalTo("target1")))
.andExpect(jsonPath("content.[1].controllerId", Matchers.equalTo("target2")))
.andExpect(jsonPath("content.[2].controllerId", Matchers.equalTo("target3")));
}
@Test
void shouldGetAssignedTargetsToSpecificGroup() throws Exception {
targetManagement.create(entityFactory.target().create().controllerId("target1").group("Europe"));
targetManagement.create(entityFactory.target().create().controllerId("target2").group("US"));
targetManagement.create(entityFactory.target().create().controllerId("target3").group("Europe"));
mvc.perform(get(MgmtRestConstants.TARGET_GROUP_V1_REQUEST_MAPPING + "/Europe/assigned")
.param(MgmtRestConstants.REQUEST_PARAMETER_SORTING, "ID:ASC"))
.andExpect(status().isOk())
.andExpect(jsonPath("content", Matchers.hasSize(2)))
.andExpect(jsonPath("content.[0].controllerId", Matchers.equalTo("target1")))
.andExpect(jsonPath("content.[1].controllerId", Matchers.equalTo("target3")));
}
@Test
void shouldAssignListOfTargetsToASpecificGroup() throws Exception {
targetManagement.create(entityFactory.target().create().controllerId("target1").group("Europe"));
targetManagement.create(entityFactory.target().create().controllerId("target2"));
targetManagement.create(entityFactory.target().create().controllerId("target3").group("Europe"));
mvc.perform(put(MgmtRestConstants.TARGET_GROUP_V1_REQUEST_MAPPING + "/newGroup/assigned")
.contentType(MediaType.APPLICATION_JSON)
.content(JsonBuilder.toArray(Arrays.asList("target1", "target2", "target3"))))
.andExpect(status().isOk());
mvc.perform(get(MgmtRestConstants.TARGET_GROUP_V1_REQUEST_MAPPING + "/newGroup/assigned")
.param(MgmtRestConstants.REQUEST_PARAMETER_SORTING, "ID:ASC")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("content", Matchers.hasSize(3)))
.andExpect(jsonPath("content.[0].controllerId", Matchers.equalTo("target1")))
.andExpect(jsonPath("content.[1].controllerId", Matchers.equalTo("target2")))
.andExpect(jsonPath("content.[2].controllerId", Matchers.equalTo("target3")));
}
@Test
void shouldReturnBadRequestWhenProvidingAnEmptyListOfControllerIds() throws Exception {
mvc.perform(put(MgmtRestConstants.TARGET_GROUP_V1_REQUEST_MAPPING + "/someGroup/assigned")
.contentType(MediaType.APPLICATION_JSON)
.content(JsonBuilder.toArray(Collections.emptyList())))
.andExpect(status().isBadRequest());
}
@Test
void shouldAssignListOfTargetsToProvidedGroupWithSubgroup() throws Exception {
targetManagement.create(entityFactory.target().create().controllerId("target1").group("Europe"));
targetManagement.create(entityFactory.target().create().controllerId("target2"));
targetManagement.create(entityFactory.target().create().controllerId("target3").group("US"));
mvc.perform(put(MgmtRestConstants.TARGET_GROUP_V1_REQUEST_MAPPING + "/assigned")
.contentType(MediaType.APPLICATION_JSON)
.content(JsonBuilder.toArray(Arrays.asList("target1", "target2", "target3")))
.param("group", "Europe/East"))
.andExpect(status().isOk());
mvc.perform(get(MgmtRestConstants.TARGET_GROUP_V1_REQUEST_MAPPING + "/assigned")
.param(MgmtRestConstants.REQUEST_PARAMETER_SORTING, "ID:ASC")
.param("group", "Europe/East")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("content", Matchers.hasSize(3)))
.andExpect(jsonPath("content.[0].controllerId", Matchers.equalTo("target1")))
.andExpect(jsonPath("content.[1].controllerId", Matchers.equalTo("target2")))
.andExpect(jsonPath("content.[2].controllerId", Matchers.equalTo("target3")));
// expect bad request if empty controllerIds
mvc.perform(put(MgmtRestConstants.TARGET_GROUP_V1_REQUEST_MAPPING + "/assigned")
.contentType(MediaType.APPLICATION_JSON)
.content(JsonBuilder.toArray(Collections.emptyList()))
.param("group", "doesNotMatter"))
.andExpect(status().isBadRequest());
}
@Test
void shouldAssignTargetsToProvidedGroupByRsql() throws Exception {
targetManagement.create(entityFactory.target().create().controllerId("target1").group("A"));
targetManagement.create(entityFactory.target().create().controllerId("target2"));
targetManagement.create(entityFactory.target().create().controllerId("shouldNotAssign").group("B"));
mvc.perform(put(MgmtRestConstants.TARGET_GROUP_V1_REQUEST_MAPPING + "/C")
.contentType(MediaType.APPLICATION_JSON)
.param("q", "controllerId==target*"))
.andExpect(status().isOk());
mvc.perform(get(MgmtRestConstants.TARGET_GROUP_V1_REQUEST_MAPPING + "/assigned")
.param(MgmtRestConstants.REQUEST_PARAMETER_SORTING, "ID:ASC")
.param("group", "C")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("content", Matchers.hasSize(2)))
.andExpect(jsonPath("content.[0].controllerId", Matchers.equalTo("target1")))
.andExpect(jsonPath("content.[1].controllerId", Matchers.equalTo("target2")));
}
@Test
void shouldUnassignTargetsFromGroup() throws Exception {
targetManagement.create(entityFactory.target().create().controllerId("target1").group("Europe"));
targetManagement.create(entityFactory.target().create().controllerId("target2").group("Europe"));
mvc.perform(delete(MgmtRestConstants.TARGET_GROUP_V1_REQUEST_MAPPING + "/assigned")
.contentType(MediaType.APPLICATION_JSON)
.content(JsonBuilder.toArray(Arrays.asList("target1", "target2"))))
.andExpect(status().isOk());
mvc.perform(get(MgmtRestConstants.TARGET_GROUP_V1_REQUEST_MAPPING + "/assigned")
.param(MgmtRestConstants.REQUEST_PARAMETER_SORTING, "ID:ASC")
.param("group", "Europe")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("content", Matchers.hasSize(0)));
// expect bad request if empty
mvc.perform(delete(MgmtRestConstants.TARGET_GROUP_V1_REQUEST_MAPPING + "/assigned")
.contentType(MediaType.APPLICATION_JSON)
.content(JsonBuilder.toArray(Collections.emptyList())))
.andExpect(status().isBadRequest());
}
@Test
void shouldUnassignTargetsFromGroupByRsqlFilter() throws Exception {
targetManagement.create(entityFactory.target().create().controllerId("target1").group("Europe"));
targetManagement.create(entityFactory.target().create().controllerId("target2").group("Europe"));
targetManagement.create(entityFactory.target().create().controllerId("nonMatchingTarget").group("Europe"));
mvc.perform(delete(MgmtRestConstants.TARGET_GROUP_V1_REQUEST_MAPPING)
.contentType(MediaType.APPLICATION_JSON)
.param("q", "controllerId==target*"))
.andExpect(status().isOk());
mvc.perform(get(MgmtRestConstants.TARGET_GROUP_V1_REQUEST_MAPPING + "/assigned")
.param(MgmtRestConstants.REQUEST_PARAMETER_SORTING, "ID:ASC")
.param("group", "Europe")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("content", Matchers.hasSize(1)))
.andExpect(jsonPath("content.[0].controllerId", Matchers.equalTo("nonMatchingTarget")));
}
@Test
void shouldUpdateTargetGroupsOfTargetsMatchingTheRsqlFilter() throws Exception {
targetManagement.create(entityFactory.target().create().controllerId("target1").group("Europe"));
targetManagement.create(entityFactory.target().create().controllerId("target2").group("Europe"));
targetManagement.create(entityFactory.target().create().controllerId("target3").group("Europe"));
targetManagement.create(entityFactory.target().create().controllerId("shouldNotBeUpdated1").group("Europe"));
targetManagement.create(entityFactory.target().create().controllerId("shouldNotBeUpdated2").group("Europe"));
mvc.perform(put(MgmtRestConstants.TARGET_GROUP_V1_REQUEST_MAPPING)
.contentType(MediaType.APPLICATION_JSON)
.param("group", "Europe/East")
.param("q", "controllerId==target*"))
.andExpect(status().isOk());
mvc.perform(get(MgmtRestConstants.TARGET_GROUP_V1_REQUEST_MAPPING + "/assigned")
.param("group", "Europe/East")
.param(MgmtRestConstants.REQUEST_PARAMETER_SORTING, "ID:ASC")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("content", Matchers.hasSize(3)))
.andExpect(jsonPath("content.[0].controllerId", Matchers.equalTo("target1")))
.andExpect(jsonPath("content.[1].controllerId", Matchers.equalTo("target2")))
.andExpect(jsonPath("content.[2].controllerId", Matchers.equalTo("target3")));
}
}

View File

@@ -620,6 +620,41 @@ public interface TargetManagement {
@PreAuthorize(HAS_AUTH_UPDATE_TARGET)
Target assignType(@NotEmpty String controllerId, @NotNull Long targetTypeId);
/**
* Finds targets by group or subgroup.
* @param group - provided group/subgroup to filter for
* @param withSubgroups - whether is a subgroup or not e.g. x/y/z
* @param pageable - page parameter
* @return all matching targets to provided group/subgroup
*/
@PreAuthorize(SpringEvalExpressions.HAS_AUTH_READ_TARGET)
Page<Target> findTargetsByGroup(@NotEmpty String group, boolean withSubgroups, @NotNull Pageable pageable);
/**
* Finds all the distinct target groups in the scope of a tenant
*
* @return list of all distinct target groups
*/
@PreAuthorize(SpringEvalExpressions.HAS_AUTH_READ_TARGET)
List<String> findGroups();
/**
* Assigns the target group of the targets matching the provided rsql filter.
*
* @param group target group parameter
* @param rsql rsql filter for {@link Target}
*/
@PreAuthorize(SpringEvalExpressions.HAS_AUTH_UPDATE_TARGET)
void assignTargetGroupWithRsql(String group, @NotNull String rsql);
/**
* Assigns the provided group to the targets which are in the provided list of controllerIds.
*
* @param group target group parameter
* @param controllerIds list of targets
*/
@PreAuthorize(SpringEvalExpressions.HAS_AUTH_UPDATE_TARGET)
void assignTargetsWithGroup(String group, @NotEmpty List<String> controllerIds);
/**
* updates the {@link Target}.
*

View File

@@ -73,6 +73,12 @@ public interface TargetCreate {
*/
TargetCreate status(@NotNull TargetUpdateStatus status);
/**
* @param group for setting the group of the target
* @return updated builder instance
*/
TargetCreate group(String group);
/**
* @return peek on current state of {@link Target} in the builder
*/

View File

@@ -70,4 +70,11 @@ public interface TargetUpdate {
* @return updated builder instance
*/
TargetUpdate requestAttributes(Boolean requestAttributes);
/**
*
* @param group for {@link Target#getGroup()}
* @return updated builder instance
*/
TargetUpdate group(String group);
}

View File

@@ -124,4 +124,10 @@ public interface Target extends NamedEntity {
* {@link #getControllerAttributes()}.
*/
boolean isRequestControllerAttributes();
/**
*
* @return the group of the target
*/
String getGroup();
}

View File

@@ -34,6 +34,7 @@ public class AbstractTargetUpdateCreate<T> extends AbstractNamedEntityBuilder<T>
protected TargetUpdateStatus status;
protected Boolean requestAttributes;
protected Long targetTypeId;
protected String group;
protected AbstractTargetUpdateCreate(final String controllerId) {
this.controllerId = AbstractBaseEntityBuilder.strip(controllerId);
@@ -83,6 +84,11 @@ public class AbstractTargetUpdateCreate<T> extends AbstractNamedEntityBuilder<T>
return (T) this;
}
public T group(final String group) {
this.group = group;
return (T) this;
}
public String getControllerId() {
return controllerId;
}
@@ -107,4 +113,6 @@ public class AbstractTargetUpdateCreate<T> extends AbstractNamedEntityBuilder<T>
return targetTypeId;
}
public Optional<String> getGroup() { return Optional.ofNullable(group); }
}

View File

@@ -0,0 +1,2 @@
ALTER TABLE sp_target ADD COLUMN target_group VARCHAR(256);
CREATE INDEX sp_idx_target_group ON sp_target (tenant, target_group);

View File

@@ -0,0 +1,2 @@
ALTER TABLE sp_target ADD COLUMN target_group VARCHAR(256);
CREATE INDEX sp_idx_target_group ON sp_target (tenant, target_group);

View File

@@ -0,0 +1,2 @@
ALTER TABLE sp_target ADD COLUMN target_group VARCHAR(256);
CREATE INDEX sp_idx_target_group ON sp_target (tenant, target_group);

View File

@@ -53,6 +53,7 @@ public class JpaTargetCreate extends AbstractTargetUpdateCreate<TargetCreate> im
target.setAddress(address);
target.setUpdateStatus(getStatus().orElse(TargetUpdateStatus.UNKNOWN));
getLastTargetQuery().ifPresent(target::setLastTargetQuery);
target.setGroup(getGroup().orElse(null));
return target;
}

View File

@@ -28,7 +28,9 @@ import java.util.stream.Collectors;
import jakarta.persistence.EntityManager;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.CriteriaQuery;
import jakarta.persistence.criteria.CriteriaUpdate;
import jakarta.persistence.criteria.MapJoin;
import jakarta.persistence.criteria.Predicate;
import jakarta.persistence.criteria.Root;
import jakarta.persistence.metamodel.MapAttribute;
import jakarta.validation.constraints.NotEmpty;
@@ -562,6 +564,57 @@ public class JpaTargetManagement implements TargetManagement {
return targetRepository.save(target);
}
@Override
public Page<Target> findTargetsByGroup(String group, final boolean withSubgroups, final Pageable pageable) {
if (withSubgroups) {
// search for eq(group) and like(group%)
return JpaManagementHelper
.findAllWithCountBySpec(targetRepository, List.of(TargetSpecifications.eqOrSubTargetGroup(group)), pageable);
} else {
return JpaManagementHelper
.findAllWithCountBySpec(targetRepository, List.of(TargetSpecifications.eqTargetGroup(group)), pageable);
}
}
@Override
public List<String> findGroups() {
return targetRepository.findDistinctGroups();
}
@Override
@Transactional
@Retryable(retryFor = { ConcurrencyFailureException.class }, maxAttempts = Constants.TX_RT_MAX,
backoff = @Backoff(delay = Constants.TX_RT_DELAY))
public void assignTargetGroupWithRsql(String group, String rsql) {
final Specification<JpaTarget> rsqlSpecification = RsqlUtility.getInstance().buildRsqlSpecification(rsql, TargetFields.class);
final CriteriaBuilder cb = entityManager.getCriteriaBuilder();
final CriteriaUpdate<JpaTarget> criteriaUpdateQuery = cb.createCriteriaUpdate(JpaTarget.class);
final Root<JpaTarget> root = criteriaUpdateQuery.getRoot();
criteriaUpdateQuery.set("group", group);
// get predicate from rsql specification using a dummy query in order to execute batch update
final Predicate predicate = rsqlSpecification.toPredicate(root, entityManager.getCriteriaBuilder().createQuery(JpaTarget.class), cb);
criteriaUpdateQuery.where(predicate);
entityManager.createQuery(criteriaUpdateQuery).executeUpdate();
}
@Override
@Transactional
@Retryable(retryFor = { ConcurrencyFailureException.class }, maxAttempts = Constants.TX_RT_MAX,
backoff = @Backoff(delay = Constants.TX_RT_DELAY))
public void assignTargetsWithGroup(String group, List<String> controllerIds) {
final CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaUpdate<JpaTarget> criteriaQuery = cb.createCriteriaUpdate(JpaTarget.class);
Root<JpaTarget> root = criteriaQuery.from(JpaTarget.class);
CriteriaBuilder.In<String> in = cb.in(root.get("controllerId"));
controllerIds.forEach(in::value);
entityManager.createQuery(criteriaQuery.set("group", group).where(in)).executeUpdate();
}
@Override
@Transactional
@Retryable(retryFor = { ConcurrencyFailureException.class }, maxAttempts = Constants.TX_RT_MAX,

View File

@@ -154,6 +154,11 @@ public class JpaTarget extends AbstractJpaNamedEntity implements Target, EventAw
// set default request controller attributes to true, because we want to request them the first time
private boolean requestControllerAttributes = true;
@Setter
@Getter
@Column(name = "target_group")
private String group;
// actually it is OneToOne - but lazy loading is not supported for OneToOne (at least for hibernate 6.6.2)
@OneToMany(fetch = FetchType.LAZY, mappedBy = "target", cascade = { CascadeType.ALL }, orphanRemoval = true)
@PrimaryKeyJoinColumn

View File

@@ -10,6 +10,7 @@
package org.eclipse.hawkbit.repository.jpa.repository;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import jakarta.persistence.EntityManager;
@@ -96,4 +97,11 @@ public interface TargetRepository extends BaseEntityRepository<JpaTarget> {
@Transactional
@Query("DELETE FROM JpaTarget t WHERE t.tenant = :tenant")
void deleteByTenant(@Param("tenant") String tenant);
/**
* Finds all available distinct target groups
* @return all target groups
*/
@Query(value = "SELECT DISTINCT target_group FROM sp_target WHERE target_group IS NOT NULL", nativeQuery = true)
List<String> findDistinctGroups();
}

View File

@@ -192,6 +192,22 @@ public final class TargetSpecifications {
};
}
public static Specification<JpaTarget> eqTargetGroup(final String targetGroup) {
return (targetRoot, query, criteriaBuilder) -> {
final String groupTextToLower = targetGroup.toLowerCase();
return criteriaBuilder.equal(criteriaBuilder.lower(targetRoot.get(JpaTarget_.group)), groupTextToLower);
};
}
public static Specification<JpaTarget> eqOrSubTargetGroup(final String targetGroupSearch) {
return (targetRoot, query, criteriaBuilder) -> {
final String searchTextToLower = targetGroupSearch.toLowerCase();
return criteriaBuilder.or(
criteriaBuilder.equal(criteriaBuilder.lower(targetRoot.get(JpaTarget_.group)), searchTextToLower),
criteriaBuilder.like(criteriaBuilder.lower(targetRoot.get(JpaTarget_.group)), searchTextToLower.concat("/%")));
};
}
/**
* {@link Specification} for retrieving {@link Target}s by "like controllerId".
*

View File

@@ -21,6 +21,7 @@ public interface Constants {
String VERSION = "Version";
String VENDOR = "Vendor";
String TYPE = "Type";
String GROUP = "Group";
String CREATED_BY = "Created by";
String CREATED_AT = "Created at";
String LAST_MODIFIED_BY = "Last modified by";

View File

@@ -345,6 +345,7 @@ public class TargetView extends TableView<MgmtTarget, String> {
private final TextField lastModifiedAt = Utils.textField(Constants.LAST_MODIFIED_AT);
private final TextField securityToken = Utils.textField(Constants.SECURITY_TOKEN);
private final TextField lastPoll = Utils.textField(Constants.LAST_POLL);
private final TextField group = Utils.textField(Constants.GROUP);
private final TextArea targetAttributes = new TextArea(Constants.ATTRIBUTES);
private transient MgmtTarget target;
@@ -355,7 +356,7 @@ public class TargetView extends TableView<MgmtTarget, String> {
description,
createdBy, createdAt,
lastModifiedBy, lastModifiedAt,
securityToken, lastPoll, targetAttributes
securityToken, lastPoll, targetAttributes, group
)
.forEach(field -> {
field.setReadOnly(true);
@@ -378,6 +379,7 @@ public class TargetView extends TableView<MgmtTarget, String> {
lastModifiedBy.setValue(target.getLastModifiedBy());
lastModifiedAt.setValue(new Date(target.getLastModifiedAt()).toString());
securityToken.setValue(target.getSecurityToken());
group.setValue(target.getGroup() != null ? target.getGroup() : "");
final MgmtPollStatus pollStatus = target.getPollStatus();
lastPoll.setValue(pollStatus == null ? NOT_AVAILABLE_NULL : new Date(pollStatus.getLastRequestAt()).toString());