Browse Source

Merge pull request #15682 from pinkevmladchy/add-related-items

Add related items logic
pull/15193/head
Igor Kulikov 3 days ago
committed by GitHub
parent
commit
b53ca8ca6e
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 19
      application/src/main/java/org/thingsboard/server/controller/IotHubController.java
  2. 353
      application/src/main/java/org/thingsboard/server/service/iot_hub/DefaultIotHubService.java
  3. 32
      application/src/main/java/org/thingsboard/server/service/iot_hub/InstallPlan.java
  4. 42
      application/src/main/java/org/thingsboard/server/service/iot_hub/InstallPlanEntry.java
  5. 38
      application/src/main/java/org/thingsboard/server/service/iot_hub/InstallPlanResult.java
  6. 19
      application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubRestClient.java
  7. 4
      application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubService.java
  8. 368
      application/src/test/java/org/thingsboard/server/service/iot_hub/DefaultIotHubServiceTest.java
  9. 3
      dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemDao.java
  10. 3
      dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemService.java
  11. 6
      dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemServiceImpl.java
  12. 6
      dao/src/main/java/org/thingsboard/server/dao/sql/iot_hub/IotHubInstalledItemRepository.java
  13. 6
      dao/src/main/java/org/thingsboard/server/dao/sql/iot_hub/JpaIotHubInstalledItemDao.java
  14. 17
      ui-ngx/src/app/core/http/iot-hub-api.service.ts
  15. 97
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.html
  16. 89
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.scss
  17. 179
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.ts
  18. 31
      ui-ngx/src/app/shared/models/iot-hub/iot-hub-installed-item.models.ts
  19. 2
      ui-ngx/src/app/shared/models/iot-hub/iot-hub-version.models.ts
  20. 11
      ui-ngx/src/assets/locale/locale.constant-en_US.json

19
application/src/main/java/org/thingsboard/server/controller/IotHubController.java

@ -46,6 +46,8 @@ import org.thingsboard.server.dao.iot_hub.IotHubInstalledItemService;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.security.system.SystemSecurityService;
import org.thingsboard.server.service.iot_hub.InstallItemVersionResult;
import org.thingsboard.server.service.iot_hub.InstallPlan;
import org.thingsboard.server.service.iot_hub.InstallPlanResult;
import org.thingsboard.server.service.iot_hub.UpdateItemVersionResult;
import org.thingsboard.server.service.iot_hub.IotHubService;
@ -71,6 +73,23 @@ public class IotHubController extends BaseController {
return iotHubService.installItemVersion(getCurrentUser(), versionId, data, request);
}
@PreAuthorize("hasAuthority('TENANT_ADMIN')")
@GetMapping("/versions/{versionId}/installPlan")
@ResponseBody
public InstallPlan resolveInstallPlan(@PathVariable String versionId) throws ThingsboardException {
return iotHubService.resolveInstallPlan(getCurrentUser(), versionId);
}
@PreAuthorize("hasAuthority('TENANT_ADMIN')")
@PostMapping("/installPlan")
@ResponseBody
public InstallPlanResult installPlan(@RequestBody InstallPlanRequest body,
HttpServletRequest request) throws ThingsboardException {
return iotHubService.installPlan(getCurrentUser(), body.plan(), body.data(), request);
}
public record InstallPlanRequest(InstallPlan plan, JsonNode data) {}
@PreAuthorize("hasAuthority('TENANT_ADMIN')")
@PostMapping("/device/register")
@ResponseBody

353
application/src/main/java/org/thingsboard/server/service/iot_hub/DefaultIotHubService.java

