Browse Source

Merge pull request #14068 from thingsboard/ai-node-resources

AI Request Node: added ability to attach files
pull/14083/head
Viacheslav Klimov 8 months ago
committed by GitHub
parent
commit
d07612ddea
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 5
      application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java
  2. 21
      application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java
  3. 17
      application/src/main/java/org/thingsboard/server/controller/TbResourceController.java
  4. 4
      application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java
  5. 9
      application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java
  6. 4
      application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java
  7. 2
      application/src/main/java/org/thingsboard/server/service/queue/DefaultTbEdgeConsumerService.java
  8. 4
      application/src/main/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerService.java
  9. 5
      application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractConsumerService.java
  10. 4
      application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractPartitionBasedConsumerService.java
  11. 1
      application/src/main/java/org/thingsboard/server/service/resource/DefaultTbResourceService.java
  12. 3
      application/src/main/resources/thingsboard.yml
  13. 23
      application/src/test/java/org/thingsboard/server/controller/TbResourceControllerTest.java
  14. 83
      application/src/test/java/org/thingsboard/server/service/resource/DefaultResourceDataCacheTest.java
  15. 121
      application/src/test/java/org/thingsboard/server/service/resource/sql/BaseTbResourceServiceTest.java
  16. 5
      common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ResourceService.java
  17. 28
      common/dao-api/src/main/java/org/thingsboard/server/dao/resource/TbResourceDataCache.java
  18. 29
      common/data/src/main/java/org/thingsboard/server/common/data/GeneralFileDescriptor.java
  19. 3
      common/data/src/main/java/org/thingsboard/server/common/data/ResourceType.java
  20. 6
      common/data/src/main/java/org/thingsboard/server/common/data/TbResource.java
  21. 31
      common/data/src/main/java/org/thingsboard/server/common/data/TbResourceDataInfo.java
  22. 3
      common/data/src/main/java/org/thingsboard/server/common/data/TbResourceDeleteResult.java
  23. 15
      common/util/src/main/java/org/thingsboard/common/util/DonAsynchron.java
  24. 5
      dao/src/main/java/org/thingsboard/server/dao/ResourceContainerDao.java
  25. 5
      dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java
  26. 72
      dao/src/main/java/org/thingsboard/server/dao/resource/BaseResourceService.java
  27. 72
      dao/src/main/java/org/thingsboard/server/dao/resource/DefaultTbResourceDataCache.java
  28. 2
      dao/src/main/java/org/thingsboard/server/dao/resource/TbResourceDao.java
  29. 2
      dao/src/main/java/org/thingsboard/server/dao/resource/TbResourceInfoDao.java
  30. 4
      dao/src/main/java/org/thingsboard/server/dao/rule/RuleChainDao.java
  31. 16
      dao/src/main/java/org/thingsboard/server/dao/sql/dashboard/DashboardInfoRepository.java
  32. 10
      dao/src/main/java/org/thingsboard/server/dao/sql/dashboard/JpaDashboardInfoDao.java
  33. 6
      dao/src/main/java/org/thingsboard/server/dao/sql/resource/JpaTbResourceDao.java
  34. 8
      dao/src/main/java/org/thingsboard/server/dao/sql/resource/JpaTbResourceInfoDao.java
  35. 6
      dao/src/main/java/org/thingsboard/server/dao/sql/resource/TbResourceInfoRepository.java
  36. 3
      dao/src/main/java/org/thingsboard/server/dao/sql/resource/TbResourceRepository.java
  37. 12
      dao/src/main/java/org/thingsboard/server/dao/sql/rule/JpaRuleChainDao.java
  38. 15
      dao/src/main/java/org/thingsboard/server/dao/sql/rule/RuleChainRepository.java
  39. 10
      dao/src/main/java/org/thingsboard/server/dao/sql/widget/JpaWidgetTypeDao.java
  40. 15
      dao/src/main/java/org/thingsboard/server/dao/sql/widget/WidgetTypeInfoRepository.java
  41. 1
      dao/src/test/java/org/thingsboard/server/dao/sql/rule/JpaRuleNodeDaoTest.java
  42. 7
      rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java
  43. 121
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java
  44. 6
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNodeConfiguration.java
  45. 223
      rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/ai/TbAiNodeTest.java
  46. 6
      ui-ngx/src/app/core/http/entity.service.ts
  47. 7
      ui-ngx/src/app/core/http/resource.service.ts
  48. 6
      ui-ngx/src/app/modules/home/components/home-components.module.ts
  49. 54
      ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.html
  50. 24
      ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.scss
  51. 116
      ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.ts
  52. 63
      ui-ngx/src/app/modules/home/components/resources/resources-library.component.html
  53. 29
      ui-ngx/src/app/modules/home/components/resources/resources-library.component.ts
  54. 10
      ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html
  55. 24
      ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.ts
  56. 2
      ui-ngx/src/app/modules/home/pages/admin/admin.module.ts
  57. 182
      ui-ngx/src/app/modules/home/pages/admin/resource/resources-library-table-config.resolve.ts
  58. 2
      ui-ngx/src/app/modules/home/pages/admin/resource/resources-table-header.component.ts
  59. 11
      ui-ngx/src/app/shared/components/entity/entity-list.component.html
  60. 29
      ui-ngx/src/app/shared/components/entity/entity-list.component.ts
  61. 14
      ui-ngx/src/app/shared/components/file-input.component.ts
  62. 12
      ui-ngx/src/app/shared/models/resource.models.ts
  63. 13
      ui-ngx/src/assets/locale/locale.constant-en_US.json

5
application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java

