Browse Source

Merge pull request #14205 from AndriiLandiak/resource-upload

Fixed error when uploading resources approximately larger than 15 MB
pull/14469/head
Viacheslav Klimov 10 months ago
committed by GitHub
parent
commit
dea0bd83a6
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 70
      application/src/main/java/org/thingsboard/server/controller/TbResourceController.java
  2. 2
      application/src/main/java/org/thingsboard/server/service/resource/DefaultTbResourceService.java
  3. 2
      application/src/main/java/org/thingsboard/server/service/resource/TbResourceService.java
  4. 4
      application/src/main/java/org/thingsboard/server/utils/LwM2mObjectModelUtils.java
  5. 69
      application/src/test/java/org/thingsboard/server/controller/TbResourceControllerTest.java
  6. 2
      common/data/src/main/java/org/thingsboard/server/common/data/TbResourceInfo.java
  7. 5
      dao/src/main/java/org/thingsboard/server/dao/resource/BaseResourceService.java
  8. 9
      dao/src/main/java/org/thingsboard/server/dao/service/validator/ResourceDataValidator.java
  9. 50
      ui-ngx/src/app/core/http/resource.service.ts
  10. 5
      ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.ts
  11. 2
      ui-ngx/src/app/modules/home/components/resources/resources-library.component.html
  12. 2
      ui-ngx/src/app/modules/home/components/resources/resources-library.component.ts
  13. 19
      ui-ngx/src/app/modules/home/pages/admin/resource/js-library-table-config.resolver.ts
  14. 2
      ui-ngx/src/app/modules/home/pages/admin/resource/js-resource.component.html
  15. 8
      ui-ngx/src/app/modules/home/pages/admin/resource/js-resource.component.ts
  16. 26
      ui-ngx/src/app/modules/home/pages/admin/resource/resources-library-table-config.resolve.ts
  17. 52
      ui-ngx/src/app/shared/components/file-input.component.ts
  18. 2
      ui-ngx/src/app/shared/models/resource.models.ts

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

