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 } }}

+ } +
    + @for (entry of installPlan?.entries; track entry.itemId) { +
  • +
    + {{ entry.name || entry.itemId }} + @if (entry.type) { + {{ (typeTranslations.get(entry.type) || entry.type) | translate }} + } + @if (entry.version) { + v{{ entry.version }} + } +
    + + @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 }} } + } + +
  • + } +
+ } @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') { - - + } + @case ('plan') { + + @if (planSummary.willInstall > 0) { + + } + } @case ('installing') { } } + @case ('partial') { + + @if (entityDetailsUrl) { + + } + } @case ('error') { } diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.scss b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.scss index 6fe3d0bfa2..bf6356a331 100644 --- a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.scss +++ b/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); + } +} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.ts b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.ts index 34056abbfe..43ed8d21b4 100644 --- a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.ts +++ b/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 { ItemType = ItemType; + PlanStatus = InstallPlanEntryStatus; EntityType = EntityType; item: MpItemVersionView; @@ -90,6 +98,15 @@ export class TbIotHubInstallDialogComponent extends DialogComponent; }>; + 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> = { [ItemType.CALCULATED_FIELD]: { allowed: [EntityType.DEVICE, EntityType.ASSET, EntityType.DEVICE_PROFILE, EntityType.ASSET_PROFILE], @@ -190,11 +207,7 @@ export class TbIotHubInstallDialogComponent extends DialogComponent { - 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 { + 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