@ -97,6 +97,7 @@ import org.thingsboard.server.dao.ota.OtaPackageService;
import org.thingsboard.server.dao.queue.QueueService;
import org.thingsboard.server.dao.queue.QueueStatsService;
import org.thingsboard.server.dao.relation.RelationService;
import org.thingsboard.server.dao.resource.TbResourceDataCache;
import org.thingsboard.server.dao.resource.ResourceService;
import org.thingsboard.server.dao.rule.RuleChainService;
import org.thingsboard.server.dao.rule.RuleNodeStateService;
@ -511,6 +512,10 @@ public class ActorSystemContext {
@Getter
private ResourceService resourceService;
@Autowired
@Getter
private TbResourceDataCache resourceDataCache;
@Lazy
@Autowired(required = false)
@Getter

21
application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java

@ -51,6 +51,7 @@ import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.DeviceProfile;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.HasRuleEngineProfile;
import org.thingsboard.server.common.data.HasTenantId;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.TenantProfile;
import org.thingsboard.server.common.data.alarm.Alarm;
@ -60,6 +61,7 @@ import org.thingsboard.server.common.data.id.AssetId;
import org.thingsboard.server.common.data.id.CustomerId;
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.RuleChainId;
import org.thingsboard.server.common.data.id.RuleNodeId;
import org.thingsboard.server.common.data.id.TenantId;
@ -110,6 +112,7 @@ import org.thingsboard.server.dao.ota.OtaPackageService;
import org.thingsboard.server.dao.queue.QueueService;
import org.thingsboard.server.dao.queue.QueueStatsService;
import org.thingsboard.server.dao.relation.RelationService;
import org.thingsboard.server.dao.resource.TbResourceDataCache;
import org.thingsboard.server.dao.resource.ResourceService;
import org.thingsboard.server.dao.rule.RuleChainService;
import org.thingsboard.server.dao.tenant.TenantService;
@ -770,6 +773,11 @@ public class DefaultTbContext implements TbContext {
return mainCtx.getResourceService();
}
@Override
public TbResourceDataCache getTbResourceDataCache() {
return mainCtx.getResourceDataCache();
}
@Override
public OtaPackageService getOtaPackageService() {
return mainCtx.getOtaPackageService();
@ -1054,7 +1062,18 @@ public class DefaultTbContext implements TbContext {
@Override
public void checkTenantEntity(EntityId entityId) throws TbNodeException {
if (!this.getTenantId().equals(TenantIdLoader.findTenantId(this, entityId))) {
TenantId actualTenantId = TenantIdLoader.findTenantId(this, entityId);
assertSameTenantId(actualTenantId, entityId);
}
@Override
public <E extends HasId<I> & HasTenantId, I extends EntityId> void checkTenantEntity(E entity) throws TbNodeException {
TenantId actualTenantId = entity.getTenantId();
assertSameTenantId(actualTenantId, entity.getId());
}
private void assertSameTenantId(TenantId tenantId, EntityId entityId) throws TbNodeException {
if (!getTenantId().equals(tenantId)) {
throw new TbNodeException("Entity with id: '" + entityId + "' specified in the configuration doesn't belong to the current tenant.", true);
}
}

17
application/src/main/java/org/thingsboard/server/controller/TbResourceController.java

@ -55,14 +55,17 @@ import org.thingsboard.server.common.data.util.ThrowingSupplier;
import org.thingsboard.server.config.annotations.ApiOperation;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.resource.TbResourceService;
import org.thingsboard.server.service.security.model.SecurityUser;
import org.thingsboard.server.service.security.permission.Operation;
import org.thingsboard.server.service.security.permission.Resource;
import java.util.ArrayList;
import java.util.Collections;
import java.util.EnumSet;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import static org.thingsboard.server.controller.ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER;
import static org.thingsboard.server.controller.ControllerConstants.LWM2M_OBJECT_DESCRIPTION;
@ -263,6 +266,20 @@ public class TbResourceController extends BaseController {
}
}
@ApiOperation(value = "Get Resource Infos by ids (getSystemOrTenantResourcesByIds)")
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')")
@GetMapping(value = "/resource", params = {"resourceIds"})
public List<TbResourceInfo> getSystemOrTenantResourcesByIds(
@Parameter(description = "A list of resource ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string")))
@RequestParam("resourceIds") Set<UUID> resourceUuids) throws ThingsboardException {
SecurityUser user = getCurrentUser();
List<TbResourceId> resourceIds = new ArrayList<>();
for (UUID resourceId : resourceUuids) {
resourceIds.add(new TbResourceId(resourceId));
}
return resourceService.findSystemOrTenantResourcesByIds(user.getTenantId(), resourceIds);
}
@ApiOperation(value = "Get All Resource Infos (getAllResources)",
notes = "Returns a page of Resource Info objects owned by tenant. " +
PAGE_DATA_PARAMETERS + RESOURCE_INFO_DESCRIPTION + TENANT_AUTHORITY_PARAGRAPH)

4
application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java

@ -35,6 +35,7 @@ import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg;
import org.thingsboard.server.common.msg.queue.ServiceType;
import org.thingsboard.server.common.msg.queue.TbCallback;
import org.thingsboard.server.common.msg.queue.TopicPartitionInfo;
import org.thingsboard.server.dao.resource.TbResourceDataCache;
import org.thingsboard.server.dao.tenant.TbTenantProfileCache;
import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldLinkedTelemetryMsgProto;
import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldTelemetryMsgProto;
@ -83,6 +84,7 @@ public class DefaultTbCalculatedFieldConsumerService extends AbstractPartitionBa
ActorSystemContext actorContext,
TbDeviceProfileCache deviceProfileCache,
TbAssetProfileCache assetProfileCache,
TbResourceDataCache tbResourceDataCache,
TbTenantProfileCache tenantProfileCache,
TbApiUsageStateService apiUsageStateService,
PartitionService partitionService,
@ -90,7 +92,7 @@ public class DefaultTbCalculatedFieldConsumerService extends AbstractPartitionBa
JwtSettingsService jwtSettingsService,
CalculatedFieldCache calculatedFieldCache,
CalculatedFieldStateService stateService) {
super(actorContext, tenantProfileCache, deviceProfileCache, assetProfileCache, calculatedFieldCache, apiUsageStateService, partitionService,
super(actorContext, tenantProfileCache, deviceProfileCache, assetProfileCache, tbResourceDataCache, calculatedFieldCache, apiUsageStateService, partitionService,
eventPublisher, jwtSettingsService);
this.queueFactory = tbQueueFactory;
this.stateService = stateService;

9
application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java

@ -48,6 +48,7 @@ import org.thingsboard.server.common.data.id.DeviceProfileId;
import org.thingsboard.server.common.data.id.EdgeId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.RuleChainId;
import org.thingsboard.server.common.data.id.TbResourceId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.msg.TbMsgType;
import org.thingsboard.server.common.data.page.PageData;
@ -435,8 +436,9 @@ public class DefaultTbClusterService implements TbClusterService {
@Override
public void onResourceChange(TbResourceInfo resource, TbQueueCallback callback) {
TenantId tenantId = resource.getTenantId();
TbResourceId resourceId = resource.getId();
if (resource.getResourceType() == ResourceType.LWM2M_MODEL) {
TenantId tenantId = resource.getTenantId();
log.trace("[{}][{}][{}] Processing change resource", tenantId, resource.getResourceType(), resource.getResourceKey());
ResourceUpdateMsg resourceUpdateMsg = ResourceUpdateMsg.newBuilder()
.setTenantIdMSB(tenantId.getId().getMostSignificantBits())
@ -447,6 +449,7 @@ public class DefaultTbClusterService implements TbClusterService {
ToTransportMsg transportMsg = ToTransportMsg.newBuilder().setResourceUpdateMsg(resourceUpdateMsg).build();
broadcast(transportMsg, DataConstants.LWM2M_TRANSPORT_NAME, callback);
}
broadcastEntityStateChangeEvent(tenantId, resourceId, ComponentLifecycleEvent.UPDATED);
}
@Override
@ -462,6 +465,7 @@ public class DefaultTbClusterService implements TbClusterService {
ToTransportMsg transportMsg = ToTransportMsg.newBuilder().setResourceDeleteMsg(resourceDeleteMsg).build();
broadcast(transportMsg, DataConstants.LWM2M_TRANSPORT_NAME, callback);
}
broadcastEntityStateChangeEvent(resource.getTenantId(), resource.getId(), ComponentLifecycleEvent.DELETED);
}
private <T> void broadcastEntityChangeToTransport(TenantId tenantId, EntityId entityid, T entity, TbQueueCallback callback) {
@ -592,7 +596,8 @@ public class DefaultTbClusterService implements TbClusterService {
EntityType.TENANT_PROFILE,
EntityType.DEVICE_PROFILE,
EntityType.ASSET_PROFILE,
EntityType.JOB)
EntityType.JOB,
EntityType.TB_RESOURCE)
|| (entityType == EntityType.ASSET && msg.getEvent() == ComponentLifecycleEvent.UPDATED)
|| (entityType == EntityType.DEVICE && msg.getEvent() == ComponentLifecycleEvent.UPDATED)
) {

4
application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java

@ -55,6 +55,7 @@ import org.thingsboard.server.common.stats.StatsFactory;
import org.thingsboard.server.common.util.KvProtoUtil;
import org.thingsboard.server.common.util.ProtoUtils;
import org.thingsboard.server.dao.resource.ImageCacheKey;
import org.thingsboard.server.dao.resource.TbResourceDataCache;
import org.thingsboard.server.dao.tenant.TbTenantProfileCache;
import org.thingsboard.server.gen.transport.TransportProtos;
import org.thingsboard.server.gen.transport.TransportProtos.DeviceStateServiceMsgProto;
@ -176,10 +177,11 @@ public class DefaultTbCoreConsumerService extends AbstractConsumerService<ToCore
NotificationSchedulerService notificationSchedulerService,
NotificationRuleProcessor notificationRuleProcessor,
TbImageService imageService,
TbResourceDataCache tbResourceDataCache,
RuleEngineCallService ruleEngineCallService,
CalculatedFieldCache calculatedFieldCache,
EdqsService edqsService) {
super(actorContext, tenantProfileCache, deviceProfileCache, assetProfileCache, calculatedFieldCache, apiUsageStateService, partitionService,
super(actorContext, tenantProfileCache, deviceProfileCache, assetProfileCache, tbResourceDataCache, calculatedFieldCache, apiUsageStateService, partitionService,
eventPublisher, jwtSettingsService);
this.stateService = stateService;
this.localSubscriptionService = localSubscriptionService;

2
application/src/main/java/org/thingsboard/server/service/queue/DefaultTbEdgeConsumerService.java

@ -87,7 +87,7 @@ public class DefaultTbEdgeConsumerService extends AbstractConsumerService<ToEdge
public DefaultTbEdgeConsumerService(TbCoreQueueFactory tbCoreQueueFactory, ActorSystemContext actorContext,
StatsFactory statsFactory, EdgeContextComponent edgeCtx) {
super(actorContext, null, null, null, null, null, null,
super(actorContext, null, null, null, null, null, null, null,
null, null);
this.edgeCtx = edgeCtx;
this.stats = new EdgeConsumerStats(statsFactory);

4
application/src/main/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerService.java

@ -33,6 +33,7 @@ import org.thingsboard.server.common.msg.queue.TbCallback;
import org.thingsboard.server.common.msg.rpc.FromDeviceRpcResponse;
import org.thingsboard.server.common.util.ProtoUtils;
import org.thingsboard.server.dao.queue.QueueService;
import org.thingsboard.server.dao.resource.TbResourceDataCache;
import org.thingsboard.server.dao.tenant.TbTenantProfileCache;
import org.thingsboard.server.gen.transport.TransportProtos;
import org.thingsboard.server.gen.transport.TransportProtos.QueueDeleteMsg;
@ -78,13 +79,14 @@ public class DefaultTbRuleEngineConsumerService extends AbstractPartitionBasedCo
QueueService queueService,
TbDeviceProfileCache deviceProfileCache,
TbAssetProfileCache assetProfileCache,
TbResourceDataCache tbResourceDataCache,
TbTenantProfileCache tenantProfileCache,
TbApiUsageStateService apiUsageStateService,
PartitionService partitionService,
ApplicationEventPublisher eventPublisher,
JwtSettingsService jwtSettingsService,
CalculatedFieldCache calculatedFieldCache) {
super(actorContext, tenantProfileCache, deviceProfileCache, assetProfileCache, calculatedFieldCache, apiUsageStateService, partitionService, eventPublisher, jwtSettingsService);
super(actorContext, tenantProfileCache, deviceProfileCache, assetProfileCache, tbResourceDataCache, calculatedFieldCache, apiUsageStateService, partitionService, eventPublisher, jwtSettingsService);
this.ctx = ctx;
this.tbDeviceRpcService = tbDeviceRpcService;
this.queueService = queueService;

5
application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractConsumerService.java

@ -30,12 +30,14 @@ import org.thingsboard.server.common.data.id.CalculatedFieldId;
import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.DeviceProfileId;
import org.thingsboard.server.common.data.id.TbResourceId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.TenantProfileId;
import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent;
import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg;
import org.thingsboard.server.common.msg.queue.ServiceType;
import org.thingsboard.server.common.msg.queue.TbCallback;
import org.thingsboard.server.dao.resource.TbResourceDataCache;
import org.thingsboard.server.dao.tenant.TbTenantProfileCache;
import org.thingsboard.server.queue.TbQueueConsumer;
import org.thingsboard.server.queue.common.TbProtoQueueMsg;
@ -72,6 +74,7 @@ public abstract class AbstractConsumerService<N extends com.google.protobuf.Gene
protected final TbTenantProfileCache tenantProfileCache;
protected final TbDeviceProfileCache deviceProfileCache;
protected final TbAssetProfileCache assetProfileCache;
protected final TbResourceDataCache tbResourceDataCache;
protected final CalculatedFieldCache calculatedFieldCache;
protected final TbApiUsageStateService apiUsageStateService;
protected final PartitionService partitionService;
@ -202,6 +205,8 @@ public abstract class AbstractConsumerService<N extends com.google.protobuf.Gene
} else {
calculatedFieldCache.evict((CalculatedFieldId) componentLifecycleMsg.getEntityId());
}
} else if (EntityType.TB_RESOURCE.equals(componentLifecycleMsg.getEntityId().getEntityType())) {
tbResourceDataCache.evictResourceData(tenantId, new TbResourceId(componentLifecycleMsg.getEntityId().getId()));
}
eventPublisher.publishEvent(componentLifecycleMsg);

4
application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractPartitionBasedConsumerService.java

@ -18,6 +18,7 @@ package org.thingsboard.server.service.queue.processing;
import jakarta.annotation.PostConstruct;
import org.springframework.context.ApplicationEventPublisher;
import org.thingsboard.server.actors.ActorSystemContext;
import org.thingsboard.server.dao.resource.TbResourceDataCache;
import org.thingsboard.server.dao.tenant.TbTenantProfileCache;
import org.thingsboard.server.queue.discovery.PartitionService;
import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent;
@ -43,12 +44,13 @@ public abstract class AbstractPartitionBasedConsumerService<N extends com.google
TbTenantProfileCache tenantProfileCache,
TbDeviceProfileCache deviceProfileCache,
TbAssetProfileCache assetProfileCache,
TbResourceDataCache tbResourceDataCache,
CalculatedFieldCache calculatedFieldCache,
TbApiUsageStateService apiUsageStateService,
PartitionService partitionService,
ApplicationEventPublisher eventPublisher,
JwtSettingsService jwtSettingsService) {
super(actorContext, tenantProfileCache, deviceProfileCache, assetProfileCache, calculatedFieldCache, apiUsageStateService, partitionService, eventPublisher, jwtSettingsService);
super(actorContext, tenantProfileCache, deviceProfileCache, assetProfileCache, tbResourceDataCache, calculatedFieldCache, apiUsageStateService, partitionService, eventPublisher, jwtSettingsService);
}
@PostConstruct

1
application/src/main/java/org/thingsboard/server/service/resource/DefaultTbResourceService.java

@ -102,7 +102,6 @@ public class DefaultTbResourceService extends AbstractTbEntityService implements
if (result.isSuccess()) {
logEntityActionService.logEntityAction(tenantId, resourceId, tbResource, actionType, user, resourceId.toString());
}
return result;
} catch (Exception e) {
logEntityActionService.logEntityAction(tenantId, emptyId(EntityType.TB_RESOURCE),

3
application/src/main/resources/thingsboard.yml

@ -676,6 +676,9 @@ cache:
maxSize: "${CACHE_SPECS_IMAGE_ETAGS_MAX_SIZE:10000}" # 0 means the cache is disabled
systemImagesBrowserTtlInMinutes: "${CACHE_SPECS_IMAGE_SYSTEM_BROWSER_TTL:0}" # Browser cache TTL for system images in minutes. 0 means the cache is disabled
tenantImagesBrowserTtlInMinutes: "${CACHE_SPECS_IMAGE_TENANT_BROWSER_TTL:0}" # Browser cache TTL for tenant images in minutes. 0 means the cache is disabled
tbResourceData:
timeToLiveInMinutes: "${CACHE_SPECS_RESOURCE_DATA_TTL:10080}" # TB resource data cache TTL
maxSize: "${CACHE_SPECS_RESOURCE_DATA_MAX_SIZE:100000}" # 0 means the cache is disabled
# Spring data parameters
spring.data.redis.repositories.enabled: false # Disable this because it is not required.

23
application/src/test/java/org/thingsboard/server/controller/TbResourceControllerTest.java

@ -31,6 +31,7 @@ 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;
import org.thingsboard.server.common.data.ResourceType;
import org.thingsboard.server.common.data.StringUtils;
@ -321,18 +322,15 @@ public class TbResourceControllerTest extends AbstractControllerTest {
var referenceValues = JacksonUtil.toJsonNode(deleteResponse).get("references");
Assert.assertNotNull(referenceValues);
var widgetTypeInfos = JacksonUtil.readValue(referenceValues.toString(), new TypeReference<HashMap<String, List<WidgetTypeInfo>>>() {
var widgetTypeInfos = JacksonUtil.readValue(referenceValues.toString(), new TypeReference<HashMap<String, List<EntityInfo>>>() {
});
Assert.assertNotNull(widgetTypeInfos);
Assert.assertFalse(widgetTypeInfos.isEmpty());
Assert.assertEquals(1, widgetTypeInfos.size());
var dashboardInfo = widgetTypeInfos.get(EntityType.WIDGET_TYPE.name()).get(0);
Assert.assertNotNull(dashboardInfo);
WidgetTypeInfo foundedWidgetType = doGet("/api/widgetTypeInfo/" + savedWidgetType.getId().getId().toString(), WidgetTypeInfo.class);
Assert.assertNotNull(foundedWidgetType);
Assert.assertEquals(foundedWidgetType, dashboardInfo);
var widgetTypeInfo = widgetTypeInfos.get(EntityType.WIDGET_TYPE.name()).get(0);
Assert.assertNotNull(widgetTypeInfo);
Assert.assertEquals(new EntityInfo(savedWidgetType.getId(), savedWidgetType.getName()), widgetTypeInfo);
}
@Test
@ -372,7 +370,7 @@ public class TbResourceControllerTest extends AbstractControllerTest {
Assert.assertTrue(isSuccess);
var referenceValues = JacksonUtil.toJsonNode(deleteResponse).get("references");
var widgetTypeInfos = JacksonUtil.readValue(referenceValues.toString(), new TypeReference<HashMap<String, List<WidgetTypeInfo>>>() {
var widgetTypeInfos = JacksonUtil.readValue(referenceValues.toString(), new TypeReference<HashMap<String, List<EntityInfo>>>() {
});
Assert.assertNull(widgetTypeInfos);
}
@ -417,7 +415,7 @@ public class TbResourceControllerTest extends AbstractControllerTest {
var referenceValues = JacksonUtil.toJsonNode(deleteResponse).get("references");
Assert.assertNotNull(referenceValues);
var dashboardInfos = JacksonUtil.readValue(referenceValues.toString(), new TypeReference<HashMap<String, List<DashboardInfo>>>() {
var dashboardInfos = JacksonUtil.readValue(referenceValues.toString(), new TypeReference<HashMap<String, List<EntityInfo>>>() {
});
Assert.assertNotNull(dashboardInfos);
Assert.assertFalse(dashboardInfos.isEmpty());
@ -425,10 +423,7 @@ public class TbResourceControllerTest extends AbstractControllerTest {
var dashboardInfo = dashboardInfos.get(EntityType.DASHBOARD.name()).get(0);
Assert.assertNotNull(dashboardInfo);
DashboardInfo foundDashboard = doGet("/api/dashboard/info/" + savedDashboard.getId().getId().toString(), DashboardInfo.class);
Assert.assertNotNull(foundDashboard);
Assert.assertEquals(foundDashboard, dashboardInfo);
Assert.assertEquals(new EntityInfo(savedDashboard.getId(), savedDashboard.getName()), dashboardInfo);
}
@Test
@ -469,7 +464,7 @@ public class TbResourceControllerTest extends AbstractControllerTest {
Assert.assertTrue(isSuccess);
var referenceValues = JacksonUtil.toJsonNode(deleteResponse).get("references");
var dashboardInfos = JacksonUtil.readValue(referenceValues.toString(), new TypeReference<HashMap<String, List<DashboardInfo>>>() {
var dashboardInfos = JacksonUtil.readValue(referenceValues.toString(), new TypeReference<HashMap<String, List<EntityInfo>>>() {
});
Assert.assertNull(dashboardInfos);
}

83
application/src/test/java/org/thingsboard/server/service/resource/DefaultResourceDataCacheTest.java

@ -0,0 +1,83 @@
/**
* 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.service.resource;
import org.junit.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.bean.override.mockito.MockitoSpyBean;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.GeneralFileDescriptor;
import org.thingsboard.server.common.data.ResourceType;
import org.thingsboard.server.common.data.TbResource;
import org.thingsboard.server.common.data.TbResourceDataInfo;
import org.thingsboard.server.common.data.TbResourceInfo;
import org.thingsboard.server.controller.AbstractControllerTest;
import org.thingsboard.server.dao.resource.ResourceService;
import org.thingsboard.server.dao.resource.TbResourceDataCache;
import org.thingsboard.server.dao.service.DaoSqlTest;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.clearInvocations;
import static org.mockito.Mockito.timeout;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
@DaoSqlTest
public class DefaultResourceDataCacheTest extends AbstractControllerTest {
@MockitoSpyBean
private ResourceService resourceService;
@Autowired
private TbResourceService tbResourceService;
@MockitoSpyBean
private TbResourceDataCache resourceDataCache;
@Test
public void testGetCachedResourceData() throws Exception {
loginTenantAdmin();
TbResource resource = new TbResource();
resource.setTenantId(tenantId);
resource.setTitle("File for AI request");
resource.setResourceType(ResourceType.GENERAL);
resource.setFileName("myTestJson.json");
GeneralFileDescriptor descriptor = new GeneralFileDescriptor("application/json");
resource.setDescriptorValue(descriptor);
byte[] data = "This is a test prompt for AI request.".getBytes();
resource.setData(data);
TbResourceInfo savedResource = tbResourceService.save(resource);
verify(resourceDataCache, timeout(2000).times(1)).evictResourceData(tenantId, savedResource.getId());
TbResourceDataInfo cachedData = resourceDataCache.getResourceDataInfoAsync(tenantId, savedResource.getId()).get();
assertThat(cachedData.getData()).isEqualTo(data);
assertThat(JacksonUtil.treeToValue(cachedData.getDescriptor(), GeneralFileDescriptor.class)).isEqualTo(descriptor);
verify(resourceService).getResourceDataInfo(tenantId, savedResource.getId());
// retrieve resource data second time
clearInvocations(resourceService);
TbResourceDataInfo cachedData2 = resourceDataCache.getResourceDataInfoAsync(tenantId, savedResource.getId()).get();
assertThat(cachedData2.getData()).isEqualTo(data);
verifyNoMoreInteractions(resourceService);
// delete resource, check cache
TbResource resourceById = resourceService.findResourceById(tenantId, savedResource.getId());
tbResourceService.delete(resourceById, true, null);
verify(resourceDataCache, timeout(2000).times(2)).evictResourceData(tenantId, savedResource.getId());
TbResourceDataInfo cachedDataAfterDeletion = resourceDataCache.getResourceDataInfoAsync(tenantId, savedResource.getId()).get();
assertThat(cachedDataAfterDeletion).isEqualTo(null);
}
}

121
application/src/test/java/org/thingsboard/server/service/resource/sql/BaseTbResourceServiceTest.java

@ -17,6 +17,7 @@ package org.thingsboard.server.service.resource.sql;
import com.datastax.oss.driver.api.core.uuid.Uuids;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.fasterxml.jackson.databind.node.TextNode;
import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
@ -24,8 +25,10 @@ import org.junit.Test;
import org.junit.jupiter.api.Assertions;
import org.springframework.beans.factory.annotation.Autowired;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.rule.engine.ai.TbAiNode;
import org.thingsboard.rule.engine.ai.TbAiNodeConfiguration;
import org.thingsboard.rule.engine.ai.TbResponseFormat;
import org.thingsboard.server.common.data.Dashboard;
import org.thingsboard.server.common.data.DashboardInfo;
import org.thingsboard.server.common.data.DataConstants;
import org.thingsboard.server.common.data.EntityInfo;
import org.thingsboard.server.common.data.EntityType;
@ -37,26 +40,40 @@ import org.thingsboard.server.common.data.TbResourceInfoFilter;
import org.thingsboard.server.common.data.Tenant;
import org.thingsboard.server.common.data.TenantProfile;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.ai.AiModel;
import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig;
import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig;
import org.thingsboard.server.common.data.debug.DebugSettings;
import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.id.RuleChainId;
import org.thingsboard.server.common.data.id.TbResourceId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.rule.RuleChain;
import org.thingsboard.server.common.data.rule.RuleChainMetaData;
import org.thingsboard.server.common.data.rule.RuleChainType;
import org.thingsboard.server.common.data.rule.RuleNode;
import org.thingsboard.server.common.data.security.Authority;
import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration;
import org.thingsboard.server.common.data.widget.WidgetTypeDetails;
import org.thingsboard.server.common.data.widget.WidgetTypeInfo;
import org.thingsboard.server.controller.AbstractControllerTest;
import org.thingsboard.server.dao.ai.AiModelService;
import org.thingsboard.server.dao.dashboard.DashboardService;
import org.thingsboard.server.dao.exception.DataValidationException;
import org.thingsboard.server.dao.resource.ResourceService;
import org.thingsboard.server.dao.rule.RuleChainService;
import org.thingsboard.server.dao.service.DaoSqlTest;
import org.thingsboard.server.dao.widget.WidgetTypeService;
import org.thingsboard.server.service.resource.TbResourceService;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Base64;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.function.Function;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@ -135,6 +152,10 @@ public class BaseTbResourceServiceTest extends AbstractControllerTest {
private WidgetTypeService widgetTypeService;
@Autowired
private DashboardService dashboardService;
@Autowired
private RuleChainService ruleChainService;
@Autowired
private AiModelService aiModelService;
private Tenant savedTenant;
private User tenantAdmin;
@ -453,11 +474,9 @@ public class BaseTbResourceServiceTest extends AbstractControllerTest {
Assert.assertFalse(result.getReferences().isEmpty());
Assert.assertEquals(1, result.getReferences().size());
WidgetTypeInfo widgetTypeInfo = (WidgetTypeInfo) result.getReferences().get(EntityType.WIDGET_TYPE.name()).get(0);
WidgetTypeInfo foundWidgetTypeInfo = new WidgetTypeInfo(foundWidgetType);
EntityInfo widgetTypeInfo = (EntityInfo) result.getReferences().get(EntityType.WIDGET_TYPE.name()).get(0);
Assert.assertNotNull(widgetTypeInfo);
Assert.assertNotNull(foundWidgetTypeInfo);
Assert.assertEquals(widgetTypeInfo, foundWidgetTypeInfo);
Assert.assertEquals(widgetTypeInfo, new EntityInfo(foundWidgetType.getId(), foundWidgetType.getName()));
TbResourceInfo foundResourceInfo = resourceService.findResourceInfoById(savedTenant.getId(), savedResource.getId());
Assert.assertNotNull(foundResource);
@ -546,11 +565,9 @@ public class BaseTbResourceServiceTest extends AbstractControllerTest {
Assert.assertNotNull(result.getReferences());
Assert.assertEquals(1, result.getReferences().size());
DashboardInfo dashboardInfo = (DashboardInfo) result.getReferences().get(EntityType.DASHBOARD.name()).get(0);
DashboardInfo foundDashboardInfo = dashboardService.findDashboardInfoById(savedTenant.getId(), savedDashboard.getId());
EntityInfo dashboardInfo = (EntityInfo) result.getReferences().get(EntityType.DASHBOARD.name()).get(0);
Assert.assertNotNull(dashboardInfo);
Assert.assertNotNull(foundDashboardInfo);
Assert.assertEquals(foundDashboardInfo, dashboardInfo);
Assert.assertEquals(new EntityInfo(savedDashboard.getId(), savedDashboard.getName()), dashboardInfo);
foundResource = resourceService.findResourceById(savedTenant.getId(), savedResource.getId());
Assert.assertNotNull(foundResource);
@ -598,6 +615,90 @@ public class BaseTbResourceServiceTest extends AbstractControllerTest {
Assert.assertNull(foundResource);
}
@Test
public void testShouldNotDeleteResourceIfUsedInAiNode() throws Exception {
TbResource resource = new TbResource();
resource.setResourceType(ResourceType.GENERAL);
resource.setTitle("My resource");
resource.setFileName("test.json");
resource.setTenantId(savedTenant.getId());
resource.setData("<test></test>".getBytes());
TbResourceInfo savedResource = tbResourceService.save(resource);
RuleChainMetaData ruleChain = createRuleChainReferringResource(savedResource.getId());
TbResourceDeleteResult result = tbResourceService.delete(savedResource, false, null);
assertThat(result).isNotNull();
assertThat(result.isSuccess()).isFalse();
assertThat(result.getReferences()).isNotEmpty().hasSize(1);
EntityInfo entityInfo = (EntityInfo) result.getReferences().get(EntityType.RULE_CHAIN.name()).get(0);
assertThat(entityInfo).isEqualTo(new EntityInfo(ruleChain.getRuleChainId(), "Test"));
TbResource foundResource = resourceService.findResourceById(savedTenant.getId(), savedResource.getId());
assertThat(foundResource).isNotNull();
// force delete
TbResourceDeleteResult deleteResult = tbResourceService.delete(savedResource, true, null);
assertThat(deleteResult).isNotNull();
assertThat(deleteResult.isSuccess()).isTrue();
TbResource resourceAfterDeletion = resourceService.findResourceById(savedTenant.getId(), savedResource.getId());
assertThat(resourceAfterDeletion).isNull();
}
private RuleChainMetaData createRuleChainReferringResource(TbResourceId resourceId) {
AiModel model = constructValidOpenAiModel("Test model");
AiModel saved = aiModelService.save(model);
RuleChain ruleChain = new RuleChain();
ruleChain.setTenantId(tenantId);
ruleChain.setName("Test");
ruleChain.setType(RuleChainType.CORE);
ruleChain.setDebugMode(true);
ruleChain.setConfiguration(JacksonUtil.newObjectNode().set("a", new TextNode("b")));
ruleChain = ruleChainService.saveRuleChain(ruleChain);
RuleChainId ruleChainId = ruleChain.getId();
RuleChainMetaData metaData = new RuleChainMetaData();
metaData.setRuleChainId(ruleChainId);
RuleNode aiNode = new RuleNode();
aiNode.setName("Ai request");
aiNode.setType(org.thingsboard.rule.engine.ai.TbAiNode.class.getName());
aiNode.setConfigurationVersion(TbAiNode.class.getAnnotation(org.thingsboard.rule.engine.api.RuleNode.class).version());
aiNode.setDebugSettings(DebugSettings.all());
TbAiNodeConfiguration configuration = new TbAiNodeConfiguration();
configuration.setResourceIds(Set.of(resourceId.getId()));
configuration.setModelId(saved.getId());
configuration.setResponseFormat(new TbResponseFormat.TbJsonResponseFormat());
configuration.setTimeoutSeconds(1);
configuration.setUserPrompt("What is temp");
aiNode.setConfiguration(JacksonUtil.valueToTree(configuration));
metaData.setNodes(Arrays.asList(aiNode));
metaData.setFirstNodeIndex(0);
ruleChainService.saveRuleChainMetaData(tenantId, metaData, Function.identity());
return ruleChainService.loadRuleChainMetaData(tenantId, ruleChainId);
}
private AiModel constructValidOpenAiModel(String name) {
var modelConfig = OpenAiChatModelConfig.builder()
.providerConfig(new OpenAiProviderConfig("test-api-key"))
.modelId("gpt-4o")
.temperature(0.5)
.topP(0.3)
.frequencyPenalty(0.1)
.presencePenalty(0.2)
.maxOutputTokens(1000)
.timeoutSeconds(60)
.maxRetries(2)
.build();
return AiModel.builder()
.tenantId(tenantId)
.name(name)
.configuration(modelConfig)
.build();
}
@Test
public void testFindTenantResourcesByTenantId() throws Exception {
loginSysAdmin();

5
common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ResourceService.java

@ -21,6 +21,7 @@ import org.thingsboard.server.common.data.ResourceExportData;
import org.thingsboard.server.common.data.ResourceSubType;
import org.thingsboard.server.common.data.ResourceType;
import org.thingsboard.server.common.data.TbResource;
import org.thingsboard.server.common.data.TbResourceDataInfo;
import org.thingsboard.server.common.data.TbResourceDeleteResult;
import org.thingsboard.server.common.data.TbResourceInfo;
import org.thingsboard.server.common.data.TbResourceInfoFilter;
@ -46,6 +47,8 @@ public interface ResourceService extends EntityDaoService {
byte[] getResourceData(TenantId tenantId, TbResourceId resourceId);
TbResourceDataInfo getResourceDataInfo(TenantId tenantId, TbResourceId resourceId);
ResourceExportData exportResource(TbResourceInfo resourceInfo);
List<ResourceExportData> exportResources(TenantId tenantId, Collection<TbResourceInfo> resources);
@ -90,4 +93,6 @@ public interface ResourceService extends EntityDaoService {
TbResource createOrUpdateSystemResource(ResourceType resourceType, ResourceSubType resourceSubType, String resourceKey, byte[] data);
List<TbResourceInfo> findSystemOrTenantResourcesByIds(TenantId tenantId, List<TbResourceId> resourceIds);
}

28
common/dao-api/src/main/java/org/thingsboard/server/dao/resource/TbResourceDataCache.java

@ -0,0 +1,28 @@
/**
* 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.dao.resource;
import com.google.common.util.concurrent.FluentFuture;
import org.thingsboard.server.common.data.TbResourceDataInfo;
import org.thingsboard.server.common.data.id.TbResourceId;
import org.thingsboard.server.common.data.id.TenantId;
public interface TbResourceDataCache {
FluentFuture<TbResourceDataInfo> getResourceDataInfoAsync(TenantId tenantId, TbResourceId resourceId);
void evictResourceData(TenantId tenantId, TbResourceId resourceId);
}

29
common/data/src/main/java/org/thingsboard/server/common/data/GeneralFileDescriptor.java

@ -0,0 +1,29 @@
/**
* 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;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
@Data
@EqualsAndHashCode
@AllArgsConstructor
@NoArgsConstructor
public class GeneralFileDescriptor {
private String mediaType;
}

3
common/data/src/main/java/org/thingsboard/server/common/data/ResourceType.java

@ -25,7 +25,8 @@ public enum ResourceType {
PKCS_12("application/x-pkcs12", false, false),
JS_MODULE("application/javascript", true, true),
IMAGE(null, true, true),
DASHBOARD("application/json", true, true);
DASHBOARD("application/json", true, true),
GENERAL(null, false, true);
@Getter
private final String mediaType;

6
common/data/src/main/java/org/thingsboard/server/common/data/TbResource.java

@ -16,6 +16,7 @@
package org.thingsboard.server.common.data;
import com.fasterxml.jackson.annotation.JsonGetter;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonSetter;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@ -86,6 +87,11 @@ public class TbResource extends TbResourceInfo {
.orElse(null);
}
@JsonIgnore
public TbResourceDataInfo toResourceDataInfo() {
return new TbResourceDataInfo(data, getDescriptor());
}
@Override
public String toString() {
return super.toString();

31
common/data/src/main/java/org/thingsboard/server/common/data/TbResourceDataInfo.java

@ -0,0 +1,31 @@
/**
* 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;
import com.fasterxml.jackson.databind.JsonNode;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class TbResourceDataInfo {
private byte[] data;
private JsonNode descriptor;
}

3
common/data/src/main/java/org/thingsboard/server/common/data/TbResourceDeleteResult.java

@ -17,7 +17,6 @@ package org.thingsboard.server.common.data;
import lombok.Builder;
import lombok.Data;
import org.thingsboard.server.common.data.id.HasId;
import java.util.List;
import java.util.Map;
@ -27,6 +26,6 @@ import java.util.Map;
public class TbResourceDeleteResult {
private boolean success;
private Map<String, List<? extends HasId<?>>> references;
private Map<String, List<EntityInfo>> references;
}

15
common/util/src/main/java/org/thingsboard/common/util/DonAsynchron.java

@ -15,12 +15,15 @@
*/
package org.thingsboard.common.util;
import com.google.common.util.concurrent.FluentFuture;
import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.MoreExecutors;
import com.google.common.util.concurrent.SettableFuture;
import java.util.concurrent.Callable;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;
import java.util.function.Consumer;
@ -71,4 +74,16 @@ public class DonAsynchron {
return future;
}
public static <T> FluentFuture<T> toFluentFuture(CompletableFuture<T> completable) {
SettableFuture<T> future = SettableFuture.create();
completable.whenComplete((result, exception) -> {
if (exception != null) {
future.setException(exception);
} else {
future.set(result);
}
});
return FluentFuture.from(future);
}
}

5
dao/src/main/java/org/thingsboard/server/dao/ResourceContainerDao.java

@ -15,6 +15,7 @@
*/
package org.thingsboard.server.dao;
import org.thingsboard.server.common.data.EntityInfo;
import org.thingsboard.server.common.data.id.HasId;
import org.thingsboard.server.common.data.id.TenantId;
@ -22,8 +23,8 @@ import java.util.List;
public interface ResourceContainerDao<T extends HasId<?>> {
List<T> findByTenantIdAndResourceLink(TenantId tenantId, String link, int limit);
List<EntityInfo> findByTenantIdAndResource(TenantId tenantId, String reference, int limit);
List<T> findByResourceLink(String link, int limit);
List<EntityInfo> findByResource(String reference, int limit);
}

5
dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java

@ -50,6 +50,7 @@ import org.thingsboard.server.dao.ImageContainerDao;
import org.thingsboard.server.dao.asset.AssetProfileDao;
import org.thingsboard.server.dao.dashboard.DashboardInfoDao;
import org.thingsboard.server.dao.device.DeviceProfileDao;
import org.thingsboard.server.dao.rule.RuleChainDao;
import org.thingsboard.server.dao.service.Validator;
import org.thingsboard.server.dao.service.validator.ResourceDataValidator;
import org.thingsboard.server.dao.util.ImageUtils;
@ -109,8 +110,8 @@ public class BaseImageService extends BaseResourceService implements ImageServic
public BaseImageService(TbResourceDao resourceDao, TbResourceInfoDao resourceInfoDao, ResourceDataValidator resourceValidator,
AssetProfileDao assetProfileDao, DeviceProfileDao deviceProfileDao, WidgetsBundleDao widgetsBundleDao,
WidgetTypeDao widgetTypeDao, DashboardInfoDao dashboardInfoDao) {
super(resourceDao, resourceInfoDao, resourceValidator, widgetTypeDao, dashboardInfoDao);
WidgetTypeDao widgetTypeDao, DashboardInfoDao dashboardInfoDao, RuleChainDao ruleChainDao) {
super(resourceDao, resourceInfoDao, resourceValidator, widgetTypeDao, dashboardInfoDao, ruleChainDao);
this.assetProfileDao = assetProfileDao;
this.deviceProfileDao = deviceProfileDao;
this.widgetsBundleDao = widgetsBundleDao;

72
dao/src/main/java/org/thingsboard/server/dao/resource/BaseResourceService.java

@ -35,11 +35,13 @@ import org.thingsboard.server.cache.resourceInfo.ResourceInfoCacheKey;
import org.thingsboard.server.cache.resourceInfo.ResourceInfoEvictEvent;
import org.thingsboard.server.common.data.Dashboard;
import org.thingsboard.server.common.data.DataConstants;
import org.thingsboard.server.common.data.EntityInfo;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.ResourceExportData;
import org.thingsboard.server.common.data.ResourceSubType;
import org.thingsboard.server.common.data.ResourceType;
import org.thingsboard.server.common.data.TbResource;
import org.thingsboard.server.common.data.TbResourceDataInfo;
import org.thingsboard.server.common.data.TbResourceDeleteResult;
import org.thingsboard.server.common.data.TbResourceInfo;
import org.thingsboard.server.common.data.TbResourceInfoFilter;
@ -56,6 +58,7 @@ import org.thingsboard.server.dao.entity.AbstractCachedEntityService;
import org.thingsboard.server.dao.eventsourcing.DeleteEntityEvent;
import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent;
import org.thingsboard.server.dao.exception.DataValidationException;
import org.thingsboard.server.dao.rule.RuleChainDao;
import org.thingsboard.server.dao.service.PaginatedRemover;
import org.thingsboard.server.dao.service.Validator;
import org.thingsboard.server.dao.service.validator.ResourceDataValidator;
@ -92,13 +95,16 @@ public class BaseResourceService extends AbstractCachedEntityService<ResourceInf
protected final ResourceDataValidator resourceValidator;
protected final WidgetTypeDao widgetTypeDao;
protected final DashboardInfoDao dashboardInfoDao;
private final Map<EntityType, ResourceContainerDao<?>> resourceContainerDaoMap = new HashMap<>();
protected final RuleChainDao ruleChainDao;
private final Map<EntityType, ResourceContainerDao<?>> resourceLinkContainerDaoMap = new HashMap<>();
private final Map<EntityType, ResourceContainerDao<?>> generalResourceContainerDaoMap = new HashMap<>();
protected static final int MAX_ENTITIES_TO_FIND = 10;
@PostConstruct
public void init() {
resourceContainerDaoMap.put(EntityType.WIDGET_TYPE, widgetTypeDao);
resourceContainerDaoMap.put(EntityType.DASHBOARD, dashboardInfoDao);
resourceLinkContainerDaoMap.put(EntityType.WIDGET_TYPE, widgetTypeDao);
resourceLinkContainerDaoMap.put(EntityType.DASHBOARD, dashboardInfoDao);
generalResourceContainerDaoMap.put(EntityType.RULE_CHAIN, ruleChainDao);
}
@Autowired @Lazy
@ -206,6 +212,12 @@ public class BaseResourceService extends AbstractCachedEntityService<ResourceInf
return resourceDao.getResourceData(tenantId, resourceId);
}
@Override
public TbResourceDataInfo getResourceDataInfo(TenantId tenantId, TbResourceId resourceId) {
log.trace("Executing getResourceDataInfo [{}] [{}]", tenantId, resourceId);
return resourceDao.getResourceDataInfo(tenantId, resourceId);
}
@Override
public ResourceExportData exportResource(TbResourceInfo resourceInfo) {
byte[] data = getResourceData(resourceInfo.getTenantId(), resourceInfo.getId());
@ -344,32 +356,48 @@ public class BaseResourceService extends AbstractCachedEntityService<ResourceInf
}
if (!force) {
if (resource.getResourceType() == ResourceType.JS_MODULE) {
var link = resource.getLink();
Map<String, List<? extends HasId<?>>> affectedEntities = new HashMap<>();
resourceContainerDaoMap.forEach((entityType, resourceContainerDao) -> {
var entities = tenantId.isSysTenantId() ? resourceContainerDao.findByResourceLink(link, MAX_ENTITIES_TO_FIND) :
resourceContainerDao.findByTenantIdAndResourceLink(tenantId, link, MAX_ENTITIES_TO_FIND);
if (!entities.isEmpty()) {
affectedEntities.put(entityType.name(), entities);
}
});
if (!affectedEntities.isEmpty()) {
success = false;
result.references(affectedEntities);
}
Map<String, List<EntityInfo>> references = findResourceReferences(tenantId, resource);
if (!references.isEmpty()) {
success = false;
result.references(references);
}
}
if (success) {
resourceDao.removeById(tenantId, resourceId.getId());
publishEvictEvent(new ResourceInfoEvictEvent(tenantId, resourceId));
eventPublisher.publishEvent(DeleteEntityEvent.builder().tenantId(tenantId).entity(resource).entityId(resourceId).build());
}
return result.success(success).build();
}
private Map<String, List<EntityInfo>> findResourceReferences(TenantId tenantId, TbResourceInfo resource) {
Map<String, List<EntityInfo>> references = new HashMap<>();
if (resource.getResourceType() == ResourceType.JS_MODULE) {
var ref = resource.getLink();
findReferences(tenantId, references, ref, resourceLinkContainerDaoMap);
}
if (resource.getResourceType() == ResourceType.GENERAL) {
var ref = resource.getId().getId().toString();
findReferences(tenantId, references, ref, generalResourceContainerDaoMap);
}
return references;
}
private void findReferences(TenantId tenantId, Map<String, List<EntityInfo>> references, String ref, Map<EntityType, ResourceContainerDao<?>> resourceLinkContainerDaoMap) {
resourceLinkContainerDaoMap.forEach((entityType, dao) -> {
List<EntityInfo> entities = tenantId.isSysTenantId()
? dao.findByResource(ref, MAX_ENTITIES_TO_FIND)
: dao.findByTenantIdAndResource(tenantId, ref, MAX_ENTITIES_TO_FIND);
if (!entities.isEmpty()) {
references.put(entityType.name(), entities);
}
});
}
@Override
public void deleteEntity(TenantId tenantId, EntityId id, boolean force) {
deleteResource(tenantId, (TbResourceId) id, force);
@ -663,6 +691,12 @@ public class BaseResourceService extends AbstractCachedEntityService<ResourceInf
return saveResource(resource);
}
@Override
public List<TbResourceInfo> findSystemOrTenantResourcesByIds(TenantId tenantId, List<TbResourceId> resourceIds) {
log.trace("Executing findSystemOrTenantResourcesByIds, tenantId [{}], resourceIds [{}]", tenantId, resourceIds);
return resourceInfoDao.findSystemOrTenantResourcesByIds(tenantId, resourceIds);
}
@Override
public String calculateEtag(byte[] data) {
return Hashing.sha256().hashBytes(data).toString();

72
dao/src/main/java/org/thingsboard/server/dao/resource/DefaultTbResourceDataCache.java

@ -0,0 +1,72 @@
/**
* 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.dao.resource;
import com.github.benmanes.caffeine.cache.AsyncLoadingCache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.google.common.util.concurrent.FluentFuture;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.thingsboard.common.util.DonAsynchron;
import org.thingsboard.server.common.data.TbResourceDataInfo;
import org.thingsboard.server.common.data.id.TbResourceId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.dao.sql.JpaExecutorService;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
@Service
@RequiredArgsConstructor
@Slf4j
public class DefaultTbResourceDataCache implements TbResourceDataCache {
private final ResourceService resourceService;
private final JpaExecutorService executorService;
@Value("${cache.tbResourceData.maxSize:100000}")
private int cacheMaxSize;
@Value("${cache.tbResourceData.timeToLiveInMinutes:44640}")
private int cacheValueTtl;
private AsyncLoadingCache<ResourceDataKey, TbResourceDataInfo> cache;
@PostConstruct
private void init() {
cache = Caffeine.newBuilder()
.maximumSize(cacheMaxSize)
.expireAfterAccess(cacheValueTtl, TimeUnit.MINUTES)
.executor(executorService)
.buildAsync((key, executor) -> CompletableFuture.supplyAsync(() -> resourceService.getResourceDataInfo(key.tenantId(), key.resourceId()), executor));
}
@Override
public FluentFuture<TbResourceDataInfo> getResourceDataInfoAsync(TenantId tenantId, TbResourceId resourceId) {
log.trace("Retrieving resource data info by id [{}], tenant id [{}] from cache", resourceId, tenantId);
return DonAsynchron.toFluentFuture(cache.get(new ResourceDataKey(tenantId, resourceId)));
}
@Override
public void evictResourceData(TenantId tenantId, TbResourceId resourceId) {
cache.asMap().remove(new ResourceDataKey(tenantId, resourceId));
log.trace("Evicted resource data info with id [{}], tenant id [{}]", resourceId, tenantId);
}
record ResourceDataKey (TenantId tenantId, TbResourceId resourceId) {}
}

2
dao/src/main/java/org/thingsboard/server/dao/resource/TbResourceDao.java

@ -18,6 +18,7 @@ package org.thingsboard.server.dao.resource;
import org.thingsboard.server.common.data.ResourceSubType;
import org.thingsboard.server.common.data.ResourceType;
import org.thingsboard.server.common.data.TbResource;
import org.thingsboard.server.common.data.TbResourceDataInfo;
import org.thingsboard.server.common.data.id.TbResourceId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.page.PageData;
@ -51,4 +52,5 @@ public interface TbResourceDao extends Dao<TbResource>, TenantEntityWithDataDao,
long getResourceSize(TenantId tenantId, TbResourceId resourceId);
TbResourceDataInfo getResourceDataInfo(TenantId tenantId, TbResourceId resourceId);
}

2
dao/src/main/java/org/thingsboard/server/dao/resource/TbResourceInfoDao.java

@ -18,6 +18,7 @@ package org.thingsboard.server.dao.resource;
import org.thingsboard.server.common.data.ResourceType;
import org.thingsboard.server.common.data.TbResourceInfo;
import org.thingsboard.server.common.data.TbResourceInfoFilter;
import org.thingsboard.server.common.data.id.TbResourceId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
@ -46,4 +47,5 @@ public interface TbResourceInfoDao extends Dao<TbResourceInfo> {
TbResourceInfo findPublicResourceByKey(ResourceType resourceType, String publicResourceKey);
List<TbResourceInfo> findSystemOrTenantResourcesByIds(TenantId tenantId, List<TbResourceId> resourceIds);
}

4
dao/src/main/java/org/thingsboard/server/dao/rule/RuleChainDao.java

@ -21,8 +21,10 @@ import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.rule.RuleChain;
import org.thingsboard.server.common.data.rule.RuleChainType;
import org.thingsboard.server.common.data.rule.RuleNode;
import org.thingsboard.server.dao.Dao;
import org.thingsboard.server.dao.ExportableEntityDao;
import org.thingsboard.server.dao.ResourceContainerDao;
import org.thingsboard.server.dao.TenantEntityDao;
import java.util.Collection;
@ -31,7 +33,7 @@ import java.util.UUID;
/**
* Created by igor on 3/12/18.
*/
public interface RuleChainDao extends Dao<RuleChain>, TenantEntityDao<RuleChain>, ExportableEntityDao<RuleChainId, RuleChain> {
public interface RuleChainDao extends Dao<RuleChain>, TenantEntityDao<RuleChain>, ExportableEntityDao<RuleChainId, RuleChain>, ResourceContainerDao<RuleChain> {
/**
* Find rule chains by tenantId and page link.

16
dao/src/main/java/org/thingsboard/server/dao/sql/dashboard/DashboardInfoRepository.java

@ -20,6 +20,7 @@ import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.thingsboard.server.common.data.EntityInfo;
import org.thingsboard.server.dao.model.sql.DashboardInfoEntity;
import java.util.List;
@ -87,12 +88,15 @@ public interface DashboardInfoRepository extends JpaRepository<DashboardInfoEnti
)
List<DashboardInfoEntity> findByImageLink(@Param("imageLink") String imageLink, @Param("limit") int limit);
@Query(value = "SELECT * FROM dashboard d WHERE d.tenant_id = :tenantId and d.configuration ILIKE CONCAT('%', :link, '%') limit :limit",
nativeQuery = true)
List<DashboardInfoEntity> findDashboardInfosByTenantIdAndResourceLink(@Param("tenantId") UUID tenantId, @Param("link") String link, @Param("limit") int limit);
@Query("SELECT new org.thingsboard.server.common.data.EntityInfo(d.id, 'DASHBOARD', d.title) " +
"FROM DashboardEntity d WHERE d.tenantId = :tenantId AND ilike(cast(d.configuration as string), CONCAT('%', :link, '%')) = true")
List<EntityInfo> findDashboardInfosByTenantIdAndResourceLink(@Param("tenantId") UUID tenantId,
@Param("link") String link,
Pageable pageable);
@Query(value = "SELECT * FROM dashboard d WHERE d.configuration ILIKE CONCAT('%', :link, '%') limit :limit",
nativeQuery = true)
List<DashboardInfoEntity> findDashboardInfosByResourceLink(@Param("link") String link, @Param("limit") int limit);
@Query("SELECT new org.thingsboard.server.common.data.EntityInfo(d.id, 'DASHBOARD', d.title) " +
"FROM DashboardEntity d WHERE ilike(cast(d.configuration as string), CONCAT('%', :link, '%')) = true")
List<EntityInfo> findDashboardInfosByResourceLink(@Param("link") String link,
Pageable pageable);
}

10
dao/src/main/java/org/thingsboard/server/dao/sql/dashboard/JpaDashboardInfoDao.java

@ -17,9 +17,11 @@ package org.thingsboard.server.dao.sql.dashboard;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Component;
import org.thingsboard.server.common.data.DashboardInfo;
import org.thingsboard.server.common.data.EntityInfo;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
@ -135,13 +137,13 @@ public class JpaDashboardInfoDao extends JpaAbstractDao<DashboardInfoEntity, Das
}
@Override
public List<DashboardInfo> findByTenantIdAndResourceLink(TenantId tenantId, String url, int limit) {
return DaoUtil.convertDataList(dashboardInfoRepository.findDashboardInfosByTenantIdAndResourceLink(tenantId.getId(), url, limit));
public List<EntityInfo> findByTenantIdAndResource(TenantId tenantId, String reference, int limit) {
return dashboardInfoRepository.findDashboardInfosByTenantIdAndResourceLink(tenantId.getId(), reference, PageRequest.of(0, limit));
}
@Override
public List<DashboardInfo> findByResourceLink(String link, int limit) {
return DaoUtil.convertDataList(dashboardInfoRepository.findDashboardInfosByResourceLink(link, limit));
public List<EntityInfo> findByResource(String reference, int limit) {
return dashboardInfoRepository.findDashboardInfosByResourceLink(reference, PageRequest.of(0, limit));
}
}

6
dao/src/main/java/org/thingsboard/server/dao/sql/resource/JpaTbResourceDao.java

@ -22,6 +22,7 @@ import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.ResourceSubType;
import org.thingsboard.server.common.data.ResourceType;
import org.thingsboard.server.common.data.TbResource;
import org.thingsboard.server.common.data.TbResourceDataInfo;
import org.thingsboard.server.common.data.id.TbResourceId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.page.PageData;
@ -115,6 +116,11 @@ public class JpaTbResourceDao extends JpaAbstractDao<TbResourceEntity, TbResourc
return resourceRepository.getDataSizeById(resourceId.getId());
}
@Override
public TbResourceDataInfo getResourceDataInfo(TenantId tenantId, TbResourceId resourceId) {
return resourceRepository.getDataInfoById(resourceId.getId());
}
@Override
public Long sumDataSizeByTenantId(TenantId tenantId) {
return resourceRepository.sumDataSizeByTenantId(tenantId.getId());

8
dao/src/main/java/org/thingsboard/server/dao/sql/resource/JpaTbResourceInfoDao.java

@ -23,6 +23,7 @@ import org.thingsboard.server.common.data.ResourceSubType;
import org.thingsboard.server.common.data.ResourceType;
import org.thingsboard.server.common.data.TbResourceInfo;
import org.thingsboard.server.common.data.TbResourceInfoFilter;
import org.thingsboard.server.common.data.id.TbResourceId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
@ -40,6 +41,8 @@ import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
import static org.thingsboard.server.dao.DaoUtil.toUUIDs;
@Slf4j
@Component
@SqlDao
@ -126,4 +129,9 @@ public class JpaTbResourceInfoDao extends JpaAbstractDao<TbResourceInfoEntity, T
public TbResourceInfo findPublicResourceByKey(ResourceType resourceType, String publicResourceKey) {
return DaoUtil.getData(resourceInfoRepository.findByResourceTypeAndPublicResourceKeyAndIsPublicTrue(resourceType.name(), publicResourceKey));
}
@Override
public List<TbResourceInfo> findSystemOrTenantResourcesByIds(TenantId tenantId, List<TbResourceId> resourceIds) {
return DaoUtil.convertDataList(resourceInfoRepository.findSystemOrTenantResourcesByIdIn(tenantId.getId(), TenantId.NULL_UUID, toUUIDs(resourceIds)));
}
}

6
dao/src/main/java/org/thingsboard/server/dao/sql/resource/TbResourceInfoRepository.java

@ -79,4 +79,10 @@ public interface TbResourceInfoRepository extends JpaRepository<TbResourceInfoEn
TbResourceInfoEntity findByResourceTypeAndPublicResourceKeyAndIsPublicTrue(String resourceType, String publicResourceKey);
@Query("SELECT tr FROM TbResourceInfoEntity tr WHERE " +
"tr.id IN (:resourceIds) AND (tr.tenantId = :tenantId OR tr.tenantId = :systemTenantId)")
List<TbResourceInfoEntity> findSystemOrTenantResourcesByIdIn(@Param("tenantId") UUID tenantId,
@Param("systemTenantId") UUID systemTenantId,
@Param("resourceIds") List<UUID> resourceIds);
}

3
dao/src/main/java/org/thingsboard/server/dao/sql/resource/TbResourceRepository.java

@ -20,6 +20,7 @@ import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.thingsboard.server.common.data.TbResourceDataInfo;
import org.thingsboard.server.dao.ExportableEntityRepository;
import org.thingsboard.server.dao.model.sql.TbResourceEntity;
@ -101,4 +102,6 @@ public interface TbResourceRepository extends JpaRepository<TbResourceEntity, UU
@Query("SELECT r.id FROM TbResourceInfoEntity r WHERE r.tenantId = :tenantId")
Page<UUID> findIdsByTenantId(@Param("tenantId") UUID tenantId, Pageable pageable);
@Query("SELECT new org.thingsboard.server.common.data.TbResourceDataInfo(r.data, r.descriptor) FROM TbResourceEntity r WHERE r.id = :id")
TbResourceDataInfo getDataInfoById(UUID id);
}

12
dao/src/main/java/org/thingsboard/server/dao/sql/rule/JpaRuleChainDao.java

@ -18,8 +18,10 @@ package org.thingsboard.server.dao.sql.rule;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Limit;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Component;
import org.thingsboard.server.common.data.EntityInfo;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.edqs.fields.RuleChainFields;
import org.thingsboard.server.common.data.id.RuleChainId;
@ -141,6 +143,16 @@ public class JpaRuleChainDao extends JpaAbstractDao<RuleChainEntity, RuleChain>
return findRuleChainsByTenantId(tenantId.getId(), pageLink);
}
@Override
public List<EntityInfo> findByTenantIdAndResource(TenantId tenantId, String reference, int limit) {
return ruleChainRepository.findRuleChainsByTenantIdAndResource(tenantId.getId(), reference, PageRequest.of(0, limit));
}
@Override
public List<EntityInfo> findByResource(String reference, int limit) {
return ruleChainRepository.findRuleChainsByResource(reference, PageRequest.of(0, limit));
}
@Override
public List<RuleChainFields> findNextBatch(UUID id, int batchSize) {
return ruleChainRepository.findNextBatch(id, Limit.of(batchSize));

15
dao/src/main/java/org/thingsboard/server/dao/sql/rule/RuleChainRepository.java

@ -17,10 +17,12 @@ package org.thingsboard.server.dao.sql.rule;
import org.springframework.data.domain.Limit;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.thingsboard.server.common.data.EntityInfo;
import org.thingsboard.server.common.data.edqs.fields.RuleChainFields;
import org.thingsboard.server.common.data.rule.RuleChainType;
import org.thingsboard.server.dao.ExportableEntityRepository;
@ -72,6 +74,19 @@ public interface RuleChainRepository extends JpaRepository<RuleChainEntity, UUID
@Query("SELECT externalId FROM RuleChainEntity WHERE id = :id")
UUID getExternalIdById(@Param("id") UUID id);
@Query("SELECT new org.thingsboard.server.common.data.EntityInfo(rc.id, 'RULE_CHAIN', rc.name) " +
"FROM RuleChainEntity rc WHERE rc.tenantId = :tenantId AND EXISTS " +
"(SELECT 1 FROM RuleNodeEntity rn WHERE rn.ruleChainId = rc.id AND cast(rn.configuration as string) LIKE CONCAT('%', :resourceId, '%'))")
List<EntityInfo> findRuleChainsByTenantIdAndResource(@Param("tenantId") UUID tenantId,
@Param("resourceId") String resourceId,
PageRequest of);
@Query("SELECT new org.thingsboard.server.common.data.EntityInfo(rc.id, 'RULE_CHAIN', rc.name) " +
"FROM RuleChainEntity rc WHERE EXISTS " +
"(SELECT 1 FROM RuleNodeEntity rn WHERE rn.ruleChainId = rc.id AND cast(rn.configuration as string) LIKE CONCAT('%', :resourceId, '%'))")
List<EntityInfo> findRuleChainsByResource(@Param("resourceId") String resourceId,
Pageable pageable);
@Query("SELECT new org.thingsboard.server.common.data.edqs.fields.RuleChainFields(r.id, r.createdTime, r.tenantId," +
"r.name, r.version, r.additionalInfo) FROM RuleChainEntity r WHERE r.id > :id ORDER BY r.id")
List<RuleChainFields> findNextBatch(@Param("id") UUID id, Limit limit);

10
dao/src/main/java/org/thingsboard/server/dao/sql/widget/JpaWidgetTypeDao.java

@ -17,8 +17,10 @@ package org.thingsboard.server.dao.sql.widget;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Limit;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Component;
import org.thingsboard.server.common.data.EntityInfo;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.edqs.fields.WidgetTypeFields;
import org.thingsboard.server.common.data.id.TenantId;
@ -269,13 +271,13 @@ public class JpaWidgetTypeDao extends JpaAbstractDao<WidgetTypeDetailsEntity, Wi
}
@Override
public List<WidgetTypeInfo> findByTenantIdAndResourceLink(TenantId tenantId, String link, int limit) {
return DaoUtil.convertDataList(widgetTypeInfoRepository.findWidgetTypeInfosByTenantIdAndResourceLink(tenantId.getId(), link, limit));
public List<EntityInfo> findByTenantIdAndResource(TenantId tenantId, String reference, int limit) {
return widgetTypeInfoRepository.findWidgetTypeInfosByTenantIdAndResourceLink(tenantId.getId(), reference, PageRequest.of(0, limit));
}
@Override
public List<WidgetTypeInfo> findByResourceLink(String link, int limit) {
return DaoUtil.convertDataList(widgetTypeInfoRepository.findWidgetTypeInfosByResourceLink(link, limit));
public List<EntityInfo> findByResource(String reference, int limit) {
return widgetTypeInfoRepository.findWidgetTypeInfosByResourceLink(reference, PageRequest.of(0, limit));
}
@Override

15
dao/src/main/java/org/thingsboard/server/dao/sql/widget/WidgetTypeInfoRepository.java

@ -20,6 +20,7 @@ import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.thingsboard.server.common.data.EntityInfo;
import org.thingsboard.server.dao.model.sql.WidgetTypeInfoEntity;
import java.util.List;
@ -214,10 +215,14 @@ public interface WidgetTypeInfoRepository extends JpaRepository<WidgetTypeInfoEn
)
List<WidgetTypeInfoEntity> findByImageUrl(@Param("imageLink") String imageLink, @Param("limit") int limit);
@Query(value = "SELECT * FROM widget_type_info_view w WHERE w.tenant_id = :tenantId AND w.descriptor ILIKE CONCAT('%', :link, '%') LIMIT :limit ", nativeQuery = true)
List<WidgetTypeInfoEntity> findWidgetTypeInfosByTenantIdAndResourceLink(@Param("tenantId") UUID tenantId, @Param("link") String link, @Param("limit") int limit);
@Query(value = "SELECT * FROM widget_type_info_view w WHERE w.descriptor ILIKE CONCAT('%', :link, '%') LIMIT :limit ", nativeQuery = true)
List<WidgetTypeInfoEntity> findWidgetTypeInfosByResourceLink(@Param("link") String link, @Param("limit") int limit);
@Query("SELECT new org.thingsboard.server.common.data.EntityInfo(w.id, 'WIDGET_TYPE', w.name) " +
"FROM WidgetTypeEntity w WHERE w.tenantId = :tenantId AND ilike(cast(w.descriptor as string), CONCAT('%', :link, '%')) = true")
List<EntityInfo> findWidgetTypeInfosByTenantIdAndResourceLink(@Param("tenantId") UUID tenantId,
@Param("link") String link,
Pageable pageable);
@Query("SELECT new org.thingsboard.server.common.data.EntityInfo(w.id, 'WIDGET_TYPE', w.name) " +
"FROM WidgetTypeEntity w WHERE ilike(cast(w.descriptor as string), CONCAT('%', :link, '%')) = true")
List<EntityInfo> findWidgetTypeInfosByResourceLink(@Param("link") String link,
Pageable pageable);
}

1
dao/src/test/java/org/thingsboard/server/dao/sql/rule/JpaRuleNodeDaoTest.java

@ -24,6 +24,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.id.RuleChainId;
import org.thingsboard.server.common.data.id.RuleNodeId;
import org.thingsboard.server.common.data.id.TbResourceId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;

7
rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java

@ -24,6 +24,7 @@ import org.thingsboard.server.cluster.TbClusterService;
import org.thingsboard.server.common.data.Customer;
import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.DeviceProfile;
import org.thingsboard.server.common.data.HasTenantId;
import org.thingsboard.server.common.data.TenantProfile;
import org.thingsboard.server.common.data.alarm.Alarm;
import org.thingsboard.server.common.data.asset.Asset;
@ -32,6 +33,7 @@ import org.thingsboard.server.common.data.id.AssetId;
import org.thingsboard.server.common.data.id.CustomerId;
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.RuleChainId;
import org.thingsboard.server.common.data.id.RuleNodeId;
import org.thingsboard.server.common.data.id.TenantId;
@ -78,6 +80,7 @@ import org.thingsboard.server.dao.queue.QueueService;
import org.thingsboard.server.dao.queue.QueueStatsService;
import org.thingsboard.server.dao.relation.RelationService;
import org.thingsboard.server.dao.resource.ResourceService;
import org.thingsboard.server.dao.resource.TbResourceDataCache;
import org.thingsboard.server.dao.rule.RuleChainService;
import org.thingsboard.server.dao.tenant.TenantService;
import org.thingsboard.server.dao.timeseries.TimeseriesService;
@ -252,6 +255,8 @@ public interface TbContext {
void checkTenantEntity(EntityId entityId) throws TbNodeException;
<E extends HasId<I> & HasTenantId, I extends EntityId> void checkTenantEntity(E entity) throws TbNodeException;
boolean isLocalEntity(EntityId entityId);
RuleNodeId getSelfId();
@ -308,6 +313,8 @@ public interface TbContext {
ResourceService getResourceService();
TbResourceDataCache getTbResourceDataCache();
OtaPackageService getOtaPackageService();
RuleEngineDeviceProfileCache getDeviceProfileCache();

121
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java

@ -18,12 +18,20 @@ package org.thingsboard.rule.engine.ai;
import com.fasterxml.jackson.databind.JsonNode;
import com.google.common.util.concurrent.FluentFuture;
import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.MoreExecutors;
import dev.langchain4j.data.message.ChatMessage;
import dev.langchain4j.data.message.Content;
import dev.langchain4j.data.message.ImageContent;
import dev.langchain4j.data.message.PdfFileContent;
import dev.langchain4j.data.message.SystemMessage;
import dev.langchain4j.data.message.TextContent;
import dev.langchain4j.data.message.UserMessage;
import dev.langchain4j.model.chat.request.ChatRequest;
import dev.langchain4j.model.chat.request.ResponseFormat;
import dev.langchain4j.model.chat.response.ChatResponse;
import lombok.extern.slf4j.Slf4j;
import org.checkerframework.checker.nullness.qual.NonNull;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.rule.engine.api.RuleNode;
@ -33,24 +41,38 @@ import org.thingsboard.rule.engine.api.TbNodeConfiguration;
import org.thingsboard.rule.engine.api.TbNodeException;
import org.thingsboard.rule.engine.api.util.TbNodeUtils;
import org.thingsboard.rule.engine.external.TbAbstractExternalNode;
import org.thingsboard.server.common.data.GeneralFileDescriptor;
import org.thingsboard.server.common.data.ResourceType;
import org.thingsboard.server.common.data.TbResourceDataInfo;
import org.thingsboard.server.common.data.TbResourceInfo;
import org.thingsboard.server.common.data.ai.AiModel;
import org.thingsboard.server.common.data.ai.model.AiModelType;
import org.thingsboard.server.common.data.ai.model.chat.AiChatModelConfig;
import org.thingsboard.server.common.data.id.AiModelId;
import org.thingsboard.server.common.data.id.TbResourceId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.plugin.ComponentType;
import org.thingsboard.server.common.data.rule.RuleChainType;
import org.thingsboard.server.common.msg.TbMsg;
import org.thingsboard.server.dao.exception.DataValidationException;
import org.thingsboard.server.dao.resource.TbResourceDataCache;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Base64;
import java.util.HashSet;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
import static org.thingsboard.rule.engine.ai.TbResponseFormat.TbResponseFormatType;
import static org.thingsboard.server.dao.service.ConstraintValidator.validateFields;
@Slf4j
@RuleNode(
type = ComponentType.EXTERNAL,
name = "AI request",
@ -77,6 +99,7 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode {
private String systemPrompt;
private String userPrompt;
private Set<TbResourceId> resourceIds;
private ResponseFormat responseFormat;
private int timeoutSeconds;
private AiModelId modelId;
@ -111,6 +134,14 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode {
// LangChain4j AnthropicChatModel rejects requests with non-null ResponseFormat even if ResponseFormatType is TEXT
responseFormat = config.getResponseFormat().toLangChainResponseFormat();
}
if (config.getResourceIds() != null && !config.getResourceIds().isEmpty()) {
resourceIds = new HashSet<>(config.getResourceIds().size());
for (UUID resourceId : config.getResourceIds()) {
TbResourceId tbResourceId = new TbResourceId(resourceId);
validateResource(ctx, tbResourceId);
resourceIds.add(tbResourceId);
}
}
systemPrompt = config.getSystemPrompt();
userPrompt = config.getUserPrompt();
@ -126,12 +157,42 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode {
@Override
public void onMsg(TbContext ctx, TbMsg msg) {
var ackedMsg = ackIfNeeded(ctx, msg);
final String processedUserPrompt = TbNodeUtils.processPattern(this.userPrompt, ackedMsg);
final ListenableFuture<UserMessage> userMessageFuture =
resourceIds == null
? Futures.immediateFuture(UserMessage.from(processedUserPrompt))
: Futures.transform(
loadResources(ctx),
resources -> UserMessage.from(buildContents(processedUserPrompt, resources)),
ctx.getDbCallbackExecutor()
);
Futures.addCallback(
userMessageFuture,
new FutureCallback<>() {
@Override
public void onSuccess(UserMessage userMessage) {
buildAndSendRequest(ctx, ackedMsg, userMessage);
}
@Override
public void onFailure(Throwable t) {
tellFailure(ctx, ackedMsg, t);
}
},
MoreExecutors.directExecutor()
);
}
private void buildAndSendRequest(TbContext ctx, TbMsg ackedMsg, UserMessage userMessage) {
List<ChatMessage> chatMessages = new ArrayList<>(2);
if (systemPrompt != null) {
if (systemPrompt != null && !systemPrompt.isBlank()) {
chatMessages.add(SystemMessage.from(TbNodeUtils.processPattern(systemPrompt, ackedMsg)));
}
chatMessages.add(UserMessage.from(TbNodeUtils.processPattern(userPrompt, ackedMsg)));
chatMessages.add(userMessage);
var chatRequest = ChatRequest.builder()
.messages(chatMessages)
@ -192,11 +253,67 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode {
return JacksonUtil.newObjectNode().put("response", response).toString();
}
private void validateResource(TbContext ctx, TbResourceId tbResourceId) throws TbNodeException {
TbResourceInfo resource = ctx.getResourceService().findResourceInfoById(ctx.getTenantId(), tbResourceId);
if (resource == null) {
throw new TbNodeException("[" + ctx.getTenantId() + "] Resource with ID: [" + tbResourceId + "] was not found", true);
}
if (!ResourceType.GENERAL.equals(resource.getResourceType())) {
throw new TbNodeException("[" + ctx.getTenantId() + "] Resource with ID: [" + tbResourceId + "] has unsupported resource type: " + resource.getResourceType(), true);
}
ctx.checkTenantEntity(resource);
}
private ListenableFuture<List<TbResourceDataInfo>> loadResources(TbContext ctx) {
final TenantId tenantId = ctx.getTenantId();
final TbResourceDataCache cache = ctx.getTbResourceDataCache();
List<? extends ListenableFuture<TbResourceDataInfo>> futures = resourceIds.stream()
.map(id -> cache.getResourceDataInfoAsync(tenantId, id))
.toList();
return Futures.allAsList(futures);
}
private List<Content> buildContents(String userPrompt, List<TbResourceDataInfo> resources) {
List<Content> contents = new ArrayList<>(1 + resources.size());
contents.add(new TextContent(userPrompt)); // user prompt first
resources.stream()
.filter(Objects::nonNull)
.map(this::toContent)
.forEach(contents::add);
return contents;
}
private Content toContent(TbResourceDataInfo resource) {
if (resource.getDescriptor() == null) {
throw new RuntimeException("Missing descriptor for resource");
}
GeneralFileDescriptor descriptor = JacksonUtil.treeToValue(resource.getDescriptor(), GeneralFileDescriptor.class);
String mediaType = descriptor.getMediaType();
if (mediaType == null) {
throw new RuntimeException("Missing mediaType in resource descriptor " + resource.getDescriptor());
}
byte[] data = resource.getData();
if (mediaType.startsWith("text/")) {
return new TextContent(new String(data, StandardCharsets.UTF_8));
}
if (mediaType.equals("application/pdf")) {
return new PdfFileContent(Base64.getEncoder().encodeToString(data), mediaType);
}
if (mediaType.startsWith("image/")) {
return new ImageContent(Base64.getEncoder().encodeToString(data), mediaType);
}
log.debug("Trying to create text content for {}", resource.getDescriptor());
return new TextContent(new String(data, StandardCharsets.UTF_8));
}
@Override
public void destroy() {
super.destroy();
systemPrompt = null;
userPrompt = null;
resourceIds = null;
responseFormat = null;
modelId = null;
}

6
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNodeConfiguration.java

@ -20,12 +20,14 @@ import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import lombok.Data;
import org.thingsboard.rule.engine.api.NodeConfiguration;
import org.thingsboard.server.common.data.id.AiModelId;
import org.thingsboard.server.common.data.validation.Length;
import java.util.Set;
import java.util.UUID;
import static org.thingsboard.rule.engine.ai.TbResponseFormat.TbJsonResponseFormat;
@Data
@ -41,6 +43,8 @@ public class TbAiNodeConfiguration implements NodeConfiguration<TbAiNodeConfigur
@Length(min = 1, max = 500_000)
private String userPrompt;
private Set<@NotNull(message = "references to resources cannot be null") UUID> resourceIds;
@NotNull
@Valid
private TbResponseFormat responseFormat;

223
rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/ai/TbAiNodeTest.java

@ -17,8 +17,11 @@ package org.thingsboard.rule.engine.ai;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.google.common.util.concurrent.FluentFuture;
import com.google.common.util.concurrent.Futures;
import dev.langchain4j.data.message.AiMessage;
import dev.langchain4j.data.message.ImageContent;
import dev.langchain4j.data.message.SystemMessage;
import dev.langchain4j.data.message.TextContent;
import dev.langchain4j.data.message.UserMessage;
import dev.langchain4j.model.chat.request.ResponseFormat;
import dev.langchain4j.model.chat.request.ResponseFormatType;
@ -32,6 +35,7 @@ import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.thingsboard.common.util.JacksonUtil;
@ -43,6 +47,10 @@ import org.thingsboard.rule.engine.api.RuleEngineAiChatModelService;
import org.thingsboard.rule.engine.api.TbContext;
import org.thingsboard.rule.engine.api.TbNodeConfiguration;
import org.thingsboard.rule.engine.api.TbNodeException;
import org.thingsboard.server.common.data.GeneralFileDescriptor;
import org.thingsboard.server.common.data.ResourceType;
import org.thingsboard.server.common.data.TbResource;
import org.thingsboard.server.common.data.TbResourceDataInfo;
import org.thingsboard.server.common.data.ai.AiModel;
import org.thingsboard.server.common.data.ai.model.AiModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModelConfig;
@ -52,6 +60,7 @@ import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig;
import org.thingsboard.server.common.data.id.AiModelId;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.RuleNodeId;
import org.thingsboard.server.common.data.id.TbResourceId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.msg.TbNodeConnectionType;
import org.thingsboard.server.common.data.rule.RuleNode;
@ -59,9 +68,14 @@ import org.thingsboard.server.common.msg.TbMsg;
import org.thingsboard.server.common.msg.TbMsgMetaData;
import org.thingsboard.server.dao.ai.AiModelService;
import org.thingsboard.server.dao.exception.DataValidationException;
import org.thingsboard.server.dao.resource.ResourceService;
import org.thingsboard.server.dao.resource.TbResourceDataCache;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Stream;
@ -76,16 +90,23 @@ import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.thingsboard.server.common.data.ResourceType.GENERAL;
@ExtendWith(MockitoExtension.class)
class TbAiNodeTest {
private static final byte[] PNG_IMAGE = Base64.getDecoder().decode("iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAMAAAB+IdObAAAC9FBMVEUAAAABAQEBAgICAgICAwMCBAQDAwMDBQUDBgYEBAQEBwcECAgFCQkFCgoGBgYGCwsGDAwHBwcHDQ0HDg4ICAgIDw8IEBAJCQkJEREKEhIKExMLFBQLFRUMFhYMFxcNDQ0NGBgNGRkODg4OGhoOGxsPDw8PHBwPHR0QEBAQHh4QHx8RERERICARISESEhISIiITExMTIyMTJCQUJSUUJiYVKCgWFhYWKSkXFxcXGhwYGBgYLC0ZGRkaMDEaMTIbGxsbMjMcMzQdNTYfOTogICAgOzwiP0AiQEEjIyMjQkMkQ0QnJycnSEkoS0wpKSkrUFErUVIsLCwvV1gvWFkwWlszMzMzYGE1NTU2NjY3Zmc4aWo5OTk5ams5a2w6Ojo6bG07bm88cXI9cnM9c3Q/dndAQEBAeHlBeXpCQkJCe3xCfH1DQ0NEREREf4FFRUVFgIJGg4VHhYdISEhIhohJh4lLi41LjI5MTExMjpBNj5FNkJJOkpRQUFBQlZdRUVFSUlJTU1NTmpxUVFRUnZ9VVVVVnqBWVlZYpadZWVlZp6laqKpbW1tbqatbqqxcXFxcrK5dra9drrBeXl5er7FfsbNfsrRgs7VhYWFiYmJiuLpjubtku71lvL5lvb9mvsBnwcNowsRpxMZpxcdra2tryctsysxubm5uzc9vb29vz9Fw0dNx0tVy1Ndy1dhz1tlz19p0dHR02Nt02dx12t1229523N93d3d33eB33uF5eXl54eR6enp64+Z65Od75eh75ul8fHx85+p86Ot96ex96u2AgICA7vGA7/KB8fSC8/aD9PeD9fiEhISE9/qF+PuF+fyGhoaG+v2G+/6Hh4eH/P+IiIiMjIyNjY2Ojo6QkJCRkZGSkpKTk5Obm5ucnJyfn5+lpaWnp6eoqKipqamqqqqwsLCzs7O1tbW4uLi5ubm6urq7u7u8vLy/v7/BwcHCwsLFxcXGxsbPz8/Y2Nji4uLj4+Pv7+/4+Pj5+fn+/v7/75T///+GLm1tAAAAAWJLR0T7omo23AAABJtJREFUeNrt3Wd8E3UYB/CH0oqm1dJaS5N0IKu0qQSVinXG4gKlKFi3uMC9FVwoVQnQqCBgBVxFnKCoFFFExFGhliWt/zoYLuIMKEpB7b3xuf9dQu+MvAjXcsTf7/PJk/ul1/S+TS53r3KkNFfk0V6evDHbFGruQ3EQTzNVUFxkHOXFB6QbIQiCIAiC/GeSs/QkR6vkCPeUaNUeSUjkkdR1npCp6a7VV7U6P1dbKfNFrS89rJNas/T6rlZtkUS/i2evhw99Q92y9/r7nVzzw7VfeDX3y2qv893plTVb1uW+uw6xiyNpspAQ8bjLy8l5REiImOlUq3Pniunyxw8Ib+vqF7aB5AgdItLVmit0iOgc9W0owhDt1RSAABL3EGeDDqmXhwRXgw6pj3qESFhtgHC1DYSGrJCQjweFq4SEqzkD67zGah8Inay+p1yl4XqKWt2lF69UDxQrzzevXZprrDn2gfTIUs85Iv/oHpny8HKHdugeVZhpXNudu6u6J1P8lmpIX1ys10X6myVfPeLl919UZFi74JXjWtfCecfa5sj+odx908XSg9Taqdaw+3I1QuYLA6RG2AbiEDpE9JJnvcYP1BRhgiw3QuoAASTuIQnP6JCF8hQlcbYBwrWIKgPDIg9UGSGP2QdCnZ+QkDneKQs4swqe1CDJ09RaXfBUETWKm3a+gFMMEMc0+0AoJVX9nM1+VDsCznLurz64b5VWq7nWLLi81QfygYZfNlU7nAUP0nOwrLnGiiAIgiAIgiAIgiDI/zstLS3tMEtKSiycgAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIBYAkEQBEEQBEEQBEEQBGmrdLwuyLmhg703km8Z63k7N2Tw0jnqFt/f0bROn69WBYOfbuxiyR+8MXC9vB8QCBTQkEAgMOG2gVyvDmTzdAWuifFp077m8f503vwZr/PSd28Hg+uaTjVDlOFEIxVrINVijfwi4glCHE1XioXPz6kX9xHNFIUkvyM/xqeduIPHup95bGni8edYotOUqJCrrII0iMv4LnNFg4Sczd/9/Zw4abchD0Ygv0pIBVFZG0Nq587lu/PE02EIXSQuaSfI92l88bfNFkHqLxUnEM1+bXQEMloMY8hgn893esyQIzbzWHtveXn51GW89AtfTeyATWZIWm919s6wBtLYdfXdVCyuuEdCHhoxwr/mAzdDtMQKoaP4duQmRVG+kUtyu83X3OuylX09f+9r0c6eOvkjx82fdPdLiHrdjsrD1Z39LP5W06ExQ475g8eqSR6PZ+oXvLSVNWk/nmmGKNcSXaBYBXEPFkMXV1GlhFyYlSof3t19ZOxfPJp+4/HTeh47JhGdqLQxJDtpyRJxBgUi+0g7QkYSlVsHoVtFrcNiyO0SsoXHDxIykej4v/8F+XxDKLRxmXWQfo2jyGJIh894PDs9FArNeIGXvlwbCn37Upl5rXObOMPtf1K4z5u8ne/sx0tl6hbfgtNkBEGQPZs4uUBwTxoTH5DxtM0TD46+20lpHrfXX7e52/jtyj9kFKbIT2L3FQAAAABJRU5ErkJggg==");
@Mock
TbContext ctxMock;
@Mock
AiModelService aiModelServiceMock;
@Mock
RuleEngineAiChatModelService aiChatModelServiceMock;
@Mock
TbResourceDataCache tbResourceDataCacheMock;
@Mock
ResourceService resourceServiceMock;
TbAiNode aiNode;
TbAiNodeConfiguration config;
@ -141,6 +162,8 @@ class TbAiNodeTest {
lenient().when(ctxMock.getAiModelService()).thenReturn(aiModelServiceMock);
lenient().when(ctxMock.getAiChatModelService()).thenReturn(aiChatModelServiceMock);
lenient().when(ctxMock.getDbCallbackExecutor()).thenReturn(new TestDbCallbackExecutor());
lenient().when(ctxMock.getTbResourceDataCache()).thenReturn(tbResourceDataCacheMock);
lenient().when(ctxMock.getResourceService()).thenReturn(resourceServiceMock);
}
@Test
@ -158,6 +181,7 @@ class TbAiNodeTest {
assertThat(config.getResponseFormat()).isEqualTo(new TbJsonResponseFormat());
assertThat(config.getTimeoutSeconds()).isEqualTo(60);
assertThat(config.isForceAck()).isTrue();
assertThat(config.getResourceIds()).isNull();
}
/* -- Node initialization tests -- */
@ -373,6 +397,36 @@ class TbAiNodeTest {
.matches(e -> ((TbNodeException) e).isUnrecoverable());
}
@Test
void givenNotExistingResources_whenInit_thenThrowsException() {
// GIVEN
config = constructValidConfig();
UUID resourceId = UUID.randomUUID();
config.setResourceIds(Set.of(resourceId));
// WHEN-THEN
assertThatThrownBy(() -> aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))))
.isInstanceOf(TbNodeException.class)
.hasMessageContaining("[" + tenantId + "] Resource with ID: [" + resourceId + "] was not found");
}
@Test
void givenResourceOfWrongType_whenInit_thenThrowsException() {
// GIVEN
config = constructValidConfig();
UUID resourceId = UUID.randomUUID();
config.setResourceIds(Set.of(resourceId));
// WHEN-THEN
TbResource tbResource = new TbResource();
tbResource.setResourceType(ResourceType.DASHBOARD);
given(resourceServiceMock.findResourceInfoById(any(), any())).willReturn(tbResource);
assertThatThrownBy(() -> aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))))
.isInstanceOf(TbNodeException.class)
.hasMessageContaining("[" + tenantId + "] Resource with ID: [" + resourceId + "] has unsupported resource type: " + ResourceType.DASHBOARD);
}
/* -- Message processing tests -- */
@Test
@ -560,6 +614,166 @@ class TbAiNodeTest {
);
}
@Test
void givenSystemPromptAndUserPromptAndResourcesConfigured_whenOnMsg_thenRequestContainsSystemAndUserAndResourceContent() throws TbNodeException {
String systemPrompt = "Respond with valid JSON";
String userPrompt = "Tell me a joke";
String textData = "Text resource content for AI request.";
String xmlData = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><test></test>";
// GIVEN
config = constructValidConfig();
config.setSystemPrompt(systemPrompt);
config.setUserPrompt(userPrompt);
UUID resourceId = UUID.randomUUID();
UUID resourceId2 = UUID.randomUUID();
UUID resourceId3 = UUID.randomUUID();
config.setResourceIds(Set.of(resourceId, resourceId2, resourceId3));
// WHEN-THEN
TbResource textResource = buildGeneralResource(textData.getBytes(), "text/plain");
TbResource xmlResource = buildGeneralResource(xmlData.getBytes(), "application/xml");
TbResource imageResource = buildGeneralResource(PNG_IMAGE, "image/png");
given(resourceServiceMock.findResourceInfoById(any(), eq(new TbResourceId(resourceId)))).willReturn(textResource);
given(resourceServiceMock.findResourceInfoById(any(), eq(new TbResourceId(resourceId2)))).willReturn(xmlResource);
given(resourceServiceMock.findResourceInfoById(any(), eq(new TbResourceId(resourceId3)))).willReturn(imageResource);
given(tbResourceDataCacheMock.getResourceDataInfoAsync(any(), eq(new TbResourceId(resourceId)))).willReturn(FluentFuture.from(Futures.immediateFuture(textResource.toResourceDataInfo())));
given(tbResourceDataCacheMock.getResourceDataInfoAsync(any(), eq(new TbResourceId(resourceId2)))).willReturn(FluentFuture.from(Futures.immediateFuture(xmlResource.toResourceDataInfo())));
given(tbResourceDataCacheMock.getResourceDataInfoAsync(any(), eq(new TbResourceId(resourceId3)))).willReturn(FluentFuture.from(Futures.immediateFuture(imageResource.toResourceDataInfo())));
aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)));
var msg = TbMsg.newMsg()
.originator(deviceId)
.data(TbMsg.EMPTY_JSON_OBJECT)
.metaData(TbMsgMetaData.EMPTY)
.build();
var chatResponse = ChatResponse.builder()
.aiMessage(AiMessage.from("{\"type\":\"joke\",\"setup\":\"Why did the scarecrow win an award?\",\"punchline\":\"Because he was outstanding in his field.\"}"))
.build();
given(aiChatModelServiceMock.sendChatRequestAsync(any(), any())).willReturn(FluentFuture.from(immediateFuture(chatResponse)));
// WHEN
aiNode.onMsg(ctxMock, msg);
// THEN
then(aiChatModelServiceMock).should().sendChatRequestAsync(any(),
argThat(actualChatRequest -> {
assertThat(actualChatRequest.messages()).hasSize(2);
assertThat(actualChatRequest.messages().get(0)).isEqualTo(SystemMessage.from(systemPrompt));
assertThat(((UserMessage)actualChatRequest.messages().get(1)).contents())
.containsAll(List.of(new TextContent(userPrompt), new TextContent(textData),
new TextContent(xmlData), new ImageContent(Base64.getEncoder().encodeToString(PNG_IMAGE), "image/png")));
return true;
})
);
}
@Test
void givenNullResource_whenOnMsg_thenRequestContainsSystemAndUserPrompt() throws TbNodeException {
// GIVEN
config = constructValidConfig();
UUID resourceId = UUID.randomUUID();
config.setResourceIds(Set.of(resourceId));
// WHEN-THEN
TbResource tbResource = buildGeneralResource("Text resource content for AI request.".getBytes(), "text/plain");
given(resourceServiceMock.findResourceInfoById(any(), eq(new TbResourceId(resourceId)))).willReturn(tbResource);
given(tbResourceDataCacheMock.getResourceDataInfoAsync(any(), eq(new TbResourceId(resourceId)))).willReturn(FluentFuture.from(Futures.immediateFuture(null)));
aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)));
var msg = TbMsg.newMsg()
.originator(deviceId)
.data(TbMsg.EMPTY_JSON_OBJECT)
.metaData(TbMsgMetaData.EMPTY)
.build();
// WHEN
aiNode.onMsg(ctxMock, msg);
// THEN
then(aiChatModelServiceMock).should().sendChatRequestAsync(any(),
argThat(actualChatRequest -> {
assertThat(actualChatRequest.messages()).hasSize(2);
assertThat(actualChatRequest.messages().get(0)).isEqualTo(SystemMessage.from(config.getSystemPrompt()));
assertThat(((UserMessage)actualChatRequest.messages().get(1)).contents())
.containsAll(List.of(new TextContent(config.getUserPrompt())));
return true;
})
);
}
@Test
void givenResourceWithNoDescriptor_whenOnMsg_thenEnqueueForTellFailure() throws TbNodeException {
// GIVEN
config = constructValidConfig();
UUID resourceId = UUID.randomUUID();
config.setResourceIds(Set.of(resourceId));
// WHEN-THEN
TbResource tbResource = buildGeneralResource("Text resource content for AI request.".getBytes(), "text/plain");
TbResourceDataInfo resourceDataInfo = new TbResourceDataInfo(tbResource.getData(), null);
given(resourceServiceMock.findResourceInfoById(any(), eq(new TbResourceId(resourceId)))).willReturn(tbResource);
given(tbResourceDataCacheMock.getResourceDataInfoAsync(any(), eq(new TbResourceId(resourceId)))).willReturn(FluentFuture.from(Futures.immediateFuture(resourceDataInfo)));
aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)));
var msg = TbMsg.newMsg()
.originator(deviceId)
.data(TbMsg.EMPTY_JSON_OBJECT)
.metaData(TbMsgMetaData.EMPTY)
.build();
// WHEN
aiNode.onMsg(ctxMock, msg);
// THEN
var exceptionCaptor = ArgumentCaptor.forClass(Throwable.class);
then(ctxMock).should().enqueueForTellFailure(any(), exceptionCaptor.capture());
Throwable actualException = exceptionCaptor.getValue();
assertThat(actualException.getMessage()).isEqualTo("Missing descriptor for resource");
}
@Test
void givenResourceWithNoMediaType_whenOnMsg_thenEnqueueForTellFailure() throws TbNodeException {
// GIVEN
config = constructValidConfig();
UUID resourceId = UUID.randomUUID();
config.setResourceIds(Set.of(resourceId));
// WHEN-THEN
TbResource tbResource = buildGeneralResource("Text resource content for AI request.".getBytes(), "text/plain");
TbResourceDataInfo resourceDataInfo = new TbResourceDataInfo(tbResource.getData(), JacksonUtil.newObjectNode());
given(resourceServiceMock.findResourceInfoById(any(), eq(new TbResourceId(resourceId)))).willReturn(tbResource);
given(tbResourceDataCacheMock.getResourceDataInfoAsync(any(), eq(new TbResourceId(resourceId)))).willReturn(FluentFuture.from(Futures.immediateFuture(resourceDataInfo)));
aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)));
var msg = TbMsg.newMsg()
.originator(deviceId)
.data(TbMsg.EMPTY_JSON_OBJECT)
.metaData(TbMsgMetaData.EMPTY)
.build();
// WHEN
aiNode.onMsg(ctxMock, msg);
// THEN
var exceptionCaptor = ArgumentCaptor.forClass(Throwable.class);
then(ctxMock).should().enqueueForTellFailure(any(), exceptionCaptor.capture());
Throwable actualException = exceptionCaptor.getValue();
assertThat(actualException.getMessage()).isEqualTo("Missing mediaType in resource descriptor {}");
}
@Test
void givenTemplatedPrompts_whenOnMsg_thenRequestContainsSubstitutedMessages() throws TbNodeException {
// GIVEN
@ -950,4 +1164,13 @@ class TbAiNodeTest {
then(ctxMock).should(never()).tellFailure(any(), any());
}
private TbResource buildGeneralResource(byte[] data, String mediaType) {
TbResource tbResource = new TbResource();
tbResource.setResourceType(GENERAL);
GeneralFileDescriptor descriptor = new GeneralFileDescriptor(mediaType);
tbResource.setDescriptorValue(descriptor);
tbResource.setData(data);
return tbResource;
}
}

