From 16243394c340085d8988fa65089138dc0bc0ac34 Mon Sep 17 00:00:00 2001 From: imbeacon Date: Tue, 5 Aug 2025 15:03:53 +0300 Subject: [PATCH 01/22] Updated environmental variable names in docker compose file for gateway launch command --- .../DeviceConnectivityControllerTest.java | 7 ++++--- .../server/dao/util/DeviceConnectivityUtil.java | 14 ++++++++------ 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/controller/DeviceConnectivityControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/DeviceConnectivityControllerTest.java index fdf54d0109..724b1c622f 100644 --- a/application/src/test/java/org/thingsboard/server/controller/DeviceConnectivityControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/DeviceConnectivityControllerTest.java @@ -321,9 +321,10 @@ public class DeviceConnectivityControllerTest extends AbstractControllerTest { "\n" + " # Environment variables\n" + " environment:\n" + - " - host=host.docker.internal\n" + - " - port=1883\n" + - " - accessToken=" + credentials.getCredentialsId() + "\n" + + " - TB_GW_HOST=host.docker.internal\n" + + " - TB_GW_PORT=1883\n" + + " - TB_GW_SECURITY_TYPE=accessToken\n" + + " - TB_GW_ACCESS_TOKEN=" + credentials.getCredentialsId() + "\n" + "\n" + " # Volumes bind\n" + " volumes:\n" + diff --git a/dao/src/main/java/org/thingsboard/server/dao/util/DeviceConnectivityUtil.java b/dao/src/main/java/org/thingsboard/server/dao/util/DeviceConnectivityUtil.java index cae2d56ae5..46a7b650f7 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/util/DeviceConnectivityUtil.java +++ b/dao/src/main/java/org/thingsboard/server/dao/util/DeviceConnectivityUtil.java @@ -117,24 +117,26 @@ public class DeviceConnectivityUtil { dockerComposeBuilder.append("\n"); dockerComposeBuilder.append(" # Environment variables\n"); dockerComposeBuilder.append(" environment:\n"); - dockerComposeBuilder.append(" - host=").append(isLocalhost(host) ? HOST_DOCKER_INTERNAL : host).append("\n"); - dockerComposeBuilder.append(" - port=1883\n"); + dockerComposeBuilder.append(" - TB_GW_HOST=").append(isLocalhost(host) ? HOST_DOCKER_INTERNAL : host).append("\n"); + dockerComposeBuilder.append(" - TB_GW_PORT=1883\n"); switch (deviceCredentials.getCredentialsType()) { case ACCESS_TOKEN: - dockerComposeBuilder.append(" - accessToken=").append(deviceCredentials.getCredentialsId()).append("\n"); + dockerComposeBuilder.append(" - TB_GW_SECURITY_TYPE=accessToken\n"); + dockerComposeBuilder.append(" - TB_GW_ACCESS_TOKEN=").append(deviceCredentials.getCredentialsId()).append("\n"); break; case MQTT_BASIC: + dockerComposeBuilder.append(" - TB_GW_SECURITY_TYPE=usernamePassword\n"); BasicMqttCredentials credentials = JacksonUtil.fromString(deviceCredentials.getCredentialsValue(), BasicMqttCredentials.class); if (credentials != null) { if (StringUtils.isNotEmpty(credentials.getClientId())) { - dockerComposeBuilder.append(" - clientId=").append(credentials.getClientId()).append("\n"); + dockerComposeBuilder.append(" - TB_GW_CLIENT_ID=").append(credentials.getClientId()).append("\n"); } if (StringUtils.isNotEmpty(credentials.getUserName())) { - dockerComposeBuilder.append(" - username=").append(credentials.getUserName()).append("\n"); + dockerComposeBuilder.append(" - TB_GW_USERNAME=").append(credentials.getUserName()).append("\n"); } if (StringUtils.isNotEmpty(credentials.getPassword())) { - dockerComposeBuilder.append(" - password=").append(credentials.getPassword()).append("\n"); + dockerComposeBuilder.append(" - TB_GW_PASSWORD=").append(credentials.getPassword()).append("\n"); } } break; From 57527f2c970a842af37ea3231ca0f3e760ff1519 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Wed, 22 Oct 2025 10:33:56 +0300 Subject: [PATCH 02/22] Upload resource using multipart endpoint --- .../controller/TbResourceController.java | 67 +++++++++++++++++ .../resource/DefaultTbResourceService.java | 2 +- .../service/resource/TbResourceService.java | 2 +- .../controller/TbResourceControllerTest.java | 71 +++++++++++-------- .../server/common/data/TbResourceInfo.java | 2 + .../dao/resource/BaseResourceService.java | 7 +- .../validator/ResourceDataValidator.java | 9 ++- 7 files changed, 122 insertions(+), 38 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java b/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java index 54d2494679..2ff54604c7 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java @@ -17,6 +17,7 @@ package org.thingsboard.server.controller; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -32,12 +33,16 @@ 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.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; +import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.ResourceSubType; import org.thingsboard.server.common.data.ResourceType; import org.thingsboard.server.common.data.TbResource; @@ -67,6 +72,7 @@ import java.util.List; import java.util.Set; import java.util.UUID; +import static org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE; import static org.thingsboard.server.controller.ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER; import static org.thingsboard.server.controller.ControllerConstants.LWM2M_OBJECT_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS; @@ -215,6 +221,7 @@ public class TbResourceController extends BaseController { "\n\nResource combination of the title with the key is unique in the scope of tenant. " + "Remove 'id', 'tenantId' from the request body example (below) to create new Resource entity." + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) + @Deprecated // resource should be save or update with an upload request @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") @PostMapping(value = "/resource") public TbResourceInfo saveResource(@Parameter(description = "A JSON value representing the Resource.") @@ -224,6 +231,66 @@ public class TbResourceController extends BaseController { return tbResourceService.save(resource, getCurrentUser()); } + @ApiOperation(value = "Upload Resource via Multipart File (uploadResource)", + notes = "Upload the Resource using multipart file upload. " + + "When creating the Resource, platform generates Resource id as " + UUID_WIKI_LINK + + "The newly created Resource id will be present in the response. " + + "Specify existing Resource id to update the Resource. " + + "Referencing non-existing Resource Id will cause 'Not Found' error. " + + "\n\nResource combination of the title with the key is unique in the scope of tenant. " + + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH, + requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody(content = @Content(mediaType = MULTIPART_FORM_DATA_VALUE))) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + @PostMapping(value = "/resource/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public TbResourceInfo uploadResource(@Parameter(description = RESOURCE_ID_PARAM_DESCRIPTION) + @RequestParam(name = RESOURCE_ID, required = false) UUID resourceId, + @Parameter(description = "Resource title.", example = "Title") + @RequestParam(name = "title", required = false) String title, + @Parameter(description = "Resource type.", schema = @Schema(implementation = ResourceType.class, nullable = true, example = "GENERAL")) + @RequestParam(name = "resourceType") ResourceType resourceType, + @Parameter(description = "Resource descriptor (JSON).") + @RequestParam(name = "descriptor", required = false) String descriptor, + @Parameter(description = "Resource search text.") + @RequestParam(name = "searchText", required = false) String searchText, + @Parameter(description = "Resource file.") + @RequestPart MultipartFile file) throws Exception { + TbResource resource = new TbResource(); + resource.setTenantId(getTenantId()); + resource.setId(resourceId != null ? new TbResourceId(resourceId) : null); + resource.setTitle(StringUtils.isNotEmpty(title) ? title : file.getOriginalFilename()); + resource.setResourceType(resourceType); + + if (StringUtils.isNotEmpty(descriptor)) { + resource.setDescriptor(JacksonUtil.toJsonNode(descriptor)); + } else { + String mediaType = resourceType.getMediaType() != null ? resourceType.getMediaType() : file.getContentType(); + resource.setDescriptor(JacksonUtil.newObjectNode().put("mediaType", mediaType)); + } + + resource.setSearchText(StringUtils.isNotEmpty(searchText) ? searchText : resource.getTitle()); + resource.setFileName(file.getOriginalFilename()); + resource.setData(file.getBytes()); + + checkEntity(resource.getId(), resource, Resource.TB_RESOURCE); + return tbResourceService.save(resource, getCurrentUser()); + } + + @ApiOperation(value = "Update Resource title", + notes = "Updates the title of the existing Resource by resourceId. " + + "Only the title can be updated. " + + "Referencing a non-existing Resource Id will cause a 'Not Found' error. " + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + @PutMapping(value = "/resource/{id}/title") + public TbResourceInfo updateResourceTitle(@Parameter(description = "Unique identifier of the Resource to update", required = true) + @PathVariable UUID id, + @Parameter(description = "New title for the Resource", example = "Title", required = true) + @RequestBody String title) throws Exception { + TbResourceId tbResourceId = new TbResourceId(id); + TbResource resourceInfo = new TbResource(checkResourceInfoId(tbResourceId, Operation.WRITE)); + resourceInfo.setTitle(title); + return tbResourceService.save(resourceInfo, getCurrentUser()); + } + @ApiOperation(value = "Get Resource Infos (getResources)", notes = "Returns a page of Resource Info objects owned by tenant or sysadmin. " + PAGE_DATA_PARAMETERS + RESOURCE_INFO_DESCRIPTION + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) diff --git a/application/src/main/java/org/thingsboard/server/service/resource/DefaultTbResourceService.java b/application/src/main/java/org/thingsboard/server/service/resource/DefaultTbResourceService.java index 28ec9af4b2..43d243eeb8 100644 --- a/application/src/main/java/org/thingsboard/server/service/resource/DefaultTbResourceService.java +++ b/application/src/main/java/org/thingsboard/server/service/resource/DefaultTbResourceService.java @@ -75,7 +75,7 @@ public class DefaultTbResourceService extends AbstractTbEntityService implements ActionType actionType = resource.getId() == null ? ActionType.ADDED : ActionType.UPDATED; TenantId tenantId = resource.getTenantId(); try { - if (ResourceType.LWM2M_MODEL.equals(resource.getResourceType())) { + if (ResourceType.LWM2M_MODEL.equals(resource.getResourceType()) && resource.getId() == null) { toLwm2mResource(resource); } else if (resource.getResourceKey() == null) { resource.setResourceKey(resource.getFileName()); diff --git a/application/src/main/java/org/thingsboard/server/service/resource/TbResourceService.java b/application/src/main/java/org/thingsboard/server/service/resource/TbResourceService.java index 2e6e43e7bf..6ae186b83f 100644 --- a/application/src/main/java/org/thingsboard/server/service/resource/TbResourceService.java +++ b/application/src/main/java/org/thingsboard/server/service/resource/TbResourceService.java @@ -19,8 +19,8 @@ import org.thingsboard.server.common.data.Dashboard; import org.thingsboard.server.common.data.ResourceExportData; import org.thingsboard.server.common.data.TbResource; import org.thingsboard.server.common.data.TbResourceDeleteResult; -import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.TbResourceInfo; +import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.lwm2m.LwM2mObject; diff --git a/application/src/test/java/org/thingsboard/server/controller/TbResourceControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/TbResourceControllerTest.java index 352e1fcfeb..e99b0a1091 100644 --- a/application/src/test/java/org/thingsboard/server/controller/TbResourceControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/TbResourceControllerTest.java @@ -25,11 +25,12 @@ import org.junit.Before; import org.junit.Test; import org.mockito.Mockito; import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.mock.web.MockPart; import org.springframework.test.web.servlet.ResultActions; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.Dashboard; -import org.thingsboard.server.common.data.DashboardInfo; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.EntityType; @@ -47,15 +48,14 @@ import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.common.data.widget.WidgetType; import org.thingsboard.server.common.data.widget.WidgetTypeDetails; -import org.thingsboard.server.common.data.widget.WidgetTypeInfo; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.service.DaoSqlTest; import java.util.ArrayList; import java.util.Base64; -import java.util.Collections; import java.util.HashMap; import java.util.List; +import java.util.Objects; import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.containsString; @@ -64,7 +64,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. @DaoSqlTest public class TbResourceControllerTest extends AbstractControllerTest { - private IdComparator idComparator = new IdComparator<>(); + private final IdComparator idComparator = new IdComparator<>(); private static final String DEFAULT_FILE_NAME = "test.jks"; private static final String DEFAULT_FILE_NAME_2 = "test2.jks"; @@ -126,13 +126,9 @@ public class TbResourceControllerTest extends AbstractControllerTest { Assert.assertEquals(DEFAULT_FILE_NAME, savedResource.getResourceKey()); Assert.assertArrayEquals(resource.getData(), download(savedResource.getId())); - TbResource foundResource = doGet("/api/resource/" + savedResource.getId().getId().toString(), TbResource.class); - foundResource.setTitle("My new resource"); - foundResource.setData(null); - - savedResource = save(foundResource); - - Assert.assertEquals(foundResource.getTitle(), savedResource.getTitle()); + String resourceTitle = "My new resource"; + savedResource = doPut("/api/resource/" + savedResource.getUuidId() + "/title", resourceTitle, TbResourceInfo.class); + assertThat(savedResource.getTitle()).isEqualTo(resourceTitle); testNotifyEntityAllOneTimeLogEntityActionEntityEqClass(savedResource, savedResource.getId(), savedResource.getId(), savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), @@ -501,8 +497,8 @@ public class TbResourceControllerTest extends AbstractControllerTest { savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ADDED, cntEntity, cntEntity, cntEntity); - Collections.sort(resources, idComparator); - Collections.sort(loadedResources, idComparator); + resources.sort(idComparator); + loadedResources.sort(idComparator); Assert.assertEquals(resources, loadedResources); } @@ -549,8 +545,8 @@ public class TbResourceControllerTest extends AbstractControllerTest { savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ADDED, jksCntEntity + lwm2mCntEntity, jksCntEntity + lwm2mCntEntity, jksCntEntity + lwm2mCntEntity); - Collections.sort(resources, idComparator); - Collections.sort(loadedResources, idComparator); + resources.sort(idComparator); + loadedResources.sort(idComparator); Assert.assertEquals(resources, loadedResources); } @@ -581,8 +577,8 @@ public class TbResourceControllerTest extends AbstractControllerTest { } } while (pageData.hasNext()); - Collections.sort(resources, idComparator); - Collections.sort(loadedResources, idComparator); + resources.sort(idComparator); + loadedResources.sort(idComparator); Assert.assertEquals(resources, loadedResources); @@ -654,8 +650,8 @@ public class TbResourceControllerTest extends AbstractControllerTest { } } while (pageData.hasNext()); - Collections.sort(jksResources, idComparator); - Collections.sort(loadedResources, idComparator); + jksResources.sort(idComparator); + loadedResources.sort(idComparator); Assert.assertEquals(jksResources, loadedResources); @@ -736,8 +732,8 @@ public class TbResourceControllerTest extends AbstractControllerTest { } } while (pageData.hasNext()); - Collections.sort(expectedResources, idComparator); - Collections.sort(loadedResources, idComparator); + expectedResources.sort(idComparator); + loadedResources.sort(idComparator); Assert.assertEquals(expectedResources, loadedResources); @@ -770,7 +766,7 @@ public class TbResourceControllerTest extends AbstractControllerTest { MockHttpServletResponse response = resultActions.andReturn().getResponse(); String eTag = response.getHeader("ETag"); Assert.assertNotNull(eTag); - Assert.assertEquals(Base64.getEncoder().encodeToString(response.getContentAsByteArray()), TEST_DATA); + Assert.assertEquals(TEST_DATA, Base64.getEncoder().encodeToString(response.getContentAsByteArray())); //download with if-none-match header HttpHeaders headers = new HttpHeaders(); @@ -814,7 +810,7 @@ public class TbResourceControllerTest extends AbstractControllerTest { MockHttpServletResponse response = resultActions.andReturn().getResponse(); String eTag = response.getHeader("ETag"); Assert.assertNotNull(eTag); - Assert.assertEquals(Base64.getEncoder().encodeToString(response.getContentAsByteArray()), TEST_DATA); + Assert.assertEquals(TEST_DATA, Base64.getEncoder().encodeToString(response.getContentAsByteArray())); //download with if-none-match header HttpHeaders headers = new HttpHeaders(); @@ -859,10 +855,9 @@ public class TbResourceControllerTest extends AbstractControllerTest { .andExpect(status().isBadRequest()) .andExpect(statusReason(containsString("can't be updated"))); - foundResource.setData(null); - foundResource.setTitle("Updated resource"); - savedResource = doPost("/api/resource", foundResource, TbResource.class); - assertThat(savedResource.getTitle()).isEqualTo("Updated resource"); + String resourceTitle = "Updated resource"; + savedResource = doPut("/api/resource/" + savedResource.getUuidId() + "/title", resourceTitle, TbResourceInfo.class); + assertThat(savedResource.getTitle()).isEqualTo(resourceTitle); assertThat(savedResource.getFileName()).isEqualTo(resource.getFileName()); assertThat(savedResource.getEtag()).isEqualTo(resource.getEtag()); assertThat(download(savedResource.getId())).asBase64Encoded().isEqualTo(TEST_DATA); @@ -923,8 +918,24 @@ public class TbResourceControllerTest extends AbstractControllerTest { } private TbResourceInfo save(TbResource tbResource) throws Exception { - return doPostWithTypedResponse("/api/resource", tbResource, new TypeReference<>() { - }); + byte[] data = tbResource.getData() != null ? tbResource.getData() : tbResource.getEncodedData() != null ? Base64.getDecoder().decode(tbResource.getEncodedData()) : null; + List parts = new ArrayList<>(); + parts.add(new MockPart("resourceType", tbResource.getResourceType().name().getBytes())); + + if (tbResource.getId() != null) { + parts.add(new MockPart("resourceId", tbResource.getId().getId().toString().getBytes())); + } + if (tbResource.getTitle() != null) { + parts.add(new MockPart("title", tbResource.getTitle().getBytes())); + } + if (tbResource.getDescriptor() != null) { + parts.add(new MockPart("descriptor", tbResource.getDescriptor().toString().getBytes())); + } + if (tbResource.getSearchText() != null) { + parts.add(new MockPart("searchText", tbResource.getSearchText().getBytes())); + } + + return uploadResource(HttpMethod.POST, "/api/resource/upload", tbResource.getFileName(), tbResource.getResourceType().getMediaType(), data, parts); } private TbResourceInfo findResourceInfo(TbResourceId id) throws Exception { @@ -949,7 +960,7 @@ public class TbResourceControllerTest extends AbstractControllerTest { for (String model : models) { String fileName = model + ".xml"; - byte[] bytes = IOUtils.toByteArray(getClass().getClassLoader().getResourceAsStream("lwm2m/" + fileName)); + byte[] bytes = IOUtils.toByteArray(Objects.requireNonNull(getClass().getClassLoader().getResourceAsStream("lwm2m/" + fileName))); TbResource resource = new TbResource(); resource.setResourceType(ResourceType.LWM2M_MODEL); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceInfo.java index a3bf383503..77de18e446 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceInfo.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceInfo.java @@ -28,6 +28,7 @@ import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.validation.Length; import org.thingsboard.server.common.data.validation.NoXss; +import java.io.Serial; import java.util.function.UnaryOperator; @Schema @@ -36,6 +37,7 @@ import java.util.function.UnaryOperator; @EqualsAndHashCode(callSuper = true) public class TbResourceInfo extends BaseData implements HasName, HasTenantId, ExportableEntity { + @Serial private static final long serialVersionUID = 7282664529021651736L; @Schema(description = "JSON object with Tenant Id. Tenant Id of the resource can't be changed.", accessMode = Schema.AccessMode.READ_ONLY) diff --git a/dao/src/main/java/org/thingsboard/server/dao/resource/BaseResourceService.java b/dao/src/main/java/org/thingsboard/server/dao/resource/BaseResourceService.java index 7f0dd4a6bf..478fecb037 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/resource/BaseResourceService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/resource/BaseResourceService.java @@ -89,7 +89,9 @@ import static org.thingsboard.server.dao.service.Validator.validateId; @Primary public class BaseResourceService extends AbstractCachedEntityService implements ResourceService { - public static final String INCORRECT_RESOURCE_ID = "Incorrect resourceId "; + protected static final String INCORRECT_RESOURCE_ID = "Incorrect resourceId "; + protected static final int MAX_ENTITIES_TO_FIND = 10; + protected final TbResourceDao resourceDao; protected final TbResourceInfoDao resourceInfoDao; protected final ResourceDataValidator resourceValidator; @@ -98,7 +100,6 @@ public class BaseResourceService extends AbstractCachedEntityService> resourceLinkContainerDaoMap = new HashMap<>(); private final Map> generalResourceContainerDaoMap = new HashMap<>(); - protected static final int MAX_ENTITIES_TO_FIND = 10; @PostConstruct public void init() { @@ -275,7 +276,7 @@ public class BaseResourceService extends AbstractCachedEntityService { @Override protected TbResource validateUpdate(TenantId tenantId, TbResource resource) { - if (resource.getData() != null && !resource.getResourceType().isUpdatable() && - tenantId != null && !tenantId.isSysTenantId()) { + if ((resource.getData() != null && !resource.getResourceType().isUpdatable() && tenantId != null && !tenantId.isSysTenantId()) + || resource.getResourceType().equals(ResourceType.LWM2M_MODEL)) { throw new DataValidationException("This type of resource can't be updated"); } return resource; @@ -81,7 +83,7 @@ public class ResourceDataValidator extends DataValidator { if (StringUtils.isEmpty(resource.getFileName())) { throw new DataValidationException("Resource file name should be specified!"); } - if (StringUtils.containsAny(resource.getFileName(), "/", "\\")) { + if (Strings.CS.containsAny(resource.getFileName(), "/", "\\")) { throw new DataValidationException("File name contains forbidden symbols"); } if (StringUtils.isEmpty(resource.getResourceKey())) { @@ -104,4 +106,5 @@ public class ResourceDataValidator extends DataValidator { validateMaxSumDataSizePerTenant(tenantId, resourceDao, maxSumResourcesDataInBytes, dataSize, TB_RESOURCE); } } + } From 26a73e6975a3c8359f9d5a222cd38a81124eb7b7 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Fri, 31 Oct 2025 12:05:44 +0200 Subject: [PATCH 03/22] After review --- .../controller/TbResourceController.java | 63 +++++++++---------- .../server/utils/LwM2mObjectModelUtils.java | 4 +- .../server/controller/AbstractWebTest.java | 4 +- .../controller/TbResourceControllerTest.java | 14 ++--- 4 files changed, 41 insertions(+), 44 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java b/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java index 2ff54604c7..6f7b8181fd 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java @@ -17,7 +17,6 @@ package org.thingsboard.server.controller; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; -import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -72,7 +71,6 @@ import java.util.List; import java.util.Set; import java.util.UUID; -import static org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE; import static org.thingsboard.server.controller.ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER; import static org.thingsboard.server.controller.ControllerConstants.LWM2M_OBJECT_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS; @@ -232,32 +230,25 @@ public class TbResourceController extends BaseController { } @ApiOperation(value = "Upload Resource via Multipart File (uploadResource)", - notes = "Upload the Resource using multipart file upload. " + - "When creating the Resource, platform generates Resource id as " + UUID_WIKI_LINK + - "The newly created Resource id will be present in the response. " + - "Specify existing Resource id to update the Resource. " + - "Referencing non-existing Resource Id will cause 'Not Found' error. " + + notes = "Create the Resource using multipart file upload. " + "\n\nResource combination of the title with the key is unique in the scope of tenant. " + - SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH, - requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody(content = @Content(mediaType = MULTIPART_FORM_DATA_VALUE))) + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") @PostMapping(value = "/resource/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - public TbResourceInfo uploadResource(@Parameter(description = RESOURCE_ID_PARAM_DESCRIPTION) - @RequestParam(name = RESOURCE_ID, required = false) UUID resourceId, - @Parameter(description = "Resource title.", example = "Title") - @RequestParam(name = "title", required = false) String title, - @Parameter(description = "Resource type.", schema = @Schema(implementation = ResourceType.class, nullable = true, example = "GENERAL")) - @RequestParam(name = "resourceType") ResourceType resourceType, + public TbResourceInfo uploadResource(@Parameter(description = "Resource title.", example = "Title") + @RequestPart(name = "title", required = false) String title, + @Parameter(description = "Resource type.") + @RequestPart(name = "resourceType") String resourceTypeStr, @Parameter(description = "Resource descriptor (JSON).") - @RequestParam(name = "descriptor", required = false) String descriptor, - @Parameter(description = "Resource search text.") - @RequestParam(name = "searchText", required = false) String searchText, + @RequestPart(name = "descriptor", required = false) String descriptor, + @Parameter(description = "Resource sub type.") + @RequestPart(name = "resourceSubType", required = false) String resourceSubTypeStr, @Parameter(description = "Resource file.") @RequestPart MultipartFile file) throws Exception { TbResource resource = new TbResource(); resource.setTenantId(getTenantId()); - resource.setId(resourceId != null ? new TbResourceId(resourceId) : null); resource.setTitle(StringUtils.isNotEmpty(title) ? title : file.getOriginalFilename()); + ResourceType resourceType = ResourceType.valueOf(resourceTypeStr); resource.setResourceType(resourceType); if (StringUtils.isNotEmpty(descriptor)) { @@ -267,7 +258,9 @@ public class TbResourceController extends BaseController { resource.setDescriptor(JacksonUtil.newObjectNode().put("mediaType", mediaType)); } - resource.setSearchText(StringUtils.isNotEmpty(searchText) ? searchText : resource.getTitle()); + if (StringUtils.isNotEmpty(resourceSubTypeStr)) { + resource.setResourceSubType(ResourceSubType.valueOf(resourceSubTypeStr)); + } resource.setFileName(file.getOriginalFilename()); resource.setData(file.getBytes()); @@ -275,20 +268,26 @@ public class TbResourceController extends BaseController { return tbResourceService.save(resource, getCurrentUser()); } - @ApiOperation(value = "Update Resource title", - notes = "Updates the title of the existing Resource by resourceId. " + - "Only the title can be updated. " + - "Referencing a non-existing Resource Id will cause a 'Not Found' error. " + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") - @PutMapping(value = "/resource/{id}/title") - public TbResourceInfo updateResourceTitle(@Parameter(description = "Unique identifier of the Resource to update", required = true) - @PathVariable UUID id, - @Parameter(description = "New title for the Resource", example = "Title", required = true) - @RequestBody String title) throws Exception { + @PutMapping(value = "/resource/{id}/data", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public TbResourceInfo updateResourceData(@Parameter(description = "Unique identifier of the Resource to update", required = true) + @PathVariable UUID id, + @Parameter(description = "Resource file.") + @RequestPart MultipartFile file) throws Exception { TbResourceId tbResourceId = new TbResourceId(id); - TbResource resourceInfo = new TbResource(checkResourceInfoId(tbResourceId, Operation.WRITE)); - resourceInfo.setTitle(title); - return tbResourceService.save(resourceInfo, getCurrentUser()); + TbResource resource = checkResourceId(tbResourceId, Operation.WRITE); + resource.setFileName(file.getOriginalFilename()); + resource.setData(file.getBytes()); + return tbResourceService.save(resource, getCurrentUser()); + } + + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + @PutMapping("/resource/{id}/info") + public TbResourceInfo updateResourceInfo(@Parameter(description = "A JSON value representing the Resource Info.") + @RequestBody TbResourceInfo resourceInfo) throws Exception { + checkResourceInfoId(resourceInfo.getId(), Operation.WRITE); + TbResource resource = new TbResource(resourceInfo); + return tbResourceService.save(resource, getCurrentUser()); } @ApiOperation(value = "Get Resource Infos (getResources)", diff --git a/application/src/main/java/org/thingsboard/server/utils/LwM2mObjectModelUtils.java b/application/src/main/java/org/thingsboard/server/utils/LwM2mObjectModelUtils.java index fb6125bc0b..172a3e8e2d 100644 --- a/application/src/main/java/org/thingsboard/server/utils/LwM2mObjectModelUtils.java +++ b/application/src/main/java/org/thingsboard/server/utils/LwM2mObjectModelUtils.java @@ -73,7 +73,7 @@ public class LwM2mObjectModelUtils { try { List objectModels = ddfFileParser.parse(new ByteArrayInputStream(resource.getData()), resource.getSearchText()); - if (objectModels.size() == 0) { + if (objectModels.isEmpty()) { return null; } else { ObjectModel obj = objectModels.get(0); @@ -95,7 +95,7 @@ public class LwM2mObjectModelUtils { resources.add(lwM2MResourceObserve); } }); - if (isSave || resources.size() > 0) { + if (isSave || !resources.isEmpty()) { instance.setResources(resources.toArray(LwM2mResourceObserve[]::new)); lwM2mObject.setInstances(new LwM2mInstance[]{instance}); return lwM2mObject; diff --git a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java index fcbe203e79..37f6e41e88 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java @@ -1117,7 +1117,7 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { Awaitility.await("CF state for entity actor ready to refresh dynamic arguments").atMost(TIMEOUT, TimeUnit.SECONDS).until(() -> { CalculatedFieldState calculatedFieldState = statesMap.get(cfId); boolean isReady = calculatedFieldState != null && ((GeofencingCalculatedFieldState) calculatedFieldState).getLastDynamicArgumentsRefreshTs() - < System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(scheduledUpdateInterval); + < System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(scheduledUpdateInterval); log.warn("entityId {}, cfId {}, state ready to refresh == {}", entityId, cfId, isReady); return isReady; }); @@ -1308,7 +1308,7 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { protected List findJobs(List types, List entities) throws Exception { return doGetTypedWithPageLink("/api/jobs?types=" + types.stream().map(Enum::name).collect(Collectors.joining(",")) + - "&entities=" + entities.stream().map(UUID::toString).collect(Collectors.joining(",")) + "&", + "&entities=" + entities.stream().map(UUID::toString).collect(Collectors.joining(",")) + "&", new TypeReference>() {}, new PageLink(100, 0, null, new SortOrder("createdTime", SortOrder.Direction.DESC))).getData(); } diff --git a/application/src/test/java/org/thingsboard/server/controller/TbResourceControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/TbResourceControllerTest.java index e99b0a1091..b5ddefb160 100644 --- a/application/src/test/java/org/thingsboard/server/controller/TbResourceControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/TbResourceControllerTest.java @@ -127,7 +127,8 @@ public class TbResourceControllerTest extends AbstractControllerTest { Assert.assertArrayEquals(resource.getData(), download(savedResource.getId())); String resourceTitle = "My new resource"; - savedResource = doPut("/api/resource/" + savedResource.getUuidId() + "/title", resourceTitle, TbResourceInfo.class); + savedResource.setTitle(resourceTitle); + savedResource = doPut("/api/resource/" + savedResource.getUuidId() + "/info", savedResource, TbResourceInfo.class); assertThat(savedResource.getTitle()).isEqualTo(resourceTitle); testNotifyEntityAllOneTimeLogEntityActionEntityEqClass(savedResource, savedResource.getId(), savedResource.getId(), @@ -856,7 +857,8 @@ public class TbResourceControllerTest extends AbstractControllerTest { .andExpect(statusReason(containsString("can't be updated"))); String resourceTitle = "Updated resource"; - savedResource = doPut("/api/resource/" + savedResource.getUuidId() + "/title", resourceTitle, TbResourceInfo.class); + savedResource.setTitle(resourceTitle); + savedResource = doPut("/api/resource/" + savedResource.getUuidId() + "/info", savedResource, TbResourceInfo.class); assertThat(savedResource.getTitle()).isEqualTo(resourceTitle); assertThat(savedResource.getFileName()).isEqualTo(resource.getFileName()); assertThat(savedResource.getEtag()).isEqualTo(resource.getEtag()); @@ -921,18 +923,14 @@ public class TbResourceControllerTest extends AbstractControllerTest { byte[] data = tbResource.getData() != null ? tbResource.getData() : tbResource.getEncodedData() != null ? Base64.getDecoder().decode(tbResource.getEncodedData()) : null; List parts = new ArrayList<>(); parts.add(new MockPart("resourceType", tbResource.getResourceType().name().getBytes())); - - if (tbResource.getId() != null) { - parts.add(new MockPart("resourceId", tbResource.getId().getId().toString().getBytes())); - } if (tbResource.getTitle() != null) { parts.add(new MockPart("title", tbResource.getTitle().getBytes())); } if (tbResource.getDescriptor() != null) { parts.add(new MockPart("descriptor", tbResource.getDescriptor().toString().getBytes())); } - if (tbResource.getSearchText() != null) { - parts.add(new MockPart("searchText", tbResource.getSearchText().getBytes())); + if (tbResource.getResourceSubType() != null) { + parts.add(new MockPart("resourceSubType", tbResource.getResourceSubType().name().getBytes())); } return uploadResource(HttpMethod.POST, "/api/resource/upload", tbResource.getFileName(), tbResource.getResourceType().getMediaType(), data, parts); From 6fccfc3c4018150f88573d70e765381cbe8f028d Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Mon, 3 Nov 2025 11:07:54 +0200 Subject: [PATCH 04/22] Small changes of update info endpoint --- .../server/controller/TbResourceController.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java b/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java index 6f7b8181fd..394b745032 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java @@ -283,9 +283,13 @@ public class TbResourceController extends BaseController { @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") @PutMapping("/resource/{id}/info") - public TbResourceInfo updateResourceInfo(@Parameter(description = "A JSON value representing the Resource Info.") + public TbResourceInfo updateResourceInfo(@Parameter(description = "Unique identifier of the Resource to update", required = true) + @PathVariable UUID id, + @Parameter(description = "A JSON value representing the Resource Info.") @RequestBody TbResourceInfo resourceInfo) throws Exception { - checkResourceInfoId(resourceInfo.getId(), Operation.WRITE); + TbResourceId tbResourceId = new TbResourceId(id); + checkResourceInfoId(tbResourceId, Operation.WRITE); + resourceInfo.setId(tbResourceId); TbResource resource = new TbResource(resourceInfo); return tbResourceService.save(resource, getCurrentUser()); } From 71afb3a438c5b4212dd04ec2e00627e903831e8f Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Fri, 21 Nov 2025 11:53:32 +0200 Subject: [PATCH 05/22] Fix alarm rule propagation settings update --- .../server/service/cf/ctx/state/CalculatedFieldCtx.java | 3 +++ .../configuration/AlarmCalculatedFieldConfiguration.java | 7 +++++++ 2 files changed, 10 insertions(+) diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java index 393851eb02..76872f731d 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java @@ -640,6 +640,9 @@ public class CalculatedFieldCtx implements Closeable { // if the rules have any changes not tracked by hasStateChanges return true; } + if (!thisConfig.propagationSettingsEqual(otherConfig)) { + return true; + } } if (scheduledUpdateIntervalMillis != other.scheduledUpdateIntervalMillis) { return true; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AlarmCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AlarmCalculatedFieldConfiguration.java index d36ba33849..6f3cdbdd3c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AlarmCalculatedFieldConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AlarmCalculatedFieldConfiguration.java @@ -87,4 +87,11 @@ public class AlarmCalculatedFieldConfiguration implements ArgumentsBasedCalculat }); } + public boolean propagationSettingsEqual(AlarmCalculatedFieldConfiguration other) { + return this.propagate == other.propagate && + this.propagateToOwner == other.propagateToOwner && + this.propagateToTenant == other.propagateToTenant && + Objects.equals(this.propagateRelationTypes, other.propagateRelationTypes); + } + } From 615f9d284c1221c495544560374225ba30faf52f Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Mon, 24 Nov 2025 10:34:27 +0200 Subject: [PATCH 06/22] Alarm rules: no-data filter predicate --- ...alculatedFieldManagerMessageProcessor.java | 2 +- .../cf/ctx/state/CalculatedFieldCtx.java | 5 ++- .../alarm/AlarmCalculatedFieldState.java | 12 ++++++ .../common/data/alarm/rule/AlarmRule.java | 2 +- .../alarm/rule/condition/AlarmCondition.java | 5 +++ .../expression/AlarmConditionExpression.java | 5 +++ .../expression/AlarmConditionFilter.java | 19 ++++++++- .../SimpleAlarmConditionExpression.java | 6 +++ .../predicate/FilterPredicateType.java | 1 + .../predicate/KeyFilterPredicate.java | 4 +- .../predicate/NoDataFilterPredicate.java | 39 +++++++++++++++++++ 11 files changed, 94 insertions(+), 6 deletions(-) create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/NoDataFilterPredicate.java diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java index dc6757f88f..356d66e21c 100644 --- a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java @@ -179,7 +179,7 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware cfsReevaluationTask = systemContext.getScheduler().scheduleWithFixedDelay(() -> { try { calculatedFields.values().forEach(cf -> { - if (cf.isRequiresScheduledReevaluation()) { + if (cf.requiresScheduledReevaluation()) { applyToTargetCfEntityActors(cf, TbCallback.EMPTY, (entityId, callback) -> { log.debug("[{}][{}] Pushing scheduled CF reevaluate msg", entityId, cf.getCfId()); getOrCreateActor(entityId).tell(new CalculatedFieldReevaluateMsg(tenantId, cf)); diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java index 76872f731d..a196bbe4b7 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java @@ -210,7 +210,7 @@ public class CalculatedFieldCtx implements Closeable { this.maxSingleValueArgumentSize = systemContext.getApiLimitService().getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxSingleValueArgumentSizeInKBytes) * 1024; } - public boolean isRequiresScheduledReevaluation() { + public boolean requiresScheduledReevaluation() { long now = System.currentTimeMillis(); if (calculatedField.getConfiguration() instanceof EntityAggregationCalculatedFieldConfiguration entityAggregationConfig) { Watermark watermark = entityAggregationConfig.getWatermark(); @@ -230,8 +230,9 @@ public class CalculatedFieldCtx implements Closeable { } boolean requiresScheduledReevaluation = calculatedField.getConfiguration().requiresScheduledReevaluation(); if (calculatedField.getConfiguration() instanceof AlarmCalculatedFieldConfiguration) { - long reevaluationIntervalMillis = TimeUnit.SECONDS.toMillis(systemContext.getAlarmRulesReevaluationInterval()); + // TODO: handle reeaval for no-data conditions! if (requiresScheduledReevaluation) { + long reevaluationIntervalMillis = TimeUnit.SECONDS.toMillis(systemContext.getAlarmRulesReevaluationInterval()); if (now - lastReevaluationTs >= reevaluationIntervalMillis) { lastReevaluationTs = now; return true; diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java index 342f7534c2..8823ca397f 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java @@ -46,6 +46,7 @@ import org.thingsboard.server.common.data.alarm.rule.condition.expression.TbelAl import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.BooleanFilterPredicate; import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.ComplexFilterPredicate; import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.KeyFilterPredicate; +import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.NoDataFilterPredicate; import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.NumericFilterPredicate; import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.StringFilterPredicate; import org.thingsboard.server.common.data.audit.ActionType; @@ -428,6 +429,7 @@ public class AlarmCalculatedFieldState extends BaseCalculatedFieldState { case STRING -> evalStrPredicate(argument, (StringFilterPredicate) predicate); case NUMERIC -> evalNumPredicate(argument, (NumericFilterPredicate) predicate); case BOOLEAN -> evalBooleanPredicate(argument, (BooleanFilterPredicate) predicate); + case NO_DATA -> evalNoDataPredicate(argument, (NoDataFilterPredicate) predicate); case COMPLEX -> evalComplexPredicate(argument, (ComplexFilterPredicate) predicate); }; } @@ -512,6 +514,16 @@ public class AlarmCalculatedFieldState extends BaseCalculatedFieldState { }; } + private boolean evalNoDataPredicate(SingleValueArgumentEntry argument, NoDataFilterPredicate predicate) { + long passedMs = System.currentTimeMillis() - argument.getTs(); + long duration = resolveValue(predicate.getDuration(), KvUtil::getLongValue); + if (duration > 0) { + return passedMs >= predicate.getUnit().toMillis(duration); + } else { + return false; + } + } + protected T resolveValue(AlarmConditionValue conditionValue, Function mapper) { T value = conditionValue.getStaticValue(); if (value == null) { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/AlarmRule.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/AlarmRule.java index 9a4e875154..23dbc8fede 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/AlarmRule.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/AlarmRule.java @@ -33,7 +33,7 @@ public class AlarmRule { @JsonIgnore public boolean requiresScheduledReevaluation() { - return condition.hasSchedule(); + return condition.requiresScheduledReevaluation(); } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/AlarmCondition.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/AlarmCondition.java index 9bb549994b..96d63f78d3 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/AlarmCondition.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/AlarmCondition.java @@ -50,6 +50,11 @@ public abstract class AlarmCondition { return schedule != null && !(schedule.getStaticValue() instanceof AnyTimeSchedule); } + @JsonIgnore + public boolean requiresScheduledReevaluation() { + return hasSchedule() || expression.requiresScheduledReevaluation(); + } + @JsonIgnore public abstract AlarmConditionType getType(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/AlarmConditionExpression.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/AlarmConditionExpression.java index e855f8efd3..0502a10105 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/AlarmConditionExpression.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/AlarmConditionExpression.java @@ -32,4 +32,9 @@ public interface AlarmConditionExpression { @JsonIgnore AlarmConditionExpressionType getType(); + @JsonIgnore + default boolean requiresScheduledReevaluation() { + return false; + } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/AlarmConditionFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/AlarmConditionFilter.java index e99849ea82..4c6df3825d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/AlarmConditionFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/AlarmConditionFilter.java @@ -17,8 +17,11 @@ package org.thingsboard.server.common.data.alarm.rule.condition.expression; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; import lombok.Data; +import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.ComplexFilterPredicate; +import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.FilterPredicateType; import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.KeyFilterPredicate; import org.thingsboard.server.common.data.query.EntityKeyValueType; @@ -34,7 +37,21 @@ public class AlarmConditionFilter implements Serializable { private EntityKeyValueType valueType; private ComplexOperation operation; @Valid - @NotNull + @NotEmpty private List predicates; + public boolean hasPredicate(FilterPredicateType type) { + return containsPredicate(predicates, type); + } + + private boolean containsPredicate(List predicates, FilterPredicateType type) { + return predicates.stream().anyMatch(predicate -> { + if (predicate instanceof ComplexFilterPredicate complexPredicate) { + return containsPredicate(complexPredicate.getPredicates(), type); + } else { + return predicate.getType() == type; + } + }); + } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/SimpleAlarmConditionExpression.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/SimpleAlarmConditionExpression.java index 8c27400961..b0afbcc7ba 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/SimpleAlarmConditionExpression.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/SimpleAlarmConditionExpression.java @@ -20,6 +20,7 @@ import jakarta.validation.constraints.NotEmpty; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; +import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.FilterPredicateType; import java.util.List; @@ -38,4 +39,9 @@ public class SimpleAlarmConditionExpression implements AlarmConditionExpression return AlarmConditionExpressionType.SIMPLE; } + @Override + public boolean requiresScheduledReevaluation() { + return filters.stream().anyMatch(filter -> filter.hasPredicate(FilterPredicateType.NO_DATA)); + } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/FilterPredicateType.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/FilterPredicateType.java index af7c45ac5b..687b39932e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/FilterPredicateType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/FilterPredicateType.java @@ -19,5 +19,6 @@ public enum FilterPredicateType { STRING, NUMERIC, BOOLEAN, + NO_DATA, COMPLEX } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/KeyFilterPredicate.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/KeyFilterPredicate.java index 58355c627d..ca4531c123 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/KeyFilterPredicate.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/KeyFilterPredicate.java @@ -27,7 +27,9 @@ import java.io.Serializable; @Type(value = StringFilterPredicate.class, name = "STRING"), @Type(value = NumericFilterPredicate.class, name = "NUMERIC"), @Type(value = BooleanFilterPredicate.class, name = "BOOLEAN"), - @Type(value = ComplexFilterPredicate.class, name = "COMPLEX")}) + @Type(value = NoDataFilterPredicate.class, name = "NO_DATA"), + @Type(value = ComplexFilterPredicate.class, name = "COMPLEX") +}) public interface KeyFilterPredicate extends Serializable { @JsonIgnore diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/NoDataFilterPredicate.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/NoDataFilterPredicate.java new file mode 100644 index 0000000000..11a014fd44 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/NoDataFilterPredicate.java @@ -0,0 +1,39 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import org.thingsboard.server.common.data.alarm.rule.condition.AlarmConditionValue; + +import java.util.concurrent.TimeUnit; + +@Data +public class NoDataFilterPredicate implements KeyFilterPredicate { + + @NotNull + private TimeUnit unit; + @Valid + @NotNull + private AlarmConditionValue duration; + + @Override + public FilterPredicateType getType() { + return FilterPredicateType.NO_DATA; + } + +} From 3922788c837a9c3d938814d048867b7c3a4b2ad3 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Mon, 24 Nov 2025 16:24:25 +0200 Subject: [PATCH 07/22] Handling of no-data alarm rule predicate --- .../cf/ctx/state/CalculatedFieldCtx.java | 1 - .../alarm/AlarmCalculatedFieldState.java | 45 +++++++++------ .../cf/ctx/state/alarm/AlarmEvalResult.java | 10 ++++ .../cf/ctx/state/alarm/AlarmRuleState.java | 27 +++++---- .../thingsboard/server/cf/AlarmRulesTest.java | 56 +++++++++++++++++++ .../alarm/rule/condition/AlarmCondition.java | 10 ++++ .../predicate/NoDataFilterPredicate.java | 4 ++ .../predicate/NumericFilterPredicate.java | 4 ++ 8 files changed, 128 insertions(+), 29 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java index a196bbe4b7..01f5fbd950 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java @@ -230,7 +230,6 @@ public class CalculatedFieldCtx implements Closeable { } boolean requiresScheduledReevaluation = calculatedField.getConfiguration().requiresScheduledReevaluation(); if (calculatedField.getConfiguration() instanceof AlarmCalculatedFieldConfiguration) { - // TODO: handle reeaval for no-data conditions! if (requiresScheduledReevaluation) { long reevaluationIntervalMillis = TimeUnit.SECONDS.toMillis(systemContext.getAlarmRulesReevaluationInterval()); if (now - lastReevaluationTs >= reevaluationIntervalMillis) { diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java index 8823ca397f..bd2c272bca 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java @@ -71,6 +71,8 @@ import java.util.function.Function; import static org.thingsboard.server.common.data.StringUtils.equalsAny; import static org.thingsboard.server.common.data.StringUtils.splitByCommaWithoutQuotes; +import static org.thingsboard.server.service.cf.ctx.state.alarm.AlarmEvalResult.Cause.NEW_EVENT; +import static org.thingsboard.server.service.cf.ctx.state.alarm.AlarmEvalResult.Cause.SCHEDULED_REEVALUATION; import static org.thingsboard.server.service.cf.ctx.state.alarm.AlarmEvalResult.Status.FALSE; import static org.thingsboard.server.service.cf.ctx.state.alarm.AlarmEvalResult.Status.NOT_YET_TRUE; import static org.thingsboard.server.service.cf.ctx.state.alarm.AlarmEvalResult.Status.TRUE; @@ -213,9 +215,9 @@ public class AlarmCalculatedFieldState extends BaseCalculatedFieldState { state.setDurationCheckFuture(future); } } - return evalResult; + return evalResult.withCause(NEW_EVENT); } else { - return state.reeval(System.currentTimeMillis(), ctx); + return state.reeval(System.currentTimeMillis(), ctx).withCause(SCHEDULED_REEVALUATION); } }, ctx); return Futures.immediateFuture(AlarmCalculatedFieldResult.builder() @@ -248,12 +250,12 @@ public class AlarmCalculatedFieldState extends BaseCalculatedFieldState { private TbAlarmResult createOrClearAlarms(Function evalFunction, CalculatedFieldCtx ctx) { - TbAlarmResult result = null; + AlarmEvalResult evalResult = null; AlarmRuleState resultState = null; AlarmRuleState.StateInfo resultStateInfo = null; for (AlarmRuleState state : createRuleStates.values()) { - AlarmEvalResult evalResult = evalFunction.apply(state); + evalResult = evalFunction.apply(state); log.debug("Evaluated create rule {} with args {}. Result: {}", state, arguments, evalResult); if (evalResult.getStatus() == TRUE) { resultState = state; @@ -263,13 +265,14 @@ public class AlarmCalculatedFieldState extends BaseCalculatedFieldState { } } + TbAlarmResult result = null; if (resultState != null) { - result = calculateAlarmResult(resultState, ctx); + result = calculateAlarmResult(resultState, evalResult, ctx); resultStateInfo = resultState.getStateInfo(); log.debug("Alarm result for state {}: {}", resultState, result); clearState(clearRuleState); } else if (currentAlarm != null && clearRuleState != null) { - AlarmEvalResult evalResult = evalFunction.apply(clearRuleState); + evalResult = evalFunction.apply(clearRuleState); log.debug("Evaluated clear rule {} with args {}. Result: {}", clearRuleState, arguments, evalResult); if (evalResult.getStatus() == TRUE) { resultStateInfo = clearRuleState.getStateInfo(); @@ -316,21 +319,25 @@ public class AlarmCalculatedFieldState extends BaseCalculatedFieldState { } } - private TbAlarmResult calculateAlarmResult(AlarmRuleState ruleState, CalculatedFieldCtx ctx) { + private TbAlarmResult calculateAlarmResult(AlarmRuleState ruleState, AlarmEvalResult evalResult, CalculatedFieldCtx ctx) { AlarmSeverity severity = ruleState.getSeverity(); if (currentAlarm != null) { - currentAlarm.setEndTs(System.currentTimeMillis()); AlarmSeverity oldSeverity = currentAlarm.getSeverity(); - // Skip update if severity is decreased. - if (severity.ordinal() <= oldSeverity.ordinal()) { - currentAlarm.setDetails(createDetails(ruleState)); - currentAlarm.setSeverity(severity); - AlarmApiCallResult result = ctx.getAlarmService().updateAlarm(AlarmUpdateRequest.fromAlarm(currentAlarm)); - currentAlarm = result.getAlarm(); - return TbAlarmResult.fromAlarmResult(result); - } else { + if (severity.ordinal() > oldSeverity.ordinal()) { + log.trace("Skipping alarm update for result state {} for eval result {} because severity is decreased", ruleState, evalResult); + return null; + } + if (severity.ordinal() == oldSeverity.ordinal() && evalResult.getCause() == SCHEDULED_REEVALUATION) { + log.trace("Skipping alarm update for result state {} for eval result {}", ruleState, evalResult); return null; } + + currentAlarm.setEndTs(System.currentTimeMillis()); + currentAlarm.setDetails(createDetails(ruleState)); + currentAlarm.setSeverity(severity); + AlarmApiCallResult result = ctx.getAlarmService().updateAlarm(AlarmUpdateRequest.fromAlarm(currentAlarm)); + currentAlarm = result.getAlarm(); + return TbAlarmResult.fromAlarmResult(result); } else { var newAlarm = new Alarm(); newAlarm.setType(alarmType); @@ -339,7 +346,7 @@ public class AlarmCalculatedFieldState extends BaseCalculatedFieldState { newAlarm.setSeverity(severity); long startTs = latestTimestamp; long currentTime = System.currentTimeMillis(); - if (startTs == 0L || startTs > currentTime) { + if (startTs <= 0L || startTs > currentTime) { startTs = currentTime; } newAlarm.setStartTs(startTs); @@ -518,7 +525,9 @@ public class AlarmCalculatedFieldState extends BaseCalculatedFieldState { long passedMs = System.currentTimeMillis() - argument.getTs(); long duration = resolveValue(predicate.getDuration(), KvUtil::getLongValue); if (duration > 0) { - return passedMs >= predicate.getUnit().toMillis(duration); + long requiredDuration = predicate.getUnit().toMillis(duration); + log.trace("[{}] No data for argument {} during {} ms, required duration: {} ms", ctx, argument, passedMs, requiredDuration); + return passedMs >= requiredDuration; } else { return false; } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmEvalResult.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmEvalResult.java index 4f1a8638ee..2569f837fa 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmEvalResult.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmEvalResult.java @@ -30,6 +30,7 @@ public class AlarmEvalResult { private final Status status; private final long leftDuration; private final long leftEvents; + private Cause cause; public AlarmEvalResult(Status status) { this(status, 0, 0); @@ -39,8 +40,17 @@ public class AlarmEvalResult { return new AlarmEvalResult(Status.NOT_YET_TRUE, leftDuration, leftEvents); } + public AlarmEvalResult withCause(Cause cause) { + this.cause = cause; + return this; + } + public enum Status { FALSE, NOT_YET_TRUE, TRUE; } + public enum Cause { + NEW_EVENT, SCHEDULED_REEVALUATION; + } + } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmRuleState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmRuleState.java index 4c189887b4..c6a5cbf418 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmRuleState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmRuleState.java @@ -43,6 +43,8 @@ import java.time.ZonedDateTime; import java.util.Optional; import java.util.concurrent.ScheduledFuture; +import static org.thingsboard.server.service.cf.ctx.state.alarm.AlarmEvalResult.Status.TRUE; + @Data @Slf4j public class AlarmRuleState { @@ -81,16 +83,20 @@ public class AlarmRuleState { boolean active = isActive(ts); switch (condition.getType()) { case SIMPLE, REPEATING -> { - if (this.active == null || active != this.active) { - this.active = active; - if (active) { - return doEval(false, ctx); - } + boolean activeChanged = this.active == null || active != this.active; + this.active = active; + if (!active) { + return AlarmEvalResult.EMPTY; } - if (active) { - return AlarmEvalResult.NOT_YET_TRUE; - } else { - return AlarmEvalResult.FALSE; + + if ((condition.hasSchedule() && activeChanged) || + condition.getExpression().requiresScheduledReevaluation()) { + AlarmEvalResult result = doEval(false, ctx); + if (result.getStatus() == TRUE) { + return result; + } else { + return AlarmEvalResult.EMPTY; + } } } case DURATION -> { @@ -116,7 +122,7 @@ public class AlarmRuleState { } } } - return AlarmEvalResult.FALSE; + return AlarmEvalResult.EMPTY; } public AlarmEvalResult doEval(boolean newEvent, CalculatedFieldCtx ctx) { @@ -338,6 +344,7 @@ public class AlarmRuleState { } public record StateInfo(Long eventCount, Long duration) { + static final StateInfo EMPTY = new StateInfo(null, null); } diff --git a/application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java b/application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java index 2ac5d59b3a..5f91dab190 100644 --- a/application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java +++ b/application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java @@ -42,6 +42,7 @@ import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmC import org.thingsboard.server.common.data.alarm.rule.condition.expression.ComplexOperation; import org.thingsboard.server.common.data.alarm.rule.condition.expression.SimpleAlarmConditionExpression; import org.thingsboard.server.common.data.alarm.rule.condition.expression.TbelAlarmConditionExpression; +import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.NoDataFilterPredicate; import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.NumericFilterPredicate; import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.NumericFilterPredicate.NumericOperation; import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.StringFilterPredicate; @@ -475,6 +476,61 @@ public class AlarmRulesTest extends AbstractControllerTest { }); } + @Test + public void testCreateAlarm_noDataPredicate() throws Exception { + Argument temperatureArgument = new Argument(); + temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + temperatureArgument.setDefaultValue("0"); + Map arguments = Map.of( + "temperature", temperatureArgument + ); + + long majorNoDataDuration = 3L; + long criticalNoDataDuration = 10L; + + SimpleAlarmConditionExpression majorExpression = new SimpleAlarmConditionExpression(); + AlarmConditionFilter majorFilter = new AlarmConditionFilter(); + majorFilter.setArgument("temperature"); + majorFilter.setValueType(EntityKeyValueType.NUMERIC); + majorFilter.setPredicates(List.of( + new NumericFilterPredicate(NumericOperation.GREATER, new AlarmConditionValue<>(25.0, null)), + new NoDataFilterPredicate(TimeUnit.SECONDS, new AlarmConditionValue(majorNoDataDuration, null)) + )); + majorExpression.setFilters(List.of(majorFilter)); + + SimpleAlarmConditionExpression criticalExpression = new SimpleAlarmConditionExpression(); + AlarmConditionFilter criticalFilter = new AlarmConditionFilter(); + criticalFilter.setArgument("temperature"); + criticalFilter.setValueType(EntityKeyValueType.NUMERIC); + criticalFilter.setPredicates(List.of( + new NumericFilterPredicate(NumericOperation.GREATER, new AlarmConditionValue<>(25.0, null)), + new NoDataFilterPredicate(TimeUnit.SECONDS, new AlarmConditionValue(criticalNoDataDuration, null)) + )); + criticalExpression.setFilters(List.of(criticalFilter)); + + Map createRules = Map.of( + AlarmSeverity.MAJOR, new Condition(majorExpression, null, null), + AlarmSeverity.CRITICAL, new Condition(criticalExpression, null, null) + ); + + CalculatedField calculatedField = createAlarmCf(deviceId, "No Temperature Alarm", + arguments, createRules, null); + + postTelemetry(deviceId, "{\"temperature\":50}"); + + checkAlarmResult(calculatedField, alarmResult -> { + assertThat(alarmResult.isCreated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.MAJOR); + assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); + }); + + checkAlarmResult(calculatedField, alarmResult -> { + assertThat(alarmResult.isSeverityUpdated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); + assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); + }); + } + @Test public void testChangeAlarmType() throws Exception { Argument temperatureArgument = new Argument(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/AlarmCondition.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/AlarmCondition.java index 96d63f78d3..b4e1446798 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/AlarmCondition.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/AlarmCondition.java @@ -21,6 +21,7 @@ import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonSubTypes.Type; import com.fasterxml.jackson.annotation.JsonTypeInfo; import jakarta.validation.Valid; +import jakarta.validation.constraints.AssertTrue; import lombok.Data; import lombok.NoArgsConstructor; import org.jetbrains.annotations.NotNull; @@ -55,6 +56,15 @@ public abstract class AlarmCondition { return hasSchedule() || expression.requiresScheduledReevaluation(); } + @JsonIgnore + @AssertTrue(message = "Expressions requiring scheduled reevaluation can only be used with simple alarm conditions") + public boolean isValid() { + if (getType() != AlarmConditionType.SIMPLE && expression.requiresScheduledReevaluation()) { + return false; + } + return true; + } + @JsonIgnore public abstract AlarmConditionType getType(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/NoDataFilterPredicate.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/NoDataFilterPredicate.java index 11a014fd44..82b366f6ae 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/NoDataFilterPredicate.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/NoDataFilterPredicate.java @@ -17,12 +17,16 @@ package org.thingsboard.server.common.data.alarm.rule.condition.expression.predi import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; import lombok.Data; +import lombok.NoArgsConstructor; import org.thingsboard.server.common.data.alarm.rule.condition.AlarmConditionValue; import java.util.concurrent.TimeUnit; @Data +@AllArgsConstructor +@NoArgsConstructor public class NoDataFilterPredicate implements KeyFilterPredicate { @NotNull diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/NumericFilterPredicate.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/NumericFilterPredicate.java index 65316eda88..4bc547695b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/NumericFilterPredicate.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/NumericFilterPredicate.java @@ -17,10 +17,14 @@ package org.thingsboard.server.common.data.alarm.rule.condition.expression.predi import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; import lombok.Data; +import lombok.NoArgsConstructor; import org.thingsboard.server.common.data.alarm.rule.condition.AlarmConditionValue; @Data +@AllArgsConstructor +@NoArgsConstructor public class NumericFilterPredicate implements SimpleKeyFilterPredicate { @NotNull From 1a9b5eaae1ecc8183ab53b72f8a49a9dbf064583 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Tue, 25 Nov 2025 13:04:02 +0200 Subject: [PATCH 08/22] Decrease alarm rules reevaluation interval --- .../java/org/thingsboard/server/actors/ActorSystemContext.java | 2 +- application/src/main/resources/thingsboard.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java index f38a1972c0..0a31f0b981 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java +++ b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java @@ -673,7 +673,7 @@ public class ActorSystemContext { @Getter private long cfCheckInterval; - @Value("${actors.alarms.reevaluation_interval:120}") + @Value("${actors.alarms.reevaluation_interval:60}") @Getter private long alarmRulesReevaluationInterval; diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 4ffa7f31d2..d3d04bcea0 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -542,7 +542,7 @@ actors: check_interval: "${ACTORS_CALCULATED_FIELDS_CHECK_INTERVAL_SEC:60}" alarms: # Interval in seconds to re-evaluate Alarm rules that have a time schedule. 2 minutes by default. - reevaluation_interval: "${ACTORS_ALARMS_REEVALUATION_INTERVAL_SEC:120}" + reevaluation_interval: "${ACTORS_ALARMS_REEVALUATION_INTERVAL_SEC:60}" debug: settings: From e8bf7b99709fab07a0f7b8eaca4da4150f9623e2 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Tue, 25 Nov 2025 13:04:47 +0200 Subject: [PATCH 09/22] Improvements for tests --- .../server/actors/app/AppActor.java | 2 +- .../cf/DefaultCalculatedFieldCache.java | 8 ++++--- .../server/controller/EdqsControllerTest.java | 7 +++++- .../EdqsEntityQueryControllerTest.java | 4 ++-- .../controller/EntityQueryControllerTest.java | 23 +++++++++++-------- .../entitiy/EdqsEntityServiceTest.java | 4 ++-- ...faultTelemetrySubscriptionServiceTest.java | 6 +---- .../cache/TbCaffeineCacheConfiguration.java | 5 ++-- 8 files changed, 33 insertions(+), 26 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/actors/app/AppActor.java b/application/src/main/java/org/thingsboard/server/actors/app/AppActor.java index da16e55db8..cfac432017 100644 --- a/application/src/main/java/org/thingsboard/server/actors/app/AppActor.java +++ b/application/src/main/java/org/thingsboard/server/actors/app/AppActor.java @@ -178,7 +178,7 @@ public class AppActor extends ContextAwareActor { }); } } - if (!msg.getEntityId().getEntityType().isOneOf(EntityType.TENANT_PROFILE, EntityType.TB_RESOURCE)) { + if (!msg.getEntityId().getEntityType().isOneOf(EntityType.TENANT_PROFILE, EntityType.TB_RESOURCE, EntityType.USER)) { log.warn("Message has system tenant id: {}", msg); } } else { diff --git a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java index f2b8d4d9db..e7c4801c12 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java @@ -33,6 +33,7 @@ import org.thingsboard.server.common.data.id.AssetId; import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.HasId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantProfileId; import org.thingsboard.server.common.data.page.PageDataIterable; @@ -243,11 +244,12 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache { @Override public EntityId getProfileId(TenantId tenantId, EntityId entityId) { - return switch (entityId.getEntityType()) { - case ASSET -> assetProfileCache.get(tenantId, (AssetId) entityId).getId(); - case DEVICE -> deviceProfileCache.get(tenantId, (DeviceId) entityId).getId(); + HasId profile = switch (entityId.getEntityType()) { + case ASSET -> assetProfileCache.get(tenantId, (AssetId) entityId); + case DEVICE -> deviceProfileCache.get(tenantId, (DeviceId) entityId); default -> null; }; + return profile != null ? profile.getId() : null; } @Override diff --git a/application/src/test/java/org/thingsboard/server/controller/EdqsControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/EdqsControllerTest.java index 91be3f4744..c3f11ce8d8 100644 --- a/application/src/test/java/org/thingsboard/server/controller/EdqsControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/EdqsControllerTest.java @@ -22,6 +22,7 @@ import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.Device; @@ -41,6 +42,7 @@ import org.thingsboard.server.common.data.query.FilterPredicateValue; import org.thingsboard.server.common.data.query.KeyFilter; import org.thingsboard.server.common.data.query.StringFilterPredicate; import org.thingsboard.server.dao.service.DaoSqlTest; +import org.thingsboard.server.edqs.util.EdqsRocksDb; import java.util.ArrayList; import java.util.Collections; @@ -62,8 +64,11 @@ public class EdqsControllerTest extends AbstractControllerTest { @Autowired private JdbcTemplate jdbcTemplate; + @MockitoBean + private EdqsRocksDb edqsRocksDb; + @Before - public void beforeEdqsControllerTest() throws Exception { + public void before() throws Exception { loginTenantAdmin(); } diff --git a/application/src/test/java/org/thingsboard/server/controller/EdqsEntityQueryControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/EdqsEntityQueryControllerTest.java index 655ab417e6..15ad57b62e 100644 --- a/application/src/test/java/org/thingsboard/server/controller/EdqsEntityQueryControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/EdqsEntityQueryControllerTest.java @@ -19,8 +19,8 @@ import org.assertj.core.api.ThrowingConsumer; import org.junit.Before; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.thingsboard.server.common.data.edqs.EdqsState; import org.thingsboard.server.common.data.edqs.EdqsState.EdqsApiMode; import org.thingsboard.server.common.data.edqs.ToCoreEdqsRequest; @@ -59,7 +59,7 @@ public class EdqsEntityQueryControllerTest extends EntityQueryControllerTest { @Autowired private DiscoveryService discoveryService; - @MockBean // so that we don't do backup for tests + @MockitoBean // so that we don't do backup for tests private EdqsRocksDb edqsRocksDb; @Before diff --git a/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java index 775df230b6..e32a2e8086 100644 --- a/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java @@ -23,6 +23,7 @@ import org.junit.Before; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.ResultActions; import org.testcontainers.shaded.org.apache.commons.lang3.RandomStringUtils; import org.thingsboard.common.util.JacksonUtil; @@ -71,6 +72,7 @@ import org.thingsboard.server.common.data.relation.RelationEntityTypeFilter; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.dao.queue.QueueStatsService; import org.thingsboard.server.dao.service.DaoSqlTest; +import org.thingsboard.server.edqs.util.EdqsRocksDb; import java.util.ArrayList; import java.util.Arrays; @@ -102,6 +104,9 @@ public class EntityQueryControllerTest extends AbstractControllerTest { @Autowired private QueueStatsService queueStatsService; + @MockitoBean + private EdqsRocksDb edqsRocksDb; + @Before public void beforeTest() throws Exception { loginSysAdmin(); @@ -436,7 +441,7 @@ public class EntityQueryControllerTest extends AbstractControllerTest { EntityTypeFilter assetTypeFilter = new EntityTypeFilter(); assetTypeFilter.setEntityType(EntityType.ASSET); - AlarmDataQuery assetAlarmQuery = new AlarmDataQuery(assetTypeFilter, pageLink, null, null, null, alarmFields); + AlarmDataQuery assetAlarmQuery = new AlarmDataQuery(assetTypeFilter, pageLink, null, null, null, alarmFields); PageData alarmPageData = findAlarmsByQueryAndCheck(assetAlarmQuery, 10); List retrievedAlarmTypes = alarmPageData.getData().stream().map(AlarmData::getType).toList(); @@ -446,8 +451,8 @@ public class EntityQueryControllerTest extends AbstractControllerTest { KeyFilter nameFilter = buildStringKeyFilter(EntityKeyType.ENTITY_FIELD, "name", StringFilterPredicate.StringOperation.STARTS_WITH, "Asset1"); List keyFilters = Collections.singletonList(nameFilter); - AlarmDataQuery filteredAssetAlarmQuery = new AlarmDataQuery(assetTypeFilter, pageLink, null, null, keyFilters, alarmFields); - PageData filteredAssetAlamData = doPostWithTypedResponse("/api/alarmsQuery/find", filteredAssetAlarmQuery, new TypeReference<>() { + AlarmDataQuery filteredAssetAlarmQuery = new AlarmDataQuery(assetTypeFilter, pageLink, null, null, keyFilters, alarmFields); + PageData filteredAssetAlamData = doPostWithTypedResponse("/api/alarmsQuery/find", filteredAssetAlarmQuery, new TypeReference<>() { }); Assert.assertEquals(1, filteredAssetAlamData.getTotalElements()); } @@ -509,16 +514,16 @@ public class EntityQueryControllerTest extends AbstractControllerTest { EntityTypeFilter assetTypeFilter = new EntityTypeFilter(); assetTypeFilter.setEntityType(EntityType.ASSET); - AlarmDataQuery assetAlarmQuery = new AlarmDataQuery(assetTypeFilter, pageLink, null, null, null, Collections.emptyList()); + AlarmDataQuery assetAlarmQuery = new AlarmDataQuery(assetTypeFilter, pageLink, null, null, null, Collections.emptyList()); - PageData alarmPageData = findAlarmsByQueryAndCheck(assetAlarmQuery, 10); + PageData alarmPageData = findAlarmsByQueryAndCheck(assetAlarmQuery, 10); List retrievedAlarmTypes = alarmPageData.getData().stream().map(Alarm::getType).toList(); assertThat(retrievedAlarmTypes).containsExactlyInAnyOrderElementsOf(assetAlarmTypes); KeyFilter nameFilter = buildStringKeyFilter(EntityKeyType.ENTITY_FIELD, "name", StringFilterPredicate.StringOperation.STARTS_WITH, "Asset1"); List keyFilters = Collections.singletonList(nameFilter); - AlarmDataQuery filteredAssetAlarmQuery = new AlarmDataQuery(assetTypeFilter, pageLink, null, null, keyFilters, Collections.emptyList()); - PageData filteredAssetAlamData = doPostWithTypedResponse("/api/alarmsQuery/find", filteredAssetAlarmQuery, new TypeReference<>() { + AlarmDataQuery filteredAssetAlarmQuery = new AlarmDataQuery(assetTypeFilter, pageLink, null, null, keyFilters, Collections.emptyList()); + PageData filteredAssetAlamData = doPostWithTypedResponse("/api/alarmsQuery/find", filteredAssetAlarmQuery, new TypeReference<>() { }); Assert.assertEquals(1, filteredAssetAlamData.getTotalElements()); } @@ -574,7 +579,7 @@ public class EntityQueryControllerTest extends AbstractControllerTest { EntityTypeFilter deviceTypeFilter = new EntityTypeFilter(); deviceTypeFilter.setEntityType(EntityType.DEVICE); - AlarmDataQuery deviceAlarmQuery = new AlarmDataQuery(deviceTypeFilter, pageLink, entityFields, latestValues, null, alarmFields); + AlarmDataQuery deviceAlarmQuery = new AlarmDataQuery(deviceTypeFilter, pageLink, entityFields, latestValues, null, alarmFields); PageData alarmPageData = findAlarmsByQueryAndCheck(deviceAlarmQuery, 10); List retrievedAlarmTemps = alarmPageData.getData().stream().map(alarmData -> alarmData.getLatest().get(EntityKeyType.TIME_SERIES).get("temperature").getValue()).toList(); @@ -1291,7 +1296,7 @@ public class EntityQueryControllerTest extends AbstractControllerTest { findByQueryAndCheck(query, 0); } - private void checkEntitiesByQuery(EntityDataQuery query, int expectedNumOfDevices, BiConsumer checkFunction) throws Exception { + private void checkEntitiesByQuery(EntityDataQuery query, int expectedNumOfDevices, BiConsumer checkFunction) throws Exception { await() .alias("data by query") .atMost(30, TimeUnit.SECONDS) diff --git a/application/src/test/java/org/thingsboard/server/service/entitiy/EdqsEntityServiceTest.java b/application/src/test/java/org/thingsboard/server/service/entitiy/EdqsEntityServiceTest.java index 2f244807bd..610e27944e 100644 --- a/application/src/test/java/org/thingsboard/server/service/entitiy/EdqsEntityServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/entitiy/EdqsEntityServiceTest.java @@ -19,8 +19,8 @@ import com.google.common.collect.Lists; import org.junit.Before; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.id.CustomerId; @@ -61,7 +61,7 @@ public class EdqsEntityServiceTest extends EntityServiceTest { @Autowired private EdqsService edqsService; - @MockBean + @MockitoBean private EdqsRocksDb edqsRocksDb; @Before diff --git a/application/src/test/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionServiceTest.java b/application/src/test/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionServiceTest.java index 153228a865..3c62333122 100644 --- a/application/src/test/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionServiceTest.java @@ -162,7 +162,7 @@ class DefaultTelemetrySubscriptionServiceTest { apiUsageState.setDbStorageState(ApiUsageStateValue.ENABLED); lenient().when(apiUsageStateService.getApiUsageState(tenantId)).thenReturn(apiUsageState); - lenient().when(partitionService.resolve(ServiceType.TB_CORE, tenantId, entityId)).thenReturn(tpi); + lenient().when(partitionService.resolve(eq(ServiceType.TB_CORE), eq(tenantId), any())).thenReturn(tpi); lenient().when(tsService.save(tenantId, entityId, sampleTimeseries, sampleTtl)).thenReturn(immediateFuture(TimeseriesSaveResult.of(sampleTimeseries.size(), listOfNNumbers(sampleTimeseries.size())))); lenient().when(tsService.saveWithoutLatest(tenantId, entityId, sampleTimeseries, sampleTtl)).thenReturn(immediateFuture(TimeseriesSaveResult.of(sampleTimeseries.size(), null))); @@ -310,8 +310,6 @@ class DefaultTelemetrySubscriptionServiceTest { given(tbEntityViewService.findEntityViewsByTenantIdAndEntityIdAsync(tenantId, entityId)).willReturn(immediateFuture(List.of(entityView))); // mock that save latest call for entity view is successful given(tsService.saveLatest(tenantId, entityView.getId(), sampleTimeseries)).willReturn(immediateFuture(TimeseriesSaveResult.of(sampleTimeseries.size(), listOfNNumbers(sampleTimeseries.size())))); - // mock TPI for entity view - given(partitionService.resolve(ServiceType.TB_CORE, tenantId, entityView.getId())).willReturn(tpi); var request = TimeseriesSaveRequest.builder() .tenantId(tenantId) @@ -373,8 +371,6 @@ class DefaultTelemetrySubscriptionServiceTest { lenient().when(tbEntityViewService.findEntityViewsByTenantIdAndEntityIdAsync(tenantId, entityId)).thenReturn(immediateFuture(List.of(entityView))); // mock that save latest call for entity view is successful lenient().when(tsService.saveLatest(tenantId, entityView.getId(), sampleTimeseries)).thenReturn(immediateFuture(TimeseriesSaveResult.of(sampleTimeseries.size(), listOfNNumbers(sampleTimeseries.size())))); - // mock TPI for entity view - lenient().when(partitionService.resolve(ServiceType.TB_CORE, tenantId, entityView.getId())).thenReturn(tpi); var request = TimeseriesSaveRequest.builder() .tenantId(tenantId) diff --git a/common/cache/src/main/java/org/thingsboard/server/cache/TbCaffeineCacheConfiguration.java b/common/cache/src/main/java/org/thingsboard/server/cache/TbCaffeineCacheConfiguration.java index 09a363853f..ddefa966f0 100644 --- a/common/cache/src/main/java/org/thingsboard/server/cache/TbCaffeineCacheConfiguration.java +++ b/common/cache/src/main/java/org/thingsboard/server/cache/TbCaffeineCacheConfiguration.java @@ -70,11 +70,10 @@ public class TbCaffeineCacheConfiguration { } private CaffeineCache buildCache(String name, CacheSpecs cacheSpec) { - - final Caffeine caffeineBuilder - = Caffeine.newBuilder() + Caffeine caffeineBuilder = Caffeine.newBuilder() .weigher(collectionSafeWeigher()) .maximumWeight(cacheSpec.getMaxSize()) + .recordStats() .ticker(ticker()); if (!cacheSpec.getTimeToLiveInMinutes().equals(0)) { caffeineBuilder.expireAfterWrite(cacheSpec.getTimeToLiveInMinutes(), TimeUnit.MINUTES); From 0ed9deb186e27566823da2eafe5fcb8a318f1beb Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Wed, 26 Nov 2025 18:04:27 +0200 Subject: [PATCH 10/22] UI: Add API to upload large file resources --- ui-ngx/src/app/core/http/resource.service.ts | 50 +++++++++++++++++- .../resources/resources-dialog.component.ts | 5 +- .../resources-library.component.html | 2 +- .../resources/resources-library.component.ts | 2 +- .../js-library-table-config.resolver.ts | 19 +++++-- .../admin/resource/js-resource.component.html | 2 +- .../admin/resource/js-resource.component.ts | 14 ++--- .../resources-library-table-config.resolve.ts | 26 +++++++--- .../shared/components/file-input.component.ts | 52 ++++++++++--------- .../src/app/shared/models/resource.models.ts | 2 +- 10 files changed, 124 insertions(+), 50 deletions(-) diff --git a/ui-ngx/src/app/core/http/resource.service.ts b/ui-ngx/src/app/core/http/resource.service.ts index 168c63b3b1..81c20be472 100644 --- a/ui-ngx/src/app/core/http/resource.service.ts +++ b/ui-ngx/src/app/core/http/resource.service.ts @@ -17,7 +17,7 @@ import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { PageLink } from '@shared/models/page/page-link'; -import { defaultHttpOptionsFromConfig, RequestConfig } from '@core/http/http-utils'; +import { defaultHttpOptionsFromConfig, defaultHttpUploadOptions, RequestConfig } from '@core/http/http-utils'; import { forkJoin, Observable, of } from 'rxjs'; import { PageData } from '@shared/models/page/page-data'; import { Resource, ResourceInfo, ResourceSubType, ResourceType, TBResourceScope } from '@shared/models/resource.models'; @@ -90,6 +90,54 @@ export class ResourceService { return this.http.post('/api/resource', resource, defaultHttpOptionsFromConfig(config)); } + public uploadResources(resources: Resource[], config?: RequestConfig): Observable { + let partSize = 100; + partSize = resources.length > partSize ? partSize : resources.length; + const resourceObservables: Observable[] = []; + for (let i = 0; i < partSize; i++) { + resourceObservables.push(this.uploadResource(resources[i], config).pipe(catchError(() => of({} as Resource)))); + } + return forkJoin(resourceObservables).pipe( + mergeMap((resource) => { + resources.splice(0, partSize); + if (resources.length) { + return this.uploadResources(resources, config); + } else { + return of(resource); + } + }) + ); + } + + public uploadResource(resource: Resource, config?: RequestConfig): Observable { + if (!config) { + config = {}; + } + const formData = new FormData(); + formData.append('file', resource.data); + formData.append('title', resource.title); + formData.append('resourceType', resource.resourceType); + if (resource.resourceSubType) { + formData.append('resourceSubType', resource.resourceSubType); + } + return this.http.post('/api/resource/upload', formData, + defaultHttpUploadOptions(config.ignoreLoading, config.ignoreErrors, config.resendRequest)); + } + + public updatedResourceInfo(resourceId: string, updatedResources: Partial>, config?: RequestConfig): Observable { + return this.http.put(`/api/resource/${resourceId}/info`, updatedResources, defaultHttpOptionsFromConfig(config)); + } + + public updatedResourceData(resourceId: string, data: File, config?: RequestConfig): Observable { + if (!config) { + config = {}; + } + const formData = new FormData(); + formData.append('file', data); + return this.http.put(`/api/resource/${resourceId}/data`, formData, + defaultHttpUploadOptions(config.ignoreLoading, config.ignoreErrors, config.resendRequest)); + } + public deleteResource(resourceId: string, force = false, config?: RequestConfig) { return this.http.delete(`/api/resource/${resourceId}?force=${force}`, defaultHttpOptionsFromConfig(config)); } diff --git a/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.ts b/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.ts index 6216f087b1..3848879dc5 100644 --- a/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.ts @@ -98,18 +98,17 @@ export class ResourcesDialogComponent extends DialogComponent response[0]) ).subscribe(result => this.dialogRef.close(result)); } else { if (resource.resourceType !== ResourceType.GENERAL) { delete resource.descriptor; } - this.resourceService.saveResource(resource).subscribe(result => this.dialogRef.close(result)); + this.resourceService.uploadResource(resource).subscribe(result => this.dialogRef.close(result)); } } } diff --git a/ui-ngx/src/app/modules/home/components/resources/resources-library.component.html b/ui-ngx/src/app/modules/home/components/resources/resources-library.component.html index 819753e105..8fb61d2af2 100644 --- a/ui-ngx/src/app/modules/home/components/resources/resources-library.component.html +++ b/ui-ngx/src/app/modules/home/components/resources/resources-library.component.html @@ -70,7 +70,7 @@ impleme return this.fb.group({ title: [entity ? entity.title : '', [Validators.required, Validators.maxLength(255)]], resourceType: [entity?.resourceType ? entity.resourceType : ResourceType.LWM2M_MODEL, Validators.required], - fileName: [entity ? entity.fileName : null, Validators.required], + fileName: [entity ? entity.fileName : null], data: [entity ? entity.data : null, this.isAdd ? [Validators.required] : []], descriptor: this.fb.group({ mediaType: [''] diff --git a/ui-ngx/src/app/modules/home/pages/admin/resource/js-library-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/admin/resource/js-library-table-config.resolver.ts index 341bbd2e06..c605018f71 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/resource/js-library-table-config.resolver.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/resource/js-library-table-config.resolver.ts @@ -32,7 +32,7 @@ import { ResourceType, toResourceDeleteResult } from '@shared/models/resource.models'; -import { EntityType, entityTypeResources } from '@shared/models/entity-type.models'; +import { EntityType } from '@shared/models/entity-type.models'; import { NULL_UUID } from '@shared/models/id/has-uuid'; import { DatePipe } from '@angular/common'; import { TranslateService } from '@ngx-translate/core'; @@ -47,7 +47,7 @@ import { JsLibraryTableHeaderComponent } from '@home/pages/admin/resource/js-lib import { JsResourceComponent } from '@home/pages/admin/resource/js-resource.component'; import { catchError, map, switchMap } from 'rxjs/operators'; import { ResourceTabsComponent } from '@home/pages/admin/resource/resource-tabs.component'; -import { forkJoin, of } from 'rxjs'; +import { forkJoin, Observable, of } from 'rxjs'; import { parseHttpErrorMessage } from '@core/utils'; import { ActionNotificationShow } from '@core/notification/notification.actions'; import { MatDialog } from '@angular/material/dialog'; @@ -118,9 +118,20 @@ export class JsLibraryTableConfigResolver { return this.resourceService.getResourceInfoById(id.id) } }; - this.config.saveEntity = resource => { + this.config.saveEntity = (resource: Resource, originalResource: Resource) => { resource.resourceType = ResourceType.JS_MODULE; - let saveObservable = this.resourceService.saveResource(resource); + let saveObservable: Observable; + if (!originalResource) { + saveObservable = this.resourceService.uploadResource(resource); + } else { + const { data, ...resourceInfo } = resource; + saveObservable = this.resourceService.updatedResourceInfo(resource.id.id, resourceInfo); + if (data) { + saveObservable = saveObservable.pipe( + switchMap(() => this.resourceService.updatedResourceData(resource.id.id, data)) + ) + } + } if (resource.resourceSubType === ResourceSubType.MODULE) { saveObservable = saveObservable.pipe( switchMap((saved) => this.resourceService.getResource(saved.id.id)) diff --git a/ui-ngx/src/app/modules/home/pages/admin/resource/js-resource.component.html b/ui-ngx/src/app/modules/home/pages/admin/resource/js-resource.component.html index c9f7483bdc..fd7fd7ee6b 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/resource/js-resource.component.html +++ b/ui-ngx/src/app/modules/home/pages/admin/resource/js-resource.component.html @@ -70,7 +70,7 @@ formControlName="data" [required]="isAdd" label="{{ 'javascript.resource-file' | translate }}" - [readAsBinary]="true" + [workFromFileObj]="true" [maxSizeByte]="maxResourceSize" [allowedExtensions]="getAllowedExtensions()" [contentConvertFunction]="convertToBase64File" diff --git a/ui-ngx/src/app/modules/home/pages/admin/resource/js-resource.component.ts b/ui-ngx/src/app/modules/home/pages/admin/resource/js-resource.component.ts index 4a67bd10c8..c8e9ccc82e 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/resource/js-resource.component.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/resource/js-resource.component.ts @@ -32,7 +32,7 @@ import { } from '@shared/models/resource.models'; import { startWith, takeUntil } from 'rxjs/operators'; import { ActionNotificationShow } from '@core/notification/notification.actions'; -import { isDefinedAndNotNull } from '@core/utils'; +import { base64toString, isDefinedAndNotNull, stringToBase64 } from '@core/utils'; import { getCurrentAuthState } from '@core/auth/auth.selectors'; @Component({ @@ -82,15 +82,15 @@ export class JsResourceComponent extends EntityComponent implements On return this.fb.group({ title: [entity ? entity.title : '', [Validators.required, Validators.maxLength(255)]], resourceSubType: [entity?.resourceSubType ? entity.resourceSubType : ResourceSubType.EXTENSION, Validators.required], - fileName: [entity ? entity.fileName : null, Validators.required], + fileName: [entity ? entity.fileName : null], data: [entity ? entity.data : null, this.isAdd ? [Validators.required] : []], - content: [entity?.data?.length ? window.atob(entity.data) : '', Validators.required] + content: [entity?.data?.length ? base64toString(entity.data) : '', Validators.required] }); } updateForm(entity: Resource): void { this.entityForm.patchValue(entity); - const content = entity.resourceSubType === ResourceSubType.MODULE && entity?.data?.length ? window.atob(entity.data) : ''; + const content = entity.resourceSubType === ResourceSubType.MODULE && entity?.data?.length ? base64toString(entity.data) : ''; this.entityForm.get('content').patchValue(content); } @@ -110,7 +110,9 @@ export class JsResourceComponent extends EntityComponent implements On if (!formValue.fileName) { formValue.fileName = formValue.title + '.js'; } - formValue.data = window.btoa((formValue as any).content); + formValue.data = new File([(formValue as any).content], formValue.fileName, { + type: 'text/javascript' + }); delete (formValue as any).content; } return super.prepareFormValue(formValue); @@ -125,7 +127,7 @@ export class JsResourceComponent extends EntityComponent implements On } convertToBase64File(data: string): string { - return window.btoa(data); + return stringToBase64(data); } onResourceIdCopied(): void { diff --git a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library-table-config.resolve.ts b/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library-table-config.resolve.ts index 1bce24f984..0499cb2d1b 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library-table-config.resolve.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library-table-config.resolve.ts @@ -24,7 +24,8 @@ import { import { Router } from '@angular/router'; import { Resource, - ResourceInfo, ResourceInfoWithReferences, + ResourceInfo, + ResourceInfoWithReferences, ResourceType, ResourceTypeTranslationMap, toResourceDeleteResult @@ -41,10 +42,10 @@ import { Authority } from '@shared/models/authority.enum'; import { ResourcesLibraryComponent } from '@home/components/resources/resources-library.component'; import { PageLink } from '@shared/models/page/page-link'; import { EntityAction } from '@home/models/entity/entity-component.models'; -import { catchError, map } from 'rxjs/operators'; +import { catchError, map, switchMap } from 'rxjs/operators'; import { ResourcesTableHeaderComponent } from '@home/pages/admin/resource/resources-table-header.component'; import { ResourceLibraryTabsComponent } from '@home/pages/admin/resource/resource-library-tabs.component'; -import { forkJoin, of } from "rxjs"; +import { forkJoin, Observable, of } from "rxjs"; import { ResourcesInUseDialogComponent, ResourcesInUseDialogData @@ -114,27 +115,36 @@ export class ResourcesLibraryTableConfigResolver { this.config.entitiesFetchFunction = pageLink => this.resourceService.getResources(pageLink, this.config.componentsData.resourceType); this.config.loadEntity = id => this.resourceService.getResourceInfoById(id.id); - this.config.saveEntity = resource => this.saveResource(resource); + this.config.saveEntity = (resource, originalResource) => this.saveResource(resource, originalResource); this.config.onEntityAction = action => this.onResourceAction(action); } - saveResource(resource) { + saveResource(resource: Resource & {data?: File | File[]}, originalResource: Resource) { if (Array.isArray(resource.data)) { const resources = []; resource.data.forEach((data, index) => { resources.push({ resourceType: resource.resourceType, data, - fileName: resource.fileName[index], title: resource.title }); }); - return this.resourceService.saveResources(resources, {resendRequest: true}).pipe( + return this.resourceService.uploadResources(resources, {resendRequest: true}).pipe( map((response) => response[0]) ); + } else if (!originalResource) { + return this.resourceService.uploadResource(resource); } else { - return this.resourceService.saveResource(resource); + const { data, ...resourceInfo } = resource; + let saveObservable: Observable; + saveObservable = this.resourceService.updatedResourceInfo(resource.id.id, resourceInfo); + if (data) { + saveObservable = saveObservable.pipe( + switchMap(() => this.resourceService.updatedResourceData(resource.id.id, data)) + ) + } + return saveObservable; } } diff --git a/ui-ngx/src/app/shared/components/file-input.component.ts b/ui-ngx/src/app/shared/components/file-input.component.ts index 6960db73dd..518e5920c1 100644 --- a/ui-ngx/src/app/shared/components/file-input.component.ts +++ b/ui-ngx/src/app/shared/components/file-input.component.ts @@ -180,17 +180,18 @@ export class FileInputComponent extends PageComponent implements AfterViewInit, if (readers.length) { Promise.all(readers).then((files) => { - files = files.filter(file => file.fileContent != null || file.files != null); - if (files.length === 1) { - this.fileContent = files[0].fileContent; - this.fileName = files[0].fileName; - this.files = files[0].files; - this.mediaType = files[0].mediaType; + const validResults = files.filter(file => file.fileContent != null || file.files != null); + + if (validResults.length === 1) { + this.fileContent = validResults[0].fileContent; + this.fileName = validResults[0].fileName; + this.files = validResults[0].files; + this.mediaType = validResults[0].mediaType; this.updateModel(); - } else if (files.length > 1) { - this.fileContent = files.map(content => content.fileContent); - this.fileName = files.map(content => content.fileName); - this.files = files.map(content => content.files); + } else if (validResults.length > 1) { + this.fileContent = validResults.map(content => content.fileContent); + this.fileName = validResults.map(content => content.fileName); + this.files = validResults.map(content => content.files); this.updateModel(); } }); @@ -204,29 +205,32 @@ export class FileInputComponent extends PageComponent implements AfterViewInit, private readerAsFile(file: flowjs.FlowFile): Promise { return new Promise((resolve) => { + if (this.workFromFileObj) { + resolve({ + fileContent: null, + fileName: file.name, + files: file.file, + mediaType: file.file.type || null + }); + return; + } + const reader = new FileReader(); reader.onload = () => { let fileName = null; let fileContent = null; - let files = null; let mediaType = null; if (reader.readyState === reader.DONE) { - if (!this.workFromFileObj) { - fileContent = reader.result; - if (fileContent && fileContent.length > 0) { - if (this.contentConvertFunction) { - fileContent = this.contentConvertFunction(fileContent); - } - fileName = fileContent ? file.name : null; - mediaType = file?.file?.type || null; + fileContent = reader.result; + if (fileContent && fileContent.length > 0) { + if (this.contentConvertFunction) { + fileContent = this.contentConvertFunction(fileContent); } - } else if (file.name || file.file){ - files = file.file; - fileName = file.name; - mediaType = file.file.type || null; + fileName = fileContent ? file.name : null; + mediaType = file?.file?.type || null; } } - resolve({fileContent, fileName, files, mediaType}); + resolve({fileContent, fileName, files: null, mediaType}); }; reader.onerror = () => { resolve({fileContent: null, fileName: null, files: null, mediaType: null}); diff --git a/ui-ngx/src/app/shared/models/resource.models.ts b/ui-ngx/src/app/shared/models/resource.models.ts index e19f1c82a0..be25cb7871 100644 --- a/ui-ngx/src/app/shared/models/resource.models.ts +++ b/ui-ngx/src/app/shared/models/resource.models.ts @@ -89,7 +89,7 @@ export interface TbResourceInfo extends Omit, 'name' | export type ResourceInfo = TbResourceInfo; export interface Resource extends ResourceInfo { - data?: string; + data?: any; name?: string; } From 18b1f882ec6b1c85c9e958effc02f3e33644585d Mon Sep 17 00:00:00 2001 From: ArtemDzhereleiko Date: Fri, 28 Nov 2025 10:20:50 +0200 Subject: [PATCH 11/22] UI: Add missing for option for filters --- .../alarm-rules/alarm-rule.module.ts | 6 +- ...alarm-rule-condition-dialog.component.html | 6 +- ...f-alarm-rule-condition-dialog.component.ts | 32 +++- ...mplex-filter-predicate-dialog.component.ts | 11 +- .../alarm-rule-filter-dialog.component.ts | 2 +- .../alarm-rule-filter-list.component.html | 2 +- ...rm-rule-filter-predicate-list.component.ts | 26 +-- ...ter-predicate-no-data-value.component.html | 77 +++++++++ ...ilter-predicate-no-data-value.component.ts | 159 ++++++++++++++++++ ...rule-filter-predicate-value.component.html | 9 + ...m-rule-filter-predicate-value.component.ts | 26 ++- ...alarm-rule-filter-predicate.component.html | 17 +- .../alarm-rule-filter-predicate.component.ts | 86 +++++++--- .../alarm-rule-filter-text.component.ts | 34 ++-- .../app/shared/models/alarm-rule.models.ts | 94 ++++++++++- .../assets/locale/locale.constant-en_US.json | 8 +- 16 files changed, 519 insertions(+), 76 deletions(-) create mode 100644 ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-no-data-value.component.html create mode 100644 ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-no-data-value.component.ts diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule.module.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule.module.ts index c02c11a170..52d91188bc 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule.module.ts +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule.module.ts @@ -50,6 +50,9 @@ import { AlarmRuleFilterDialogComponent } from "@home/components/alarm-rules/fil import { AlarmRuleDetailsDialogComponent } from "@home/components/alarm-rules/alarm-rule-details-dialog.component"; import { AlarmRuleFilterConfigComponent } from "@home/components/alarm-rules/alarm-rule-filter-config.component"; import { AlarmRuleTableHeaderComponent } from "@home/components/alarm-rules/alarm-rule-table-header.component"; +import { + AlarmRuleFilterPredicateNoDataValueComponent +} from "@home/components/alarm-rules/filter/alarm-rule-filter-predicate-no-data-value.component"; @NgModule({ declarations: [ @@ -69,7 +72,8 @@ import { AlarmRuleTableHeaderComponent } from "@home/components/alarm-rules/alar AlarmRuleComplexFilterPredicateDialogComponent, AlarmRuleDetailsDialogComponent, AlarmRuleFilterConfigComponent, - AlarmRuleTableHeaderComponent + AlarmRuleTableHeaderComponent, + AlarmRuleFilterPredicateNoDataValueComponent ], imports: [ CommonModule, diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition-dialog.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition-dialog.component.html index feafcaa0a4..5d94476e6e 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition-dialog.component.html @@ -78,10 +78,14 @@ alarm-rule.condition-type - + {{ alarmConditionTypeTranslation.get(alarmConditionType) | translate }} + @if (isNoData) { + alarm-rule.condition-type-hint + } @if (conditionFormGroup.get('type').value == AlarmConditionType.DURATION) {
diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition-dialog.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition-dialog.component.ts index 48b290b32c..3635d415d7 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition-dialog.component.ts @@ -28,7 +28,8 @@ import { AlarmRuleCondition, AlarmRuleConditionType, AlarmRuleConditionTypeTranslationMap, - AlarmRuleExpressionType + AlarmRuleExpressionType, + AlarmRuleFilter } from "@shared/models/alarm-rule.models"; import { CalculatedFieldArgument, @@ -102,6 +103,8 @@ export class CfAlarmRuleConditionDialogComponent extends DialogComponent; + isNoData: boolean = false; + constructor(protected store: Store, protected router: Router, @Inject(MAT_DIALOG_DATA) public data: CfAlarmRuleConditionDialogData, @@ -136,12 +139,20 @@ export class CfAlarmRuleConditionDialogComponent extends DialogComponent { this.updateValidators(type, true); }); + this.conditionFormGroup.get('expression.filters').valueChanges.pipe( + takeUntilDestroyed() + ).subscribe((filters) => { + this.checkIsNoData(filters); + }); + this.conditionFormGroup.get('expression.type').valueChanges.pipe( takeUntilDestroyed() ).subscribe((type) => { @@ -185,6 +196,25 @@ export class CfAlarmRuleConditionDialogComponent extends DialogComponent) { + this.isNoData = this.hasNoData(filters); + if (this.isNoData && this.conditionFormGroup.get('type').value !== AlarmRuleConditionType.SIMPLE) { + this.conditionFormGroup.get('type').patchValue(AlarmRuleConditionType.SIMPLE); + } + } + + private hasNoData(data: Array) { + const search = (filter) => { + if (!filter) return false; + if (Array.isArray(filter)) return filter.some(search); + if (typeof filter !== 'object') return false; + if (filter.type === 'NO_DATA') return true; + if (filter.predicates?.length) return filter.predicates.some(search); + return false; + }; + return search(data); + }; + private updateValidators(type: AlarmRuleConditionType, emitEvent = false) { switch (type) { case AlarmRuleConditionType.DURATION: diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-complex-filter-predicate-dialog.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-complex-filter-predicate-dialog.component.ts index 941ce326e1..d2c9389de1 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-complex-filter-predicate-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-complex-filter-predicate-dialog.component.ts @@ -24,10 +24,13 @@ import { DialogComponent } from '@app/shared/components/dialog.component'; import { ComplexOperation, complexOperationTranslationMap, - EntityKeyValueType, - FilterPredicateType + EntityKeyValueType } from '@shared/models/query/query.models'; -import { AlarmRuleFilterPredicate, ComplexAlarmRuleFilterPredicate } from "@shared/models/alarm-rule.models"; +import { + AlarmRuleFilterPredicate, + AlarmRuleFilterPredicateType, + ComplexAlarmRuleFilterPredicate +} from "@shared/models/alarm-rule.models"; import { CalculatedFieldArgument } from "@shared/models/calculated-field.models"; export interface AlarmRuleComplexFilterPredicateDialogData { @@ -81,7 +84,7 @@ export class AlarmRuleComplexFilterPredicateDialogComponent extends save(): void { const predicate = this.complexFilterFormGroup.value as ComplexAlarmRuleFilterPredicate; - predicate.type = FilterPredicateType.COMPLEX; + predicate.type = AlarmRuleFilterPredicateType.COMPLEX; this.dialogRef.close(predicate); } } diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-dialog.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-dialog.component.ts index 6961524bdd..0858fd90ae 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-dialog.component.ts @@ -78,7 +78,7 @@ export class AlarmRuleFilterDialogComponent extends DialogComponent
{{ filterControl.value?.argument }}
-
{{ FilterPredicateTypeTranslationMap.get(filterControl.value?.predicates[0]?.type) | translate }}
+
{{ FilterPredicateTypeTranslationMap.get(filterControl.value?.valueType) | translate }}
diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-value.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-value.component.ts index 036083c826..3300a1fceb 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-value.component.ts +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-value.component.ts @@ -14,7 +14,7 @@ /// limitations under the License. /// -import { Component, DestroyRef, forwardRef, Input, OnInit } from '@angular/core'; +import { Component, DestroyRef, forwardRef, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'; import { ControlValueAccessor, FormBuilder, @@ -49,7 +49,7 @@ import { FormControlsFrom } from "@shared/models/tenant.model"; } ] }) -export class AlarmRuleFilterPredicateValueComponent implements ControlValueAccessor, Validator, OnInit { +export class AlarmRuleFilterPredicateValueComponent implements ControlValueAccessor, Validator, OnInit, OnChanges { @Input() arguments: Record; @@ -110,6 +110,20 @@ export class AlarmRuleFilterPredicateValueComponent implements ControlValueAcces ).subscribe(value => this.updateValueModeValidators(value)); } + ngOnChanges(changes: SimpleChanges) { + if (changes.argumentInUse) { + const argumentInUseChanges = changes.argumentInUse; + if (!argumentInUseChanges.firstChange && argumentInUseChanges.currentValue !== argumentInUseChanges.previousValue) { + if (this.dynamicModeControl.value) { + if (this.argumentInUse === this.filterPredicateValueFormGroup.get('dynamicValueArgument').value) { + this.filterPredicateValueFormGroup.get('dynamicValueArgument').setErrors({argumentInUse: true}); + this.filterPredicateValueFormGroup.updateValueAndValidity(); + } + } + } + } + } + setDisabledState(isDisabled: boolean): void { if (isDisabled) { this.filterPredicateValueFormGroup.disable({emitEvent: false}); @@ -125,6 +139,12 @@ export class AlarmRuleFilterPredicateValueComponent implements ControlValueAcces if (isDynamicMode) { this.filterPredicateValueFormGroup.get('staticValue').disable({emitEvent: false}); this.filterPredicateValueFormGroup.get('dynamicValueArgument').enable(); + setTimeout(()=> { + if (this.filterPredicateValueFormGroup.get('dynamicValueArgument').value && this.argumentInUse === this.filterPredicateValueFormGroup.get('dynamicValueArgument').value) { + this.filterPredicateValueFormGroup.get('dynamicValueArgument').setErrors({argumentInUse: true}); + this.filterPredicateValueFormGroup.updateValueAndValidity(); + } + }, 0); } else { this.filterPredicateValueFormGroup.get('dynamicValueArgument').disable({emitEvent: false}); this.filterPredicateValueFormGroup.get('staticValue').enable(); @@ -146,7 +166,7 @@ export class AlarmRuleFilterPredicateValueComponent implements ControlValueAcces writeValue(predicateValue: AlarmRuleValue): void { this.filterPredicateValueFormGroup.patchValue(predicateValue, {emitEvent: false}); - this.dynamicModeControl.patchValue(!!predicateValue.dynamicValueArgument?.length, {emitEvent: false}); + this.dynamicModeControl.patchValue(!!predicateValue?.dynamicValueArgument?.length, {emitEvent: false}); } private updateModel() { diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate.component.html index d73c222f9d..b297117b5d 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate.component.html +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate.component.html @@ -27,9 +27,11 @@ - - {{ 'alarm-rule.ignore-case' | translate }} - + @if (filterPredicateFormGroup.get('operation').value !== stringOperation.NO_DATA) { + + {{ 'alarm-rule.ignore-case' | translate }} + + }
} @case (filterPredicateType.NUMERIC) { @@ -68,7 +70,14 @@ } } - @if (type !== filterPredicateType.COMPLEX) { + @if (filterPredicateFormGroup.get('operation').value === stringOperation.NO_DATA) { + + + } @else if (type !== filterPredicateType.COMPLEX) { { }; @@ -132,11 +134,45 @@ export class AlarmRuleFilterPredicateComponent implements ControlValueAccessor, writeValue(predicate: AlarmRuleFilterPredicate): void { this.type = predicate.type; - this.filterPredicateFormGroup.patchValue(predicate, {emitEvent: false}); + if (predicate.type === AlarmRuleFilterPredicateType.NO_DATA) { + this.type = AlarmRuleFilterPredicateType[this.valueType]; + this.filterPredicateFormGroup.patchValue({operation: 'NO_DATA', duration: predicate}, {emitEvent: false}); + } else { + this.filterPredicateFormGroup.patchValue(predicate, {emitEvent: false}); + } } private updateModel() { - this.propagateChange({type: this.type, ...this.filterPredicateFormGroup.value}); + const predicate = this.filterPredicateFormGroup.value; + if (predicate.operation === 'NO_DATA') { + this.propagateChange(predicate.duration); + } else { + if (!predicate.value) { + switch (this.valueType) { + case EntityKeyValueType.STRING: + predicate.value = { + staticValue: '' + }; + break; + case EntityKeyValueType.NUMERIC: + predicate.value = { + staticValue: 0 + }; + break; + case EntityKeyValueType.DATE_TIME: + predicate.value = { + staticValue: Date.now() + }; + break; + case EntityKeyValueType.BOOLEAN: + predicate.value = { + staticValue: false + }; + break; + } + } + this.propagateChange({type: this.type, ...predicate}); + } } public openComplexFilterDialog() { diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-text.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-text.component.ts index 498b2a0a81..f824fdc876 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-text.component.ts +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-text.component.ts @@ -16,25 +16,27 @@ import { Component, Input } from '@angular/core'; import { - booleanOperationTranslationMap, ComplexOperation, complexOperationTranslationMap, - EntityKeyValueType, - FilterPredicateType, - numericOperationTranslationMap, - stringOperationTranslationMap + EntityKeyValueType } from '@shared/models/query/query.models'; import { TranslateService } from '@ngx-translate/core'; import { DatePipe } from '@angular/common'; import { + alarmRuleBooleanOperationTranslationMap, AlarmRuleExpression, AlarmRuleExpressionType, AlarmRuleFilter, AlarmRuleFilterPredicate, + AlarmRuleFilterPredicateType, + alarmRuleNumericOperationTranslationMap, + AlarmRuleStringOperation, + alarmRuleStringOperationTranslationMap, ComplexAlarmRuleFilterPredicate } from "@shared/models/alarm-rule.models"; import { CalculatedFieldArgument } from "@shared/models/calculated-field.models"; import { coerceBoolean } from "@shared/decorators/coercion"; +import { timeUnitTranslationMap } from "@shared/models/time/time.models"; @Component({ selector: 'tb-alarm-rule-filter-text', @@ -137,21 +139,21 @@ export class AlarmRuleFilterTextComponent { const filterOperation: ComplexOperation = complexOperation ? complexOperation : (keyFilter.operation ?? ComplexOperation.AND); const predicates = keyFilterPredicates.map((keyFilterPredicate: AlarmRuleFilterPredicate) => { - if (keyFilterPredicate.type === FilterPredicateType.COMPLEX) { + if (keyFilterPredicate.type === AlarmRuleFilterPredicateType.COMPLEX) { const complexPredicate = keyFilterPredicate as ComplexAlarmRuleFilterPredicate; const complexOperation = complexPredicate.operation ?? ComplexOperation.AND; return this.filterPredicateToText(translate, datePipe, keyFilter, complexPredicate.predicates, complexOperation); } else { let operation: string; let value: string; - const val = keyFilterPredicate.value; + const val = keyFilterPredicate.type === AlarmRuleFilterPredicateType.NO_DATA ? keyFilterPredicate.duration : keyFilterPredicate.value; const dynamicValue = val?.dynamicValueArgument?.length; if (dynamicValue) { value = '' + val?.dynamicValueArgument + ''; } switch (keyFilterPredicate.type) { - case FilterPredicateType.STRING: - operation = translate.instant(stringOperationTranslationMap.get(keyFilterPredicate.operation)); + case AlarmRuleFilterPredicateType.STRING: + operation = translate.instant(alarmRuleStringOperationTranslationMap.get(keyFilterPredicate.operation)); if (keyFilterPredicate.ignoreCase) { operation += ' ' + translate.instant('filter.ignore-case'); } @@ -159,8 +161,8 @@ export class AlarmRuleFilterTextComponent { value = `'${keyFilterPredicate.value.staticValue}'`; } break; - case FilterPredicateType.NUMERIC: - operation = translate.instant(numericOperationTranslationMap.get(keyFilterPredicate.operation)); + case AlarmRuleFilterPredicateType.NUMERIC: + operation = translate.instant(alarmRuleNumericOperationTranslationMap.get(keyFilterPredicate.operation)); if (!dynamicValue) { if (keyFilter.valueType === EntityKeyValueType.DATE_TIME) { value = datePipe.transform(keyFilterPredicate.value.staticValue, 'yyyy-MM-dd HH:mm'); @@ -169,12 +171,18 @@ export class AlarmRuleFilterTextComponent { } } break; - case FilterPredicateType.BOOLEAN: - operation = translate.instant(booleanOperationTranslationMap.get(keyFilterPredicate.operation)); + case AlarmRuleFilterPredicateType.BOOLEAN: + operation = translate.instant(alarmRuleBooleanOperationTranslationMap.get(keyFilterPredicate.operation)); if (!dynamicValue) { value = translate.instant(keyFilterPredicate.value.staticValue ? 'value.true' : 'value.false'); } break; + case AlarmRuleFilterPredicateType.NO_DATA: + operation = translate.instant(alarmRuleStringOperationTranslationMap.get(AlarmRuleStringOperation.NO_DATA)); + if (!dynamicValue) { + value = keyFilterPredicate.duration.staticValue + ' ' + translate.instant(timeUnitTranslationMap.get(keyFilterPredicate.unit)).toLowerCase(); + } + break; } if (!dynamicValue) { value = `${value}`; diff --git a/ui-ngx/src/app/shared/models/alarm-rule.models.ts b/ui-ngx/src/app/shared/models/alarm-rule.models.ts index c9b549d78a..b92bf8bea6 100644 --- a/ui-ngx/src/app/shared/models/alarm-rule.models.ts +++ b/ui-ngx/src/app/shared/models/alarm-rule.models.ts @@ -118,7 +118,8 @@ export interface AlarmRulePredicateInfo { export type AlarmRuleFilterPredicate = StringAlarmRuleFilterPredicate | NumericAlarmRuleFilterPredicate | BooleanAlarmRuleFilterPredicate | - ComplexAlarmRuleFilterPredicate; + ComplexAlarmRuleFilterPredicate | + NoDataAlarmRuleFilterPredicate; export interface AlarmRuleValue { dynamicValueArgument?: string; @@ -126,26 +127,33 @@ export interface AlarmRuleValue { } export interface StringAlarmRuleFilterPredicate { - type: FilterPredicateType.STRING; - operation: StringOperation; + type: AlarmRuleFilterPredicateType.STRING; + operation: AlarmRuleStringOperation; value: AlarmRuleValue; ignoreCase: boolean; } export interface NumericAlarmRuleFilterPredicate { - type: FilterPredicateType.NUMERIC; - operation: NumericOperation; + type: AlarmRuleFilterPredicateType.NUMERIC; + operation: AlarmRuleNumericOperation; value: AlarmRuleValue; } export interface BooleanAlarmRuleFilterPredicate { - type: FilterPredicateType.BOOLEAN; - operation: BooleanOperation; + type: AlarmRuleFilterPredicateType.BOOLEAN; + operation: AlarmRuleBooleanOperation; value: AlarmRuleValue; } +export interface NoDataAlarmRuleFilterPredicate { + type: AlarmRuleFilterPredicateType.NO_DATA; + unit: TimeUnit, + operation: AlarmRuleStringOperation.NO_DATA | AlarmRuleNumericOperation.NO_DATA | AlarmRuleBooleanOperation.NO_DATA; + duration: AlarmRuleValue; +} + export interface BaseComplexFilterPredicate { - type: FilterPredicateType.COMPLEX; + type: AlarmRuleFilterPredicateType.COMPLEX; operation: ComplexOperation; predicates: Array; } @@ -157,3 +165,73 @@ export interface AlarmRuleFilterConfig { entityType?: EntityType; entities?: Array; } + +export enum AlarmRuleFilterPredicateType { + STRING = 'STRING', + NUMERIC = 'NUMERIC', + BOOLEAN = 'BOOLEAN', + COMPLEX = 'COMPLEX', + NO_DATA = 'NO_DATA' +} + +export enum AlarmRuleStringOperation { + EQUAL = 'EQUAL', + NOT_EQUAL = 'NOT_EQUAL', + NO_DATA = 'NO_DATA', + STARTS_WITH = 'STARTS_WITH', + ENDS_WITH = 'ENDS_WITH', + CONTAINS = 'CONTAINS', + NOT_CONTAINS = 'NOT_CONTAINS', + IN = 'IN', + NOT_IN = 'NOT_IN', +} + +export const alarmRuleStringOperationTranslationMap = new Map( + [ + [AlarmRuleStringOperation.EQUAL, 'filter.operation.equal'], + [AlarmRuleStringOperation.NOT_EQUAL, 'filter.operation.not-equal'], + [AlarmRuleStringOperation.STARTS_WITH, 'filter.operation.starts-with'], + [AlarmRuleStringOperation.ENDS_WITH, 'filter.operation.ends-with'], + [AlarmRuleStringOperation.CONTAINS, 'filter.operation.contains'], + [AlarmRuleStringOperation.NOT_CONTAINS, 'filter.operation.not-contains'], + [AlarmRuleStringOperation.IN, 'filter.operation.in'], + [AlarmRuleStringOperation.NOT_IN, 'filter.operation.not-in'], + [AlarmRuleStringOperation.NO_DATA, 'alarm-rule.missing-for'] + ] +); + +export enum AlarmRuleNumericOperation { + EQUAL = 'EQUAL', + NOT_EQUAL = 'NOT_EQUAL', + NO_DATA = 'NO_DATA', + GREATER = 'GREATER', + LESS = 'LESS', + GREATER_OR_EQUAL = 'GREATER_OR_EQUAL', + LESS_OR_EQUAL = 'LESS_OR_EQUAL' +} + +export const alarmRuleNumericOperationTranslationMap = new Map( + [ + [AlarmRuleNumericOperation.EQUAL, 'filter.operation.equal'], + [AlarmRuleNumericOperation.NOT_EQUAL, 'filter.operation.not-equal'], + [AlarmRuleNumericOperation.GREATER, 'filter.operation.greater'], + [AlarmRuleNumericOperation.LESS, 'filter.operation.less'], + [AlarmRuleNumericOperation.GREATER_OR_EQUAL, 'filter.operation.greater-or-equal'], + [AlarmRuleNumericOperation.LESS_OR_EQUAL, 'filter.operation.less-or-equal'], + [AlarmRuleNumericOperation.NO_DATA, 'alarm-rule.missing-for'] + ] +); + +export enum AlarmRuleBooleanOperation { + EQUAL = 'EQUAL', + NOT_EQUAL = 'NOT_EQUAL', + NO_DATA = 'NO_DATA', +} + +export const alarmRuleBooleanOperationTranslationMap = new Map( + [ + [AlarmRuleBooleanOperation.EQUAL, 'filter.operation.equal'], + [AlarmRuleBooleanOperation.NOT_EQUAL, 'filter.operation.not-equal'], + [AlarmRuleBooleanOperation.NO_DATA, 'alarm-rule.missing-for'] + ] +); diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 538b3a8352..1899802715 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -1466,6 +1466,7 @@ "enter-alarm-rule-condition-prompt": "Please add alarm rule condition", "edit-alarm-rule-condition": "Edit alarm rule condition", "condition-type": "Condition type", + "condition-type-hint": "\"Duration\" and \"Repeating\" options are not available when the \"Missing for\" operation is used in the filter.", "select-alarm-severity": "Select alarm severity", "add-create-alarm-rule-prompt": "Please add create alarm rule", "add-create-alarm-rule": "Add create condition", @@ -1497,7 +1498,12 @@ "no-alarm-rule-types-matching": "No alarm rule types matching '{{entitySubtype}}' were found.", "alarm-rule-type-list-empty": "No alarm rule types selected.", "alarm-rule-type-list": "Alarm rule type list", - "alarm-rule-entity-list": "Entity list" + "alarm-rule-entity-list": "Entity list", + "missing-for": "missing for", + "time-unit": "Unit", + "value-required": "Value is required.", + "min-value": "Value must be 0 or greater.", + "argument-in-use": "Argument is used as general argument." }, "ai-models": { "ai-models": "AI models", From c5050890ba4620bda8c7b3c6d70f69732ad0342b Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Fri, 28 Nov 2025 12:55:56 +0200 Subject: [PATCH 12/22] Update VC for alarm rules CFs --- .../impl/DefaultEntityExportService.java | 9 + .../impl/BaseEntityImportService.java | 9 + .../server/controller/AbstractWebTest.java | 31 ++- .../CalculatedFieldControllerTest.java | 20 -- .../service/sync/vc/VersionControlTest.java | 187 +++++++++++++----- .../common/data/alarm/rule/AlarmRule.java | 4 + .../alarm/rule/condition/AlarmCondition.java | 2 +- .../TbelAlarmConditionExpression.java | 4 + 8 files changed, 187 insertions(+), 79 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DefaultEntityExportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DefaultEntityExportService.java index 5d634c4178..70e90b2636 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DefaultEntityExportService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DefaultEntityExportService.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.service.sync.ie.exporting.impl; +import org.apache.commons.lang3.tuple.Pair; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Lazy; import org.springframework.context.annotation.Primary; @@ -24,6 +25,7 @@ import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.ExportableEntity; import org.thingsboard.server.common.data.HasVersion; import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.configuration.AlarmCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.ArgumentsBasedCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingCalculatedFieldConfiguration; import org.thingsboard.server.common.data.exception.ThingsboardException; @@ -170,6 +172,13 @@ public class DefaultEntityExportService { + if (rule.getDashboardId() != null) { + rule.setDashboardId(getExternalIdOrElseInternal(ctx, rule.getDashboardId())); + } + }); + } }); return calculatedFields; } diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/BaseEntityImportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/BaseEntityImportService.java index c95fffc205..61aca6eb8f 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/BaseEntityImportService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/BaseEntityImportService.java @@ -22,6 +22,7 @@ import lombok.AllArgsConstructor; import lombok.Data; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.tuple.Pair; import org.checkerframework.checker.nullness.qual.Nullable; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Lazy; @@ -35,6 +36,7 @@ import org.thingsboard.server.common.data.HasVersion; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.configuration.AlarmCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.ArgumentsBasedCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingCalculatedFieldConfiguration; import org.thingsboard.server.common.data.exception.ThingsboardException; @@ -338,6 +340,13 @@ public abstract class BaseEntityImportService { + if (rule.getDashboardId() != null) { + rule.setDashboardId(idProvider.getInternalId(rule.getDashboardId(), ctx.isFinalImportAttempt())); + } + }); + } }).toList(); for (CalculatedField existingField : existing) { diff --git a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java index 51d77bd134..80e79280df 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java @@ -84,6 +84,7 @@ import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.DeviceProfileType; import org.thingsboard.server.common.data.DeviceTransportType; +import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EventInfo; import org.thingsboard.server.common.data.SaveDeviceWithCredentialsRequest; import org.thingsboard.server.common.data.StringUtils; @@ -93,6 +94,7 @@ import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.asset.AssetProfile; import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.CalculatedFieldInfo; import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.device.data.DefaultDeviceConfiguration; import org.thingsboard.server.common.data.device.data.DefaultDeviceTransportConfiguration; @@ -1207,8 +1209,8 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { Map statesMap = (Map) ReflectionTestUtils.getField(processor, "states"); Awaitility.await("CF state for entity actor ready to refresh dynamic arguments").atMost(TIMEOUT, TimeUnit.SECONDS).until(() -> { CalculatedFieldState calculatedFieldState = statesMap.get(cfId); - boolean isReady = calculatedFieldState != null && ((GeofencingCalculatedFieldState) calculatedFieldState).getLastDynamicArgumentsRefreshTs() - < System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(scheduledUpdateInterval); + boolean isReady = calculatedFieldState != null && ((GeofencingCalculatedFieldState) calculatedFieldState).getLastDynamicArgumentsRefreshTs() < + System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(scheduledUpdateInterval); log.warn("entityId {}, cfId {}, state ready to refresh == {}", entityId, cfId, isReady); return isReady; }); @@ -1399,7 +1401,7 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { protected List findJobs(List types, List entities) throws Exception { return doGetTypedWithPageLink("/api/jobs?types=" + types.stream().map(Enum::name).collect(Collectors.joining(",")) + - "&entities=" + entities.stream().map(UUID::toString).collect(Collectors.joining(",")) + "&", + "&entities=" + entities.stream().map(UUID::toString).collect(Collectors.joining(",")) + "&", new TypeReference>() {}, new PageLink(100, 0, null, new SortOrder("createdTime", SortOrder.Direction.DESC))).getData(); } @@ -1413,12 +1415,12 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { protected void postTelemetry(EntityId entityId, String payload) throws Exception { doPostAsync("/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() + - "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode(payload), 30_000L).andExpect(status().isOk()); + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode(payload), 30_000L).andExpect(status().isOk()); } protected void postAttributes(EntityId entityId, AttributeScope scope, String payload) throws Exception { doPostAsync("/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() + - "/attributes/" + scope, JacksonUtil.toJsonNode(payload), 30_000L).andExpect(status().isOk()); + "/attributes/" + scope, JacksonUtil.toJsonNode(payload), 30_000L).andExpect(status().isOk()); } protected CalculatedField saveCalculatedField(CalculatedField calculatedField) { @@ -1427,7 +1429,24 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { protected PageData getEntityCalculatedFields(EntityId entityId, CalculatedFieldType type, PageLink pageLink) throws Exception { return doGetTypedWithPageLink("/api/" + entityId.getEntityType() + "/" + entityId.getId() + "/calculatedFields" + - (type != null ? "?type=" + type.name() + "&" : "?"), new TypeReference<>() {}, pageLink); + (type != null ? "?type=" + type.name() + "&" : "?"), new TypeReference<>() {}, pageLink); + } + + protected PageData getCalculatedFieldNames(CalculatedFieldType type, PageLink pageLink) throws Exception { + return doGetTypedWithPageLink("/api/calculatedFields/names?type=" + type + "&", + new TypeReference>() {}, pageLink); + } + + protected List getCalculatedFields(CalculatedFieldType type, + EntityType entityType, + List entities, + List names) throws Exception { + return doGetTypedWithPageLink("/api/calculatedFields?type=" + type + "&" + + (entityType != null ? "entityType=" + entityType + "&" : "") + + (entities != null ? "entities=" + String.join(",", + entities.stream().map(UUID::toString).toList()) + "&" : "") + + (names != null ? names.stream().map(name -> "name=" + name + "&").collect(Collectors.joining("")) : ""), + new TypeReference>() {}, new PageLink(10)).getData(); } protected PageData getDebugEvents(TenantId tenantId, EntityId entityId, int limit) throws Exception { diff --git a/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java index 635fc747e5..33f6bfa250 100644 --- a/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java @@ -15,7 +15,6 @@ */ package org.thingsboard.server.controller; -import com.fasterxml.jackson.core.type.TypeReference; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -56,9 +55,7 @@ import org.thingsboard.server.dao.service.DaoSqlTest; import java.util.Comparator; import java.util.List; import java.util.Map; -import java.util.UUID; import java.util.concurrent.TimeUnit; -import java.util.stream.Collectors; import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.containsString; @@ -295,23 +292,6 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest { assertThat(names.getData()).containsOnly(deviceCalculatedField.getName()); } - private PageData getCalculatedFieldNames(CalculatedFieldType type, PageLink pageLink) throws Exception { - return doGetTypedWithPageLink("/api/calculatedFields/names?type=" + type + "&", - new TypeReference>() {}, pageLink); - } - - private List getCalculatedFields(CalculatedFieldType type, - EntityType entityType, - List entities, - List names) throws Exception { - return doGetTypedWithPageLink("/api/calculatedFields?type=" + type + "&" + - (entityType != null ? "entityType=" + entityType + "&" : "") + - (entities != null ? "entities=" + String.join(",", - entities.stream().map(UUID::toString).toList()) + "&" : "") + - (names != null ? names.stream().map(name -> "name=" + name + "&").collect(Collectors.joining("")) : ""), - new TypeReference>() {}, new PageLink(10)).getData(); - } - @Test public void testDeleteCalculatedField() throws Exception { Device testDevice = createDevice("Test device", "1234567890"); diff --git a/application/src/test/java/org/thingsboard/server/service/sync/vc/VersionControlTest.java b/application/src/test/java/org/thingsboard/server/service/sync/vc/VersionControlTest.java index c6ad3c12d3..cc55e09fd5 100644 --- a/application/src/test/java/org/thingsboard/server/service/sync/vc/VersionControlTest.java +++ b/application/src/test/java/org/thingsboard/server/service/sync/vc/VersionControlTest.java @@ -20,6 +20,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.TextNode; import com.google.common.collect.Streams; +import org.apache.commons.lang3.tuple.Pair; import org.junit.Before; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -45,10 +46,15 @@ import org.thingsboard.server.common.data.TbResource; import org.thingsboard.server.common.data.TbResourceInfo; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.alarm.AlarmSeverity; +import org.thingsboard.server.common.data.alarm.rule.AlarmRule; +import org.thingsboard.server.common.data.alarm.rule.condition.SimpleAlarmCondition; +import org.thingsboard.server.common.data.alarm.rule.condition.expression.TbelAlarmConditionExpression; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.asset.AssetProfile; import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.cf.configuration.AlarmCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.Argument; import org.thingsboard.server.common.data.cf.configuration.ArgumentType; import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; @@ -306,6 +312,54 @@ public class VersionControlTest extends AbstractControllerTest { checkImportedOtaPackageData(software, importedSoftwareOta); } + @Test + public void testDeviceVc_withAlarmRules_betweenTenants() throws Exception { + DeviceProfile deviceProfile = createDeviceProfile(null, null, "Device profile of tenant 1"); + Dashboard dashboard = createDashboard(null, "Mobile dashboard"); + createAlarmRule(deviceProfile.getId(), "Profile alarm rule", dashboard.getId()); + Device device = createDevice(deviceProfile.getId(), "Device of tenant 1", "test1"); + createAlarmRule(device.getId(), "Device alarm rule", dashboard.getId()); + String version = createVersion("devices, profiles and dashboards", EntityType.DEVICE, EntityType.DEVICE_PROFILE, EntityType.DASHBOARD); + + loginTenant2(); + Map result = loadVersion(version, config -> { + config.setLoadCredentials(false); + }, EntityType.DEVICE, EntityType.DEVICE_PROFILE, EntityType.DASHBOARD); + assertThat(result.get(EntityType.DEVICE).getCreated()).isEqualTo(1); + assertThat(result.get(EntityType.DEVICE_PROFILE).getCreated()).isEqualTo(1); + assertThat(result.get(EntityType.DASHBOARD).getCreated()).isEqualTo(1); + + Device importedDevice = findDevice(device.getName()); + checkImportedEntity(tenantId1, device, tenantId2, importedDevice); + checkImportedDeviceData(device, importedDevice); + + DeviceProfile importedDeviceProfile = findDeviceProfile(deviceProfile.getName()); + checkImportedEntity(tenantId1, deviceProfile, tenantId2, importedDeviceProfile); + checkImportedDeviceProfileData(deviceProfile, importedDeviceProfile); + assertThat(importedDevice.getDeviceProfileId()).isEqualTo(importedDeviceProfile.getId()); + + Dashboard importedDashboard = findDashboard(dashboard.getName()); + checkImportedEntity(tenantId1, dashboard, tenantId2, importedDashboard); + checkImportedDashboardData(dashboard, importedDashboard); + + getCalculatedFields(CalculatedFieldType.ALARM, EntityType.DEVICE_PROFILE, + List.of(importedDeviceProfile.getUuidId()), null).forEach(alarmRuleCf -> { + assertThat(alarmRuleCf.getName()).isEqualTo("Profile alarm rule"); + AlarmCalculatedFieldConfiguration config = (AlarmCalculatedFieldConfiguration) alarmRuleCf.getConfiguration(); + config.getAllRules().map(Pair::getValue).forEach(alarmRule -> { + assertThat(alarmRule.getDashboardId()).isEqualTo(importedDashboard.getId()); + }); + }); + getCalculatedFields(CalculatedFieldType.ALARM, EntityType.DEVICE_PROFILE, + List.of(importedDevice.getUuidId()), null).forEach(alarmRuleCf -> { + assertThat(alarmRuleCf.getName()).isEqualTo("Device alarm rule"); + AlarmCalculatedFieldConfiguration config = (AlarmCalculatedFieldConfiguration) alarmRuleCf.getConfiguration(); + config.getAllRules().map(Pair::getValue).forEach(alarmRule -> { + assertThat(alarmRule.getDashboardId()).isEqualTo(importedDashboard.getId()); + }); + }); + } + @Test public void testDashboardVc_betweenTenants() throws Exception { Dashboard dashboard = createDashboard(null, "Dashboard of tenant 1"); @@ -368,42 +422,42 @@ public class VersionControlTest extends AbstractControllerTest { String aliasId = "23c4185d-1497-9457-30b2-6d91e69a5b2c"; String unknownUuid = "ea0dc8b0-3d85-11ed-9200-77fc04fa14fa"; String entityAliases = "{\n" + - "\"" + aliasId + "\": {\n" + - "\"alias\": \"assets\",\n" + - "\"filter\": {\n" + - " \"entityList\": [\n" + - " \"" + asset1.getId() + "\",\n" + - " \"" + asset2.getId() + "\",\n" + - " \"" + tenantId1.getId() + "\",\n" + - " \"" + existingDeviceProfile.getId() + "\",\n" + - " \"" + unknownUuid + "\"\n" + - " ],\n" + - " \"id\":\"" + asset1.getId() + "\",\n" + - " \"resolveMultiple\": true\n" + - "},\n" + - "\"id\": \"" + aliasId + "\"\n" + - "}\n" + - "}"; + "\"" + aliasId + "\": {\n" + + "\"alias\": \"assets\",\n" + + "\"filter\": {\n" + + " \"entityList\": [\n" + + " \"" + asset1.getId() + "\",\n" + + " \"" + asset2.getId() + "\",\n" + + " \"" + tenantId1.getId() + "\",\n" + + " \"" + existingDeviceProfile.getId() + "\",\n" + + " \"" + unknownUuid + "\"\n" + + " ],\n" + + " \"id\":\"" + asset1.getId() + "\",\n" + + " \"resolveMultiple\": true\n" + + "},\n" + + "\"id\": \"" + aliasId + "\"\n" + + "}\n" + + "}"; String widgetId = "ea8f34a0-264a-f11f-cde3-05201bb4ff4b"; String actionId = "4a8e6efa-3e68-fa59-7feb-d83366130cae"; String widgets = "{\n" + - " \"" + widgetId + "\": {\n" + - " \"config\": {\n" + - " \"actions\": {\n" + - " \"rowClick\": [\n" + - " {\n" + - " \"name\": \"go to dashboard\",\n" + - " \"targetDashboardId\": \"" + otherDashboard.getId() + "\",\n" + - " \"id\": \"" + actionId + "\"\n" + - " }\n" + - " ]\n" + - " }\n" + - " },\n" + - " \"row\": 0,\n" + - " \"col\": 0,\n" + - " \"id\": \"" + widgetId + "\"\n" + - " }\n" + - "}"; + " \"" + widgetId + "\": {\n" + + " \"config\": {\n" + + " \"actions\": {\n" + + " \"rowClick\": [\n" + + " {\n" + + " \"name\": \"go to dashboard\",\n" + + " \"targetDashboardId\": \"" + otherDashboard.getId() + "\",\n" + + " \"id\": \"" + actionId + "\"\n" + + " }\n" + + " ]\n" + + " }\n" + + " },\n" + + " \"row\": 0,\n" + + " \"col\": 0,\n" + + " \"id\": \"" + widgetId + "\"\n" + + " }\n" + + "}"; ObjectNode dashboardConfiguration = JacksonUtil.newObjectNode(); dashboardConfiguration.set("entityAliases", JacksonUtil.toJsonNode(entityAliases)); @@ -499,11 +553,12 @@ public class VersionControlTest extends AbstractControllerTest { generatorNodeConfig.setMsgCount(1); generatorNodeConfig.setScriptLang(ScriptLanguage.JS); UUID someUuid = UUID.randomUUID(); - generatorNodeConfig.setJsScript("var msg = { temp: 42, humidity: 77 };\n" + - "var metadata = { data: 40 };\n" + - "var msgType = \"POST_TELEMETRY_REQUEST\";\n" + - "var someUuid = \"" + someUuid + "\";\n" + - "return { msg: msg, metadata: metadata, msgType: msgType };"); + generatorNodeConfig.setJsScript(""" + var msg = { temp: 42, humidity: 77 }; + var metadata = { data: 40 }; + var msgType = "POST_TELEMETRY_REQUEST"; + var someUuid = "%s"; + return { msg: msg, metadata: metadata, msgType: msgType };""".formatted(someUuid)); generatorNode.setConfiguration(JacksonUtil.valueToTree(generatorNodeConfig)); nodes.add(generatorNode); metaData.setNodes(nodes); @@ -1018,20 +1073,21 @@ public class VersionControlTest extends AbstractControllerTest { protected Dashboard createDashboard(CustomerId customerId, String name, AssetId assetForEntityAlias) { Dashboard dashboard = createDashboard(customerId, name); - String entityAliases = "{\n" + - "\t\"23c4185d-1497-9457-30b2-6d91e69a5b2c\": {\n" + - "\t\t\"alias\": \"assets\",\n" + - "\t\t\"filter\": {\n" + - "\t\t\t\"entityList\": [\n" + - "\t\t\t\t\"" + assetForEntityAlias.getId().toString() + "\"\n" + - "\t\t\t],\n" + - "\t\t\t\"entityType\": \"ASSET\",\n" + - "\t\t\t\"resolveMultiple\": true,\n" + - "\t\t\t\"type\": \"entityList\"\n" + - "\t\t},\n" + - "\t\t\"id\": \"23c4185d-1497-9457-30b2-6d91e69a5b2c\"\n" + - "\t}\n" + - "}"; + String entityAliases = """ + { + "23c4185d-1497-9457-30b2-6d91e69a5b2c": { + "alias": "assets", + "filter": { + "entityList": [ + "%s" + ], + "entityType": "ASSET", + "resolveMultiple": true, + "type": "entityList" + }, + "id": "23c4185d-1497-9457-30b2-6d91e69a5b2c" + } + }""".formatted(assetForEntityAlias.getId().toString()); ObjectNode dashboardConfiguration = JacksonUtil.newObjectNode(); dashboardConfiguration.set("entityAliases", JacksonUtil.toJsonNode(entityAliases)); dashboardConfiguration.set("description", new TextNode("hallo")); @@ -1134,6 +1190,33 @@ public class VersionControlTest extends AbstractControllerTest { return doPost("/api/calculatedField", calculatedField, CalculatedField.class); } + private CalculatedField createAlarmRule(EntityId entityId, String alarmType, DashboardId mobileDashboardId) { + Argument temperatureArgument = new Argument(); + temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + temperatureArgument.setDefaultValue("0"); + Map arguments = Map.of( + "temperature", temperatureArgument + ); + + CalculatedField calculatedField = new CalculatedField(); + calculatedField.setEntityId(entityId); + calculatedField.setName(alarmType); + calculatedField.setType(CalculatedFieldType.ALARM); + AlarmCalculatedFieldConfiguration configuration = new AlarmCalculatedFieldConfiguration(); + configuration.setArguments(arguments); + SimpleAlarmCondition createCondition = new SimpleAlarmCondition(); + createCondition.setExpression(new TbelAlarmConditionExpression("return temperature >= 50;")); + configuration.setCreateRules(Map.of( + AlarmSeverity.CRITICAL, new AlarmRule(createCondition, "", mobileDashboardId) + )); + SimpleAlarmCondition clearCondition = new SimpleAlarmCondition(); + clearCondition.setExpression(new TbelAlarmConditionExpression("return temperature < 50;")); + configuration.setClearRule(new AlarmRule(clearCondition, "", mobileDashboardId)); + calculatedField.setConfiguration(configuration); + calculatedField.setDebugSettings(DebugSettings.all()); + return saveCalculatedField(calculatedField); + } + private CalculatedFieldConfiguration getCalculatedFieldConfig(EntityId referencedEntityId) { SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/AlarmRule.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/AlarmRule.java index 23dbc8fede..7dcf7ffe66 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/AlarmRule.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/AlarmRule.java @@ -18,11 +18,15 @@ package org.thingsboard.server.common.data.alarm.rule; import com.fasterxml.jackson.annotation.JsonIgnore; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; import lombok.Data; +import lombok.NoArgsConstructor; import org.thingsboard.server.common.data.alarm.rule.condition.AlarmCondition; import org.thingsboard.server.common.data.id.DashboardId; @Data +@AllArgsConstructor +@NoArgsConstructor public class AlarmRule { @Valid diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/AlarmCondition.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/AlarmCondition.java index b4e1446798..073d9347fa 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/AlarmCondition.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/AlarmCondition.java @@ -22,9 +22,9 @@ import com.fasterxml.jackson.annotation.JsonSubTypes.Type; import com.fasterxml.jackson.annotation.JsonTypeInfo; import jakarta.validation.Valid; import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.NotNull; import lombok.Data; import lombok.NoArgsConstructor; -import org.jetbrains.annotations.NotNull; import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionExpression; import org.thingsboard.server.common.data.alarm.rule.condition.schedule.AlarmSchedule; import org.thingsboard.server.common.data.alarm.rule.condition.schedule.AnyTimeSchedule; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/TbelAlarmConditionExpression.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/TbelAlarmConditionExpression.java index 50f73e887b..2562e19be4 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/TbelAlarmConditionExpression.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/TbelAlarmConditionExpression.java @@ -16,9 +16,13 @@ package org.thingsboard.server.common.data.alarm.rule.condition.expression; import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; import lombok.Data; +import lombok.NoArgsConstructor; @Data +@AllArgsConstructor +@NoArgsConstructor public class TbelAlarmConditionExpression implements AlarmConditionExpression { @NotBlank From 81f167dc36c502a622fb45e4ccfb6da982be4ddb Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Fri, 28 Nov 2025 14:59:06 +0200 Subject: [PATCH 13/22] Add API keys auth to swagger --- .../server/config/SwaggerConfiguration.java | 149 +++++++++++------- 1 file changed, 90 insertions(+), 59 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java b/application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java index 410b05456e..42a29b8d74 100644 --- a/application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java +++ b/application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java @@ -86,6 +86,9 @@ public class SwaggerConfiguration { public static final String LOGIN_ENDPOINT = "/api/auth/login"; public static final String REFRESH_TOKEN_ENDPOINT = "/api/auth/token"; + private static final String LOGIN_PASSWORD_SCHEME = "HTTP login form"; + private static final String API_KEY_SCHEME = "API key form"; + private static final ApiResponses loginResponses = loginResponses(); private static final ApiResponses defaultErrorResponses = defaultErrorResponses(false); private static final ApiResponses defaultPostErrorResponses = defaultErrorResponses(true); @@ -142,14 +145,28 @@ public class SwaggerConfiguration { .license(license) .version(apiVersion); - SecurityScheme securityScheme = new SecurityScheme() + SecurityScheme loginPasswordScheme = new SecurityScheme() .type(SecurityScheme.Type.HTTP) .description("Enter Username / Password") .scheme("loginPassword") .bearerFormat("/api/auth/login|X-Authorization"); + SecurityScheme apiKeyScheme = new SecurityScheme() + .type(SecurityScheme.Type.APIKEY) + .name("X-Authorization") + .in(SecurityScheme.In.HEADER) + .description(""" + Enter the API key value with 'ApiKey' prefix in format: **ApiKey ** + + Example: **ApiKey tb_5te51SkLRYpjGrujUGwqkjFvooWBlQpVe2An2Dr3w13wjfxDW** + +
**NOTE**: Use only ONE authentication method at a time. If both are authorized, JWT auth takes the priority.
+ """); + var openApi = new OpenAPI() - .components(new Components().addSecuritySchemes("HTTP login form", securityScheme)) + .components(new Components() + .addSecuritySchemes(LOGIN_PASSWORD_SCHEME, loginPasswordScheme) + .addSecuritySchemes(API_KEY_SCHEME, apiKeyScheme)) .info(info); addDefaultSchemas(openApi); addLoginOperation(openApi); @@ -198,13 +215,14 @@ public class SwaggerConfiguration { operation.summary("Login method to get user JWT token data"); operation.description(""" Login method used to authenticate user and get JWT token data. - + Value of the response **token** field can be used as **X-Authorization** header value: - + `X-Authorization: Bearer $JWT_TOKEN_VALUE`."""); + var requestBody = new RequestBody().description("Login request") .content(new Content().addMediaType(APPLICATION_JSON_VALUE, - new MediaType().schema(new Schema().$ref("#/components/schemas/LoginRequest")))); + new MediaType().schema(new Schema().$ref("#/components/schemas/LoginRequest")))); operation.requestBody(requestBody); operation.responses(loginResponses); @@ -218,11 +236,11 @@ public class SwaggerConfiguration { var operation = new Operation(); operation.summary("Refresh user JWT token data"); operation.description(""" - Method to refresh JWT token. Provide a valid refresh token to get a new JWT token. - - The response contains a new token that can be used for authorization. - - `X-Authorization: Bearer $JWT_TOKEN_VALUE`"""); + Method to refresh JWT token. Provide a valid refresh token to get a new JWT token. + + The response contains a new token that can be used for authorization. + + `X-Authorization: Bearer $JWT_TOKEN_VALUE`"""); var requestBody = new RequestBody().description("Refresh token request") .content(new Content().addMediaType(APPLICATION_JSON_VALUE, @@ -291,8 +309,9 @@ public class SwaggerConfiguration { return (routerOperation, handlerMethod) -> { String[] pNames = localSpringDocParameterNameDiscoverer.getParameterNames(handlerMethod.getMethod()); String[] reflectionParametersNames = Arrays.stream(handlerMethod.getMethod().getParameters()).map(java.lang.reflect.Parameter::getName).toArray(String[]::new); - if (pNames == null || Arrays.stream(pNames).anyMatch(Objects::isNull)) + if (pNames == null || Arrays.stream(pNames).anyMatch(Objects::isNull)) { pNames = reflectionParametersNames; + } MethodParameter[] parameters = handlerMethod.getMethodParameters(); List requestParams = new ArrayList<>(); for (var i = 0; i < parameters.length; i++) { @@ -324,26 +343,25 @@ public class SwaggerConfiguration { } private OpenApiCustomizer customOpenApiCustomizer() { - var loginForm = new SecurityRequirement().addList("HTTP login form", Arrays.asList( - Authority.SYS_ADMIN.name(), - Authority.TENANT_ADMIN.name(), - Authority.CUSTOMER_USER.name() - )); + var loginRequirement = createSecurityRequirement(LOGIN_PASSWORD_SCHEME); + var apiKeyRequirement = createSecurityRequirement(API_KEY_SCHEME); + return openAPI -> { var paths = openAPI.getPaths(); - paths.entrySet().stream().peek(entry -> { - securityCustomization(loginForm, entry); - if (!entry.getKey().equals(LOGIN_ENDPOINT)) { - defaultErrorResponsesCustomization(entry.getValue()); - } - }).map(this::tagsCustomization).filter(Objects::nonNull).distinct().sorted(Comparator.comparing(Tag::getName)).forEach(openAPI::addTagsItem); + paths.entrySet().stream() + .peek(entry -> { + securityCustomization(entry, loginRequirement, apiKeyRequirement); + if (!entry.getKey().equals(LOGIN_ENDPOINT)) { + defaultErrorResponsesCustomization(entry.getValue()); + } + }) + .map(this::extractTagFromPath).filter(Objects::nonNull).distinct().sorted(Comparator.comparing(Tag::getName)).forEach(openAPI::addTagsItem); var pathItemsByTags = new TreeMap>(); paths.forEach((k, v) -> { var tagItem = tagItemFromPathItem(v); if (tagItem != null) { - var pathItemMap = pathItemsByTags.computeIfAbsent(tagItem, k1 -> new TreeMap<>()); - pathItemMap.put(k, v); + pathItemsByTags.computeIfAbsent(tagItem, k1 -> new TreeMap<>()).put(k, v); } }); var sortedPaths = new Paths(); @@ -357,13 +375,35 @@ public class SwaggerConfiguration { }; } + private SecurityRequirement createSecurityRequirement(String schemeName) { + return new SecurityRequirement().addList(schemeName, Arrays.asList( + Authority.SYS_ADMIN.name(), + Authority.TENANT_ADMIN.name(), + Authority.CUSTOMER_USER.name() + )); + } + + private void securityCustomization(Map.Entry entry, SecurityRequirement jwtBearerRequirement, SecurityRequirement apiKeyRequirement) { + var path = entry.getKey(); + + if (path.matches(securityPathRegex) + && !path.matches(nonSecurityPathRegex) + && !path.equals(LOGIN_ENDPOINT) + && !path.equals(REFRESH_TOKEN_ENDPOINT)) { - private Tag tagsCustomization(Map.Entry entry) { - var tagItem = tagItemFromPathItem(entry.getValue()); - if (tagItem != null) { - return tagFromTagItem(tagItem); + entry.getValue() + .readOperationsMap() + .values() + .forEach(operation -> { + operation.addSecurityItem(jwtBearerRequirement); + operation.addSecurityItem(apiKeyRequirement); + }); } - return null; + } + + private Tag extractTagFromPath(Map.Entry entry) { + var tagName = tagItemFromPathItem(entry.getValue()); + return tagName != null ? tagFromTagItem(tagName) : null; } private String tagItemFromPathItem(PathItem item) { @@ -383,40 +423,33 @@ public class SwaggerConfiguration { StringBuilder sb = new StringBuilder(); for (String word : words) { - sb.append(word.substring(0, 1).toUpperCase()); - sb.append(word.substring(1).toLowerCase()); - sb.append(" "); + if (!word.isEmpty()) { + sb.append(word.substring(0, 1).toUpperCase()); + sb.append(word.substring(1).toLowerCase()); + sb.append(" "); + } } return new Tag().name(tagItem).description(sb.toString().trim()); } private void defaultErrorResponsesCustomization(PathItem pathItem) { - pathItem.readOperationsMap().forEach(((httpMethod, operation) -> { + pathItem.readOperationsMap().forEach((httpMethod, operation) -> { var errorResponses = httpMethod.equals(PathItem.HttpMethod.POST) ? defaultPostErrorResponses : defaultErrorResponses; + var responses = operation.getResponses(); if (responses == null) { - responses = errorResponses; - } else { - ApiResponses updated = responses; - errorResponses.forEach((key, apiResponse) -> { - if (!updated.containsKey(key)) { - updated.put(key, apiResponse); - } - }); + responses = new ApiResponses(); + operation.setResponses(responses); } - operation.setResponses(responses); - })); - } - private void securityCustomization(SecurityRequirement loginForm, Map.Entry entry) { - var path = entry.getKey(); - if (path.matches(securityPathRegex) && !path.matches(nonSecurityPathRegex) && !path.equals(LOGIN_ENDPOINT)) { - entry.getValue() - .readOperationsMap() - .values() - .forEach(operation -> operation.addSecurityItem(loginForm)); - } + ApiResponses finalResponses = responses; + errorResponses.forEach((code, response) -> { + if (!finalResponses.containsKey(code)) { + finalResponses.put(code, response); + } + }); + }); } private static ApiResponses loginResponses() { @@ -430,6 +463,7 @@ public class SwaggerConfiguration { private static ApiResponses defaultErrorResponses(boolean isPost) { ApiResponses apiResponses = new ApiResponses(); + apiResponses.addApiResponse("400", errorResponse("400", "Bad Request", ThingsboardErrorResponse.of(isPost ? "Invalid request body" : "Invalid UUID string: 123", ThingsboardErrorCode.BAD_REQUEST_PARAMS, HttpStatus.BAD_REQUEST))); @@ -465,8 +499,7 @@ public class SwaggerConfiguration { ThingsboardErrorResponse.of("Authentication failed", ThingsboardErrorCode.AUTHENTICATION, HttpStatus.UNAUTHORIZED)) ) )); - var credentialsExpiredSchema = new Schema(); - credentialsExpiredSchema.$ref("#/components/schemas/ThingsboardCredentialsExpiredResponse"); + var credentialsExpiredSchema = new Schema().$ref("#/components/schemas/ThingsboardCredentialsExpiredResponse"); apiResponses.addApiResponse("401 ", errorResponse("Unauthorized (**Expired credentials**)", Map.of( "credentials-expired", errorExample("Expired credentials", @@ -482,15 +515,13 @@ public class SwaggerConfiguration { } private static ApiResponse errorResponse(String description, Map examples) { - var schema = new Schema(); - schema.$ref("#/components/schemas/ThingsboardErrorResponse"); + var schema = new Schema().$ref("#/components/schemas/ThingsboardErrorResponse"); return errorResponse(description, examples, schema); } private static ApiResponse errorResponse(String description, Map examples, Schema errorResponseSchema) { - MediaType mediaType = new MediaType().schema(errorResponseSchema); - mediaType.setExamples(examples); - Content content = new Content().addMediaType(org.springframework.http.MediaType.APPLICATION_JSON_VALUE, mediaType); + MediaType mediaType = new MediaType().schema(errorResponseSchema).examples(examples); + Content content = new Content().addMediaType(org.springframework.http.MediaType.APPLICATION_JSON_VALUE, mediaType); return new ApiResponse().description(description).content(content); } From 87345e7161111c86ef3dab5f99f5441df6802645 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Fri, 28 Nov 2025 15:33:11 +0200 Subject: [PATCH 14/22] Fix EdqsEntityQueryControllerTest init --- .../server/controller/EdqsEntityQueryControllerTest.java | 5 ----- 1 file changed, 5 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/controller/EdqsEntityQueryControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/EdqsEntityQueryControllerTest.java index 15ad57b62e..dca25a83a1 100644 --- a/application/src/test/java/org/thingsboard/server/controller/EdqsEntityQueryControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/EdqsEntityQueryControllerTest.java @@ -20,7 +20,6 @@ import org.junit.Before; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.test.context.TestPropertySource; -import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.thingsboard.server.common.data.edqs.EdqsState; import org.thingsboard.server.common.data.edqs.EdqsState.EdqsApiMode; import org.thingsboard.server.common.data.edqs.ToCoreEdqsRequest; @@ -33,7 +32,6 @@ import org.thingsboard.server.common.data.query.EntityData; import org.thingsboard.server.common.data.query.EntityDataQuery; import org.thingsboard.server.common.msg.edqs.EdqsService; import org.thingsboard.server.dao.service.DaoSqlTest; -import org.thingsboard.server.edqs.util.EdqsRocksDb; import org.thingsboard.server.queue.discovery.DiscoveryService; import java.util.concurrent.TimeUnit; @@ -59,9 +57,6 @@ public class EdqsEntityQueryControllerTest extends EntityQueryControllerTest { @Autowired private DiscoveryService discoveryService; - @MockitoBean // so that we don't do backup for tests - private EdqsRocksDb edqsRocksDb; - @Before public void before() { await().atMost(TIMEOUT, TimeUnit.SECONDS).until(() -> edqsService.getState().isApiEnabled()); From 143b8f99af9daa5ac1a77a69ab006ce46829b188 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Mon, 1 Dec 2025 12:17:22 +0200 Subject: [PATCH 15/22] Minor changes --- .../server/config/SwaggerConfiguration.java | 49 +++++++++---------- 1 file changed, 22 insertions(+), 27 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java b/application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java index 42a29b8d74..f6851a5a71 100644 --- a/application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java +++ b/application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java @@ -383,24 +383,6 @@ public class SwaggerConfiguration { )); } - private void securityCustomization(Map.Entry entry, SecurityRequirement jwtBearerRequirement, SecurityRequirement apiKeyRequirement) { - var path = entry.getKey(); - - if (path.matches(securityPathRegex) - && !path.matches(nonSecurityPathRegex) - && !path.equals(LOGIN_ENDPOINT) - && !path.equals(REFRESH_TOKEN_ENDPOINT)) { - - entry.getValue() - .readOperationsMap() - .values() - .forEach(operation -> { - operation.addSecurityItem(jwtBearerRequirement); - operation.addSecurityItem(apiKeyRequirement); - }); - } - } - private Tag extractTagFromPath(Map.Entry entry) { var tagName = tagItemFromPathItem(entry.getValue()); return tagName != null ? tagFromTagItem(tagName) : null; @@ -439,19 +421,32 @@ public class SwaggerConfiguration { var responses = operation.getResponses(); if (responses == null) { - responses = new ApiResponses(); - operation.setResponses(responses); + responses = errorResponses; + } else { + ApiResponses updated = responses; + errorResponses.forEach((key, apiResponse) -> { + if (!updated.containsKey(key)) { + updated.put(key, apiResponse); + } + }); } - - ApiResponses finalResponses = responses; - errorResponses.forEach((code, response) -> { - if (!finalResponses.containsKey(code)) { - finalResponses.put(code, response); - } - }); + operation.setResponses(responses); }); } + private void securityCustomization(Map.Entry entry, SecurityRequirement jwtBearerRequirement, SecurityRequirement apiKeyRequirement) { + var path = entry.getKey(); + if (path.matches(securityPathRegex) && !path.matches(nonSecurityPathRegex) && !path.equals(LOGIN_ENDPOINT) && !path.equals(REFRESH_TOKEN_ENDPOINT)) { + entry.getValue() + .readOperationsMap() + .values() + .forEach(operation -> { + operation.addSecurityItem(jwtBearerRequirement); + operation.addSecurityItem(apiKeyRequirement); + }); + } + } + private static ApiResponses loginResponses() { ApiResponses apiResponses = new ApiResponses(); apiResponses.addApiResponse("200", new ApiResponse().description("OK") From a5256c14e7076e7b93623f57ef7f8d85ba51e0bf Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Mon, 1 Dec 2025 16:43:39 +0200 Subject: [PATCH 16/22] Fix broken rest client for `/entitiesQuery/find/keys`; refactoring --- .../controller/EntityQueryController.java | 86 ++++++------ .../query/DefaultEntityQueryService.java | 129 +++++------------- .../service/query/EntityQueryService.java | 9 +- .../dao/attributes/AttributesService.java | 2 +- .../dao/timeseries/TimeseriesService.java | 3 + .../data/query/AvailableEntityKeys.java | 67 +++++++++ .../server/dao/attributes/AttributesDao.java | 2 +- .../dao/attributes/BaseAttributesService.java | 7 +- .../attributes/CachedAttributesService.java | 6 +- .../dao/sql/attributes/JpaAttributeDao.java | 8 +- .../CachedRedisSqlTimeseriesLatestDao.java | 4 + .../dao/sqlts/SqlTimeseriesLatestDao.java | 12 +- .../dao/timeseries/BaseTimeseriesService.java | 9 +- .../CassandraBaseTimeseriesLatestDao.java | 8 +- .../dao/timeseries/TimeseriesLatestDao.java | 6 +- .../attributes/BaseAttributesServiceTest.java | 2 +- .../thingsboard/rest/client/RestClient.java | 20 ++- 17 files changed, 203 insertions(+), 177 deletions(-) create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/query/AvailableEntityKeys.java diff --git a/application/src/main/java/org/thingsboard/server/controller/EntityQueryController.java b/application/src/main/java/org/thingsboard/server/controller/EntityQueryController.java index 70fa3a5ed0..f471c9e7b4 100644 --- a/application/src/main/java/org/thingsboard/server/controller/EntityQueryController.java +++ b/application/src/main/java/org/thingsboard/server/controller/EntityQueryController.java @@ -17,33 +17,30 @@ package org.thingsboard.server.controller; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Schema; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.ResponseEntity; +import lombok.RequiredArgsConstructor; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.context.request.async.DeferredResult; +import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.edqs.EdqsState; import org.thingsboard.server.common.data.edqs.ToCoreEdqsRequest; import org.thingsboard.server.common.data.exception.ThingsboardException; -import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.query.AlarmCountQuery; import org.thingsboard.server.common.data.query.AlarmData; import org.thingsboard.server.common.data.query.AlarmDataQuery; +import org.thingsboard.server.common.data.query.AvailableEntityKeys; import org.thingsboard.server.common.data.query.EntityCountQuery; import org.thingsboard.server.common.data.query.EntityData; import org.thingsboard.server.common.data.query.EntityDataPageLink; import org.thingsboard.server.common.data.query.EntityDataQuery; import org.thingsboard.server.common.data.query.EntityFilter; -import org.thingsboard.server.common.msg.edqs.EdqsApiService; import org.thingsboard.server.common.msg.edqs.EdqsService; import org.thingsboard.server.config.annotations.ApiOperation; import org.thingsboard.server.queue.util.TbCoreComponent; @@ -51,52 +48,46 @@ import org.thingsboard.server.service.query.EntityQueryService; import org.thingsboard.server.service.security.permission.Operation; import static org.thingsboard.server.controller.ControllerConstants.ALARM_DATA_QUERY_DESCRIPTION; -import static org.thingsboard.server.controller.ControllerConstants.ATTRIBUTES_SCOPE_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.ENTITY_COUNT_QUERY_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.ENTITY_DATA_QUERY_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH; @RestController @TbCoreComponent @RequestMapping("/api") +@RequiredArgsConstructor public class EntityQueryController extends BaseController { - @Autowired - private EntityQueryService entityQueryService; - @Autowired - private EdqsService edqsService; - @Autowired - private EdqsApiService edqsApiService; + private final EntityQueryService entityQueryService; + private final EdqsService edqsService; private static final int MAX_PAGE_SIZE = 100; @ApiOperation(value = "Count Entities by Query", notes = ENTITY_COUNT_QUERY_DESCRIPTION) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/entitiesQuery/count", method = RequestMethod.POST) - @ResponseBody + @PostMapping("/entitiesQuery/count") public long countEntitiesByQuery( @Parameter(description = "A JSON value representing the entity count query. See API call notes above for more details.") @RequestBody EntityCountQuery query) throws ThingsboardException { checkNotNull(query); resolveQuery(query); - return this.entityQueryService.countEntitiesByQuery(getCurrentUser(), query); + return entityQueryService.countEntitiesByQuery(getCurrentUser(), query); } @ApiOperation(value = "Find Entity Data by Query", notes = ENTITY_DATA_QUERY_DESCRIPTION) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/entitiesQuery/find", method = RequestMethod.POST) - @ResponseBody + @PostMapping("/entitiesQuery/find") public PageData findEntityDataByQuery( @Parameter(description = "A JSON value representing the entity data query. See API call notes above for more details.") @RequestBody EntityDataQuery query) throws ThingsboardException { checkNotNull(query); resolveQuery(query); - return this.entityQueryService.findEntityDataByQuery(getCurrentUser(), query); + return entityQueryService.findEntityDataByQuery(getCurrentUser(), query); } @ApiOperation(value = "Find Alarms by Query", notes = ALARM_DATA_QUERY_DESCRIPTION) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/alarmsQuery/find", method = RequestMethod.POST) - @ResponseBody + @PostMapping("/alarmsQuery/find") public PageData findAlarmDataByQuery( @Parameter(description = "A JSON value representing the alarm data query. See API call notes above for more details.") @RequestBody AlarmDataQuery query) throws ThingsboardException { @@ -107,13 +98,12 @@ public class EntityQueryController extends BaseController { checkUserId(assigneeId, Operation.READ); } resolveQuery(query); - return this.entityQueryService.findAlarmDataByQuery(getCurrentUser(), query); + return entityQueryService.findAlarmDataByQuery(getCurrentUser(), query); } @ApiOperation(value = "Count Alarms by Query (countAlarmsByQuery)", notes = "Returns the number of alarms that match the query definition.") @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/alarmsQuery/count", method = RequestMethod.POST) - @ResponseBody + @PostMapping("/alarmsQuery/count") public long countAlarmsByQuery(@Parameter(description = "A JSON value representing the alarm count query.") @RequestBody AlarmCountQuery query) throws ThingsboardException { checkNotNull(query); @@ -122,31 +112,47 @@ public class EntityQueryController extends BaseController { checkUserId(assigneeId, Operation.READ); } resolveQuery(query); - return this.entityQueryService.countAlarmsByQuery(getCurrentUser(), query); + return entityQueryService.countAlarmsByQuery(getCurrentUser(), query); } - @ApiOperation(value = "Find Entity Keys by Query", - notes = "Uses entity data query (see 'Find Entity Data by Query') to find first 100 entities. Then fetch and return all unique time-series and/or attribute keys. Used mostly for UI hints.") + @ApiOperation( + value = "Find Available Entity Keys by Query", + notes = """ + Returns unique time series and/or attribute key names from entities matching the query.\n + Executes the Entity Data Query to find up to 100 entities, then fetches and aggregates all distinct key names.\n + Primarily used for UI features like autocomplete suggestions.""" + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH + ) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/entitiesQuery/find/keys", method = RequestMethod.POST) - @ResponseBody - public DeferredResult findEntityTimeseriesAndAttributesKeysByQuery( - @Parameter(description = "A JSON value representing the entity data query. See API call notes above for more details.") + @PostMapping("/entitiesQuery/find/keys") + public DeferredResult findAvailableEntityKeysByQuery( + @Parameter(description = "Entity data query to find entities. Page size is capped at 100.") @RequestBody EntityDataQuery query, - @Parameter(description = "Include all unique time-series keys to the result.") - @RequestParam("timeseries") boolean isTimeseries, - @Parameter(description = "Include all unique attribute keys to the result.") - @RequestParam("attributes") boolean isAttributes, - @Parameter(description = ATTRIBUTES_SCOPE_DESCRIPTION, schema = @Schema(allowableValues = {"SERVER_SCOPE", "SHARED_SCOPE", "CLIENT_SCOPE"})) - @RequestParam(value = "scope", required = false) String scope) throws ThingsboardException { - TenantId tenantId = getTenantId(); - checkNotNull(query); + + // fixme: combination of timeseries = false and attributes = false is allowed, but always results in empty response, therefore does not make any sense + // such combinations should NOT be allowed, but changing this will break clients + + @Parameter(description = """ + When true, includes unique time series key names in the response. + When false, the 'timeseries' list will be empty.""") + @RequestParam("timeseries") boolean includeTimeseries, + + @Parameter(description = """ + When true, includes unique attribute key names in the response. + When false, the 'attribute' list will be empty. Use 'scope' parameter to filter by attribute scope.""") + @RequestParam("attributes") boolean includeAttributes, + + @Parameter(description = """ + Filters attribute keys by scope. Only applies when 'attributes' is true. + If not specified, returns attribute keys from all scopes.""", + schema = @Schema(allowableValues = {"SERVER_SCOPE", "SHARED_SCOPE", "CLIENT_SCOPE"})) + @RequestParam(value = "scope", required = false) AttributeScope scope + ) throws ThingsboardException { resolveQuery(query); EntityDataPageLink pageLink = query.getPageLink(); if (pageLink.getPageSize() > MAX_PAGE_SIZE) { pageLink.setPageSize(MAX_PAGE_SIZE); } - return entityQueryService.getKeysByQuery(getCurrentUser(), tenantId, query, isTimeseries, isAttributes, scope); + return wrapFuture(entityQueryService.getKeysByQuery(getCurrentUser(), getTenantId(), query, includeTimeseries, includeAttributes, scope)); } @PreAuthorize("hasAnyAuthority('SYS_ADMIN')") diff --git a/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java b/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java index f39769526a..6ef6b239e5 100644 --- a/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java +++ b/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java @@ -15,24 +15,18 @@ */ package org.thingsboard.server.service.query; -import com.fasterxml.jackson.databind.node.ArrayNode; -import com.fasterxml.jackson.databind.node.ObjectNode; -import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import lombok.extern.slf4j.Slf4j; -import org.checkerframework.checker.nullness.qual.Nullable; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.util.CollectionUtils; -import org.springframework.web.context.request.async.DeferredResult; -import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.KvUtil; import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; +import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.kv.AttributeKvEntry; @@ -40,6 +34,7 @@ import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.query.AlarmCountQuery; import org.thingsboard.server.common.data.query.AlarmData; import org.thingsboard.server.common.data.query.AlarmDataQuery; +import org.thingsboard.server.common.data.query.AvailableEntityKeys; import org.thingsboard.server.common.data.query.ComplexFilterPredicate; import org.thingsboard.server.common.data.query.DynamicValue; import org.thingsboard.server.common.data.query.EntityCountQuery; @@ -56,16 +51,13 @@ import org.thingsboard.server.common.data.query.SimpleKeyFilterPredicate; import org.thingsboard.server.dao.alarm.AlarmService; import org.thingsboard.server.dao.attributes.AttributesService; import org.thingsboard.server.dao.entity.EntityService; -import org.thingsboard.server.dao.model.ModelConstants; import org.thingsboard.server.dao.sql.query.EntityKeyMapping; import org.thingsboard.server.dao.timeseries.TimeseriesService; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.executors.DbCallbackExecutorService; -import org.thingsboard.server.service.security.AccessValidator; import org.thingsboard.server.service.security.model.SecurityUser; import java.util.ArrayList; -import java.util.Collection; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; @@ -73,9 +65,10 @@ import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.concurrent.ExecutionException; -import java.util.function.Consumer; import java.util.stream.Collectors; +import static com.google.common.util.concurrent.Futures.immediateFuture; + @Service @Slf4j @TbCoreComponent @@ -138,20 +131,12 @@ public class DefaultEntityQueryService implements EntityQueryService { } private void resolveDynamicValue(DynamicValue dynamicValue, SecurityUser user, FilterPredicateType predicateType) { - EntityId entityId; - switch (dynamicValue.getSourceType()) { - case CURRENT_TENANT: - entityId = user.getTenantId(); - break; - case CURRENT_CUSTOMER: - entityId = user.getCustomerId(); - break; - case CURRENT_USER: - entityId = user.getId(); - break; - default: - throw new RuntimeException("Not supported operation for source type: {" + dynamicValue.getSourceType() + "}"); - } + EntityId entityId = switch (dynamicValue.getSourceType()) { + case CURRENT_TENANT -> user.getTenantId(); + case CURRENT_CUSTOMER -> user.getCustomerId(); + case CURRENT_USER -> user.getId(); + default -> throw new RuntimeException("Not supported operation for source type: {" + dynamicValue.getSourceType() + "}"); + }; try { Optional valueOpt = attributesService.find(user.getTenantId(), entityId, @@ -242,101 +227,51 @@ public class DefaultEntityQueryService implements EntityQueryService { } @Override - public DeferredResult getKeysByQuery(SecurityUser securityUser, TenantId tenantId, EntityDataQuery query, - boolean isTimeseries, boolean isAttributes, String attributesScope) { - final DeferredResult response = new DeferredResult<>(); + public ListenableFuture getKeysByQuery(SecurityUser securityUser, TenantId tenantId, EntityDataQuery query, + boolean isTimeseries, boolean isAttributes, AttributeScope scope) { if (!isAttributes && !isTimeseries) { - replyWithEmptyResponse(response); - return response; + return immediateFuture(AvailableEntityKeys.none()); } - List ids = this.findEntityDataByQuery(securityUser, query).getData().stream() + List ids = findEntityDataByQuery(securityUser, query).getData().stream() .map(EntityData::getEntityId) - .collect(Collectors.toList()); + .toList(); if (ids.isEmpty()) { - replyWithEmptyResponse(response); - return response; + return immediateFuture(AvailableEntityKeys.none()); } Set types = ids.stream().map(EntityId::getEntityType).collect(Collectors.toSet()); - final ListenableFuture> timeseriesKeysFuture; - final ListenableFuture> attributesKeysFuture; + ListenableFuture> timeseriesKeysFuture; + ListenableFuture> attributesKeysFuture; if (isTimeseries) { - timeseriesKeysFuture = dbCallbackExecutor.submit(() -> timeseriesService.findAllKeysByEntityIds(tenantId, ids)); + timeseriesKeysFuture = timeseriesService.findAllKeysByEntityIdsAsync(tenantId, ids); } else { - timeseriesKeysFuture = null; + timeseriesKeysFuture = immediateFuture(Collections.emptyList()); } if (isAttributes) { Map> typesMap = ids.stream().collect(Collectors.groupingBy(EntityId::getEntityType)); List>> futures = new ArrayList<>(typesMap.size()); - typesMap.forEach((type, entityIds) -> futures.add(dbCallbackExecutor.submit(() -> attributesService.findAllKeysByEntityIds(tenantId, entityIds, attributesScope)))); + typesMap.forEach((type, entityIds) -> futures.add(dbCallbackExecutor.submit(() -> attributesService.findAllKeysByEntityIds(tenantId, entityIds, scope)))); attributesKeysFuture = Futures.transform(Futures.allAsList(futures), lists -> { if (CollectionUtils.isEmpty(lists)) { return Collections.emptyList(); } - return lists.stream().flatMap(List::stream).distinct().sorted().collect(Collectors.toList()); - }, dbCallbackExecutor); - } else { - attributesKeysFuture = null; - } - - if (isTimeseries && isAttributes) { - Futures.whenAllComplete(timeseriesKeysFuture, attributesKeysFuture).run(() -> { - try { - replyWithResponse(response, types, timeseriesKeysFuture.get(), attributesKeysFuture.get()); - } catch (Exception e) { - log.error("Failed to fetch timeseries and attributes keys!", e); - AccessValidator.handleError(e, response, HttpStatus.INTERNAL_SERVER_ERROR); - } + return lists.stream().flatMap(List::stream).distinct().sorted().toList(); }, dbCallbackExecutor); - } else if (isTimeseries) { - addCallback(timeseriesKeysFuture, keys -> replyWithResponse(response, types, keys, null), - error -> { - log.error("Failed to fetch timeseries keys!", error); - AccessValidator.handleError(error, response, HttpStatus.INTERNAL_SERVER_ERROR); - }); } else { - addCallback(attributesKeysFuture, keys -> replyWithResponse(response, types, null, keys), - error -> { - log.error("Failed to fetch attributes keys!", error); - AccessValidator.handleError(error, response, HttpStatus.INTERNAL_SERVER_ERROR); - }); + attributesKeysFuture = immediateFuture(Collections.emptyList()); } - return response; - } - - private void replyWithResponse(DeferredResult response, Set types, List timeseriesKeys, List attributesKeys) { - ObjectNode json = JacksonUtil.newObjectNode(); - addItemsToArrayNode(json.putArray("entityTypes"), types); - addItemsToArrayNode(json.putArray("timeseries"), timeseriesKeys); - addItemsToArrayNode(json.putArray("attribute"), attributesKeys); - response.setResult(new ResponseEntity<>(json, HttpStatus.OK)); - } - private void replyWithEmptyResponse(DeferredResult response) { - replyWithResponse(response, Collections.emptySet(), Collections.emptyList(), Collections.emptyList()); - } - - private void addItemsToArrayNode(ArrayNode arrayNode, Collection collection) { - if (!CollectionUtils.isEmpty(collection)) { - collection.forEach(item -> arrayNode.add(item.toString())); - } - } - - private void addCallback(ListenableFuture> future, Consumer> success, Consumer error) { - Futures.addCallback(future, new FutureCallback>() { - @Override - public void onSuccess(@Nullable List keys) { - success.accept(keys); - } - - @Override - public void onFailure(Throwable t) { - error.accept(t); - } - }, dbCallbackExecutor); + return Futures.whenAllComplete(timeseriesKeysFuture, attributesKeysFuture) + .call(() -> { + try { + return new AvailableEntityKeys(types, Futures.getDone(timeseriesKeysFuture), Futures.getDone(attributesKeysFuture)); + } catch (ExecutionException e) { + throw new ThingsboardException(e.getCause(), ThingsboardErrorCode.DATABASE); + } + }, dbCallbackExecutor); } } diff --git a/application/src/main/java/org/thingsboard/server/service/query/EntityQueryService.java b/application/src/main/java/org/thingsboard/server/service/query/EntityQueryService.java index 78ea2519fd..ac6553d738 100644 --- a/application/src/main/java/org/thingsboard/server/service/query/EntityQueryService.java +++ b/application/src/main/java/org/thingsboard/server/service/query/EntityQueryService.java @@ -15,13 +15,14 @@ */ package org.thingsboard.server.service.query; -import org.springframework.http.ResponseEntity; -import org.springframework.web.context.request.async.DeferredResult; +import com.google.common.util.concurrent.ListenableFuture; +import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.query.AlarmCountQuery; import org.thingsboard.server.common.data.query.AlarmData; import org.thingsboard.server.common.data.query.AlarmDataQuery; +import org.thingsboard.server.common.data.query.AvailableEntityKeys; import org.thingsboard.server.common.data.query.EntityCountQuery; import org.thingsboard.server.common.data.query.EntityData; import org.thingsboard.server.common.data.query.EntityDataQuery; @@ -37,7 +38,7 @@ public interface EntityQueryService { long countAlarmsByQuery(SecurityUser securityUser, AlarmCountQuery query); - DeferredResult getKeysByQuery(SecurityUser securityUser, TenantId tenantId, EntityDataQuery query, - boolean isTimeseries, boolean isAttributes, String attributesScope); + ListenableFuture getKeysByQuery(SecurityUser securityUser, TenantId tenantId, EntityDataQuery query, + boolean isTimeseries, boolean isAttributes, AttributeScope scope); } diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/attributes/AttributesService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/attributes/AttributesService.java index 0d5d3dcd13..2cdad499de 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/attributes/AttributesService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/attributes/AttributesService.java @@ -48,7 +48,7 @@ public interface AttributesService { List findAllKeysByEntityIds(TenantId tenantId, List entityIds); - List findAllKeysByEntityIds(TenantId tenantId, List entityIds, String scope); + List findAllKeysByEntityIds(TenantId tenantId, List entityIds, AttributeScope scope); int removeAllByEntityId(TenantId tenantId, EntityId entityId); diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesService.java index e239e22ee9..0b88ce17cc 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesService.java @@ -63,5 +63,8 @@ public interface TimeseriesService { List findAllKeysByEntityIds(TenantId tenantId, List entityIds); + ListenableFuture> findAllKeysByEntityIdsAsync(TenantId tenantId, List entityIds); + void cleanup(long systemTtl); + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/AvailableEntityKeys.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/AvailableEntityKeys.java new file mode 100644 index 0000000000..4cac646908 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/AvailableEntityKeys.java @@ -0,0 +1,67 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.query; + +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; +import org.thingsboard.server.common.data.EntityType; + +import java.util.List; +import java.util.Set; + +import static java.util.Collections.emptyList; +import static java.util.Collections.emptySet; +import static java.util.Objects.requireNonNullElse; + +@Schema( + description = "Contains unique time series and attribute key names discovered from entities matching a query. Used primarily for UI hints such as autocomplete suggestions." +) +public record AvailableEntityKeys( + @Schema( + description = "Set of entity types found among the matched entities.", + example = "[\"DEVICE\", \"ASSET\"]", + requiredMode = Schema.RequiredMode.REQUIRED + ) + Set entityTypes, + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + @ArraySchema( + arraySchema = @Schema(description = "List of unique time series key names available on the matched entities."), + schema = @Schema(implementation = String.class, example = "temperature"), + uniqueItems = true + ) + List timeseries, + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + @ArraySchema( + arraySchema = @Schema(description = "List of unique attribute key names available on the matched entities."), + schema = @Schema(implementation = String.class, example = "serialNumber"), + uniqueItems = true + ) + List attribute +) { + + public AvailableEntityKeys { + entityTypes = requireNonNullElse(entityTypes, emptySet()); + timeseries = requireNonNullElse(timeseries, emptyList()); + attribute = requireNonNullElse(attribute, emptyList()); + } + + public static AvailableEntityKeys none() { + return new AvailableEntityKeys(emptySet(), emptyList(), emptyList()); + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/attributes/AttributesDao.java b/dao/src/main/java/org/thingsboard/server/dao/attributes/AttributesDao.java index 5527d17add..a1af985887 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/attributes/AttributesDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/attributes/AttributesDao.java @@ -53,7 +53,7 @@ public interface AttributesDao { List findAllKeysByEntityIds(TenantId tenantId, List entityIds); - List findAllKeysByEntityIdsAndAttributeType(TenantId tenantId, List entityIds, String attributeType); + List findAllKeysByEntityIdsAndScope(TenantId tenantId, List entityIds, AttributeScope scope); List> removeAllByEntityId(TenantId tenantId, EntityId entityId); diff --git a/dao/src/main/java/org/thingsboard/server/dao/attributes/BaseAttributesService.java b/dao/src/main/java/org/thingsboard/server/dao/attributes/BaseAttributesService.java index 62b35caa7f..362aa95ee6 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/attributes/BaseAttributesService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/attributes/BaseAttributesService.java @@ -28,7 +28,6 @@ import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.ObjectType; -import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.edqs.AttributeKv; import org.thingsboard.server.common.data.id.DeviceProfileId; import org.thingsboard.server.common.data.id.EntityId; @@ -93,11 +92,11 @@ public class BaseAttributesService implements AttributesService { } @Override - public List findAllKeysByEntityIds(TenantId tenantId, List entityIds, String scope) { - if (StringUtils.isEmpty(scope)) { + public List findAllKeysByEntityIds(TenantId tenantId, List entityIds, AttributeScope scope) { + if (scope == null) { return attributesDao.findAllKeysByEntityIds(tenantId, entityIds); } else { - return attributesDao.findAllKeysByEntityIdsAndAttributeType(tenantId, entityIds, scope); + return attributesDao.findAllKeysByEntityIdsAndScope(tenantId, entityIds, scope); } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/attributes/CachedAttributesService.java b/dao/src/main/java/org/thingsboard/server/dao/attributes/CachedAttributesService.java index 44b2daaf8e..11ba48773d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/attributes/CachedAttributesService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/attributes/CachedAttributesService.java @@ -212,11 +212,11 @@ public class CachedAttributesService implements AttributesService { } @Override - public List findAllKeysByEntityIds(TenantId tenantId, List entityIds, String scope) { - if (StringUtils.isEmpty(scope)) { + public List findAllKeysByEntityIds(TenantId tenantId, List entityIds, AttributeScope scope) { + if (scope == null) { return attributesDao.findAllKeysByEntityIds(tenantId, entityIds); } else { - return attributesDao.findAllKeysByEntityIdsAndAttributeType(tenantId, entityIds, scope); + return attributesDao.findAllKeysByEntityIdsAndScope(tenantId, entityIds, scope); } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/JpaAttributeDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/JpaAttributeDao.java index 0a8b8f6399..1e58b23a1f 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/JpaAttributeDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/JpaAttributeDao.java @@ -177,10 +177,12 @@ public class JpaAttributeDao extends JpaAbstractDaoListeningExecutorService impl } @Override - public List findAllKeysByEntityIdsAndAttributeType(TenantId tenantId, List entityIds, String attributeType) { + public List findAllKeysByEntityIdsAndScope(TenantId tenantId, List entityIds, AttributeScope scope) { return attributeKvRepository - .findAllKeysByEntityIdsAndAttributeType(entityIds.stream().map(EntityId::getId).collect(Collectors.toList()), AttributeScope.valueOf(attributeType).getId()) - .stream().map(id -> keyDictionaryDao.getKey(id)).collect(Collectors.toList()); + .findAllKeysByEntityIdsAndAttributeType(entityIds.stream().map(EntityId::getId).toList(), scope.getId()) + .stream() + .map(keyDictionaryDao::getKey) + .toList(); } @Override diff --git a/dao/src/main/java/org/thingsboard/server/dao/sqlts/CachedRedisSqlTimeseriesLatestDao.java b/dao/src/main/java/org/thingsboard/server/dao/sqlts/CachedRedisSqlTimeseriesLatestDao.java index d45182442a..bac5529249 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sqlts/CachedRedisSqlTimeseriesLatestDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sqlts/CachedRedisSqlTimeseriesLatestDao.java @@ -167,5 +167,9 @@ public class CachedRedisSqlTimeseriesLatestDao extends BaseAbstractSqlTimeseries return sqlDao.findAllKeysByEntityIds(tenantId, entityIds); } + @Override + public ListenableFuture> findAllKeysByEntityIdsAsync(TenantId tenantId, List entityIds) { + return sqlDao.findAllKeysByEntityIdsAsync(tenantId, entityIds); + } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sqlts/SqlTimeseriesLatestDao.java b/dao/src/main/java/org/thingsboard/server/dao/sqlts/SqlTimeseriesLatestDao.java index c546fc21ea..27470bfe8a 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sqlts/SqlTimeseriesLatestDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sqlts/SqlTimeseriesLatestDao.java @@ -24,7 +24,6 @@ import jakarta.annotation.PreDestroy; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; -import org.springframework.data.domain.Page; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.id.DeviceProfileId; import org.thingsboard.server.common.data.id.EntityId; @@ -38,8 +37,6 @@ import org.thingsboard.server.common.data.kv.ReadTsKvQueryResult; import org.thingsboard.server.common.data.kv.StringDataEntry; import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.data.kv.TsKvLatestRemovingResult; -import org.thingsboard.server.common.data.page.PageData; -import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.stats.StatsFactory; import org.thingsboard.server.dao.DaoUtil; import org.thingsboard.server.dao.dictionary.KeyDictionaryDao; @@ -64,7 +61,6 @@ import java.util.Map; import java.util.Optional; import java.util.UUID; import java.util.function.Function; -import java.util.stream.Collectors; @Slf4j @Component @@ -185,9 +181,13 @@ public class SqlTimeseriesLatestDao extends BaseAbstractSqlTimeseriesDao impleme @Override public List findAllKeysByEntityIds(TenantId tenantId, List entityIds) { - return tsKvLatestRepository.findAllKeysByEntityIds(entityIds.stream().map(EntityId::getId).collect(Collectors.toList())); + return tsKvLatestRepository.findAllKeysByEntityIds(entityIds.stream().map(EntityId::getId).toList()); } + @Override + public ListenableFuture> findAllKeysByEntityIdsAsync(TenantId tenantId, List entityIds) { + return service.submit(() -> findAllKeysByEntityIds(tenantId, entityIds)); + } private ListenableFuture getNewLatestEntryFuture(TenantId tenantId, EntityId entityId, DeleteTsKvQuery query, Long version) { ListenableFuture> future = findNewLatestEntryFuture(tenantId, entityId, query); @@ -211,7 +211,7 @@ public class SqlTimeseriesLatestDao extends BaseAbstractSqlTimeseriesDao impleme ReadTsKvQueryResult::getData, MoreExecutors.directExecutor()); } - protected TsKvEntry doFindLatestSync(EntityId entityId, String key) { + protected TsKvEntry doFindLatestSync(EntityId entityId, String key) { TsKvLatestCompositeKey compositeKey = new TsKvLatestCompositeKey( entityId.getId(), diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java index ceb7fcf822..de197bf88f 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java @@ -156,6 +156,11 @@ public class BaseTimeseriesService implements TimeseriesService { return timeseriesLatestDao.findAllKeysByEntityIds(tenantId, entityIds); } + @Override + public ListenableFuture> findAllKeysByEntityIdsAsync(TenantId tenantId, List entityIds) { + return timeseriesLatestDao.findAllKeysByEntityIdsAsync(tenantId, entityIds); + } + @Override public void cleanup(long systemTtl) { timeseriesDao.cleanup(systemTtl); @@ -300,13 +305,13 @@ public class BaseTimeseriesService implements TimeseriesService { long interval = query.getInterval(); if (interval < 1) { throw new IncorrectParameterException("Invalid TsKvQuery: 'interval' must be greater than 0, but got " + interval + - ". Please check your query parameters and ensure 'endTs' is greater than 'startTs' or increase 'interval'."); + ". Please check your query parameters and ensure 'endTs' is greater than 'startTs' or increase 'interval'."); } long step = Math.max(interval, 1000); long intervalCounts = (query.getEndTs() - query.getStartTs()) / step; if (intervalCounts > maxTsIntervals || intervalCounts < 0) { throw new IncorrectParameterException("Incorrect TsKvQuery. Number of intervals is to high - " + intervalCounts + ". " + - "Please increase 'interval' parameter for your query or reduce the time range of the query."); + "Please increase 'interval' parameter for your query or reduce the time range of the query."); } } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesLatestDao.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesLatestDao.java index 54a7e68725..dd44a62349 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesLatestDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesLatestDao.java @@ -36,17 +36,13 @@ import org.thingsboard.server.common.data.kv.ReadTsKvQuery; import org.thingsboard.server.common.data.kv.ReadTsKvQueryResult; import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.data.kv.TsKvLatestRemovingResult; -import org.thingsboard.server.common.data.page.PageData; -import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.dao.model.ModelConstants; -import org.thingsboard.server.dao.model.sqlts.latest.TsKvLatestEntity; import org.thingsboard.server.dao.nosql.TbResultSet; import org.thingsboard.server.dao.sqlts.AggregationTimeseriesDao; import org.thingsboard.server.dao.util.NoSqlTsLatestDao; import java.util.Collections; import java.util.List; -import java.util.Map; import java.util.Optional; import static com.datastax.oss.driver.api.querybuilder.QueryBuilder.literal; @@ -103,6 +99,10 @@ public class CassandraBaseTimeseriesLatestDao extends AbstractCassandraBaseTimes return Collections.emptyList(); } + @Override + public ListenableFuture> findAllKeysByEntityIdsAsync(TenantId tenantId, List entityIds) { + return Futures.immediateFuture(Collections.emptyList()); + } @Override public ListenableFuture saveLatest(TenantId tenantId, EntityId entityId, TsKvEntry tsKvEntry) { diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesLatestDao.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesLatestDao.java index 32479301ae..74c041e4d3 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesLatestDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesLatestDao.java @@ -22,12 +22,8 @@ import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.kv.DeleteTsKvQuery; import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.data.kv.TsKvLatestRemovingResult; -import org.thingsboard.server.common.data.page.PageData; -import org.thingsboard.server.common.data.page.PageLink; -import org.thingsboard.server.dao.model.sqlts.latest.TsKvLatestEntity; import java.util.List; -import java.util.Map; import java.util.Optional; public interface TimeseriesLatestDao { @@ -54,4 +50,6 @@ public interface TimeseriesLatestDao { List findAllKeysByEntityIds(TenantId tenantId, List entityIds); + ListenableFuture> findAllKeysByEntityIdsAsync(TenantId tenantId, List entityIds); + } diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/attributes/BaseAttributesServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/attributes/BaseAttributesServiceTest.java index 5978d903c7..41a013e4c9 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/attributes/BaseAttributesServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/attributes/BaseAttributesServiceTest.java @@ -223,7 +223,7 @@ public abstract class BaseAttributesServiceTest extends AbstractServiceTest { saveAttribute(tenantId, deviceId, AttributeScope.SERVER_SCOPE, "key2", "123"); Awaitility.await().atMost(30, TimeUnit.SECONDS).untilAsserted(() -> { - List keys = attributesService.findAllKeysByEntityIds(tenantId, List.of(deviceId), AttributeScope.SERVER_SCOPE.name()); + List keys = attributesService.findAllKeysByEntityIds(tenantId, List.of(deviceId), AttributeScope.SERVER_SCOPE); assertThat(keys).containsOnly("key1", "key2"); }); } diff --git a/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java b/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java index 2c8e01d246..6eb61b3e13 100644 --- a/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java +++ b/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java @@ -39,10 +39,12 @@ import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.ThingsBoardExecutors; import org.thingsboard.rest.client.utils.RestJsonConverter; import org.thingsboard.server.common.data.AdminSettings; +import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.ClaimRequest; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.Dashboard; @@ -160,6 +162,7 @@ import org.thingsboard.server.common.data.plugin.ComponentType; import org.thingsboard.server.common.data.query.AlarmCountQuery; import org.thingsboard.server.common.data.query.AlarmData; import org.thingsboard.server.common.data.query.AlarmDataQuery; +import org.thingsboard.server.common.data.query.AvailableEntityKeys; import org.thingsboard.server.common.data.query.EntityCountQuery; import org.thingsboard.server.common.data.query.EntityData; import org.thingsboard.server.common.data.query.EntityDataQuery; @@ -592,7 +595,7 @@ public class RestClient implements Closeable { } public PageData getAllAlarmsV2(List statusList, List severityList, - List typeList, String assignedId, TimePageLink pageLink) { + List typeList, String assignedId, TimePageLink pageLink) { String urlSecondPart = "/api/v2/alarms?"; Map params = new HashMap<>(); if (!CollectionUtils.isEmpty(statusList)) { @@ -1824,12 +1827,15 @@ public class RestClient implements Closeable { }).getBody(); } - public JsonNode findEntityTimeseriesAndAttributesKeysByQuery(EntityDataQuery query) { - return restTemplate.exchange( - baseURL + "/api/entitiesQuery/find/keys", - HttpMethod.POST, new HttpEntity<>(query), - new ParameterizedTypeReference() { - }).getBody(); + public AvailableEntityKeys findAvailableEntityKeysByQuery(EntityDataQuery query, boolean includeTimeseries, boolean includeAttributes, AttributeScope scope) { + var uri = UriComponentsBuilder.fromUriString(baseURL) + .path("/api/entitiesQuery/find/keys") + .queryParam("timeseries", includeTimeseries) + .queryParam("attributes", includeAttributes) + .queryParamIfPresent("scope", Optional.ofNullable(scope)) + .build() + .toUri(); + return restTemplate.exchange(uri, HttpMethod.POST, new HttpEntity<>(query), new ParameterizedTypeReference() {}).getBody(); } public PageData findAlarmDataByQuery(AlarmDataQuery query) { From ebffe261084e1f5e14083a3b81fad3439ce705ee Mon Sep 17 00:00:00 2001 From: ArtemDzhereleiko Date: Mon, 1 Dec 2025 19:24:41 +0200 Subject: [PATCH 17/22] UI: Bug-fixes and add validation for alarm rules --- ui-ngx/src/app/core/services/menu.models.ts | 2 +- .../alarm-rule-details-dialog.component.html | 4 +- .../alarm-rule-dialog.component.html | 48 +++---- .../alarm-rule-filter-config.component.html | 4 +- .../alarm-rules/alarm-rules-table-config.ts | 6 +- ...alarm-rule-condition-dialog.component.html | 47 ++++++- ...f-alarm-rule-condition-dialog.component.ts | 129 ++++++++++++++++-- .../cf-alarm-rule-condition.component.html | 11 +- .../cf-alarm-rule-condition.component.ts | 74 +++++++++- .../alarm-rules/cf-alarm-rule.component.html | 6 +- .../cf-alarm-rules-dialog.component.scss | 5 + .../cf-alarm-schedule.component.ts | 2 +- ...lex-filter-predicate-dialog.component.html | 33 +++-- ...mplex-filter-predicate-dialog.component.ts | 13 +- .../alarm-rule-filter-dialog.component.html | 4 +- .../alarm-rule-filter-dialog.component.ts | 51 +++++-- .../alarm-rule-filter-list.component.html | 32 +++-- .../alarm-rule-filter-list.component.scss | 13 +- .../alarm-rule-filter-list.component.ts | 30 ++++ ...-rule-filter-predicate-list.component.html | 16 ++- ...-rule-filter-predicate-list.component.scss | 3 +- ...ilter-predicate-no-data-value.component.ts | 9 +- ...m-rule-filter-predicate-value.component.ts | 9 +- ...alarm-rule-filter-predicate.component.html | 2 +- .../alarm-rule-filter-predicate.component.ts | 36 +++++ .../alarm-rule-filter-text.component.html | 1 + .../alarm-rule-filter-text.component.scss | 3 + .../alarm-rule-filter-text.component.ts | 2 +- ...y-aggregation-arguments-table.component.ts | 1 + .../propagate-arguments-table.component.ts | 2 +- ...d-aggregation-arguments-table.component.ts | 3 +- .../propagation-configuration.component.html | 2 +- .../simple-configuration.component.ts | 2 +- ...entity-types-version-create.component.html | 4 +- .../vc/entity-types-version.component.scss | 11 ++ .../home/pages/alarm/alarm-routing.module.ts | 2 +- .../app/shared/models/alarm-rule.models.ts | 7 + ui-ngx/src/app/shared/models/vc.models.ts | 2 +- .../assets/locale/locale.constant-en_US.json | 50 ++++--- 39 files changed, 535 insertions(+), 146 deletions(-) diff --git a/ui-ngx/src/app/core/services/menu.models.ts b/ui-ngx/src/app/core/services/menu.models.ts index 8909663771..555717ffc0 100644 --- a/ui-ngx/src/app/core/services/menu.models.ts +++ b/ui-ngx/src/app/core/services/menu.models.ts @@ -510,7 +510,7 @@ export const menuSectionMap = new Map([ MenuId.alarms, { id: MenuId.alarms, - name: 'alarm.alarms', + name: 'alarm.alarm-list', type: 'link', path: '/alarms/alarms', icon: 'mdi:alert-outline' diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-details-dialog.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-details-dialog.component.html index 9e5036f6ff..97bd25836a 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-details-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-details-dialog.component.html @@ -27,8 +27,10 @@ - +
+ +
diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.html index b734757144..20bd3e1bde 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.html @@ -130,30 +130,32 @@ {{ 'alarm-rule.advanced-settings' | translate }} -
- - {{ 'alarm-rule.propagate-alarm' | translate }} - +
+
+ + {{ 'alarm-rule.propagate-alarm' | translate }} + +
+ @if (configFormGroup.get('propagate').value) { + + alarm-rule.alarm-rule-relation-types-list + + + {{key}} + close + + + + + + }
- @if (configFormGroup.get('propagate').value) { - - alarm-rule.alarm-rule-relation-types-list - - - {{key}} - close - - - - - - }
{{ 'alarm-rule.propagate-alarm-to-owner' | translate }} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-filter-config.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-filter-config.component.html index a71874c13c..5deddffd87 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-filter-config.component.html +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-filter-config.component.html @@ -66,7 +66,7 @@
-
alarm-rule.entity-type
+
alarm-rule.target-entity-type
{{ 'alarm-rule.any-type' | translate }} @@ -78,7 +78,7 @@
@if (alarmRuleFilterConfigForm.get('entityType').value) {
-
alarm-rule.alarm-rule-entity-list
+
alarm-rule.target-entities
{ this.columns.push(new EntityTableColumn('name', 'alarm-rule.alarm-type', this.pageMode ? '30%' :'33%', entity => this.utilsService.customTranslation(entity.name, entity.name))); if (this.pageMode) { - this.columns.push(new EntityTableColumn('entityType', 'alarm-rule.entity-type', '15%', + this.columns.push(new EntityTableColumn('entityType', 'alarm-rule.target-entity-type', '15%', entity => this.translate.instant(entityTypeTranslations.get(entity.entityId.entityType).type))); - this.columns.push(new EntityLinkTableColumn('entityName', 'alarm-rule.entity-name', '30%', + this.columns.push(new EntityLinkTableColumn('entityName', 'alarm-rule.target-entity', '30%', entity => this.utilsService.customTranslation(entity['entityName'], entity['entityName']), entity => getEntityDetailsPageURL(entity.entityId?.id, entity.entityId?.entityType as EntityType), false)); } this.columns.push(new EntityTableColumn('createRule', 'alarm-rule.severities', this.pageMode ? '15%' :'67%', entity => Object.keys(entity.configuration.createRules).map((severity) => this.translate.instant(alarmSeverityTranslations.get(severity as AlarmSeverity))).join(', '), () => ({}), false)); - this.columns.push(new EntityTableColumn('clearRule', 'alarm-rule.cleared', '70px', + this.columns.push(new EntityTableColumn('clearRule', 'alarm-rule.cleared', '90px', entity => checkBoxCell(!!entity.configuration.clearRule), ()=> { return {padding: 0, textAlign: 'center'}}, false)); this.cellActionDescriptors.push( diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition-dialog.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition-dialog.component.html index 8116431757..d67cd51a9a 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition-dialog.component.html @@ -90,6 +90,34 @@
}
+ + @if (conditionFormGroup.get('expression.type').value === AlarmRuleExpressionType.SIMPLE) { +
+ + + {{ 'alarm-rule.filter-preview' | translate }} + + +
+ @if (specText) { + {{ specText }} + } + @if (conditionFormGroup.get('expression.filters').value?.length) { + + + } @else { + {{ 'alarm-rule.no-filter-preview' | translate }} + } +
+
+
+
+ } +
{{ 'alarm-rule.condition-settings' | translate }}
@@ -150,7 +178,7 @@
- +
@@ -166,9 +194,8 @@ @if (!readonly) { } @@ -190,6 +217,9 @@ } @else if (conditionFormGroup.get(groupName).get('staticValue').hasError('pattern')) { {{ defaultValuePatternError | translate }} } + @if (type === AlarmConditionType.REPEATING) { + alarm-rule.condition-repeating-value-hint + } @@ -202,9 +232,14 @@ {{ argument }} } - - {{ 'calculated-fields.hint.argument-name-required' | translate }} - + @if (conditionFormGroup.get(groupName).get('dynamicValueArgument').hasError('required')) { + + {{ 'calculated-fields.hint.argument-name-required' | translate }} + + } + @if (type === AlarmConditionType.REPEATING) { + alarm-rule.condition-repeating-value-hint + } diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition-dialog.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition-dialog.component.ts index 3454903b55..f565a66168 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition-dialog.component.ts @@ -24,14 +24,6 @@ import { DialogComponent } from '@app/shared/components/dialog.component'; import { TimeUnit, timeUnitTranslationMap } from '@shared/models/time/time.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ScriptLanguage } from "@shared/models/rule-node.models"; -import { - AlarmRuleCondition, - AlarmRuleConditionType, - AlarmRuleConditionTypeTranslationMap, - alarmRuleDefaultScript, - AlarmRuleExpressionType, - AlarmRuleFilter -} from "@shared/models/alarm-rule.models"; import { CalculatedFieldArgument, getCalculatedFieldArgumentsEditorCompleter, @@ -39,8 +31,18 @@ import { } from "@shared/models/calculated-field.models"; import { TbEditorCompleter } from "@shared/models/ace/completion.models"; import { AceHighlightRules } from "@shared/models/ace/ace.models"; -import { ComplexOperation, complexOperationTranslationMap } from "@shared/models/query/query.models"; +import { ComplexOperation } from "@shared/models/query/query.models"; import { Observable } from "rxjs"; +import { TranslateService } from "@ngx-translate/core"; +import { + AlarmRuleCondition, + AlarmRuleConditionType, + AlarmRuleConditionTypeTranslationMap, + alarmRuleDefaultScript, + AlarmRuleExpressionType, + AlarmRuleFilter, + filterOperationTranslationMap +} from "@shared/models/alarm-rule.models"; export interface CfAlarmRuleConditionDialogData { readonly: boolean; @@ -97,7 +99,11 @@ export class CfAlarmRuleConditionDialogComponent extends DialogComponent(false); ComplexOperation = ComplexOperation; - complexOperationTranslationMap = complexOperationTranslationMap; + complexOperationTranslationMap = filterOperationTranslationMap; + + specText = ''; + + filtersValid: boolean = false; functionArgs: Array; argumentsEditorCompleter: TbEditorCompleter; @@ -112,7 +118,8 @@ export class CfAlarmRuleConditionDialogComponent extends DialogComponent, - private fb: FormBuilder) { + private fb: FormBuilder, + private translate: TranslateService) { super(store, router, dialogRef); this.functionArgs = ['ctx', ...Object.keys(this.data.arguments)]; @@ -131,28 +138,37 @@ export class CfAlarmRuleConditionDialogComponent extends DialogComponent { - this.updateValidators(type, true); + this.updateValidators(type); }); + this.conditionFormGroup.valueChanges.pipe( + takeUntilDestroyed() + ).subscribe((value) => { + this.updateSpecText(value.type); + }) + this.conditionFormGroup.get('expression.filters').valueChanges.pipe( takeUntilDestroyed() ).subscribe((filters) => { + this.filtersValid = this.areFilterAndPredicateArgumentsValid(filters, this.argumentsList); this.checkIsNoData(filters); }); @@ -175,6 +191,7 @@ export class CfAlarmRuleConditionDialogComponent extends DialogComponent 0) { + this.specText = this.specText + ':'; + } + } + cancel(): void { this.dialogRef.close(null); } diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.html index 0b722b4ec1..638075849f 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.html +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.html @@ -21,7 +21,7 @@
{{ 'alarm-rule.schedule-title' | translate }}
diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.ts index 543b0a86dc..6f17aa8b59 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.ts +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.ts @@ -14,8 +14,9 @@ /// limitations under the License. /// -import { ChangeDetectorRef, Component, forwardRef, Input } from '@angular/core'; +import { ChangeDetectorRef, Component, forwardRef, Input, OnChanges, SimpleChanges } from '@angular/core'; import { + AbstractControl, ControlValueAccessor, FormBuilder, NG_VALIDATORS, @@ -68,7 +69,7 @@ import { Observable } from "rxjs"; } ] }) -export class CfAlarmRuleConditionComponent implements ControlValueAccessor, Validator { +export class CfAlarmRuleConditionComponent implements ControlValueAccessor, Validator, OnChanges { @Input() @coerceBoolean() @@ -96,11 +97,15 @@ export class CfAlarmRuleConditionComponent implements ControlValueAccessor, Vali specText = ''; + filtersArgumentsValid: boolean = true; + schedulerArgumentsValid: boolean = true; + scheduleText = ''; private modelValue: AlarmRuleCondition; private propagateChange = (v: any) => { }; + private onValidatorChange = () => { }; constructor(private dialog: MatDialog, private fb: FormBuilder, @@ -115,6 +120,10 @@ export class CfAlarmRuleConditionComponent implements ControlValueAccessor, Vali registerOnTouched(fn: any): void { } + registerOnValidatorChange(fn: () => void): void { + this.onValidatorChange = fn; + } + setDisabledState(isDisabled: boolean): void { this.disabled = isDisabled; if (this.disabled) { @@ -127,14 +136,68 @@ export class CfAlarmRuleConditionComponent implements ControlValueAccessor, Vali writeValue(value: AlarmRuleCondition): void { this.modelValue = value; this.updateConditionInfo(); + if (value) { + this.onValidatorChange(); + } + } + + ngOnChanges(changes: SimpleChanges) { + if (changes.arguments) { + if (changes.arguments && !changes.arguments.firstChange && this.modelValue) { + this.onValidatorChange(); + } + } + } + + private isScheduleArgumentValid(obj: any, validArguments: string[]): boolean { + const arg = obj?.schedule?.dynamicValueArgument; + return !arg || validArguments.includes(arg); + } + + private areFilterAndPredicateArgumentsValid(obj: any, validArguments: string[]): boolean { + const validSet = new Set(validArguments); + const filters = obj?.expression?.filters || obj?.filters || []; + for (const filter of filters) { + if (filter.argument && !validSet.has(filter.argument)) { + return false; + } + } + function checkPredicates(predicates: any[]): boolean { + for (const p of predicates) { + if (p.value?.dynamicValueArgument) { + if (!validSet.has(p.value.dynamicValueArgument)) { + return false; + } + } + if (p.type === 'COMPLEX' && Array.isArray(p.predicates)) { + if (!checkPredicates(p.predicates)) { + return false; + } + } + } + return true; + } + for (const filter of filters) { + if (Array.isArray(filter.predicates)) { + if (!checkPredicates(filter.predicates)) { + return false; + } + } + } + return true; } public conditionSet() { return this.modelValue && (this.modelValue.expression?.expression || this.modelValue.expression?.filters); } - public validate(): ValidationErrors | null { - return this.conditionSet() ? null : { + public validate(control: AbstractControl): ValidationErrors | null { + this.filtersArgumentsValid = this.areFilterAndPredicateArgumentsValid(this.modelValue, Object.keys(this.arguments)); + this.schedulerArgumentsValid = this.isScheduleArgumentValid(this.modelValue, Object.keys(this.arguments)); + this.onValidatorChange = () => { + control.updateValueAndValidity({ emitEvent: true }); + }; + return this.conditionSet() && this.filtersArgumentsValid && this.schedulerArgumentsValid ? null : { alarmRuleCondition: { valid: false, } @@ -226,6 +289,9 @@ export class CfAlarmRuleConditionComponent implements ControlValueAccessor, Vali private updateModel() { this.updateConditionInfo(); this.propagateChange(this.modelValue); + if (this.modelValue) { + this.onValidatorChange(); + } } public openScheduleDialog($event: Event) { diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.html index 38e0bd8023..a9f4ad61a5 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.html +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.html @@ -20,11 +20,11 @@ @if (!disabled || alarmRuleFormGroup.get('alarmDetails').value) {
-
+
alarm-rule.alarm-rule-additional-info
- +
diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rules-dialog.component.scss b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rules-dialog.component.scss index b66573f417..6253aa9a4b 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rules-dialog.component.scss +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rules-dialog.component.scss @@ -20,6 +20,11 @@ display: grid; grid-template-rows: min-content minmax(auto, 1fr) min-content; } + + .spec-text { + font-size: 14px; + color: rgba(0, 0, 0, 0.54); + } } .tbel-script-lang-chip { line-height: 20px; diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-schedule.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-schedule.component.ts index b1c7a4da7f..a4572629e0 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-schedule.component.ts +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-schedule.component.ts @@ -171,7 +171,7 @@ export class CfAlarmScheduleComponent implements ControlValueAccessor, Validator if (value) { this.modelValue = value; if (this.modelValue.dynamicValueArgument) { - this.alarmScheduleForm.get('dynamicValueArgument').patchValue(this.modelValue.dynamicValueArgument, {emitEvent: false}); + this.alarmScheduleForm.get('dynamicValueArgument').patchValue(Object.keys(this.arguments).includes(this.modelValue.dynamicValueArgument) ? this.modelValue.dynamicValueArgument : null, {emitEvent: false}); } else { switch (this.modelValue.staticValue.type) { case AlarmRuleScheduleType.SPECIFIC_TIME: diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-complex-filter-predicate-dialog.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-complex-filter-predicate-dialog.component.html index 75bce37a80..c0c5a72d77 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-complex-filter-predicate-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-complex-filter-predicate-dialog.component.html @@ -25,20 +25,25 @@
- - filter.operation.operation - - - {{complexOperationTranslations.get(complexOperationEnum[operation]) | translate}} - - - - - +
+
+
+ {{ 'alarm-rule.filters' | translate }} +
+ + {{ complexOperationTranslations.get(complexOperationEnum.AND) | translate }} + {{ complexOperationTranslations.get(complexOperationEnum.OR) | translate }} + +
+ + +
diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-dialog.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-dialog.component.ts index 0858fd90ae..3f86bdbe04 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-dialog.component.ts @@ -21,18 +21,18 @@ import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { Router } from '@angular/router'; import { DialogComponent } from '@app/shared/components/dialog.component'; - import { - ComplexOperation, - complexOperationTranslationMap, - EntityKeyValueType, - entityKeyValueTypesMap - } from '@shared/models/query/query.models'; + import { ComplexOperation, EntityKeyValueType, entityKeyValueTypesMap } from '@shared/models/query/query.models'; import { DialogService } from '@core/services/dialog.service'; import { TranslateService } from '@ngx-translate/core'; - import { AlarmRuleFilter, AlarmRuleFilterPredicate } from "@shared/models/alarm-rule.models"; + import { + AlarmRuleFilter, + AlarmRuleFilterPredicate, + filterOperationTranslationMap + } from "@shared/models/alarm-rule.models"; import { CalculatedFieldArgument } from "@shared/models/calculated-field.models"; import { FormControlsFrom } from "@shared/models/tenant.model"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; + import { isDefinedAndNotNull } from "@core/utils"; export interface AlarmRuleFilterDialogData { filter: AlarmRuleFilter; @@ -57,7 +57,9 @@ export class AlarmRuleFilterDialogComponent extends DialogComponent { + this.predicatesValid = this.isPredicateArgumentsValid(predicates); + }); + this.filterFormGroup.get('valueType').valueChanges.pipe( takeUntilDestroyed(this.destroyRef) ).subscribe((valueType: EntityKeyValueType) => { @@ -106,6 +116,31 @@ export class AlarmRuleFilterDialogComponent extends DialogComponent
-
{{ filterControl.value?.argument }}
-
{{ FilterPredicateTypeTranslationMap.get(filterControl.value?.valueType) | translate }}
-
+
{{ FilterPredicateTypeTranslationMap.get(filterControl.value?.valueType) | translate }}
+ @@ -62,14 +69,15 @@ }
} - - filter.no-key-filters - + @if (!filtersFormArray.length) { + + alarm-rule.no-filter + + + }
- -