@ -71,8 +71,11 @@ import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Set;
import java.util.UUID;
@Service
@ -110,55 +113,81 @@ public class DefaultIotHubService implements IotHubService {
private final TbDeviceService tbDeviceService;
private final SolutionService solutionService;
// Field names of the marketplace version JSON payload. Both the install path and the
// install-plan resolver parse the same shape, so the contract lives here in one place.
private static final String FIELD_ID = "id";
private static final String FIELD_ITEM_ID = "itemId";
private static final String FIELD_TYPE = "type";
private static final String FIELD_NAME = "name";
private static final String FIELD_VERSION = "version";
private static final String FIELD_RELATED_ITEMS = "relatedItems";
@Override
public InstallItemVersionResult installItemVersion(SecurityUser user, String versionId, JsonNode data, HttpServletRequest request) {
TenantId tenantId = user.getTenantId();
log.info("[{}] Installing IoT Hub item version: {}", tenantId, versionId);
try {
JsonNode versionInfo = iotHubRestClient.getVersionInfo(versionId);
if (versionInfo == null) {
throw new IllegalArgumentException("Failed to get version info from IoT Hub");
}
String itemType = versionInfo.get("type").asText();
String itemName = versionInfo.get("name").asText();
UUID itemId = UUID.fromString(versionInfo.get("itemId").asText());
String version = versionInfo.get("version").asText();
log.debug("[{}] Fetched version info: {} (type: {})", tenantId, itemName, itemType);
JsonNode versionInfo = iotHubRestClient.getVersionInfo(versionId);
if (versionInfo == null) {
throw new IllegalArgumentException("Failed to get version info from IoT Hub");
}
byte[] fileData = iotHubRestClient.getVersionFileData(versionId);
log.debug("[{}] Fetched file data, size: {} bytes", tenantId, fileData != null ? fileData.length : 0);
IotHubInstalledItemDescriptor descriptor = switch (itemType) {
case "WIDGET" -> installWidget(user, tenantId, fileData);
case "DASHBOARD" -> installDashboard(user, tenantId, fileData);
case "CALCULATED_FIELD" -> installCalculatedField(user, tenantId, fileData, data);
case "ALARM_RULE" -> throw new IllegalArgumentException(
"Alarm Rules require ThingsBoard 4.3 or later. Please update your platform instance to install Alarm Rule packages.");
case "RULE_CHAIN" -> installRuleChain(user, tenantId, fileData, data);
case "DEVICE" -> installDeviceProfile(user, tenantId, fileData);
case "SOLUTION_TEMPLATE" -> installSolution(user, tenantId, fileData, request);
default -> throw new IllegalArgumentException("Unsupported IoT Hub item type: " + itemType);
};
try {
IotHubInstalledItem installedItem = doInstallVersion(user, versionId, data, request);
return InstallItemVersionResult.success(installedItem.getDescriptor());
} catch (Exception e) {
log.error("[{}] Failed to install IoT Hub item version: {}", tenantId, versionId, e);
return InstallItemVersionResult.error(e.getMessage());
}
}
IotHubInstalledItem installedItem = new IotHubInstalledItem();
installedItem.setTenantId(tenantId);
installedItem.setItemId(itemId);
installedItem.setItemVersionId(UUID.fromString(versionId));
installedItem.setItemName(itemName);
installedItem.setItemType(itemType);
installedItem.setVersion(version);
installedItem.setDescriptor(descriptor);
iotHubInstalledItemService.save(tenantId, installedItem);
/**
* Fetch + apply a single marketplace version, persist the installed-item record, and ping
* the marketplace install counter. Throws on failure so callers (cascade install) can roll back.
* Package-private so the cascade-install / rollback orchestration can be unit-tested in isolation.
*/
IotHubInstalledItem doInstallVersion(SecurityUser user, String versionId, JsonNode data, HttpServletRequest request) throws Exception {
TenantId tenantId = user.getTenantId();
JsonNode versionInfo = iotHubRestClient.getVersionInfo(versionId);
String itemType = versionInfo.get(FIELD_TYPE).asText();
String itemName = versionInfo.get(FIELD_NAME).asText();
UUID itemId = UUID.fromString(versionInfo.get(FIELD_ITEM_ID).asText());
String version = versionInfo.get(FIELD_VERSION).asText();
log.debug("[{}] Fetched version info: {} (type: {})", tenantId, itemName, itemType);
byte[] fileData = iotHubRestClient.getVersionFileData(versionId);
log.debug("[{}] Fetched file data, size: {} bytes", tenantId, fileData != null ? fileData.length : 0);
IotHubInstalledItemDescriptor descriptor = switch (itemType) {
case "WIDGET" -> installWidget(user, tenantId, fileData);
case "DASHBOARD" -> installDashboard(user, tenantId, fileData);
case "CALCULATED_FIELD" -> installCalculatedField(user, tenantId, fileData, data);
case "ALARM_RULE" -> throw new IllegalArgumentException(
"Alarm Rules require ThingsBoard 4.3 or later. Please update your platform instance to install Alarm Rule packages.");
case "RULE_CHAIN" -> installRuleChain(user, tenantId, fileData, data);
case "DEVICE" -> installDeviceProfile(user, tenantId, fileData);
case "SOLUTION_TEMPLATE" -> installSolution(user, tenantId, fileData, request);
default -> throw new IllegalArgumentException("Unsupported IoT Hub item type: " + itemType);
};
IotHubInstalledItem installedItem = new IotHubInstalledItem();
installedItem.setTenantId(tenantId);
installedItem.setItemId(itemId);
installedItem.setItemVersionId(UUID.fromString(versionId));
installedItem.setItemName(itemName);
installedItem.setItemType(itemType);
installedItem.setVersion(version);
installedItem.setDescriptor(descriptor);
installedItem = iotHubInstalledItemService.save(tenantId, installedItem);
try {
iotHubRestClient.reportVersionInstalled(versionId);
log.info("[{}] Successfully installed IoT Hub item version: {} (type: {})", tenantId, itemName, itemType);
return InstallItemVersionResult.success(descriptor);
} catch (Exception e) {
log.error("[{}] Failed to install IoT Hub item version: {}", tenantId, versionId, e);
return InstallItemVersionResult.error(e.getMessage());
// Counter ping is best-effort — do not fail the install if it errors.
log.warn("[{}] Failed to report install counter for version {}: {}", tenantId, versionId, e.getMessage());
}
log.info("[{}] Successfully installed IoT Hub item version: {} (type: {})", tenantId, itemName, itemType);
return installedItem;
}
private WidgetInstalledItemDescriptor installWidget(SecurityUser user, TenantId tenantId, byte[] fileData) throws Exception {
@ -685,6 +714,254 @@ public class DefaultIotHubService implements IotHubService {
log.info("[{}] Deleted installed IoT Hub item: {}", tenantId, installedItem.getItemName());
}
@Override
public InstallPlan resolveInstallPlan(SecurityUser user, String versionId) {
TenantId tenantId = user.getTenantId();
log.debug("[{}] Resolving install plan for version: {}", tenantId, versionId);
JsonNode rootVersion = iotHubRestClient.getVersionInfo(versionId);
if (rootVersion == null) {
throw new IllegalArgumentException("Marketplace version not found: " + versionId);
}
// Null-safe: a malformed payload without an itemId must surface the friendly
// IllegalArgumentException thrown by addPlanEntry(root) below, not an NPE here.
String rootItemId = optText(rootVersion, FIELD_ITEM_ID);
JsonNode related = rootVersion.get(FIELD_RELATED_ITEMS);
// The plan only ever touches the root and its (one-level-deep) related items, so we ask the
// DB whether just those item ids are already installed instead of loading every installed id
// for the tenant — a tenant may have thousands installed while a plan checks a handful.
Set<UUID> candidateItemIds = new HashSet<>();
addCandidateItemId(candidateItemIds, rootItemId);
if (related != null && related.isArray()) {
for (JsonNode relatedNode : related) {
addCandidateItemId(candidateItemIds, relatedNode.asText());
}
}
Set<UUID> alreadyInstalledItemIds = candidateItemIds.isEmpty()
? Set.of()
: new HashSet<>(iotHubInstalledItemService.findInstalledItemIdsByTenantIdAndItemIdIn(tenantId, candidateItemIds));
// LinkedHashMap preserves insertion order; we add the related items (deps) before the
// root so a single forward iteration yields the correct install sequence (deps first,
// root last). Related items are leaf dependencies — IoT Hub items are only ever one
// level deep, so there is no need to walk a related item's own related items.
LinkedHashMap<String, InstallPlanEntry> entries = new LinkedHashMap<>();
if (related != null && related.isArray()) {
for (JsonNode relatedNode : related) {
String relatedItemId = relatedNode.asText();
if (relatedItemId == null || relatedItemId.isEmpty()
|| relatedItemId.equals(rootItemId) || entries.containsKey(relatedItemId)) {
continue;
}
JsonNode relatedVersion;
try {
relatedVersion = iotHubRestClient.getPublishedVersionByItemId(relatedItemId);
} catch (Exception e) {
log.warn("Failed to fetch related item {}: {}", relatedItemId, e.getMessage());
entries.put(relatedItemId, missingEntry(relatedItemId, e.getMessage()));
continue;
}
if (relatedVersion == null) {
log.warn("Related IoT Hub item {} is missing or unpublished — recording in plan", relatedItemId);
entries.put(relatedItemId, missingEntry(relatedItemId, "Item not found or not published"));
continue;
}
addPlanEntry(relatedVersion, alreadyInstalledItemIds, entries, false);
}
}
addPlanEntry(rootVersion, alreadyInstalledItemIds, entries, true);
return new InstallPlan(versionId, new ArrayList<>(entries.values()));
}
/**
* Build an {@link InstallPlanEntry} for a single marketplace version and append it to the
* plan. No traversal of {@code relatedItems} happens here related items are resolved one
* level deep by {@link #resolveInstallPlan}.
*/
private void addPlanEntry(JsonNode versionInfo,
Set<UUID> alreadyInstalledItemIds,
LinkedHashMap<String, InstallPlanEntry> entries,
boolean root) {
String itemId = optText(versionInfo, FIELD_ITEM_ID);
String versionId = optText(versionInfo, FIELD_ID);
if (itemId == null || versionId == null) {
// The marketplace payload is missing its identifiers. The root must have them to be
// installable; a related item without them is recorded as missing so the rest of the
// plan can still proceed.
if (root) {
throw new IllegalArgumentException("Marketplace version is missing required '"
+ FIELD_ITEM_ID + "'/'" + FIELD_ID + "' fields");
}
String key = itemId != null ? itemId : versionId;
log.warn("Related IoT Hub item is missing required identifiers — recording in plan: {}", versionInfo);
entries.putIfAbsent(key != null ? key : versionInfo.toString(),
missingEntry(itemId, "Item descriptor is incomplete"));
return;
}
if (entries.containsKey(itemId)) {
return;
}
InstallPlanEntry entry = new InstallPlanEntry();
entry.setItemId(itemId);
entry.setVersionId(versionId);
entry.setName(optText(versionInfo, FIELD_NAME));
entry.setType(optText(versionInfo, FIELD_TYPE));
entry.setVersion(optText(versionInfo, FIELD_VERSION));
entry.setRoot(root);
entry.setStatus(isAlreadyInstalled(itemId, alreadyInstalledItemIds)
? InstallPlanEntry.Status.ALREADY_INSTALLED
: InstallPlanEntry.Status.WILL_INSTALL);
entries.put(itemId, entry);
}
private static InstallPlanEntry missingEntry(String itemId, String errorMessage) {
InstallPlanEntry entry = new InstallPlanEntry();
entry.setItemId(itemId);
entry.setStatus(InstallPlanEntry.Status.MISSING);
entry.setErrorMessage(errorMessage);
return entry;
}
private static String optText(JsonNode node, String field) {
return node != null && node.hasNonNull(field) ? node.get(field).asText() : null;
}
@Override
public InstallPlanResult installPlan(SecurityUser user, InstallPlan plan, JsonNode data, HttpServletRequest request) {
TenantId tenantId = user.getTenantId();
if (plan == null || plan.getEntries() == null || plan.getEntries().isEmpty()) {
return new InstallPlanResult(false, false, "Install plan is empty", null, new ArrayList<>(), new ArrayList<>());
}
// The plan is resolved by a separate request, so by the time it is submitted an item may
// already have been installed (stale or replayed plan). Re-check the current state for only
// the items this plan would install, so we never install the same item twice.
Set<UUID> candidateItemIds = new HashSet<>();
for (InstallPlanEntry entry : plan.getEntries()) {
if (entry.getStatus() == InstallPlanEntry.Status.WILL_INSTALL) {
addCandidateItemId(candidateItemIds, entry.getItemId());
}
}
// Mutable on purpose: as the cascade installs each item we add its id below, so a later
// duplicate entry for the same item in this plan is recognised as installed and skipped
// instead of installed twice. A client-supplied plan is not de-duplicated the way a freshly
// resolved one is, so the loop has to guard against duplicates itself.
Set<UUID> alreadyInstalledItemIds = new HashSet<>();
if (!candidateItemIds.isEmpty()) {
alreadyInstalledItemIds.addAll(iotHubInstalledItemService.findInstalledItemIdsByTenantIdAndItemIdIn(tenantId, candidateItemIds));
}
InstallPlanResult result = new InstallPlanResult();
List<InstallPlanEntry> resultEntries = new ArrayList<>();
List<String> missingItemIds = new ArrayList<>();
List<IotHubInstalledItemId> rollbackIds = new ArrayList<>();
IotHubInstalledItemDescriptor rootDescriptor = null;
for (InstallPlanEntry entry : plan.getEntries()) {
InstallPlanEntry resultEntry = cloneEntry(entry);
switch (entry.getStatus()) {
case MISSING -> {
missingItemIds.add(entry.getItemId());
resultEntries.add(resultEntry);
}
case ALREADY_INSTALLED -> resultEntries.add(resultEntry);
case WILL_INSTALL -> {
if (isAlreadyInstalled(entry.getItemId(), alreadyInstalledItemIds)) {
// Installed since the plan was resolved — skip rather than create a duplicate.
resultEntry.setStatus(InstallPlanEntry.Status.ALREADY_INSTALLED);
resultEntries.add(resultEntry);
break;
}
try {
// Only the root entry receives the user's install data (target profile entityId);
// transitive deps install with defaults.
JsonNode entryData = entry.isRoot() ? data : null;
IotHubInstalledItem installed = doInstallVersion(user, entry.getVersionId(), entryData, request);
rollbackIds.add(installed.getId());
// Record it as installed so a duplicate entry for the same item later in this
// plan is skipped rather than installed a second time.
addCandidateItemId(alreadyInstalledItemIds, entry.getItemId());
if (entry.isRoot()) {
rootDescriptor = installed.getDescriptor();
}
resultEntries.add(resultEntry);
} catch (Exception e) {
log.error("[{}] Cascade install failed at entry {} ({}): {}", tenantId,
entry.getName(), entry.getVersionId(), e.getMessage(), e);
resultEntry.setErrorMessage(e.getMessage());
resultEntries.add(resultEntry);
boolean rolledBack = rollbackInstalledItems(user, rollbackIds);
result.setSuccess(false);
result.setRolledBack(rolledBack);
result.setErrorMessage("Failed to install '" + entry.getName() + "': " + e.getMessage());
result.setEntries(resultEntries);
result.setMissingItemIds(missingItemIds);
return result;
}
}
}
}
result.setSuccess(true);
result.setRolledBack(false);
result.setRootDescriptor(rootDescriptor);
result.setEntries(resultEntries);
result.setMissingItemIds(missingItemIds);
return result;
}
/**
* Deletes the supplied installed items in reverse install order. Returns {@code true} only if
* every deletion succeeded a {@code false} result means some entities were left behind and
* the rollback is partial.
*/
private boolean rollbackInstalledItems(SecurityUser user, List<IotHubInstalledItemId> installedIds) {
boolean fullyRolledBack = true;
for (int i = installedIds.size() - 1; i >= 0; i--) {
IotHubInstalledItemId id = installedIds.get(i);
try {
deleteInstalledItem(user, id);
} catch (Exception e) {
fullyRolledBack = false;
log.error("[{}] Failed to roll back installed item {}: {}", user.getTenantId(), id, e.getMessage(), e);
}
}
return fullyRolledBack;
}
private static void addCandidateItemId(Set<UUID> candidates, String itemId) {
if (itemId == null || itemId.isEmpty()) {
return;
}
try {
candidates.add(UUID.fromString(itemId));
} catch (IllegalArgumentException ignored) {
// A non-UUID item id can never match an installed record — leave it out of the lookup.
}
}
private static boolean isAlreadyInstalled(String itemId, Set<UUID> alreadyInstalledItemIds) {
if (itemId == null) {
return false;
}
try {
return alreadyInstalledItemIds.contains(UUID.fromString(itemId));
} catch (IllegalArgumentException ex) {
return false;
}
}
private static InstallPlanEntry cloneEntry(InstallPlanEntry src) {
return new InstallPlanEntry(src.getItemId(), src.getVersionId(), src.getName(), src.getType(),
src.getVersion(), src.getStatus(), src.isRoot(), src.getErrorMessage());
}
private static Exception parseFailure(String action, String itemTypeName, Exception cause) {
return parseFailure(action, itemTypeName, SECTION_PACKAGE_DATA, cause);
}

32
application/src/main/java/org/thingsboard/server/service/iot_hub/InstallPlan.java

@ -0,0 +1,32 @@
/**
* Copyright © 2016-2026 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.iot_hub;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class InstallPlan {
private String rootVersionId;
private List<InstallPlanEntry> entries;
}

42
application/src/main/java/org/thingsboard/server/service/iot_hub/InstallPlanEntry.java

@ -0,0 +1,42 @@
/**
* Copyright © 2016-2026 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.iot_hub;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class InstallPlanEntry {
public enum Status {
WILL_INSTALL,
ALREADY_INSTALLED,
MISSING
}
private String itemId;
private String versionId;
private String name;
private String type;
private String version;
private Status status;
private boolean root;
private String errorMessage;
}

38
application/src/main/java/org/thingsboard/server/service/iot_hub/InstallPlanResult.java

@ -0,0 +1,38 @@
/**
* Copyright © 2016-2026 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.iot_hub;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.thingsboard.server.common.data.iot_hub.IotHubInstalledItemDescriptor;
import java.util.ArrayList;
import java.util.List;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class InstallPlanResult {
private boolean success;
private boolean rolledBack;
private String errorMessage;
private IotHubInstalledItemDescriptor rootDescriptor;
private List<InstallPlanEntry> entries = new ArrayList<>();
private List<String> missingItemIds = new ArrayList<>();
}

19
application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubRestClient.java

@ -21,7 +21,9 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestTemplate;
import org.thingsboard.server.queue.util.TbCoreComponent;
@ -63,6 +65,23 @@ public class IotHubRestClient {
return restTemplate.getForObject(url, JsonNode.class);
}
/**
* Returns the latest published version of an item, or {@code null} if the marketplace
* responds with 404 (item missing or never published). Other 4xx/5xx still propagate.
*/
public JsonNode getPublishedVersionByItemId(String itemId) {
String url = baseUrl + "/api/items/" + itemId + "/published";
log.debug("Fetching IoT Hub published version for item: {}", url);
try {
return restTemplate.getForObject(url, JsonNode.class);
} catch (HttpClientErrorException e) {
if (e.getStatusCode() == HttpStatus.NOT_FOUND) {
return null;
}
throw e;
}
}
public byte[] getVersionFileData(String versionId) {
String url = baseUrl + "/api/versions/" + versionId + "/fileData";
log.debug("Fetching IoT Hub version file data: {}", url);

4
application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubService.java

@ -30,4 +30,8 @@ public interface IotHubService {
InstallItemVersionResult registerDeviceInstall(SecurityUser user, String versionId, DeviceInstalledItemDescriptor descriptor);
void deleteInstalledItem(SecurityUser user, IotHubInstalledItemId installedItemId);
InstallPlan resolveInstallPlan(SecurityUser user, String versionId);
InstallPlanResult installPlan(SecurityUser user, InstallPlan plan, JsonNode data, HttpServletRequest request);
}

368
application/src/test/java/org/thingsboard/server/service/iot_hub/DefaultIotHubServiceTest.java

@ -0,0 +1,368 @@
/**
* Copyright © 2016-2026 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.iot_hub;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import jakarta.servlet.http.HttpServletRequest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InOrder;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.id.IotHubInstalledItemId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.iot_hub.DashboardInstalledItemDescriptor;
import org.thingsboard.server.common.data.iot_hub.IotHubInstalledItem;
import org.thingsboard.server.dao.iot_hub.IotHubInstalledItemService;
import org.thingsboard.server.service.security.model.SecurityUser;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class DefaultIotHubServiceTest {
private final TenantId tenantId = TenantId.fromUUID(UUID.randomUUID());
private final SecurityUser user = mock(SecurityUser.class);
@Mock
private IotHubRestClient iotHubRestClient;
@Mock
private IotHubInstalledItemService iotHubInstalledItemService;
@Spy
@InjectMocks
private DefaultIotHubService service;
private void mockTenant() {
when(user.getTenantId()).thenReturn(tenantId);
}
private IotHubInstalledItem installedItem(UUID id) {
IotHubInstalledItem item = new IotHubInstalledItem();
item.setId(new IotHubInstalledItemId(id));
item.setTenantId(tenantId);
return item;
}
private InstallPlanEntry willInstall(String versionId, boolean root) {
InstallPlanEntry entry = new InstallPlanEntry();
entry.setItemId(UUID.randomUUID().toString());
entry.setVersionId(versionId);
entry.setName(root ? "Root" : "Dep");
entry.setStatus(InstallPlanEntry.Status.WILL_INSTALL);
entry.setRoot(root);
return entry;
}
private ObjectNode version(String id, String itemId, String type, String name, String ver) {
ObjectNode node = JacksonUtil.newObjectNode();
node.put("id", id);
node.put("itemId", itemId);
node.put("type", type);
node.put("name", name);
node.put("version", ver);
return node;
}
@Test
void resolveInstallPlan_rootOnly_noRelated_marksWillInstall() {
mockTenant();
String versionId = UUID.randomUUID().toString();
String itemId = UUID.randomUUID().toString();
when(iotHubRestClient.getVersionInfo(versionId)).thenReturn(version(versionId, itemId, "DASHBOARD", "Root", "1.0"));
when(iotHubInstalledItemService.findInstalledItemIdsByTenantIdAndItemIdIn(eq(tenantId), any())).thenReturn(List.of());
InstallPlan plan = service.resolveInstallPlan(user, versionId);
assertThat(plan.getEntries()).hasSize(1);
InstallPlanEntry root = plan.getEntries().get(0);
assertThat(root.isRoot()).isTrue();
assertThat(root.getStatus()).isEqualTo(InstallPlanEntry.Status.WILL_INSTALL);
assertThat(root.getItemId()).isEqualTo(itemId);
}
@Test
void resolveInstallPlan_ordersDependenciesBeforeRoot() {
mockTenant();
String versionId = UUID.randomUUID().toString();
String rootItemId = UUID.randomUUID().toString();
String relatedItemId = UUID.randomUUID().toString();
ObjectNode root = version(versionId, rootItemId, "DASHBOARD", "Root", "1.0");
root.putArray("relatedItems").add(relatedItemId);
when(iotHubRestClient.getVersionInfo(versionId)).thenReturn(root);
when(iotHubRestClient.getPublishedVersionByItemId(relatedItemId))
.thenReturn(version(UUID.randomUUID().toString(), relatedItemId, "WIDGET", "Dep", "2.0"));
when(iotHubInstalledItemService.findInstalledItemIdsByTenantIdAndItemIdIn(eq(tenantId), any())).thenReturn(List.of());
InstallPlan plan = service.resolveInstallPlan(user, versionId);
assertThat(plan.getEntries()).hasSize(2);
assertThat(plan.getEntries().get(0).getItemId()).isEqualTo(relatedItemId);
assertThat(plan.getEntries().get(0).isRoot()).isFalse();
assertThat(plan.getEntries().get(1).getItemId()).isEqualTo(rootItemId);
assertThat(plan.getEntries().get(1).isRoot()).isTrue();
}
@Test
void resolveInstallPlan_missingRelated_recordedAsMissing() {
mockTenant();
String versionId = UUID.randomUUID().toString();
String rootItemId = UUID.randomUUID().toString();
String relatedItemId = UUID.randomUUID().toString();
ObjectNode root = version(versionId, rootItemId, "DASHBOARD", "Root", "1.0");
root.putArray("relatedItems").add(relatedItemId);
when(iotHubRestClient.getVersionInfo(versionId)).thenReturn(root);
when(iotHubRestClient.getPublishedVersionByItemId(relatedItemId)).thenReturn(null);
when(iotHubInstalledItemService.findInstalledItemIdsByTenantIdAndItemIdIn(eq(tenantId), any())).thenReturn(List.of());
InstallPlan plan = service.resolveInstallPlan(user, versionId);
InstallPlanEntry missing = plan.getEntries().get(0);
assertThat(missing.getStatus()).isEqualTo(InstallPlanEntry.Status.MISSING);
assertThat(missing.getItemId()).isEqualTo(relatedItemId);
assertThat(missing.getErrorMessage()).isNotBlank();
}
@Test
void resolveInstallPlan_relatedFetchThrows_recordedAsMissing() {
mockTenant();
String versionId = UUID.randomUUID().toString();
String rootItemId = UUID.randomUUID().toString();
String relatedItemId = UUID.randomUUID().toString();
ObjectNode root = version(versionId, rootItemId, "DASHBOARD", "Root", "1.0");
root.putArray("relatedItems").add(relatedItemId);
when(iotHubRestClient.getVersionInfo(versionId)).thenReturn(root);
when(iotHubRestClient.getPublishedVersionByItemId(relatedItemId)).thenThrow(new RuntimeException("boom"));
when(iotHubInstalledItemService.findInstalledItemIdsByTenantIdAndItemIdIn(eq(tenantId), any())).thenReturn(List.of());
InstallPlan plan = service.resolveInstallPlan(user, versionId);
assertThat(plan.getEntries().get(0).getStatus()).isEqualTo(InstallPlanEntry.Status.MISSING);
assertThat(plan.getEntries().get(0).getErrorMessage()).contains("boom");
}
@Test
void resolveInstallPlan_alreadyInstalledRoot_marksAlreadyInstalled() {
mockTenant();
String versionId = UUID.randomUUID().toString();
UUID rootItemId = UUID.randomUUID();
when(iotHubRestClient.getVersionInfo(versionId))
.thenReturn(version(versionId, rootItemId.toString(), "DASHBOARD", "Root", "1.0"));
when(iotHubInstalledItemService.findInstalledItemIdsByTenantIdAndItemIdIn(eq(tenantId), any())).thenReturn(List.of(rootItemId));
InstallPlan plan = service.resolveInstallPlan(user, versionId);
assertThat(plan.getEntries().get(0).getStatus()).isEqualTo(InstallPlanEntry.Status.ALREADY_INSTALLED);
}
@Test
void resolveInstallPlan_nullVersion_throws() {
mockTenant();
String versionId = UUID.randomUUID().toString();
when(iotHubRestClient.getVersionInfo(versionId)).thenReturn(null);
assertThatThrownBy(() -> service.resolveInstallPlan(user, versionId))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void installPlan_emptyPlan_returnsFailureWithoutInstalling() {
InstallPlanResult result = service.installPlan(user, new InstallPlan(null, List.of()), null, null);
assertThat(result.isSuccess()).isFalse();
assertThat(result.getErrorMessage()).contains("empty");
verify(iotHubInstalledItemService, never()).findInstalledItemIdsByTenantIdAndItemIdIn(any(), any());
}
@Test
void installPlan_missingEntry_collectedAndNotInstalled() {
mockTenant();
InstallPlanEntry missing = new InstallPlanEntry();
missing.setItemId(UUID.randomUUID().toString());
missing.setStatus(InstallPlanEntry.Status.MISSING);
InstallPlanResult result = service.installPlan(user, new InstallPlan(null, List.of(missing)), null, null);
assertThat(result.isSuccess()).isTrue();
assertThat(result.getMissingItemIds()).containsExactly(missing.getItemId());
assertThat(result.getRootDescriptor()).isNull();
verify(iotHubRestClient, never()).getVersionInfo(anyString());
// A plan with nothing to install never hits the DB to check what is already installed.
verify(iotHubInstalledItemService, never()).findInstalledItemIdsByTenantIdAndItemIdIn(any(), any());
}
@Test
void installPlan_stalePlan_skipsItemInstalledSinceResolution() {
mockTenant();
UUID itemId = UUID.randomUUID();
InstallPlanEntry entry = new InstallPlanEntry();
entry.setItemId(itemId.toString());
entry.setVersionId(UUID.randomUUID().toString());
entry.setStatus(InstallPlanEntry.Status.WILL_INSTALL);
entry.setRoot(true);
// The item is reported as already installed at install time, even though the plan says WILL_INSTALL.
when(iotHubInstalledItemService.findInstalledItemIdsByTenantIdAndItemIdIn(eq(tenantId), any())).thenReturn(List.of(itemId));
InstallPlanResult result = service.installPlan(user, new InstallPlan(null, List.of(entry)), null, null);
assertThat(result.isSuccess()).isTrue();
assertThat(result.getEntries().get(0).getStatus()).isEqualTo(InstallPlanEntry.Status.ALREADY_INSTALLED);
// No marketplace fetch happened — the duplicate install was avoided.
verify(iotHubRestClient, never()).getVersionInfo(anyString());
}
@Test
void installPlan_duplicateItemId_installsOnce() throws Exception {
mockTenant();
HttpServletRequest request = mock(HttpServletRequest.class);
UUID itemId = UUID.randomUUID();
String firstVersionId = UUID.randomUUID().toString();
String secondVersionId = UUID.randomUUID().toString();
// Two entries point at the same item — a client-supplied plan is not de-duplicated, so the
// cascade itself must not install the same item twice.
InstallPlanEntry first = new InstallPlanEntry();
first.setItemId(itemId.toString());
first.setVersionId(firstVersionId);
first.setStatus(InstallPlanEntry.Status.WILL_INSTALL);
first.setRoot(true);
InstallPlanEntry duplicate = new InstallPlanEntry();
duplicate.setItemId(itemId.toString());
duplicate.setVersionId(secondVersionId);
duplicate.setStatus(InstallPlanEntry.Status.WILL_INSTALL);
duplicate.setRoot(false);
// Nothing is installed yet when the plan is submitted.
when(iotHubInstalledItemService.findInstalledItemIdsByTenantIdAndItemIdIn(eq(tenantId), any())).thenReturn(List.of());
doReturn(installedItem(UUID.randomUUID())).when(service).doInstallVersion(eq(user), eq(firstVersionId), any(), any());
InstallPlanResult result = service.installPlan(user, new InstallPlan(firstVersionId, List.of(first, duplicate)), null, request);
assertThat(result.isSuccess()).isTrue();
assertThat(result.getEntries()).hasSize(2);
assertThat(result.getEntries().get(0).getStatus()).isEqualTo(InstallPlanEntry.Status.WILL_INSTALL);
// The duplicate is recognised as freshly installed and skipped.
assertThat(result.getEntries().get(1).getStatus()).isEqualTo(InstallPlanEntry.Status.ALREADY_INSTALLED);
verify(service).doInstallVersion(eq(user), eq(firstVersionId), any(), any());
verify(service, never()).doInstallVersion(eq(user), eq(secondVersionId), any(), any());
}
@Test
void installPlan_cascade_installsDependencyBeforeRoot_andRoutesDataToRootOnly() throws Exception {
mockTenant();
HttpServletRequest request = mock(HttpServletRequest.class);
JsonNode data = JacksonUtil.newObjectNode().put("entityId", UUID.randomUUID().toString());
String depVersionId = UUID.randomUUID().toString();
String rootVersionId = UUID.randomUUID().toString();
InstallPlanEntry dep = willInstall(depVersionId, false);
InstallPlanEntry root = willInstall(rootVersionId, true);
when(iotHubInstalledItemService.findInstalledItemIdsByTenantIdAndItemIdIn(eq(tenantId), any())).thenReturn(List.of());
IotHubInstalledItem depItem = installedItem(UUID.randomUUID());
IotHubInstalledItem rootItem = installedItem(UUID.randomUUID());
DashboardInstalledItemDescriptor rootDescriptor = new DashboardInstalledItemDescriptor();
rootItem.setDescriptor(rootDescriptor);
doReturn(depItem).when(service).doInstallVersion(eq(user), eq(depVersionId), any(), any());
doReturn(rootItem).when(service).doInstallVersion(eq(user), eq(rootVersionId), any(), any());
InstallPlanResult result = service.installPlan(user, new InstallPlan(rootVersionId, List.of(dep, root)), data, request);
assertThat(result.isSuccess()).isTrue();
assertThat(result.isRolledBack()).isFalse();
assertThat(result.getRootDescriptor()).isSameAs(rootDescriptor);
assertThat(result.getMissingItemIds()).isEmpty();
assertThat(result.getEntries()).hasSize(2);
// The dependency installs before the root, and only the root receives the user's install data.
InOrder inOrder = inOrder(service);
inOrder.verify(service).doInstallVersion(eq(user), eq(depVersionId), isNull(), eq(request));
inOrder.verify(service).doInstallVersion(eq(user), eq(rootVersionId), eq(data), eq(request));
verify(service, never()).deleteInstalledItem(any(), any());
}
@Test
void installPlan_dependencyFails_rollsBackInstalledItemsInReverseOrder() throws Exception {
mockTenant();
HttpServletRequest request = mock(HttpServletRequest.class);
String dep1VersionId = UUID.randomUUID().toString();
String dep2VersionId = UUID.randomUUID().toString();
String rootVersionId = UUID.randomUUID().toString();
InstallPlanEntry dep1 = willInstall(dep1VersionId, false);
InstallPlanEntry dep2 = willInstall(dep2VersionId, false);
InstallPlanEntry root = willInstall(rootVersionId, true);
when(iotHubInstalledItemService.findInstalledItemIdsByTenantIdAndItemIdIn(eq(tenantId), any())).thenReturn(List.of());
IotHubInstalledItem dep1Item = installedItem(UUID.randomUUID());
IotHubInstalledItem dep2Item = installedItem(UUID.randomUUID());
doReturn(dep1Item).when(service).doInstallVersion(eq(user), eq(dep1VersionId), any(), any());
doReturn(dep2Item).when(service).doInstallVersion(eq(user), eq(dep2VersionId), any(), any());
doThrow(new IllegalStateException("install failed")).when(service).doInstallVersion(eq(user), eq(rootVersionId), any(), any());
doNothing().when(service).deleteInstalledItem(eq(user), any());
InstallPlanResult result = service.installPlan(user, new InstallPlan(rootVersionId, List.of(dep1, dep2, root)), null, request);
assertThat(result.isSuccess()).isFalse();
assertThat(result.isRolledBack()).isTrue();
assertThat(result.getErrorMessage()).contains("install failed");
// Successfully installed dependencies are rolled back in reverse install order (dep2 before dep1).
InOrder inOrder = inOrder(service);
inOrder.verify(service).deleteInstalledItem(user, dep2Item.getId());
inOrder.verify(service).deleteInstalledItem(user, dep1Item.getId());
}
@Test
void installPlan_rollbackFailure_reportsNotFullyRolledBack() throws Exception {
mockTenant();
HttpServletRequest request = mock(HttpServletRequest.class);
String depVersionId = UUID.randomUUID().toString();
String rootVersionId = UUID.randomUUID().toString();
InstallPlanEntry dep = willInstall(depVersionId, false);
InstallPlanEntry root = willInstall(rootVersionId, true);
when(iotHubInstalledItemService.findInstalledItemIdsByTenantIdAndItemIdIn(eq(tenantId), any())).thenReturn(List.of());
IotHubInstalledItem depItem = installedItem(UUID.randomUUID());
doReturn(depItem).when(service).doInstallVersion(eq(user), eq(depVersionId), any(), any());
doThrow(new IllegalStateException("install failed")).when(service).doInstallVersion(eq(user), eq(rootVersionId), any(), any());
// Rolling back the one installed dependency itself fails — the result must report a partial rollback.
doThrow(new RuntimeException("delete failed")).when(service).deleteInstalledItem(user, depItem.getId());
InstallPlanResult result = service.installPlan(user, new InstallPlan(rootVersionId, List.of(dep, root)), null, request);
assertThat(result.isSuccess()).isFalse();
assertThat(result.isRolledBack()).isFalse();
assertThat(result.getErrorMessage()).contains("install failed");
}
}

3
dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemDao.java

@ -22,6 +22,7 @@ import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.dao.Dao;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@ -35,6 +36,8 @@ public interface IotHubInstalledItemDao extends Dao<IotHubInstalledItem> {
List<UUID> findInstalledItemIdsByTenantId(TenantId tenantId);
List<UUID> findInstalledItemIdsByTenantIdAndItemIdIn(TenantId tenantId, Collection<UUID> itemIds);
long countByTenantId(TenantId tenantId, String itemType);
Map<UUID, Long> findInstalledItemCounts(TenantId tenantId, String itemType);

3
dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemService.java

@ -21,6 +21,7 @@ import org.thingsboard.server.common.data.iot_hub.IotHubInstalledItem;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@ -35,6 +36,8 @@ public interface IotHubInstalledItemService {
List<UUID> findInstalledItemIdsByTenantId(TenantId tenantId);
List<UUID> findInstalledItemIdsByTenantIdAndItemIdIn(TenantId tenantId, Collection<UUID> itemIds);
long countByTenantId(TenantId tenantId, String itemType);
Map<UUID, Long> findInstalledItemCounts(TenantId tenantId, String itemType);

6
dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemServiceImpl.java

@ -24,6 +24,7 @@ import org.thingsboard.server.common.data.iot_hub.IotHubInstalledItem;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@ -56,6 +57,11 @@ class IotHubInstalledItemServiceImpl implements IotHubInstalledItemService {
return iotHubInstalledItemDao.findInstalledItemIdsByTenantId(tenantId);
}
@Override
public List<UUID> findInstalledItemIdsByTenantIdAndItemIdIn(TenantId tenantId, Collection<UUID> itemIds) {
return iotHubInstalledItemDao.findInstalledItemIdsByTenantIdAndItemIdIn(tenantId, itemIds);
}
@Override
public long countByTenantId(TenantId tenantId, String itemType) {
return iotHubInstalledItemDao.countByTenantId(tenantId, itemType);

6
dao/src/main/java/org/thingsboard/server/dao/sql/iot_hub/IotHubInstalledItemRepository.java

@ -24,6 +24,7 @@ import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;
import org.thingsboard.server.dao.model.sql.IotHubInstalledItemEntity;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.Set;
@ -36,6 +37,11 @@ interface IotHubInstalledItemRepository extends JpaRepository<IotHubInstalledIte
@Query("SELECT DISTINCT item.itemId FROM IotHubInstalledItemEntity item WHERE item.tenantId = :tenantId")
List<UUID> findInstalledItemIdsByTenantId(@Param("tenantId") UUID tenantId);
@Query("SELECT DISTINCT item.itemId FROM IotHubInstalledItemEntity item " +
"WHERE item.tenantId = :tenantId AND item.itemId IN :itemIds")
List<UUID> findInstalledItemIdsByTenantIdAndItemIdIn(@Param("tenantId") UUID tenantId,
@Param("itemIds") Collection<UUID> itemIds);
@Query("""
SELECT item FROM IotHubInstalledItemEntity item
WHERE item.tenantId = :tenantId

6
dao/src/main/java/org/thingsboard/server/dao/sql/iot_hub/JpaIotHubInstalledItemDao.java

@ -33,6 +33,7 @@ import org.thingsboard.server.dao.model.sql.IotHubInstalledItemEntity;
import org.thingsboard.server.dao.sql.JpaAbstractDao;
import org.thingsboard.server.dao.util.SqlDao;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@ -68,6 +69,11 @@ class JpaIotHubInstalledItemDao extends JpaAbstractDao<IotHubInstalledItemEntity
return repository.findInstalledItemIdsByTenantId(tenantId.getId());
}
@Override
public List<UUID> findInstalledItemIdsByTenantIdAndItemIdIn(TenantId tenantId, Collection<UUID> itemIds) {
return repository.findInstalledItemIdsByTenantIdAndItemIdIn(tenantId.getId(), itemIds);
}
@Override
public long countByTenantId(TenantId tenantId, String itemType) {
return repository.countByTenantId(tenantId.getId(), itemType);

17
ui-ngx/src/app/core/http/iot-hub-api.service.ts

@ -22,7 +22,7 @@ import { PageData } from '@shared/models/page/page-data';
import { PageLink } from '@shared/models/page/page-link';
import { MpItemVersionQuery, MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models';
import { CreatorView } from '@shared/models/iot-hub/iot-hub-creator.models';
import { IotHubInstalledItem, InstallItemVersionResult, UpdateItemVersionResult, ItemPublishedVersionInfo } from '@shared/models/iot-hub/iot-hub-installed-item.models';
import { IotHubInstalledItem, InstallItemVersionResult, InstallPlan, InstallPlanResult, UpdateItemVersionResult, ItemPublishedVersionInfo } from '@shared/models/iot-hub/iot-hub-installed-item.models';
import { ItemType, ItemTypeFilterInfo, WidgetCategory } from '@shared/models/iot-hub/iot-hub-item.models';
import { InterceptorHttpParams } from '@core/interceptors/interceptor-http-params';
import { InterceptorConfig } from '@core/interceptors/interceptor-config';
@ -195,6 +195,21 @@ export class IotHubApiService {
);
}
public resolveInstallPlan(versionId: string, config?: IotHubRequestConfig): Observable<InstallPlan> {
return this.http.get<InstallPlan>(
`/api/iot-hub/versions/${versionId}/installPlan`,
{ params: this.buildParams(config) }
);
}
public installPlan(plan: InstallPlan, data?: any, config?: IotHubRequestConfig): Observable<InstallPlanResult> {
return this.http.post<InstallPlanResult>(
`/api/iot-hub/installPlan`,
{ plan, data: data ?? null },
{ params: this.buildParams(config) }
);
}
public updateItemVersion(installedItemId: string, versionId: string, config?: IotHubRequestConfig, force?: boolean): Observable<UpdateItemVersionResult> {
let params = this.buildParams(config);
if (force) {

97
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.html

@ -77,6 +77,45 @@
existing: pendingOverwrite?.existingRuleChainName
}"></p>
}
@case ('plan') {
<h2 class="tb-iot-hub-install-title">{{ 'iot-hub.install-plan-title' | translate:{ name: item.name } }}</h2>
<p class="tb-iot-hub-install-desc">
@if (planSummary.willInstall > 0) {
<span>{{ 'iot-hub.install-plan-will-install' | translate:{ count: planSummary.willInstall } }}</span>
}
@if (planSummary.alreadyInstalled > 0) {
<span>{{ 'iot-hub.install-plan-will-update' | translate:{ count: planSummary.alreadyInstalled } }}</span>
}
</p>
@if (planSummary.missing > 0) {
<p class="tb-iot-hub-install-warning">{{ 'iot-hub.install-plan-missing-warning' | translate:{ count: planSummary.missing } }}</p>
}
<ul class="tb-iot-hub-plan-list">
@for (entry of installPlan?.entries; track entry.itemId) {
<li class="tb-iot-hub-plan-entry">
<div class="tb-iot-hub-plan-entry-main">
<span class="tb-iot-hub-plan-entry-name">{{ entry.name || entry.itemId }}</span>
@if (entry.type) {
<span class="tb-iot-hub-plan-entry-type">{{ (typeTranslations.get(entry.type) || entry.type) | translate }}</span>
}
@if (entry.version) {
<span class="tb-iot-hub-plan-entry-version">v{{ entry.version }}</span>
}
</div>
<span class="tb-iot-hub-plan-entry-status"
[class.tb-status-installed]="entry.status === PlanStatus.ALREADY_INSTALLED"
[class.tb-status-missing]="entry.status === PlanStatus.MISSING"
[class.tb-status-will-install]="entry.status === PlanStatus.WILL_INSTALL">
@switch (entry.status) {
@case (PlanStatus.WILL_INSTALL) { {{ 'iot-hub.install-plan-status-will' | translate }} }
@case (PlanStatus.ALREADY_INSTALLED) { {{ 'iot-hub.install-plan-status-installed' | translate }} }
@case (PlanStatus.MISSING) { {{ 'iot-hub.install-plan-status-missing' | translate }} }
}
</span>
</li>
}
</ul>
}
@case ('installing') {
<h2 class="tb-iot-hub-install-title">{{ 'iot-hub.install-confirm-title' | translate:{ name: item.name } }}</h2>
<p class="tb-iot-hub-install-desc">{{ 'iot-hub.install-desc' | translate }}</p>
@ -85,6 +124,20 @@
<h2 class="tb-iot-hub-install-title">{{ 'iot-hub.install-success-title' | translate }}</h2>
<p class="tb-iot-hub-install-desc">{{ 'iot-hub.install-success-desc' | translate:{ name: item.name } }}</p>
}
@case ('partial') {
<h2 class="tb-iot-hub-install-title">{{ 'iot-hub.install-partial-title' | translate }}</h2>
<p class="tb-iot-hub-install-desc">{{ 'iot-hub.install-partial-desc' | translate:{ name: item.name } }}</p>
<ul class="tb-iot-hub-plan-list">
@for (entry of missingEntries; track entry.itemId) {
<li class="tb-iot-hub-plan-entry">
<span class="tb-iot-hub-plan-entry-name">{{ entry.name || entry.itemId }}</span>
@if (entry.errorMessage) {
<span class="tb-iot-hub-plan-entry-error">{{ entry.errorMessage }}</span>
}
</li>
}
</ul>
}
@case ('error') {
<h2 class="tb-iot-hub-install-title">{{ 'iot-hub.install-error-title' | translate }}</h2>
<p class="tb-iot-hub-install-desc">{{ 'iot-hub.install-error-message' | translate:{ name: item.name } }}</p>
@ -101,31 +154,53 @@
<mat-dialog-actions align="end">
@switch (state) {
@case ('confirm') {
<button mat-button (click)="cancel()">{{ 'action.cancel' | translate }}</button>
<button mat-flat-button color="primary" (click)="install()">{{ 'iot-hub.install' | translate }}</button>
<button mat-button [disabled]="resolvingPlan" (click)="cancel()">{{ 'action.cancel' | translate }}</button>
<button mat-flat-button color="primary" [disabled]="resolvingPlan" (click)="install()">
@if (resolvingPlan) {
<mat-spinner diameter="18" class="mr-2 inline-block align-middle"></mat-spinner>
}
{{ 'iot-hub.install' | translate }}
</button>
}
@case ('select-entity') {
<button mat-button (click)="cancel()">{{ 'action.cancel' | translate }}</button>
<button mat-button [disabled]="resolvingPlan" (click)="cancel()">{{ 'action.cancel' | translate }}</button>
@if (item.type === ItemType.RULE_CHAIN) {
<button mat-flat-button color="primary"
[disabled]="ruleChainInstallForm.invalid"
[disabled]="ruleChainInstallForm.invalid || resolvingPlan"
(click)="onRuleChainInstall()">
@if (resolvingPlan) {
<mat-spinner diameter="18" class="mr-2 inline-block align-middle"></mat-spinner>
}
{{ 'iot-hub.install' | translate }}
</button>
} @else {
<button mat-flat-button color="primary"
[disabled]="activeSelectEntityConfig.required && !selectedEntityId"
[disabled]="(activeSelectEntityConfig.required && !selectedEntityId) || resolvingPlan"
(click)="onEntitySelectInstall()">
@if (resolvingPlan) {
<mat-spinner diameter="18" class="mr-2 inline-block align-middle"></mat-spinner>
}
{{ 'iot-hub.install' | translate }}
</button>
}
}
@case ('confirm-overwrite') {
<button mat-button (click)="confirmOverwriteCancel()">{{ 'action.cancel' | translate }}</button>
<button mat-flat-button color="primary" (click)="confirmOverwriteReplace()">
<button mat-button [disabled]="resolvingPlan" (click)="confirmOverwriteCancel()">{{ 'action.cancel' | translate }}</button>
<button mat-flat-button color="primary" [disabled]="resolvingPlan" (click)="confirmOverwriteReplace()">
@if (resolvingPlan) {
<mat-spinner diameter="18" class="mr-2 inline-block align-middle"></mat-spinner>
}
{{ 'iot-hub.rule-chain-overwrite-replace' | translate }}
</button>
}
@case ('plan') {
<button mat-button (click)="cancelPlan()">{{ 'action.cancel' | translate }}</button>
@if (planSummary.willInstall > 0) {
<button mat-flat-button color="primary" (click)="installItemWithDependencies()">
{{ 'iot-hub.install-plan-confirm' | translate }}
</button>
}
}
@case ('installing') {
<button mat-button disabled>{{ 'action.cancel' | translate }}</button>
<button mat-flat-button color="primary" disabled>
@ -141,6 +216,14 @@
</button>
}
}
@case ('partial') {
<button mat-button (click)="close()">{{ (entityDetailsUrl ? 'action.cancel' : 'action.close') | translate }}</button>
@if (entityDetailsUrl) {
<button mat-flat-button color="primary" (click)="openEntityDetails()">
{{ 'iot-hub.open-item-type' | translate:{ type: getTypeLabel() } }}
</button>
}
}
@case ('error') {
<button mat-button (click)="close()">{{ 'action.close' | translate }}</button>
}

89
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.scss

@ -57,3 +57,92 @@ tb-entity-select {
border-radius: 8px;
padding-bottom: 24px;
}
.tb-iot-hub-install-warning {
color: #b26a00;
background: #fff8e1;
border-left: 3px solid #f5b324;
padding: 8px 12px;
font-size: 13px;
line-height: 18px;
border-radius: 4px;
margin: 0;
}
.tb-iot-hub-plan-list {
list-style: none;
padding: 0;
margin: 0;
border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 8px;
max-height: 320px;
overflow-y: auto;
}
.tb-iot-hub-plan-entry {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 14px;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
&:last-child {
border-bottom: none;
}
}
.tb-iot-hub-plan-entry-main {
display: flex;
align-items: baseline;
gap: 8px;
flex-wrap: wrap;
min-width: 0;
}
.tb-iot-hub-plan-entry-name {
font-weight: 500;
color: rgba(0, 0, 0, 0.87);
}
.tb-iot-hub-plan-entry-type {
font-size: 12px;
color: rgba(0, 0, 0, 0.6);
background: rgba(0, 0, 0, 0.05);
padding: 2px 6px;
border-radius: 10px;
}
.tb-iot-hub-plan-entry-version {
font-size: 12px;
color: rgba(0, 0, 0, 0.54);
}
.tb-iot-hub-plan-entry-error {
font-size: 12px;
color: #b26a00;
margin-left: 8px;
}
.tb-iot-hub-plan-entry-status {
font-size: 12px;
font-weight: 500;
padding: 2px 8px;
border-radius: 10px;
white-space: nowrap;
&.tb-status-will-install {
color: #1565c0;
background: rgba(21, 101, 192, 0.08);
}
&.tb-status-installed {
color: #2e7d32;
background: rgba(46, 125, 50, 0.08);
}
&.tb-status-missing {
color: #b26a00;
background: rgba(245, 179, 36, 0.12);
}
}

179
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.ts

@ -26,6 +26,11 @@ import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models
import { ItemType, itemTypeTranslations } from '@shared/models/iot-hub/iot-hub-item.models';
import {
getInstalledItemUrl,
InstallPlan,
InstallPlanEntry,
InstallPlanEntryStatus,
InstallPlanResult,
IotHubInstalledItemDescriptor,
SolutionTemplateInstalledItemDescriptor
} from '@shared/models/iot-hub/iot-hub-installed-item.models';
import { IotHubApiService } from '@core/http/iot-hub-api.service';
@ -61,8 +66,10 @@ export type InstallState =
| 'select-entity'
| 'confirm-overwrite'
| 'confirm'
| 'plan'
| 'installing'
| 'success'
| 'partial'
| 'error';
@Component({
@ -74,6 +81,7 @@ export type InstallState =
export class TbIotHubInstallDialogComponent extends DialogComponent<TbIotHubInstallDialogComponent> {
ItemType = ItemType;
PlanStatus = InstallPlanEntryStatus;
EntityType = EntityType;
item: MpItemVersionView;
@ -90,6 +98,15 @@ export class TbIotHubInstallDialogComponent extends DialogComponent<TbIotHubInst
entityId: FormControl<string | null>;
}>;
installPlan: InstallPlan | null = null;
planSummary: { willInstall: number; alreadyInstalled: number; missing: number } = {
willInstall: 0,
alreadyInstalled: 0,
missing: 0
};
missingEntries: InstallPlanEntry[] = [];
resolvingPlan = false;
private readonly selectEntityConfig: Partial<Record<ItemType, SelectEntityConfig>> = {
[ItemType.CALCULATED_FIELD]: {
allowed: [EntityType.DEVICE, EntityType.ASSET, EntityType.DEVICE_PROFILE, EntityType.ASSET_PROFILE],
@ -190,11 +207,7 @@ export class TbIotHubInstallDialogComponent extends DialogComponent<TbIotHubInst
this.install();
}
},
error: (err) => {
this.state = 'error';
this.errorMessage = err?.error?.message || err?.message ||
this.translate.instant('iot-hub.install-error', { name: this.item.name });
}
error: (err) => this.handleApiError(err)
});
}
@ -234,44 +247,141 @@ export class TbIotHubInstallDialogComponent extends DialogComponent<TbIotHubInst
}
install(): void {
// Keep the current state visible (confirm / select-entity / confirm-overwrite) while resolving —
// a transient 'resolving-plan' state caused a visible "blink" for items without dependencies.
this.resolvingPlan = true;
const versionId = this.item.id as string;
this.iotHubApiService.resolveInstallPlan(versionId, { ignoreLoading: true }).subscribe({
next: (plan) => {
this.resolvingPlan = false;
const summary = this.summarizePlan(plan);
// Show the plan only when there's something worth telling the user about: extra items to
// install, items already installed, or missing dependencies. A lone root with nothing to
// skip installs directly so the dialog doesn't flash a one-line "plan".
const shouldShowPlan = plan.entries.length > 1
|| summary.alreadyInstalled > 0
|| summary.missing > 0;
if (!shouldShowPlan) {
this.installItem();
return;
}
this.installPlan = plan;
this.planSummary = summary;
this.missingEntries = plan.entries.filter(e => e.status === InstallPlanEntryStatus.MISSING);
this.state = 'plan';
},
error: (err) => {
this.resolvingPlan = false;
this.handleApiError(err);
}
});
}
installItemWithDependencies(): void {
if (!this.installPlan) {
return;
}
this.state = 'installing';
this.iotHubApiService.installPlan(this.installPlan, this.installData(), { ignoreLoading: true }).subscribe({
next: (result) => this.handlePlanResult(result),
error: (err) => this.handleApiError(err)
});
}
private installItem(): void {
this.state = 'installing';
const versionId = this.item.id as string;
const data = this.selectedEntityId ? { entityId: this.selectedEntityId } : undefined;
this.iotHubApiService.installItemVersion(versionId, { ignoreLoading: true }, data).subscribe({
this.iotHubApiService.installItemVersion(versionId, { ignoreLoading: true }, this.installData()).subscribe({
next: (result) => {
if (result.success) {
if (result.descriptor?.type === 'SOLUTION_TEMPLATE') {
const timeout = this.item.dataDescriptor?.installTimeoutMs;
const openSolutionDialog = () => {
this.dialogRef.close('installed');
this.dialog.open(SolutionInstallDialogComponent, {
disableClose: true,
autoFocus: false,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
data: { descriptor: result.descriptor as SolutionTemplateInstalledItemDescriptor }
});
};
if (timeout > 0) {
setTimeout(openSolutionDialog, timeout);
} else {
openSolutionDialog();
}
} else {
this.state = 'success';
this.entityDetailsUrl = getInstalledItemUrl(result.descriptor);
}
} else {
if (!result.success) {
this.state = 'error';
this.errorMessage = result.errorMessage || this.translate.instant('iot-hub.install-error', { name: this.item.name });
return;
}
this.handleInstalledDescriptor(result.descriptor);
},
error: (err) => {
this.state = 'error';
this.errorMessage = err?.error?.message || err?.message || this.translate.instant('iot-hub.install-error', { name: this.item.name });
}
error: (err) => this.handleApiError(err)
});
}
private summarizePlan(plan: InstallPlan): { willInstall: number; alreadyInstalled: number; missing: number } {
const summary = { willInstall: 0, alreadyInstalled: 0, missing: 0 };
for (const entry of plan.entries) {
switch (entry.status) {
case InstallPlanEntryStatus.WILL_INSTALL: summary.willInstall++; break;
case InstallPlanEntryStatus.ALREADY_INSTALLED: summary.alreadyInstalled++; break;
case InstallPlanEntryStatus.MISSING: summary.missing++; break;
}
}
return summary;
}
private handleApiError(err: any): void {
this.state = 'error';
this.errorMessage = err?.error?.message || err?.message ||
this.translate.instant('iot-hub.install-error', { name: this.item.name });
}
private handlePlanResult(result: InstallPlanResult): void {
if (!result.success) {
this.state = 'error';
let message = result.errorMessage || this.translate.instant('iot-hub.install-error', { name: this.item.name });
// A failed cascade rolls back the items installed so far. When something was actually being
// installed and the rollback came back partial (rolledBack === false), some entities are left
// behind — tell the admin so they know manual cleanup may be needed. The willInstall guard
// avoids the misleading warning on the "empty plan" failure, where nothing was installed.
if (!result.rolledBack && this.planSummary.willInstall > 0) {
message += ' ' + this.translate.instant('iot-hub.install-rollback-partial');
}
this.errorMessage = message;
return;
}
if (this.installPlan) {
this.installPlan = { ...this.installPlan, entries: result.entries ?? this.installPlan.entries };
this.missingEntries = (result.entries ?? []).filter(e => e.status === InstallPlanEntryStatus.MISSING);
}
const hasMissing = (result.missingItemIds?.length ?? 0) > 0;
if (result.rootDescriptor) {
this.handleInstalledDescriptor(result.rootDescriptor, hasMissing);
} else {
this.state = hasMissing ? 'partial' : 'success';
}
}
private handleInstalledDescriptor(descriptor: IotHubInstalledItemDescriptor, partial = false): void {
if (descriptor?.type === 'SOLUTION_TEMPLATE') {
const timeout = this.item.dataDescriptor?.installTimeoutMs;
const openSolutionDialog = () => {
this.dialogRef.close('installed');
this.dialog.open(SolutionInstallDialogComponent, {
disableClose: true,
autoFocus: false,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
data: { descriptor: descriptor as SolutionTemplateInstalledItemDescriptor }
});
};
if (timeout > 0) {
setTimeout(openSolutionDialog, timeout);
} else {
openSolutionDialog();
}
return;
}
this.entityDetailsUrl = getInstalledItemUrl(descriptor);
this.state = partial ? 'partial' : 'success';
}
private installData(): any | undefined {
return this.selectedEntityId ? { entityId: this.selectedEntityId } : undefined;
}
cancelPlan(): void {
this.installPlan = null;
this.state = this.computeInitialState();
}
openEntityDetails(): void {
if (this.entityDetailsUrl) {
this.dialogRef.close('installed');
@ -280,7 +390,8 @@ export class TbIotHubInstallDialogComponent extends DialogComponent<TbIotHubInst
}
close(): void {
this.dialogRef.close(this.state === 'success' ? 'installed' : false);
const installedStates: InstallState[] = ['success', 'partial'];
this.dialogRef.close(installedStates.includes(this.state) ? 'installed' : false);
}
cancel(): void {

31
ui-ngx/src/app/shared/models/iot-hub/iot-hub-installed-item.models.ts

@ -77,6 +77,37 @@ export interface InstallItemVersionResult {
descriptor: IotHubInstalledItemDescriptor;
}
export enum InstallPlanEntryStatus {
WILL_INSTALL = 'WILL_INSTALL',
ALREADY_INSTALLED = 'ALREADY_INSTALLED',
MISSING = 'MISSING'
}
export interface InstallPlanEntry {
itemId: string;
versionId: string;
name: string;
type: string;
version: string;
status: InstallPlanEntryStatus;
root: boolean;
errorMessage?: string;
}
export interface InstallPlan {
rootVersionId: string;
entries: InstallPlanEntry[];
}
export interface InstallPlanResult {
success: boolean;
rolledBack: boolean;
errorMessage?: string;
rootDescriptor?: IotHubInstalledItemDescriptor;
entries: InstallPlanEntry[];
missingItemIds: string[];
}
export interface UpdateItemVersionResult {
success: boolean;
entityModified: boolean;

2
ui-ngx/src/app/shared/models/iot-hub/iot-hub-version.models.ts

@ -110,6 +110,8 @@ export interface MpItemVersionView {
installCount: number;
totalInstallCount: number;
resources: MpItemVersionResource[];
relatedItems?: string[];
checksum?: string;
}
// 404 body shapes returned by the public listing item-version endpoint

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

@ -3843,6 +3843,17 @@
"goto-main-dashboard": "Go to main dashboard",
"install-error-title": "Installation Failed",
"install-error-message": "Failed to install '{{name}}'.",
"install-plan-title": "Install '{{name}}' and its dependencies",
"install-plan-will-install": "{{count}} item(s) will be installed.",
"install-plan-will-update": "{{count}} item(s) already installed and will be skipped.",
"install-plan-missing-warning": "{{count}} required item(s) could not be found on the marketplace. '{{ name }}' may not work fully without them.",
"install-plan-status-will": "Install",
"install-plan-status-installed": "Already installed",
"install-plan-status-missing": "Missing",
"install-plan-confirm": "Install",
"install-partial-title": "Installed with warnings",
"install-partial-desc": "'{{name}}' was installed, but some required items were missing.",
"install-rollback-partial": "Some of the items installed before the failure could not be removed automatically and may need to be deleted manually.",
"check-for-updates": "Check for updates",
"updates": "Updates",
"up-to-date": "Up to date",

Loading…
Cancel
Save