6
ui-ngx/src/app/core/http/entity.service.ts

@ -100,6 +100,7 @@ import { OAuth2Service } from '@core/http/oauth2.service';
import { MobileAppService } from '@core/http/mobile-app.service';
import { PlatformType } from '@shared/models/oauth2.models';
import { AiModelService } from '@core/http/ai-model.service';
import { ResourceType } from "@shared/models/resource.models";
@Injectable({
providedIn: 'root'
@ -297,6 +298,9 @@ export class EntityService {
(id) => this.ruleChainService.getRuleChain(id, config),
entityIds);
break;
case EntityType.TB_RESOURCE:
observable = this.resourceService.getResourcesByIds(entityIds, config);
break;
}
return observable;
}
@ -472,7 +476,7 @@ export class EntityService {
break;
case EntityType.TB_RESOURCE:
pageLink.sortOrder.property = 'title';
entitiesObservable = this.resourceService.getTenantResources(pageLink, config);
entitiesObservable = this.resourceService.getResources(pageLink, subType as ResourceType, null, config);
break;
case EntityType.QUEUE_STATS:
pageLink.sortOrder.property = 'createdTime';

7
ui-ngx/src/app/core/http/resource.service.ts

@ -48,7 +48,7 @@ export class ResourceService {
}
public getTenantResources(pageLink: PageLink, config?: RequestConfig): Observable<PageData<ResourceInfo>> {
return this.http.get<PageData<ResourceInfo>>(`/api/resource/tenant${pageLink.toQuery()}`, defaultHttpOptionsFromConfig(config));
return this.http.get<PageData<ResourceInfo>>(`/api/resource/tenant${pageLink.toQuery()}`, defaultHttpOptionsFromConfig(config))
}
public getResource(resourceId: string, config?: RequestConfig): Observable<Resource> {
@ -94,4 +94,9 @@ export class ResourceService {
return this.http.delete(`/api/resource/${resourceId}?force=${force}`, defaultHttpOptionsFromConfig(config));
}
public getResourcesByIds(ids: string[], config?: RequestConfig): Observable<Array<ResourceInfo>> {
return this.http.get<Array<ResourceInfo>>(`/api/resource?resourceIds=${ids.join(',')}`,
defaultHttpOptionsFromConfig(config));
}
}

6
ui-ngx/src/app/modules/home/components/home-components.module.ts

@ -205,6 +205,8 @@ import {
} from '@home/components/calculated-fields/components/test-arguments/calculated-field-test-arguments.component';
import { CheckConnectivityDialogComponent } from '@home/components/ai-model/check-connectivity-dialog.component';
import { AIModelDialogComponent } from '@home/components/ai-model/ai-model-dialog.component';
import { ResourcesDialogComponent } from "@home/components/resources/resources-dialog.component";
import { ResourcesLibraryComponent } from "@home/components/resources/resources-library.component";
@NgModule({
declarations:
@ -358,6 +360,8 @@ import { AIModelDialogComponent } from '@home/components/ai-model/ai-model-dialo
CalculatedFieldTestArgumentsComponent,
CheckConnectivityDialogComponent,
AIModelDialogComponent,
ResourcesDialogComponent,
ResourcesLibraryComponent,
],
imports: [
CommonModule,
@ -505,6 +509,8 @@ import { AIModelDialogComponent } from '@home/components/ai-model/ai-model-dialo
CalculatedFieldTestArgumentsComponent,
CheckConnectivityDialogComponent,
AIModelDialogComponent,
ResourcesDialogComponent,
ResourcesLibraryComponent,
],
providers: [
WidgetComponentService,

54
ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.html

@ -0,0 +1,54 @@
<!--
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.
-->
<form (ngSubmit)="save()" style="width: 600px;">
<mat-toolbar color="primary">
<h2>{{ 'resource.add' | translate }}</h2>
<span class="flex-1"></span>
<button mat-icon-button
(click)="cancel()"
type="button">
<mat-icon class="material-icons">close</mat-icon>
</button>
</mat-toolbar>
<mat-progress-bar color="warn" mode="indeterminate" *ngIf="isLoading$ | async">
</mat-progress-bar>
<div style="height: 4px;" *ngIf="!(isLoading$ | async)"></div>
<div mat-dialog-content>
<tb-resources-library #resourcesComponent
[standalone]="true"
[entity]="resources"
[defaultResourceType]="ResourceType.GENERAL"
[resourceTypes]="[ResourceType.GENERAL]"
[isEdit]="true">
</tb-resources-library>
</div>
<div mat-dialog-actions class="flex items-center justify-end">
<button mat-button color="primary"
type="button"
cdkFocusInitial
[disabled]="(isLoading$ | async)"
(click)="cancel()">
{{ 'action.cancel' | translate }}
</button>
<button mat-raised-button color="primary"
type="submit"
[disabled]="(isLoading$ | async) || resourcesComponent.entityForm?.invalid || !resourcesComponent.entityForm?.dirty">
{{ (isAdd ? 'action.add' : 'action.save') | translate }}
</button>
</div>
</form>

24
ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.scss

@ -0,0 +1,24 @@
/**
* 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.
*/
:host ::ng-deep {
.mat-mdc-dialog-content {
display: flex;
flex-direction: column;
height: 100%;
padding: 0 !important;
}
}

116
ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.ts

@ -0,0 +1,116 @@
///
/// 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.
///
import { AfterViewInit, Component, Inject, SkipSelf, ViewChild } from '@angular/core';
import { DialogComponent } from '@shared/components/dialog.component';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { Router } from '@angular/router';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { FormGroupDirective, NgForm, UntypedFormControl } from '@angular/forms';
import { EntityType } from '@shared/models/entity-type.models';
import { map } from 'rxjs/operators';
import { ResourcesLibraryComponent } from "@home/components/resources/resources-library.component";
import { ErrorStateMatcher } from "@angular/material/core";
import { Resource, ResourceType } from "@shared/models/resource.models";
import { ResourceService } from "@core/http/resource.service";
export interface ResourcesDialogData {
resources?: Resource;
isAdd?: boolean;
}
@Component({
selector: 'tb-resources-dialog',
templateUrl: './resources-dialog.component.html',
providers: [{provide: ErrorStateMatcher, useExisting: ResourcesDialogComponent}],
styleUrls: ['./resources-dialog.component.scss']
})
export class ResourcesDialogComponent extends DialogComponent<ResourcesDialogComponent, Resource> implements ErrorStateMatcher, AfterViewInit {
readonly entityType = EntityType;
ResourceType = ResourceType;
isAdd = false;
submitted = false;
resources: Resource;
@ViewChild('resourcesComponent', {static: true}) resourcesComponent: ResourcesLibraryComponent;
constructor(protected store: Store<AppState>,
protected router: Router,
protected dialogRef: MatDialogRef<ResourcesDialogComponent, Resource>,
@Inject(MAT_DIALOG_DATA) public data: ResourcesDialogData,
@SkipSelf() private errorStateMatcher: ErrorStateMatcher,
private resourceService: ResourceService) {
super(store, router, dialogRef);
if (this.data.isAdd) {
this.isAdd = true;
}
if (this.data.resources) {
this.resources = this.data.resources;
}
}
ngAfterViewInit(): void {
if (this.isAdd) {
setTimeout(() => {
this.resourcesComponent.entityForm.markAsDirty();
}, 0);
}
}
isErrorState(control: UntypedFormControl | null, form: FormGroupDirective | NgForm | null): boolean {
const originalErrorState = this.errorStateMatcher.isErrorState(control, form);
const customErrorState = !!(control && control.invalid && this.submitted);
return originalErrorState || customErrorState;
}
cancel(): void {
this.dialogRef.close(null);
}
save(): void {
this.submitted = true;
if (this.resourcesComponent.entityForm.valid) {
const resource = {...this.resourcesComponent.entityFormValue()};
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
});
});
this.resourceService.saveResources(resources, {resendRequest: true}).pipe(
map((response) => 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));
}
}
}
}

