diff --git a/application/src/main/java/org/thingsboard/server/controller/IotHubController.java b/application/src/main/java/org/thingsboard/server/controller/IotHubController.java
index 09a30f9266..2af21a4af5 100644
--- a/application/src/main/java/org/thingsboard/server/controller/IotHubController.java
+++ b/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
diff --git a/application/src/main/java/org/thingsboard/server/service/iot_hub/DefaultIotHubService.java b/application/src/main/java/org/thingsboard/server/service/iot_hub/DefaultIotHubService.java
index 9c75a85232..21e654baba 100644
--- a/application/src/main/java/org/thingsboard/server/service/iot_hub/DefaultIotHubService.java
+++ b/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 candidateItemIds = new HashSet<>();
+ addCandidateItemId(candidateItemIds, rootItemId);
+ if (related != null && related.isArray()) {
+ for (JsonNode relatedNode : related) {
+ addCandidateItemId(candidateItemIds, relatedNode.asText());
+ }
+ }
+ Set 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 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 alreadyInstalledItemIds,
+ LinkedHashMap 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 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 alreadyInstalledItemIds = new HashSet<>();
+ if (!candidateItemIds.isEmpty()) {
+ alreadyInstalledItemIds.addAll(iotHubInstalledItemService.findInstalledItemIdsByTenantIdAndItemIdIn(tenantId, candidateItemIds));
+ }
+
+ InstallPlanResult result = new InstallPlanResult();
+ List resultEntries = new ArrayList<>();
+ List missingItemIds = new ArrayList<>();
+ List 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 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 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 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);
}
diff --git a/application/src/main/java/org/thingsboard/server/service/iot_hub/InstallPlan.java b/application/src/main/java/org/thingsboard/server/service/iot_hub/InstallPlan.java
new file mode 100644
index 0000000000..12c268a6b4
--- /dev/null
+++ b/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 entries;
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/iot_hub/InstallPlanEntry.java b/application/src/main/java/org/thingsboard/server/service/iot_hub/InstallPlanEntry.java
new file mode 100644
index 0000000000..c073ff8e0e
--- /dev/null
+++ b/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;
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/iot_hub/InstallPlanResult.java b/application/src/main/java/org/thingsboard/server/service/iot_hub/InstallPlanResult.java
new file mode 100644
index 0000000000..2c3f49e071
--- /dev/null
+++ b/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 entries = new ArrayList<>();
+ private List missingItemIds = new ArrayList<>();
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubRestClient.java b/application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubRestClient.java
index 1ea8b60d2e..c67fef55a8 100644
--- a/application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubRestClient.java
+++ b/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);
diff --git a/application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubService.java b/application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubService.java
index e2f0ff0c39..fac0f94b10 100644
--- a/application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubService.java
+++ b/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);
}
diff --git a/application/src/test/java/org/thingsboard/server/service/iot_hub/DefaultIotHubServiceTest.java b/application/src/test/java/org/thingsboard/server/service/iot_hub/DefaultIotHubServiceTest.java
new file mode 100644
index 0000000000..d505509e4a
--- /dev/null
+++ b/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");
+ }
+}
diff --git a/dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemDao.java b/dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemDao.java
index 22692f6ee4..8e20a68eb0 100644
--- a/dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemDao.java
+++ b/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 {
List findInstalledItemIdsByTenantId(TenantId tenantId);
+ List findInstalledItemIdsByTenantIdAndItemIdIn(TenantId tenantId, Collection itemIds);
+
long countByTenantId(TenantId tenantId, String itemType);
Map findInstalledItemCounts(TenantId tenantId, String itemType);
diff --git a/dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemService.java b/dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemService.java
index 3fa15852d8..874e9bfcc5 100644
--- a/dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemService.java
+++ b/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 findInstalledItemIdsByTenantId(TenantId tenantId);
+ List findInstalledItemIdsByTenantIdAndItemIdIn(TenantId tenantId, Collection itemIds);
+
long countByTenantId(TenantId tenantId, String itemType);
Map findInstalledItemCounts(TenantId tenantId, String itemType);
diff --git a/dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemServiceImpl.java
index 5bb857ca2c..d89c21f548 100644
--- a/dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemServiceImpl.java
+++ b/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 findInstalledItemIdsByTenantIdAndItemIdIn(TenantId tenantId, Collection itemIds) {
+ return iotHubInstalledItemDao.findInstalledItemIdsByTenantIdAndItemIdIn(tenantId, itemIds);
+ }
+
@Override
public long countByTenantId(TenantId tenantId, String itemType) {
return iotHubInstalledItemDao.countByTenantId(tenantId, itemType);
diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/iot_hub/IotHubInstalledItemRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/iot_hub/IotHubInstalledItemRepository.java
index 8f06beaa39..393e40ea86 100644
--- a/dao/src/main/java/org/thingsboard/server/dao/sql/iot_hub/IotHubInstalledItemRepository.java
+++ b/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 findInstalledItemIdsByTenantId(@Param("tenantId") UUID tenantId);
+ @Query("SELECT DISTINCT item.itemId FROM IotHubInstalledItemEntity item " +
+ "WHERE item.tenantId = :tenantId AND item.itemId IN :itemIds")
+ List findInstalledItemIdsByTenantIdAndItemIdIn(@Param("tenantId") UUID tenantId,
+ @Param("itemIds") Collection itemIds);
+
@Query("""
SELECT item FROM IotHubInstalledItemEntity item
WHERE item.tenantId = :tenantId
diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/iot_hub/JpaIotHubInstalledItemDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/iot_hub/JpaIotHubInstalledItemDao.java
index 9d61b13209..5cd305b5ad 100644
--- a/dao/src/main/java/org/thingsboard/server/dao/sql/iot_hub/JpaIotHubInstalledItemDao.java
+++ b/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 findInstalledItemIdsByTenantIdAndItemIdIn(TenantId tenantId, Collection itemIds) {
+ return repository.findInstalledItemIdsByTenantIdAndItemIdIn(tenantId.getId(), itemIds);
+ }
+
@Override
public long countByTenantId(TenantId tenantId, String itemType) {
return repository.countByTenantId(tenantId.getId(), itemType);
diff --git a/ui-ngx/src/app/core/http/iot-hub-api.service.ts b/ui-ngx/src/app/core/http/iot-hub-api.service.ts
index b0f90ddeef..ad4eefbdc6 100644
--- a/ui-ngx/src/app/core/http/iot-hub-api.service.ts
+++ b/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 {
+ return this.http.get(
+ `/api/iot-hub/versions/${versionId}/installPlan`,
+ { params: this.buildParams(config) }
+ );
+ }
+
+ public installPlan(plan: InstallPlan, data?: any, config?: IotHubRequestConfig): Observable {
+ return this.http.post(
+ `/api/iot-hub/installPlan`,
+ { plan, data: data ?? null },
+ { params: this.buildParams(config) }
+ );
+ }
+
public updateItemVersion(installedItemId: string, versionId: string, config?: IotHubRequestConfig, force?: boolean): Observable {
let params = this.buildParams(config);
if (force) {
diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.html b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.html
index 102a55ad5e..bb551a4106 100644
--- a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.html
+++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.html
@@ -77,6 +77,45 @@
existing: pendingOverwrite?.existingRuleChainName
}">
}
+ @case ('plan') {
+ {{ 'iot-hub.install-plan-title' | translate:{ name: item.name } }}
+
+ @if (planSummary.willInstall > 0) {
+ {{ 'iot-hub.install-plan-will-install' | translate:{ count: planSummary.willInstall } }}
+ }
+ @if (planSummary.alreadyInstalled > 0) {
+ {{ 'iot-hub.install-plan-will-update' | translate:{ count: planSummary.alreadyInstalled } }}
+ }
+
+ @if (planSummary.missing > 0) {
+ {{ 'iot-hub.install-plan-missing-warning' | translate:{ count: planSummary.missing } }}
+ }
+
+ }
@case ('installing') {
{{ 'iot-hub.install-confirm-title' | translate:{ name: item.name } }}
{{ 'iot-hub.install-desc' | translate }}
@@ -85,6 +124,20 @@
{{ 'iot-hub.install-success-title' | translate }}
{{ 'iot-hub.install-success-desc' | translate:{ name: item.name } }}
}
+ @case ('partial') {
+ {{ 'iot-hub.install-partial-title' | translate }}
+ {{ 'iot-hub.install-partial-desc' | translate:{ name: item.name } }}
+
+ @for (entry of missingEntries; track entry.itemId) {
+ -
+ {{ entry.name || entry.itemId }}
+ @if (entry.errorMessage) {
+ {{ entry.errorMessage }}
+ }
+
+ }
+
+ }
@case ('error') {
{{ 'iot-hub.install-error-title' | translate }}
{{ 'iot-hub.install-error-message' | translate:{ name: item.name } }}
@@ -101,31 +154,53 @@
@switch (state) {
@case ('confirm') {
-
-
+
+
}
@case ('select-entity') {
-
+
@if (item.type === ItemType.RULE_CHAIN) {
} @else {
}
}
@case ('confirm-overwrite') {
-
-