@ -32,12 +32,16 @@ import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.ResourceSubType; import org.thingsboard.server.common.data.ResourceSubType;
import org.thingsboard.server.common.data.ResourceType; import org.thingsboard.server.common.data.ResourceType;
import org.thingsboard.server.common.data.TbResource; import org.thingsboard.server.common.data.TbResource;
@ -215,6 +219,7 @@ public class TbResourceController extends BaseController {
"\n\nResource combination of the title with the key is unique in the scope of tenant. " + "\n\nResource combination of the title with the key is unique in the scope of tenant. " +
"Remove 'id', 'tenantId' from the request body example (below) to create new Resource entity." + "Remove 'id', 'tenantId' from the request body example (below) to create new Resource entity." +
SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH)
@Deprecated // resource should be save or update with an upload request
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')")
@PostMapping(value = "/resource") @PostMapping(value = "/resource")
public TbResourceInfo saveResource(@Parameter(description = "A JSON value representing the Resource.") public TbResourceInfo saveResource(@Parameter(description = "A JSON value representing the Resource.")
@ -224,6 +229,71 @@ public class TbResourceController extends BaseController {
return tbResourceService.save(resource, getCurrentUser()); return tbResourceService.save(resource, getCurrentUser());
} }
@ApiOperation(value = "Upload Resource via Multipart File (uploadResource)",
notes = "Create the Resource using multipart file upload. " +
"\n\nResource combination of the title with the key is unique in the scope of tenant. " +
SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH)
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')")
@PostMapping(value = "/resource/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public TbResourceInfo uploadResource(@Parameter(description = "Resource title.", example = "Title")
@RequestPart(name = "title", required = false) String title,
@Parameter(description = "Resource type.")
@RequestPart(name = "resourceType") String resourceTypeStr,
@Parameter(description = "Resource descriptor (JSON).")
@RequestPart(name = "descriptor", required = false) String descriptor,
@Parameter(description = "Resource sub type.")
@RequestPart(name = "resourceSubType", required = false) String resourceSubTypeStr,
@Parameter(description = "Resource file.")
@RequestPart MultipartFile file) throws Exception {
TbResource resource = new TbResource();
resource.setTenantId(getTenantId());
resource.setTitle(StringUtils.isNotEmpty(title) ? title : file.getOriginalFilename());
ResourceType resourceType = ResourceType.valueOf(resourceTypeStr);
resource.setResourceType(resourceType);
if (StringUtils.isNotEmpty(descriptor)) {
resource.setDescriptor(JacksonUtil.toJsonNode(descriptor));
} else {
String mediaType = resourceType.getMediaType() != null ? resourceType.getMediaType() : file.getContentType();
resource.setDescriptor(JacksonUtil.newObjectNode().put("mediaType", mediaType));
}
if (StringUtils.isNotEmpty(resourceSubTypeStr)) {
resource.setResourceSubType(ResourceSubType.valueOf(resourceSubTypeStr));
}
resource.setFileName(file.getOriginalFilename());
resource.setData(file.getBytes());
checkEntity(resource.getId(), resource, Resource.TB_RESOURCE);
return tbResourceService.save(resource, getCurrentUser());
}
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')")
@PutMapping(value = "/resource/{id}/data", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public TbResourceInfo updateResourceData(@Parameter(description = "Unique identifier of the Resource to update", required = true)
@PathVariable UUID id,
@Parameter(description = "Resource file.")
@RequestPart MultipartFile file) throws Exception {
TbResourceId tbResourceId = new TbResourceId(id);
TbResource resource = checkResourceId(tbResourceId, Operation.WRITE);
resource.setFileName(file.getOriginalFilename());
resource.setData(file.getBytes());
return tbResourceService.save(resource, getCurrentUser());
}
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')")
@PutMapping("/resource/{id}/info")
public TbResourceInfo updateResourceInfo(@Parameter(description = "Unique identifier of the Resource to update", required = true)
@PathVariable UUID id,
@Parameter(description = "A JSON value representing the Resource Info.")
@RequestBody TbResourceInfo resourceInfo) throws Exception {
TbResourceId tbResourceId = new TbResourceId(id);
checkResourceInfoId(tbResourceId, Operation.WRITE);
resourceInfo.setId(tbResourceId);
TbResource resource = new TbResource(resourceInfo);
return tbResourceService.save(resource, getCurrentUser());
}
@ApiOperation(value = "Get Resource Infos (getResources)", @ApiOperation(value = "Get Resource Infos (getResources)",
notes = "Returns a page of Resource Info objects owned by tenant or sysadmin. " + notes = "Returns a page of Resource Info objects owned by tenant or sysadmin. " +
PAGE_DATA_PARAMETERS + RESOURCE_INFO_DESCRIPTION + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) PAGE_DATA_PARAMETERS + RESOURCE_INFO_DESCRIPTION + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH)

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

@ -75,7 +75,7 @@ public class DefaultTbResourceService extends AbstractTbEntityService implements
ActionType actionType = resource.getId() == null ? ActionType.ADDED : ActionType.UPDATED; ActionType actionType = resource.getId() == null ? ActionType.ADDED : ActionType.UPDATED;
TenantId tenantId = resource.getTenantId(); TenantId tenantId = resource.getTenantId();
try { try {
if (ResourceType.LWM2M_MODEL.equals(resource.getResourceType())) { if (ResourceType.LWM2M_MODEL.equals(resource.getResourceType()) && resource.getId() == null) {
toLwm2mResource(resource); toLwm2mResource(resource);
} else if (resource.getResourceKey() == null) { } else if (resource.getResourceKey() == null) {
resource.setResourceKey(resource.getFileName()); resource.setResourceKey(resource.getFileName());

2
application/src/main/java/org/thingsboard/server/service/resource/TbResourceService.java

@ -19,8 +19,8 @@ import org.thingsboard.server.common.data.Dashboard;
import org.thingsboard.server.common.data.ResourceExportData; import org.thingsboard.server.common.data.ResourceExportData;
import org.thingsboard.server.common.data.TbResource; import org.thingsboard.server.common.data.TbResource;
import org.thingsboard.server.common.data.TbResourceDeleteResult; import org.thingsboard.server.common.data.TbResourceDeleteResult;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.TbResourceInfo; import org.thingsboard.server.common.data.TbResourceInfo;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.lwm2m.LwM2mObject; import org.thingsboard.server.common.data.lwm2m.LwM2mObject;

4
application/src/main/java/org/thingsboard/server/utils/LwM2mObjectModelUtils.java

@ -73,7 +73,7 @@ public class LwM2mObjectModelUtils {
try { try {
List<ObjectModel> objectModels = List<ObjectModel> objectModels =
ddfFileParser.parse(new ByteArrayInputStream(resource.getData()), resource.getSearchText()); ddfFileParser.parse(new ByteArrayInputStream(resource.getData()), resource.getSearchText());
if (objectModels.size() == 0) { if (objectModels.isEmpty()) {
return null; return null;
} else { } else {
ObjectModel obj = objectModels.get(0); ObjectModel obj = objectModels.get(0);
@ -95,7 +95,7 @@ public class LwM2mObjectModelUtils {
resources.add(lwM2MResourceObserve); resources.add(lwM2MResourceObserve);
} }
}); });
if (isSave || resources.size() > 0) { if (isSave || !resources.isEmpty()) {
instance.setResources(resources.toArray(LwM2mResourceObserve[]::new)); instance.setResources(resources.toArray(LwM2mResourceObserve[]::new));
lwM2mObject.setInstances(new LwM2mInstance[]{instance}); lwM2mObject.setInstances(new LwM2mInstance[]{instance});
return lwM2mObject; return lwM2mObject;

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

@ -25,11 +25,12 @@ import org.junit.Before;
import org.junit.Test; import org.junit.Test;
import org.mockito.Mockito; import org.mockito.Mockito;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.mock.web.MockPart;
import org.springframework.test.web.servlet.ResultActions; import org.springframework.test.web.servlet.ResultActions;
import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.Dashboard; 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.Device;
import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.EntityInfo;
import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityType;
@ -47,15 +48,14 @@ import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.common.data.security.Authority;
import org.thingsboard.server.common.data.widget.WidgetType; import org.thingsboard.server.common.data.widget.WidgetType;
import org.thingsboard.server.common.data.widget.WidgetTypeDetails; import org.thingsboard.server.common.data.widget.WidgetTypeDetails;
import org.thingsboard.server.common.data.widget.WidgetTypeInfo;
import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.exception.DataValidationException;
import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.dao.service.DaoSqlTest;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Base64; import java.util.Base64;
import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Objects;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.containsString;
@ -64,7 +64,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
@DaoSqlTest @DaoSqlTest
public class TbResourceControllerTest extends AbstractControllerTest { public class TbResourceControllerTest extends AbstractControllerTest {
private IdComparator<TbResourceInfo> idComparator = new IdComparator<>(); private final IdComparator<TbResourceInfo> idComparator = new IdComparator<>();
private static final String DEFAULT_FILE_NAME = "test.jks"; private static final String DEFAULT_FILE_NAME = "test.jks";
private static final String DEFAULT_FILE_NAME_2 = "test2.jks"; private static final String DEFAULT_FILE_NAME_2 = "test2.jks";
@ -126,13 +126,10 @@ public class TbResourceControllerTest extends AbstractControllerTest {
Assert.assertEquals(DEFAULT_FILE_NAME, savedResource.getResourceKey()); Assert.assertEquals(DEFAULT_FILE_NAME, savedResource.getResourceKey());
Assert.assertArrayEquals(resource.getData(), download(savedResource.getId())); Assert.assertArrayEquals(resource.getData(), download(savedResource.getId()));
TbResource foundResource = doGet("/api/resource/" + savedResource.getId().getId().toString(), TbResource.class); String resourceTitle = "My new resource";
foundResource.setTitle("My new resource"); savedResource.setTitle(resourceTitle);
foundResource.setData(null); savedResource = doPut("/api/resource/" + savedResource.getUuidId() + "/info", savedResource, TbResourceInfo.class);
assertThat(savedResource.getTitle()).isEqualTo(resourceTitle);
savedResource = save(foundResource);
Assert.assertEquals(foundResource.getTitle(), savedResource.getTitle());
testNotifyEntityAllOneTimeLogEntityActionEntityEqClass(savedResource, savedResource.getId(), savedResource.getId(), testNotifyEntityAllOneTimeLogEntityActionEntityEqClass(savedResource, savedResource.getId(), savedResource.getId(),
savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(),
@ -501,8 +498,8 @@ public class TbResourceControllerTest extends AbstractControllerTest {
savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(),
ActionType.ADDED, cntEntity, cntEntity, cntEntity); ActionType.ADDED, cntEntity, cntEntity, cntEntity);
Collections.sort(resources, idComparator); resources.sort(idComparator);
Collections.sort(loadedResources, idComparator); loadedResources.sort(idComparator);
Assert.assertEquals(resources, loadedResources); Assert.assertEquals(resources, loadedResources);
} }
@ -549,8 +546,8 @@ public class TbResourceControllerTest extends AbstractControllerTest {
savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ADDED, savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ADDED,
jksCntEntity + lwm2mCntEntity, jksCntEntity + lwm2mCntEntity, jksCntEntity + lwm2mCntEntity); jksCntEntity + lwm2mCntEntity, jksCntEntity + lwm2mCntEntity, jksCntEntity + lwm2mCntEntity);
Collections.sort(resources, idComparator); resources.sort(idComparator);
Collections.sort(loadedResources, idComparator); loadedResources.sort(idComparator);
Assert.assertEquals(resources, loadedResources); Assert.assertEquals(resources, loadedResources);
} }
@ -581,8 +578,8 @@ public class TbResourceControllerTest extends AbstractControllerTest {
} }
} while (pageData.hasNext()); } while (pageData.hasNext());
Collections.sort(resources, idComparator); resources.sort(idComparator);
Collections.sort(loadedResources, idComparator); loadedResources.sort(idComparator);
Assert.assertEquals(resources, loadedResources); Assert.assertEquals(resources, loadedResources);
@ -654,8 +651,8 @@ public class TbResourceControllerTest extends AbstractControllerTest {
} }
} while (pageData.hasNext()); } while (pageData.hasNext());
Collections.sort(jksResources, idComparator); jksResources.sort(idComparator);
Collections.sort(loadedResources, idComparator); loadedResources.sort(idComparator);
Assert.assertEquals(jksResources, loadedResources); Assert.assertEquals(jksResources, loadedResources);
@ -736,8 +733,8 @@ public class TbResourceControllerTest extends AbstractControllerTest {
} }
} while (pageData.hasNext()); } while (pageData.hasNext());
Collections.sort(expectedResources, idComparator); expectedResources.sort(idComparator);
Collections.sort(loadedResources, idComparator); loadedResources.sort(idComparator);
Assert.assertEquals(expectedResources, loadedResources); Assert.assertEquals(expectedResources, loadedResources);
@ -770,7 +767,7 @@ public class TbResourceControllerTest extends AbstractControllerTest {
MockHttpServletResponse response = resultActions.andReturn().getResponse(); MockHttpServletResponse response = resultActions.andReturn().getResponse();
String eTag = response.getHeader("ETag"); String eTag = response.getHeader("ETag");
Assert.assertNotNull(eTag); Assert.assertNotNull(eTag);
Assert.assertEquals(Base64.getEncoder().encodeToString(response.getContentAsByteArray()), TEST_DATA); Assert.assertEquals(TEST_DATA, Base64.getEncoder().encodeToString(response.getContentAsByteArray()));
//download with if-none-match header //download with if-none-match header
HttpHeaders headers = new HttpHeaders(); HttpHeaders headers = new HttpHeaders();
@ -814,7 +811,7 @@ public class TbResourceControllerTest extends AbstractControllerTest {
MockHttpServletResponse response = resultActions.andReturn().getResponse(); MockHttpServletResponse response = resultActions.andReturn().getResponse();
String eTag = response.getHeader("ETag"); String eTag = response.getHeader("ETag");
Assert.assertNotNull(eTag); Assert.assertNotNull(eTag);
Assert.assertEquals(Base64.getEncoder().encodeToString(response.getContentAsByteArray()), TEST_DATA); Assert.assertEquals(TEST_DATA, Base64.getEncoder().encodeToString(response.getContentAsByteArray()));
//download with if-none-match header //download with if-none-match header
HttpHeaders headers = new HttpHeaders(); HttpHeaders headers = new HttpHeaders();
@ -859,10 +856,10 @@ public class TbResourceControllerTest extends AbstractControllerTest {
.andExpect(status().isBadRequest()) .andExpect(status().isBadRequest())
.andExpect(statusReason(containsString("can't be updated"))); .andExpect(statusReason(containsString("can't be updated")));
foundResource.setData(null); String resourceTitle = "Updated resource";
foundResource.setTitle("Updated resource"); savedResource.setTitle(resourceTitle);
savedResource = doPost("/api/resource", foundResource, TbResource.class); savedResource = doPut("/api/resource/" + savedResource.getUuidId() + "/info", savedResource, TbResourceInfo.class);
assertThat(savedResource.getTitle()).isEqualTo("Updated resource"); assertThat(savedResource.getTitle()).isEqualTo(resourceTitle);
assertThat(savedResource.getFileName()).isEqualTo(resource.getFileName()); assertThat(savedResource.getFileName()).isEqualTo(resource.getFileName());
assertThat(savedResource.getEtag()).isEqualTo(resource.getEtag()); assertThat(savedResource.getEtag()).isEqualTo(resource.getEtag());
assertThat(download(savedResource.getId())).asBase64Encoded().isEqualTo(TEST_DATA); assertThat(download(savedResource.getId())).asBase64Encoded().isEqualTo(TEST_DATA);
@ -923,8 +920,20 @@ public class TbResourceControllerTest extends AbstractControllerTest {
} }
private TbResourceInfo save(TbResource tbResource) throws Exception { private TbResourceInfo save(TbResource tbResource) throws Exception {
return doPostWithTypedResponse("/api/resource", tbResource, new TypeReference<>() { byte[] data = tbResource.getData() != null ? tbResource.getData() : tbResource.getEncodedData() != null ? Base64.getDecoder().decode(tbResource.getEncodedData()) : null;
}); List<MockPart> parts = new ArrayList<>();
parts.add(new MockPart("resourceType", tbResource.getResourceType().name().getBytes()));
if (tbResource.getTitle() != null) {
parts.add(new MockPart("title", tbResource.getTitle().getBytes()));
}
if (tbResource.getDescriptor() != null) {
parts.add(new MockPart("descriptor", tbResource.getDescriptor().toString().getBytes()));
}
if (tbResource.getResourceSubType() != null) {
parts.add(new MockPart("resourceSubType", tbResource.getResourceSubType().name().getBytes()));
}
return uploadResource(HttpMethod.POST, "/api/resource/upload", tbResource.getFileName(), tbResource.getResourceType().getMediaType(), data, parts);
} }
private TbResourceInfo findResourceInfo(TbResourceId id) throws Exception { private TbResourceInfo findResourceInfo(TbResourceId id) throws Exception {
@ -949,7 +958,7 @@ public class TbResourceControllerTest extends AbstractControllerTest {
for (String model : models) { for (String model : models) {
String fileName = model + ".xml"; String fileName = model + ".xml";
byte[] bytes = IOUtils.toByteArray(getClass().getClassLoader().getResourceAsStream("lwm2m/" + fileName)); byte[] bytes = IOUtils.toByteArray(Objects.requireNonNull(getClass().getClassLoader().getResourceAsStream("lwm2m/" + fileName)));
TbResource resource = new TbResource(); TbResource resource = new TbResource();
resource.setResourceType(ResourceType.LWM2M_MODEL); resource.setResourceType(ResourceType.LWM2M_MODEL);

2
common/data/src/main/java/org/thingsboard/server/common/data/TbResourceInfo.java

@ -28,6 +28,7 @@ import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.validation.Length; import org.thingsboard.server.common.data.validation.Length;
import org.thingsboard.server.common.data.validation.NoXss; import org.thingsboard.server.common.data.validation.NoXss;
import java.io.Serial;
import java.util.function.UnaryOperator; import java.util.function.UnaryOperator;
@Schema @Schema
@ -36,6 +37,7 @@ import java.util.function.UnaryOperator;
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class TbResourceInfo extends BaseData<TbResourceId> implements HasName, HasTenantId, ExportableEntity<TbResourceId> { public class TbResourceInfo extends BaseData<TbResourceId> implements HasName, HasTenantId, ExportableEntity<TbResourceId> {
@Serial
private static final long serialVersionUID = 7282664529021651736L; private static final long serialVersionUID = 7282664529021651736L;
@Schema(description = "JSON object with Tenant Id. Tenant Id of the resource can't be changed.", accessMode = Schema.AccessMode.READ_ONLY) @Schema(description = "JSON object with Tenant Id. Tenant Id of the resource can't be changed.", accessMode = Schema.AccessMode.READ_ONLY)

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

@ -91,7 +91,9 @@ import static org.thingsboard.server.dao.service.Validator.validateId;
@Primary @Primary
public class BaseResourceService extends AbstractCachedEntityService<ResourceInfoCacheKey, TbResourceInfo, ResourceInfoEvictEvent> implements ResourceService { public class BaseResourceService extends AbstractCachedEntityService<ResourceInfoCacheKey, TbResourceInfo, ResourceInfoEvictEvent> implements ResourceService {
public static final String INCORRECT_RESOURCE_ID = "Incorrect resourceId "; protected static final String INCORRECT_RESOURCE_ID = "Incorrect resourceId ";
protected static final int MAX_ENTITIES_TO_FIND = 10;
protected final TbResourceDao resourceDao; protected final TbResourceDao resourceDao;
protected final TbResourceInfoDao resourceInfoDao; protected final TbResourceInfoDao resourceInfoDao;
protected final ResourceDataValidator resourceValidator; protected final ResourceDataValidator resourceValidator;
@ -100,7 +102,6 @@ public class BaseResourceService extends AbstractCachedEntityService<ResourceInf
protected final RuleChainDao ruleChainDao; protected final RuleChainDao ruleChainDao;
private final Map<EntityType, ResourceContainerDao<?>> resourceLinkContainerDaoMap = new HashMap<>(); private final Map<EntityType, ResourceContainerDao<?>> resourceLinkContainerDaoMap = new HashMap<>();
private final Map<EntityType, ResourceContainerDao<?>> generalResourceContainerDaoMap = new HashMap<>(); private final Map<EntityType, ResourceContainerDao<?>> generalResourceContainerDaoMap = new HashMap<>();
protected static final int MAX_ENTITIES_TO_FIND = 10;
@PostConstruct @PostConstruct
public void init() { public void init() {

9
dao/src/main/java/org/thingsboard/server/dao/service/validator/ResourceDataValidator.java

@ -17,9 +17,11 @@ package org.thingsboard.server.dao.service.validator;
import org.apache.commons.io.FileUtils; import org.apache.commons.io.FileUtils;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.Strings;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy; import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.thingsboard.server.common.data.ResourceType;
import org.thingsboard.server.common.data.TbResource; import org.thingsboard.server.common.data.TbResource;
import org.thingsboard.server.common.data.id.TbResourceId; import org.thingsboard.server.common.data.id.TbResourceId;
import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantId;
@ -54,8 +56,8 @@ public class ResourceDataValidator extends DataValidator<TbResource> {
@Override @Override
protected TbResource validateUpdate(TenantId tenantId, TbResource resource) { protected TbResource validateUpdate(TenantId tenantId, TbResource resource) {
if (resource.getData() != null && !resource.getResourceType().isUpdatable() && if ((resource.getData() != null && !resource.getResourceType().isUpdatable() && tenantId != null && !tenantId.isSysTenantId())
tenantId != null && !tenantId.isSysTenantId()) { || resource.getResourceType().equals(ResourceType.LWM2M_MODEL)) {
throw new DataValidationException("This type of resource can't be updated"); throw new DataValidationException("This type of resource can't be updated");
} }
return resource; return resource;
@ -81,7 +83,7 @@ public class ResourceDataValidator extends DataValidator<TbResource> {
if (StringUtils.isEmpty(resource.getFileName())) { if (StringUtils.isEmpty(resource.getFileName())) {
throw new DataValidationException("Resource file name should be specified!"); throw new DataValidationException("Resource file name should be specified!");
} }
if (StringUtils.containsAny(resource.getFileName(), "/", "\\")) { if (Strings.CS.containsAny(resource.getFileName(), "/", "\\")) {
throw new DataValidationException("File name contains forbidden symbols"); throw new DataValidationException("File name contains forbidden symbols");
} }
if (StringUtils.isEmpty(resource.getResourceKey())) { if (StringUtils.isEmpty(resource.getResourceKey())) {
@ -104,4 +106,5 @@ public class ResourceDataValidator extends DataValidator<TbResource> {
validateMaxSumDataSizePerTenant(tenantId, resourceDao, maxSumResourcesDataInBytes, dataSize, TB_RESOURCE); validateMaxSumDataSizePerTenant(tenantId, resourceDao, maxSumResourcesDataInBytes, dataSize, TB_RESOURCE);
} }
} }
} }

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

@ -17,7 +17,7 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { PageLink } from '@shared/models/page/page-link'; import { PageLink } from '@shared/models/page/page-link';
import { defaultHttpOptionsFromConfig, RequestConfig } from '@core/http/http-utils'; import { defaultHttpOptionsFromConfig, defaultHttpUploadOptions, RequestConfig } from '@core/http/http-utils';
import { forkJoin, Observable, of } from 'rxjs'; import { forkJoin, Observable, of } from 'rxjs';
import { PageData } from '@shared/models/page/page-data'; import { PageData } from '@shared/models/page/page-data';
import { Resource, ResourceInfo, ResourceSubType, ResourceType, TBResourceScope } from '@shared/models/resource.models'; import { Resource, ResourceInfo, ResourceSubType, ResourceType, TBResourceScope } from '@shared/models/resource.models';
@ -90,6 +90,54 @@ export class ResourceService {
return this.http.post<Resource>('/api/resource', resource, defaultHttpOptionsFromConfig(config)); return this.http.post<Resource>('/api/resource', resource, defaultHttpOptionsFromConfig(config));
} }
public uploadResources(resources: Resource[], config?: RequestConfig): Observable<Resource[]> {
let partSize = 100;
partSize = resources.length > partSize ? partSize : resources.length;
const resourceObservables: Observable<Resource>[] = [];
for (let i = 0; i < partSize; i++) {
resourceObservables.push(this.uploadResource(resources[i], config).pipe(catchError(() => of({} as Resource))));
}
return forkJoin(resourceObservables).pipe(
mergeMap((resource) => {
resources.splice(0, partSize);
if (resources.length) {
return this.uploadResources(resources, config);
} else {
return of(resource);
}
})
);
}
public uploadResource(resource: Resource, config?: RequestConfig): Observable<Resource> {
if (!config) {
config = {};
}
const formData = new FormData();
formData.append('file', resource.data);
formData.append('title', resource.title);
formData.append('resourceType', resource.resourceType);
if (resource.resourceSubType) {
formData.append('resourceSubType', resource.resourceSubType);
}
return this.http.post<Resource>('/api/resource/upload', formData,
defaultHttpUploadOptions(config.ignoreLoading, config.ignoreErrors, config.resendRequest));
}
public updatedResourceInfo(resourceId: string, updatedResources: Partial<Omit<Resource, 'data'>>, config?: RequestConfig): Observable<Resource> {
return this.http.put<Resource>(`/api/resource/${resourceId}/info`, updatedResources, defaultHttpOptionsFromConfig(config));
}
public updatedResourceData(resourceId: string, data: File, config?: RequestConfig): Observable<Resource> {
if (!config) {
config = {};
}
const formData = new FormData();
formData.append('file', data);
return this.http.put<Resource>(`/api/resource/${resourceId}/data`, formData,
defaultHttpUploadOptions(config.ignoreLoading, config.ignoreErrors, config.resendRequest));
}
public deleteResource(resourceId: string, force = false, config?: RequestConfig) { public deleteResource(resourceId: string, force = false, config?: RequestConfig) {
return this.http.delete(`/api/resource/${resourceId}?force=${force}`, defaultHttpOptionsFromConfig(config)); return this.http.delete(`/api/resource/${resourceId}?force=${force}`, defaultHttpOptionsFromConfig(config));
} }

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

@ -98,18 +98,17 @@ export class ResourcesDialogComponent extends DialogComponent<ResourcesDialogCom
resources.push({ resources.push({
resourceType: resource.resourceType, resourceType: resource.resourceType,
data, data,
fileName: resource.fileName[index],
title: resource.title title: resource.title
}); });
}); });
this.resourceService.saveResources(resources, {resendRequest: true}).pipe( this.resourceService.uploadResources(resources, {resendRequest: true}).pipe(
map((response) => response[0]) map((response) => response[0])
).subscribe(result => this.dialogRef.close(result)); ).subscribe(result => this.dialogRef.close(result));
} else { } else {
if (resource.resourceType !== ResourceType.GENERAL) { if (resource.resourceType !== ResourceType.GENERAL) {
delete resource.descriptor; delete resource.descriptor;
} }
this.resourceService.saveResource(resource).subscribe(result => this.dialogRef.close(result)); this.resourceService.uploadResource(resource).subscribe(result => this.dialogRef.close(result));
} }
} }
} }

2
ui-ngx/src/app/modules/home/components/resources/resources-library.component.html

@ -70,7 +70,7 @@
<tb-file-input formControlName="data" <tb-file-input formControlName="data"
required required
label="{{ (entityForm.get('resourceType').value === resourceType.LWM2M_MODEL ? 'resource.resource-files' : 'resource.resource-file') | translate }}" label="{{ (entityForm.get('resourceType').value === resourceType.LWM2M_MODEL ? 'resource.resource-files' : 'resource.resource-file') | translate }}"
[readAsBinary]="true" [workFromFileObj]="true"
[maxSizeByte]="maxResourceSize" [maxSizeByte]="maxResourceSize"
[allowedExtensions]="getAllowedExtensions()" [allowedExtensions]="getAllowedExtensions()"
[contentConvertFunction]="convertToBase64File" [contentConvertFunction]="convertToBase64File"

2
ui-ngx/src/app/modules/home/components/resources/resources-library.component.ts

@ -89,7 +89,7 @@ export class ResourcesLibraryComponent extends EntityComponent<Resource> impleme
return this.fb.group({ return this.fb.group({
title: [entity ? entity.title : '', [Validators.required, Validators.maxLength(255)]], title: [entity ? entity.title : '', [Validators.required, Validators.maxLength(255)]],
resourceType: [entity?.resourceType ? entity.resourceType : ResourceType.LWM2M_MODEL, Validators.required], resourceType: [entity?.resourceType ? entity.resourceType : ResourceType.LWM2M_MODEL, Validators.required],
fileName: [entity ? entity.fileName : null, Validators.required], fileName: [entity ? entity.fileName : null],
data: [entity ? entity.data : null, this.isAdd ? [Validators.required] : []], data: [entity ? entity.data : null, this.isAdd ? [Validators.required] : []],
descriptor: this.fb.group({ descriptor: this.fb.group({
mediaType: [''] mediaType: ['']

19
ui-ngx/src/app/modules/home/pages/admin/resource/js-library-table-config.resolver.ts

@ -32,7 +32,7 @@ import {
ResourceType, ResourceType,
toResourceDeleteResult toResourceDeleteResult
} from '@shared/models/resource.models'; } from '@shared/models/resource.models';
import { EntityType, entityTypeResources } from '@shared/models/entity-type.models'; import { EntityType } from '@shared/models/entity-type.models';
import { NULL_UUID } from '@shared/models/id/has-uuid'; import { NULL_UUID } from '@shared/models/id/has-uuid';
import { DatePipe } from '@angular/common'; import { DatePipe } from '@angular/common';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
@ -47,7 +47,7 @@ import { JsLibraryTableHeaderComponent } from '@home/pages/admin/resource/js-lib
import { JsResourceComponent } from '@home/pages/admin/resource/js-resource.component'; import { JsResourceComponent } from '@home/pages/admin/resource/js-resource.component';
import { catchError, map, switchMap } from 'rxjs/operators'; import { catchError, map, switchMap } from 'rxjs/operators';
import { ResourceTabsComponent } from '@home/pages/admin/resource/resource-tabs.component'; import { ResourceTabsComponent } from '@home/pages/admin/resource/resource-tabs.component';
import { forkJoin, of } from 'rxjs'; import { forkJoin, Observable, of } from 'rxjs';
import { parseHttpErrorMessage } from '@core/utils'; import { parseHttpErrorMessage } from '@core/utils';
import { ActionNotificationShow } from '@core/notification/notification.actions'; import { ActionNotificationShow } from '@core/notification/notification.actions';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
@ -118,9 +118,20 @@ export class JsLibraryTableConfigResolver {
return this.resourceService.getResourceInfoById(id.id) return this.resourceService.getResourceInfoById(id.id)
} }
}; };
this.config.saveEntity = resource => { this.config.saveEntity = (resource: Resource, originalResource: Resource) => {
resource.resourceType = ResourceType.JS_MODULE; resource.resourceType = ResourceType.JS_MODULE;
let saveObservable = this.resourceService.saveResource(resource); let saveObservable: Observable<Resource>;
if (!originalResource) {
saveObservable = this.resourceService.uploadResource(resource);
} else {
const { data, ...resourceInfo } = resource;
saveObservable = this.resourceService.updatedResourceInfo(resource.id.id, resourceInfo);
if (data) {
saveObservable = saveObservable.pipe(
switchMap(() => this.resourceService.updatedResourceData(resource.id.id, data))
)
}
}
if (resource.resourceSubType === ResourceSubType.MODULE) { if (resource.resourceSubType === ResourceSubType.MODULE) {
saveObservable = saveObservable.pipe( saveObservable = saveObservable.pipe(
switchMap((saved) => this.resourceService.getResource(saved.id.id)) switchMap((saved) => this.resourceService.getResource(saved.id.id))

2
ui-ngx/src/app/modules/home/pages/admin/resource/js-resource.component.html

@ -70,7 +70,7 @@
formControlName="data" formControlName="data"
[required]="isAdd" [required]="isAdd"
label="{{ 'javascript.resource-file' | translate }}" label="{{ 'javascript.resource-file' | translate }}"
[readAsBinary]="true" [workFromFileObj]="true"
[maxSizeByte]="maxResourceSize" [maxSizeByte]="maxResourceSize"
[allowedExtensions]="getAllowedExtensions()" [allowedExtensions]="getAllowedExtensions()"
[contentConvertFunction]="convertToBase64File" [contentConvertFunction]="convertToBase64File"

8
ui-ngx/src/app/modules/home/pages/admin/resource/js-resource.component.ts

@ -82,7 +82,7 @@ export class JsResourceComponent extends EntityComponent<Resource> implements On
return this.fb.group({ return this.fb.group({
title: [entity ? entity.title : '', [Validators.required, Validators.maxLength(255)]], title: [entity ? entity.title : '', [Validators.required, Validators.maxLength(255)]],
resourceSubType: [entity?.resourceSubType ? entity.resourceSubType : ResourceSubType.EXTENSION, Validators.required], resourceSubType: [entity?.resourceSubType ? entity.resourceSubType : ResourceSubType.EXTENSION, Validators.required],
fileName: [entity ? entity.fileName : null, Validators.required], fileName: [entity ? entity.fileName : null],
data: [entity ? entity.data : null, this.isAdd ? [Validators.required] : []], data: [entity ? entity.data : null, this.isAdd ? [Validators.required] : []],
content: [entity?.data?.length ? base64toString(entity.data) : '', Validators.required] content: [entity?.data?.length ? base64toString(entity.data) : '', Validators.required]
}); });
@ -110,7 +110,9 @@ export class JsResourceComponent extends EntityComponent<Resource> implements On
if (!formValue.fileName) { if (!formValue.fileName) {
formValue.fileName = formValue.title + '.js'; formValue.fileName = formValue.title + '.js';
} }
formValue.data = stringToBase64((formValue as any).content); formValue.data = new File([(formValue as any).content], formValue.fileName, {
type: 'text/javascript'
});
delete (formValue as any).content; delete (formValue as any).content;
} }
return super.prepareFormValue(formValue); return super.prepareFormValue(formValue);
@ -125,7 +127,7 @@ export class JsResourceComponent extends EntityComponent<Resource> implements On
} }
convertToBase64File(data: string): string { convertToBase64File(data: string): string {
return window.btoa(data); return stringToBase64(data);
} }
onResourceIdCopied(): void { onResourceIdCopied(): void {

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

@ -24,7 +24,8 @@ import {
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { import {
Resource, Resource,
ResourceInfo, ResourceInfoWithReferences, ResourceInfo,
ResourceInfoWithReferences,
ResourceType, ResourceType,
ResourceTypeTranslationMap, ResourceTypeTranslationMap,
toResourceDeleteResult toResourceDeleteResult
@ -41,10 +42,10 @@ import { Authority } from '@shared/models/authority.enum';
import { ResourcesLibraryComponent } from '@home/components/resources/resources-library.component'; import { ResourcesLibraryComponent } from '@home/components/resources/resources-library.component';
import { PageLink } from '@shared/models/page/page-link'; import { PageLink } from '@shared/models/page/page-link';
import { EntityAction } from '@home/models/entity/entity-component.models'; import { EntityAction } from '@home/models/entity/entity-component.models';
import { catchError, map } from 'rxjs/operators'; import { catchError, map, switchMap } from 'rxjs/operators';
import { ResourcesTableHeaderComponent } from '@home/pages/admin/resource/resources-table-header.component'; import { ResourcesTableHeaderComponent } from '@home/pages/admin/resource/resources-table-header.component';
import { ResourceLibraryTabsComponent } from '@home/pages/admin/resource/resource-library-tabs.component'; import { ResourceLibraryTabsComponent } from '@home/pages/admin/resource/resource-library-tabs.component';
import { forkJoin, of } from "rxjs"; import { forkJoin, Observable, of } from "rxjs";
import { import {
ResourcesInUseDialogComponent, ResourcesInUseDialogComponent,
ResourcesInUseDialogData ResourcesInUseDialogData
@ -114,27 +115,36 @@ export class ResourcesLibraryTableConfigResolver {
this.config.entitiesFetchFunction = pageLink => this.resourceService.getResources(pageLink, this.config.componentsData.resourceType); this.config.entitiesFetchFunction = pageLink => this.resourceService.getResources(pageLink, this.config.componentsData.resourceType);
this.config.loadEntity = id => this.resourceService.getResourceInfoById(id.id); this.config.loadEntity = id => this.resourceService.getResourceInfoById(id.id);
this.config.saveEntity = resource => this.saveResource(resource); this.config.saveEntity = (resource, originalResource) => this.saveResource(resource, originalResource);
this.config.onEntityAction = action => this.onResourceAction(action); this.config.onEntityAction = action => this.onResourceAction(action);
} }
saveResource(resource) { saveResource(resource: Resource & {data?: File | File[]}, originalResource: Resource) {
if (Array.isArray(resource.data)) { if (Array.isArray(resource.data)) {
const resources = []; const resources = [];
resource.data.forEach((data, index) => { resource.data.forEach((data, index) => {
resources.push({ resources.push({
resourceType: resource.resourceType, resourceType: resource.resourceType,
data, data,
fileName: resource.fileName[index],
title: resource.title title: resource.title
}); });
}); });
return this.resourceService.saveResources(resources, {resendRequest: true}).pipe( return this.resourceService.uploadResources(resources, {resendRequest: true}).pipe(
map((response) => response[0]) map((response) => response[0])
); );
} else if (!originalResource) {
return this.resourceService.uploadResource(resource);
} else { } else {
return this.resourceService.saveResource(resource); const { data, ...resourceInfo } = resource;
let saveObservable: Observable<Resource>;
saveObservable = this.resourceService.updatedResourceInfo(resource.id.id, resourceInfo);
if (data) {
saveObservable = saveObservable.pipe(
switchMap(() => this.resourceService.updatedResourceData(resource.id.id, data))
)
}
return saveObservable;
} }
} }

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

@ -180,17 +180,18 @@ export class FileInputComponent extends PageComponent implements AfterViewInit,
if (readers.length) { if (readers.length) {
Promise.all(readers).then((files) => { Promise.all(readers).then((files) => {
files = files.filter(file => file.fileContent != null || file.files != null); const validResults = files.filter(file => file.fileContent != null || file.files != null);
if (files.length === 1) {
this.fileContent = files[0].fileContent; if (validResults.length === 1) {
this.fileName = files[0].fileName; this.fileContent = validResults[0].fileContent;
this.files = files[0].files; this.fileName = validResults[0].fileName;
this.mediaType = files[0].mediaType; this.files = validResults[0].files;
this.mediaType = validResults[0].mediaType;
this.updateModel(); this.updateModel();
} else if (files.length > 1) { } else if (validResults.length > 1) {
this.fileContent = files.map(content => content.fileContent); this.fileContent = validResults.map(content => content.fileContent);
this.fileName = files.map(content => content.fileName); this.fileName = validResults.map(content => content.fileName);
this.files = files.map(content => content.files); this.files = validResults.map(content => content.files);
this.updateModel(); this.updateModel();
} }
}); });
@ -204,29 +205,32 @@ export class FileInputComponent extends PageComponent implements AfterViewInit,
private readerAsFile(file: flowjs.FlowFile): Promise<any> { private readerAsFile(file: flowjs.FlowFile): Promise<any> {
return new Promise((resolve) => { return new Promise((resolve) => {
if (this.workFromFileObj) {
resolve({
fileContent: null,
fileName: file.name,
files: file.file,
mediaType: file.file.type || null
});
return;
}
const reader = new FileReader(); const reader = new FileReader();
reader.onload = () => { reader.onload = () => {
let fileName = null; let fileName = null;
let fileContent = null; let fileContent = null;
let files = null;
let mediaType = null; let mediaType = null;
if (reader.readyState === reader.DONE) { if (reader.readyState === reader.DONE) {
if (!this.workFromFileObj) { fileContent = reader.result;
fileContent = reader.result; if (fileContent && fileContent.length > 0) {
if (fileContent && fileContent.length > 0) { if (this.contentConvertFunction) {
if (this.contentConvertFunction) { fileContent = this.contentConvertFunction(fileContent);
fileContent = this.contentConvertFunction(fileContent);
}
fileName = fileContent ? file.name : null;
mediaType = file?.file?.type || null;
} }
} else if (file.name || file.file){ fileName = fileContent ? file.name : null;
files = file.file; mediaType = file?.file?.type || null;
fileName = file.name;
mediaType = file.file.type || null;
} }
} }
resolve({fileContent, fileName, files, mediaType}); resolve({fileContent, fileName, files: null, mediaType});
}; };
reader.onerror = () => { reader.onerror = () => {
resolve({fileContent: null, fileName: null, files: null, mediaType: null}); resolve({fileContent: null, fileName: null, files: null, mediaType: null});

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

@ -89,7 +89,7 @@ export interface TbResourceInfo<D> extends Omit<BaseData<TbResourceId>, 'name' |
export type ResourceInfo = TbResourceInfo<any>; export type ResourceInfo = TbResourceInfo<any>;
export interface Resource extends ResourceInfo { export interface Resource extends ResourceInfo {
data?: string; data?: any;
name?: string; name?: string;
} }

Loading…
Cancel
Save