63
ui-ngx/src/app/modules/home/pages/admin/resource/resources-library.component.html → ui-ngx/src/app/modules/home/components/resources/resources-library.component.html

@ -15,7 +15,7 @@
limitations under the License.
-->
<div class="tb-details-buttons xs:flex xs:flex-col">
<div class="tb-details-buttons xs:flex xs:flex-col" *ngIf="!standalone">
<button mat-raised-button color="primary"
[disabled]="(isLoading$ | async)"
(click)="onEntityAction($event, 'open')"
@ -48,14 +48,16 @@
<div class="mat-padding flex flex-col">
<form [formGroup]="entityForm">
<fieldset [disabled]="(isLoading$ | async) || !isEdit">
<mat-form-field class="mat-block">
<mat-label translate>resource.resource-type</mat-label>
<mat-select formControlName="resourceType" required>
<mat-option *ngFor="let resourceType of resourceTypes" [value]="resourceType">
{{ resourceTypesTranslationMap.get(resourceType) | translate }}
</mat-option>
</mat-select>
</mat-form-field>
@if (resourceTypes.length > 1) {
<mat-form-field class="mat-block">
<mat-label translate>resource.resource-type</mat-label>
<mat-select formControlName="resourceType" required>
<mat-option *ngFor="let resourceType of resourceTypes" [value]="resourceType">
{{ resourceTypesTranslationMap.get(resourceType) | translate }}
</mat-option>
</mat-select>
</mat-form-field>
}
<mat-form-field class="mat-block" *ngIf="entityForm.get('resourceType').value !== resourceType.LWM2M_MODEL || !isAdd">
<mat-label translate>resource.title</mat-label>
<input matInput formControlName="title" required>
@ -66,26 +68,29 @@
{{ 'resource.title-max-length' | translate }}
</mat-error>
</mat-form-field>
<tb-file-input *ngIf="isAdd"
formControlName="data"
required
label="{{ (entityForm.get('resourceType').value === resourceType.LWM2M_MODEL ? 'resource.resource-files' : 'resource.resource-file') | translate }}"
[readAsBinary]="true"
[maxSizeByte]="maxResourceSize"
[allowedExtensions]="getAllowedExtensions()"
[contentConvertFunction]="convertToBase64File"
[accept]="getAcceptType()"
[multipleFile]="entityForm.get('resourceType').value === resourceType.LWM2M_MODEL"
dropLabel="{{'resource.drop-resource-file-or' | translate}}"
[existingFileName]="entityForm.get('fileName')?.value"
(fileNameChanged)="entityForm?.get('fileName').patchValue($event)">
</tb-file-input>
<div *ngIf="!isAdd" class="flex flex-row xs:flex-col sm:gap-2 md:flex-col gt-md:gap-2">
<mat-form-field class="flex-1">
<mat-label translate>resource.file-name</mat-label>
<input matInput formControlName="fileName" type="text">
</mat-form-field>
</div>
@if (isAdd || ((isAdd || isEdit) && entityForm.get('resourceType').value === resourceType.GENERAL)) {
<tb-file-input formControlName="data"
required
label="{{ (entityForm.get('resourceType').value === resourceType.LWM2M_MODEL ? 'resource.resource-files' : 'resource.resource-file') | translate }}"
[readAsBinary]="true"
[maxSizeByte]="maxResourceSize"
[allowedExtensions]="getAllowedExtensions()"
[contentConvertFunction]="convertToBase64File"
[accept]="getAcceptType()"
[multipleFile]="entityForm.get('resourceType').value === resourceType.LWM2M_MODEL"
dropLabel="{{'resource.drop-resource-file-or' | translate}}"
[existingFileName]="entityForm.get('fileName')?.value"
(mediaTypeChanged)="mediaTypeChange($event)"
(fileNameChanged)="entityForm?.get('fileName').patchValue($event)">
</tb-file-input>
} @else {
<div class="flex flex-row xs:flex-col sm:gap-2 md:flex-col gt-md:gap-2">
<mat-form-field class="flex-1">
<mat-label translate>resource.file-name</mat-label>
<input matInput formControlName="fileName" type="text">
</mat-form-field>
</div>
}
</fieldset>
</form>
</div>

29
ui-ngx/src/app/modules/home/pages/admin/resource/resources-library.component.ts → ui-ngx/src/app/modules/home/components/resources/resources-library.component.ts

@ -14,7 +14,7 @@
/// limitations under the License.
///
import { ChangeDetectorRef, Component, Inject, OnDestroy, OnInit } from '@angular/core';
import { ChangeDetectorRef, Component, Inject, Input, OnDestroy, OnInit, Optional } from '@angular/core';
import { Subject } from 'rxjs';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
@ -40,8 +40,16 @@ import { getCurrentAuthState } from '@core/auth/auth.selectors';
})
export class ResourcesLibraryComponent extends EntityComponent<Resource> implements OnInit, OnDestroy {
@Input()
standalone = false;
@Input()
resourceTypes = [ResourceType.LWM2M_MODEL, ResourceType.PKCS_12, ResourceType.JKS, ResourceType.GENERAL];
@Input()
defaultResourceType = ResourceType.LWM2M_MODEL;
readonly resourceType = ResourceType;
readonly resourceTypes = [ResourceType.LWM2M_MODEL, ResourceType.PKCS_12, ResourceType.JKS];
readonly resourceTypesTranslationMap = ResourceTypeTranslationMap;
readonly maxResourceSize = getCurrentAuthState(this.store).maxResourceSize;
@ -49,8 +57,8 @@ export class ResourcesLibraryComponent extends EntityComponent<Resource> impleme
constructor(protected store: Store<AppState>,
protected translate: TranslateService,
@Inject('entity') protected entityValue: Resource,
@Inject('entitiesTableConfig') protected entitiesTableConfigValue: EntityTableConfig<Resource>,
@Optional() @Inject('entity') protected entityValue: Resource,
@Optional() @Inject('entitiesTableConfig') protected entitiesTableConfigValue: EntityTableConfig<Resource>,
public fb: FormBuilder,
protected cd: ChangeDetectorRef) {
super(store, fb, entityValue, entitiesTableConfigValue, cd);
@ -82,10 +90,19 @@ export class ResourcesLibraryComponent extends EntityComponent<Resource> impleme
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],
data: [entity ? entity.data : null, this.isAdd ? [Validators.required] : []]
data: [entity ? entity.data : null, this.isAdd ? [Validators.required] : []],
descriptor: this.fb.group({
mediaType: ['']
})
});
}
mediaTypeChange(mediaType: string): void {
if (this.entityForm.get('resourceType').value === ResourceType.GENERAL) {
this.entityForm.get('descriptor').get('mediaType').patchValue(mediaType);
}
}
updateForm(entity: Resource): void {
this.entityForm.patchValue(entity);
}
@ -138,7 +155,7 @@ export class ResourcesLibraryComponent extends EntityComponent<Resource> impleme
private observeResourceTypeChange(): void {
this.entityForm.get('resourceType').valueChanges.pipe(
startWith(ResourceType.LWM2M_MODEL),
startWith(this.defaultResourceType || ResourceType.LWM2M_MODEL),
takeUntil(this.destroy$)
).subscribe((type: ResourceType) => this.onResourceTypeChange(type));
}

10
ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html

@ -70,6 +70,16 @@
{{ 'rule-node-config.ai.user-prompt-blank' | translate }}
</mat-error>
</mat-form-field>
<tb-entity-list
class="flex-1"
allowCreateNew
placeholderText="{{ 'rule-node-config.ai.ai-resources' | translate }}"
[inlineField]="true"
[entityType]="EntityType.TB_RESOURCE"
[subType]="ResourceType.GENERAL"
(createNew)="createAiResources($event, 'resourceIds')"
formControlName="resourceIds">
</tb-entity-list>
</div>
</mat-expansion-panel>
</div>

24
ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.ts

@ -24,6 +24,8 @@ import { AiModel, AiRuleNodeResponseFormatTypeOnlyText, ResponseFormat } from '@
import { deepTrim } from '@core/utils';
import { TranslateService } from '@ngx-translate/core';
import { jsonRequired } from '@shared/components/json-object-edit.component';
import { Resource, ResourceType } from "@shared/models/resource.models";
import { ResourcesDialogComponent, ResourcesDialogData } from "@home/components/resources/resources-dialog.component";
@Component({
selector: 'tb-external-node-ai-config',
@ -38,6 +40,9 @@ export class AiConfigComponent extends RuleNodeConfigurationComponent {
responseFormat = ResponseFormat;
EntityType = EntityType;
ResourceType = ResourceType;
constructor(private fb: UntypedFormBuilder,
private translate: TranslateService,
private dialog: MatDialog) {
@ -53,6 +58,7 @@ export class AiConfigComponent extends RuleNodeConfigurationComponent {
modelId: [configuration?.modelId ?? null, [Validators.required]],
systemPrompt: [configuration?.systemPrompt ?? '', [Validators.maxLength(500_000), Validators.pattern(/.*\S.*/)]],
userPrompt: [configuration?.userPrompt ?? '', [Validators.required, Validators.maxLength(500_000), Validators.pattern(/.*\S.*/)]],
resourceIds: [configuration?.resourceIds ?? []],
responseFormat: this.fb.group({
type: [configuration?.responseFormat?.type ?? ResponseFormat.JSON, []],
schema: [configuration?.responseFormat?.schema ?? null, [jsonRequired]],
@ -116,5 +122,23 @@ export class AiConfigComponent extends RuleNodeConfigurationComponent {
this.aiConfigForm.get(formControl).markAsDirty();
}
});
};
createAiResources(name: string, formControl: string) {
this.dialog.open<ResourcesDialogComponent, ResourcesDialogData, Resource>(ResourcesDialogComponent, {
disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
data: {
resources: {title: name, resourceType: ResourceType.GENERAL},
isAdd: true
}
}).afterClosed()
.subscribe((resource) => {
if (resource) {
const resourceIds = [...(this.aiConfigForm.get(formControl).value || []), resource.id.id];
this.aiConfigForm.get(formControl).patchValue(resourceIds);
this.aiConfigForm.get(formControl).markAsDirty();
}
});
}
}

2
ui-ngx/src/app/modules/home/pages/admin/admin.module.ts

@ -26,7 +26,6 @@ import { HomeComponentsModule } from '@modules/home/components/home-components.m
import { SmsProviderComponent } from '@home/pages/admin/sms-provider.component';
import { SendTestSmsDialogComponent } from '@home/pages/admin/send-test-sms-dialog.component';
import { HomeSettingsComponent } from '@home/pages/admin/home-settings.component';
import { ResourcesLibraryComponent } from '@home/pages/admin/resource/resources-library.component';
import { ResourceTabsComponent } from '@home/pages/admin/resource/resource-tabs.component';
import { ResourcesTableHeaderComponent } from '@home/pages/admin/resource/resources-table-header.component';
import { QueueComponent } from '@home/pages/admin/queue/queue.component';
@ -49,7 +48,6 @@ import { ResourceLibraryTabsComponent } from '@home/pages/admin/resource/resourc
SendTestSmsDialogComponent,
SecuritySettingsComponent,
HomeSettingsComponent,
ResourcesLibraryComponent,
ResourceTabsComponent,
ResourceLibraryTabsComponent,
ResourcesTableHeaderComponent,

182
ui-ngx/src/app/modules/home/pages/admin/resource/resources-library-table-config.resolve.ts

@ -22,7 +22,13 @@ import {
EntityTableConfig
} from '@home/models/entity/entities-table-config.models';
import { Router } from '@angular/router';
import { Resource, ResourceInfo, ResourceType, ResourceTypeTranslationMap } from '@shared/models/resource.models';
import {
Resource,
ResourceInfo, ResourceInfoWithReferences,
ResourceType,
ResourceTypeTranslationMap,
toResourceDeleteResult
} from '@shared/models/resource.models';
import { EntityType, entityTypeResources, entityTypeTranslations } from '@shared/models/entity-type.models';
import { NULL_UUID } from '@shared/models/id/has-uuid';
import { DatePipe } from '@angular/common';
@ -32,12 +38,22 @@ import { getCurrentAuthUser } from '@core/auth/auth.selectors';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { Authority } from '@shared/models/authority.enum';
import { ResourcesLibraryComponent } from '@home/pages/admin/resource/resources-library.component';
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 { map } from 'rxjs/operators';
import { catchError, map } 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 {
ResourcesInUseDialogComponent,
ResourcesInUseDialogData
} from "@shared/components/resource/resources-in-use-dialog.component";
import { parseHttpErrorMessage } from "@core/utils";
import { ActionNotificationShow } from "@core/notification/notification.actions";
import { ResourcesDatasource } from "@home/pages/admin/resource/resources-datasource";
import { MatDialog } from "@angular/material/dialog";
import { DialogService } from "@core/services/dialog.service";
@Injectable()
export class ResourcesLibraryTableConfigResolver {
@ -49,6 +65,8 @@ export class ResourcesLibraryTableConfigResolver {
private resourceService: ResourceService,
private translate: TranslateService,
private router: Router,
private dialog: MatDialog,
private dialogService: DialogService,
private datePipe: DatePipe) {
this.config.entityType = EntityType.TB_RESOURCE;
@ -76,19 +94,27 @@ export class ResourcesLibraryTableConfigResolver {
icon: 'file_download',
isEnabled: () => true,
onAction: ($event, entity) => this.downloadResource($event, entity)
}
},
{
name: this.translate.instant('resource.delete'),
icon: 'delete',
isEnabled: (resource) => this.config.deleteEnabled(resource),
onAction: ($event, entity) => this.deleteResource($event, entity)
},
);
this.config.deleteEntityTitle = resource => this.translate.instant('resource.delete-resource-title',
{ resourceTitle: resource.title });
this.config.deleteEntityContent = () => this.translate.instant('resource.delete-resource-text');
this.config.deleteEntitiesTitle = count => this.translate.instant('resource.delete-resources-title', {count});
this.config.deleteEntitiesContent = () => this.translate.instant('resource.delete-resources-text');
this.config.groupActionDescriptors = [{
name: this.translate.instant('action.delete'),
icon: 'delete',
isEnabled: true,
onAction: ($event, entities) => this.deleteResources($event, entities)
}];
this.config.entitiesDeleteEnabled = false;
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.deleteEntity = id => this.resourceService.deleteResource(id.id);
this.config.onEntityAction = action => this.onResourceAction(action);
}
@ -147,6 +173,8 @@ export class ResourcesLibraryTableConfigResolver {
case 'downloadResource':
this.downloadResource(action.event, action.entity);
return true;
case 'deleteLibrary':
this.deleteResource(action.event, action.entity);
}
return false;
}
@ -165,4 +193,138 @@ export class ResourcesLibraryTableConfigResolver {
return authority === Authority.SYS_ADMIN;
}
}
private deleteResource($event: Event, resource: ResourceInfo) {
if ($event) {
$event.stopPropagation();
}
this.dialogService.confirm(
this.translate.instant('resource.delete-resource-title', { resourceTitle: resource.title }),
this.translate.instant('resource.delete-resource-text'),
this.translate.instant('action.no'),
this.translate.instant('action.yes'),
true
).subscribe((result) => {
if (result) {
this.resourceService.deleteResource(resource.id.id, false, {ignoreErrors: true}).pipe(
map(() => toResourceDeleteResult(resource)),
catchError((err) => of(toResourceDeleteResult(resource, err)))
).subscribe(
(deleteResult) => {
if (deleteResult.success) {
if (this.config.getEntityDetailsPage()) {
this.config.getEntityDetailsPage().goBack();
} else {
this.config.updateData(true);
}
} else if (deleteResult.resourceIsReferencedError) {
const resources: ResourceInfoWithReferences[] = [{...resource, ...{references: deleteResult.references}}];
const data = {
multiple: false,
resources,
configuration: {
title: 'resource.resource-is-in-use',
message: this.translate.instant('resource.resource-is-in-use-text', {title: resources[0].title}),
deleteText: 'resource.delete-resource-in-use-text',
selectedText: 'resource.selected-resources',
columns: ['select', 'title', 'references']
}
};
this.dialog.open<ResourcesInUseDialogComponent, ResourcesInUseDialogData,
ResourceInfo[]>(ResourcesInUseDialogComponent, {
disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
data
}).afterClosed().subscribe((resources) => {
if (resources) {
this.resourceService.deleteResource(resource.id.id, true).subscribe(() => {
if (this.config.getEntityDetailsPage()) {
this.config.getEntityDetailsPage().goBack();
} else {
this.config.updateData(true);
}
});
}
});
} else {
const errorMessageWithTimeout = parseHttpErrorMessage(deleteResult.error, this.translate);
setTimeout(() => {
this.store.dispatch(new ActionNotificationShow({message: errorMessageWithTimeout.message, type: 'error'}));
}, errorMessageWithTimeout.timeout);
}
}
);
}
});
}
private deleteResources($event: Event, resources: ResourceInfo[]) {
if ($event) {
$event.stopPropagation();
}
if (resources && resources.length) {
const title = this.translate.instant('resource.delete-resources-title', {count: resources.length});
const content = this.translate.instant('resource.delete-resources-text');
this.dialogService.confirm(title, content,
this.translate.instant('action.no'),
this.translate.instant('action.yes')).subscribe((result) => {
if (result) {
const tasks = resources.map((resource) =>
this.resourceService.deleteResource(resource.id.id, false, {ignoreErrors: true}).pipe(
map(() => toResourceDeleteResult(resource)),
catchError((err) => of(toResourceDeleteResult(resource, err)))
)
);
forkJoin(tasks).subscribe(
(deleteResults) => {
const anySuccess = deleteResults.some(res => res.success);
const referenceErrors = deleteResults.filter(res => res.resourceIsReferencedError);
const otherError = deleteResults.find(res => !res.success);
if (anySuccess) {
this.config.updateData();
}
if (referenceErrors?.length) {
const resourcesWithReferences: ResourceInfoWithReferences[] =
referenceErrors.map(ref => ({...ref.resource, ...{references: ref.references}}));
const data = {
multiple: true,
resources: resourcesWithReferences,
configuration: {
title: 'resource.resources-are-in-use',
message: this.translate.instant('resource.resources-are-in-use-text'),
deleteText: 'resource.delete-resource-in-use-text',
selectedText: 'resource.selected-resources',
datasource: new ResourcesDatasource(this.resourceService, resourcesWithReferences, () => true),
columns: ['select', 'title', 'references']
}
};
this.dialog.open<ResourcesInUseDialogComponent, ResourcesInUseDialogData,
ResourceInfo[]>(ResourcesInUseDialogComponent, {
disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
data
}).afterClosed().subscribe((forceDeleteResources) => {
if (forceDeleteResources && forceDeleteResources.length) {
const forceDeleteTasks = forceDeleteResources.map((resource) =>
this.resourceService.deleteResource(resource.id.id, true)
);
forkJoin(forceDeleteTasks).subscribe(
() => {
this.config.updateData();
}
);
}
});
} else if (otherError) {
const errorMessageWithTimeout = parseHttpErrorMessage(otherError.error, this.translate);
setTimeout(() => {
this.store.dispatch(new ActionNotificationShow({message: errorMessageWithTimeout.message, type: 'error'}));
}, errorMessageWithTimeout.timeout);
}
}
);
}
});
}
}
}

2
ui-ngx/src/app/modules/home/pages/admin/resource/resources-table-header.component.ts

@ -28,7 +28,7 @@ import { PageLink } from '@shared/models/page/page-link';
})
export class ResourcesTableHeaderComponent extends EntityTableHeaderComponent<Resource, PageLink, ResourceInfo> {
readonly resourceTypes = [ResourceType.LWM2M_MODEL, ResourceType.PKCS_12, ResourceType.JKS];
readonly resourceTypes = [ResourceType.LWM2M_MODEL, ResourceType.PKCS_12, ResourceType.JKS, ResourceType.GENERAL];
readonly resourceTypesTranslationMap = ResourceTypeTranslationMap;
constructor(protected store: Store<AppState>) {

11
ui-ngx/src/app/shared/components/entity/entity-list.component.html

@ -40,6 +40,12 @@
[matAutocompleteConnectedTo]="origin"
[matAutocomplete]="entityAutocomplete"
[matChipInputFor]="chipList">
<button mat-button color="primary" matSuffix
type="button"
*ngIf="allowCreateNew && !disabled"
(click)="createNewEntity($event)">
<span style="white-space: nowrap">{{ 'entity.create-new' | translate }}</span>
</button>
</mat-chip-grid>
<mat-autocomplete #entityAutocomplete="matAutocomplete"
class="tb-autocomplete"
@ -56,6 +62,11 @@
<span>
{{ 'entity.no-entities-matching' | translate: {entity: searchText} }}
</span>
@if (allowCreateNew) {
<span>
<a translate (click)="createNewEntity($event, searchText)">entity.create-new-key</a>
</span>
}
</ng-template>
</div>
</mat-option>

29
ui-ngx/src/app/shared/components/entity/entity-list.component.ts

@ -14,7 +14,18 @@
/// limitations under the License.
///
import { Component, ElementRef, forwardRef, Input, OnChanges, OnInit, SimpleChanges, ViewChild } from '@angular/core';
import {
Component,
ElementRef,
EventEmitter,
forwardRef,
Input,
OnChanges,
OnInit,
Output,
SimpleChanges,
ViewChild
} from '@angular/core';
import {
ControlValueAccessor,
NG_VALIDATORS,
@ -93,6 +104,7 @@ export class EntityListComponent implements ControlValueAccessor, OnInit, OnChan
}
@Input()
@coerceBoolean()
disabled: boolean;
@Input()
@ -109,6 +121,13 @@ export class EntityListComponent implements ControlValueAccessor, OnInit, OnChan
@coerceBoolean()
inlineField: boolean;
@Input()
@coerceBoolean()
allowCreateNew: boolean;
@Output()
createNew = new EventEmitter<string>();
@ViewChild('entityInput') entityInput: ElementRef<HTMLInputElement>;
@ViewChild('entityAutocomplete') matAutocomplete: MatAutocomplete;
@ViewChild('chipList', {static: true}) chipList: MatChipGrid;
@ -136,6 +155,11 @@ export class EntityListComponent implements ControlValueAccessor, OnInit, OnChan
this.entityListFormGroup.get('entities').updateValueAndValidity();
}
createNewEntity($event: Event, searchText?: string) {
$event.stopPropagation();
this.createNew.emit(searchText);
}
registerOnChange(fn: any): void {
this.propagateChange = fn;
}
@ -201,6 +225,9 @@ export class EntityListComponent implements ControlValueAccessor, OnInit, OnChan
this.modelValue = null;
}
this.dirty = true;
if (this.entityInput) {
this.entityInput.nativeElement.value = '';
}
}
validate(): ValidationErrors | null {

14
ui-ngx/src/app/shared/components/file-input.component.ts

@ -129,10 +129,15 @@ export class FileInputComponent extends PageComponent implements AfterViewInit,
@Output()
fileNameChanged = new EventEmitter<string|string[]>();
@Output()
mediaTypeChanged = new EventEmitter<string>();
fileName: string | string[];
fileContent: any;
files: File[];
mediaType: string;
@ViewChild('flow', {static: true})
flow: FlowDirective;
@ -180,6 +185,7 @@ export class FileInputComponent extends PageComponent implements AfterViewInit,
this.fileContent = files[0].fileContent;
this.fileName = files[0].fileName;
this.files = files[0].files;
this.mediaType = files[0].mediaType;
this.updateModel();
} else if (files.length > 1) {
this.fileContent = files.map(content => content.fileContent);
@ -203,6 +209,7 @@ export class FileInputComponent extends PageComponent implements AfterViewInit,
let fileName = null;
let fileContent = null;
let files = null;
let mediaType = null;
if (reader.readyState === reader.DONE) {
if (!this.workFromFileObj) {
fileContent = reader.result;
@ -211,16 +218,18 @@ export class FileInputComponent extends PageComponent implements AfterViewInit,
fileContent = this.contentConvertFunction(fileContent);
}
fileName = fileContent ? file.name : null;
mediaType = file?.file?.type || null;
}
} else if (file.name || file.file){
files = file.file;
fileName = file.name;
mediaType = file.file.type || null;
}
}
resolve({fileContent, fileName, files});
resolve({fileContent, fileName, files, mediaType});
};
reader.onerror = () => {
resolve({fileContent: null, fileName: null, files: null});
resolve({fileContent: null, fileName: null, files: null, mediaType: null});
};
if (this.readAsBinary) {
reader.readAsBinaryString(file.file);
@ -283,6 +292,7 @@ export class FileInputComponent extends PageComponent implements AfterViewInit,
this.propagateChange(this.files);
} else {
this.propagateChange(this.fileContent);
this.mediaTypeChanged.emit(this.mediaType);
this.fileNameChanged.emit(this.fileName);
}
}

12
ui-ngx/src/app/shared/models/resource.models.ts

@ -24,7 +24,8 @@ export enum ResourceType {
LWM2M_MODEL = 'LWM2M_MODEL',
PKCS_12 = 'PKCS_12',
JKS = 'JKS',
JS_MODULE = 'JS_MODULE'
JS_MODULE = 'JS_MODULE',
GENERAL = 'GENERAL',
}
export enum ResourceSubType {
@ -57,7 +58,8 @@ export const ResourceTypeTranslationMap = new Map<ResourceType, string>(
[ResourceType.LWM2M_MODEL, 'resource.type.lwm2m-model'],
[ResourceType.PKCS_12, 'resource.type.pkcs-12'],
[ResourceType.JKS, 'resource.type.jks'],
[ResourceType.JS_MODULE, 'resource.type.js-module']
[ResourceType.JS_MODULE, 'resource.type.js-module'],
[ResourceType.GENERAL, 'resource.type.general'],
]
);
@ -76,8 +78,8 @@ export interface TbResourceInfo<D> extends Omit<BaseData<TbResourceId>, 'name' |
title?: string;
resourceType: ResourceType;
resourceSubType?: ResourceSubType;
fileName: string;
public: boolean;
fileName?: string;
public?: boolean;
publicResourceKey?: string;
readonly link?: string;
readonly publicLink?: string;
@ -87,7 +89,7 @@ export interface TbResourceInfo<D> extends Omit<BaseData<TbResourceId>, 'name' |
export type ResourceInfo = TbResourceInfo<any>;
export interface Resource extends ResourceInfo {
data: string;
data?: string;
name?: string;
}

13
ui-ngx/src/assets/locale/locale.constant-en_US.json

@ -4488,7 +4488,8 @@
"jks": "JKS",
"js-module": "JS module",
"lwm2m-model": "LWM2M model",
"pkcs-12": "PKCS #12"
"pkcs-12": "PKCS #12",
"general": "General"
},
"resource-sub-type": "Sub-type",
"sub-type": {
@ -4496,7 +4497,12 @@
"scada-symbol": "Scada symbol",
"extension": "Extension",
"module": "Module"
}
},
"resource-is-in-use": "Resource is used by other entities",
"resources-are-in-use": "Resources are used by other entities",
"resource-is-in-use-text": "The Resource <b>'{{title}}'</b> was not deleted because it is used by the following entities:",
"resources-are-in-use-text": "Not all Resources have been deleted because they are used by other entities.</br>You can view referenced entities by clicking the <b>References</b> button in the corresponding resource row.</br>If you still want to delete these resources, select them in the table below and click the <b>Delete selected</b> button.",
"delete-resource-in-use-text": "If you still want to delete the resource, click the <b>Delete anyway</b> button."
},
"javascript": {
"add": "Add JavaScript resource",
@ -5467,7 +5473,8 @@
"timeout-required": "Timeout is required",
"timeout-validation": "Must be from 1 second to 10 minutes.",
"force-acknowledgement": "Force acknowledgement",
"force-acknowledgement-hint": "If enabled, the incoming message is acknowledged immediately. The model's response is then enqueued as a separate, new message."
"force-acknowledgement-hint": "If enabled, the incoming message is acknowledged immediately. The model's response is then enqueued as a separate, new message.",
"ai-resources": "AI resources"
}
},
"timezone": {

Loading…
Cancel
Save