From 7cca8463c98666d358bde17c83466308be862a36 Mon Sep 17 00:00:00 2001 From: dpinkevych Date: Fri, 22 May 2026 13:15:26 +0300 Subject: [PATCH 1/5] add related items logic --- .../server/controller/IotHubController.java | 19 ++ .../service/iot_hub/DefaultIotHubService.java | 254 +++++++++++++++--- .../server/service/iot_hub/InstallPlan.java | 32 +++ .../service/iot_hub/InstallPlanEntry.java | 42 +++ .../service/iot_hub/InstallPlanResult.java | 38 +++ .../service/iot_hub/IotHubRestClient.java | 19 ++ .../server/service/iot_hub/IotHubService.java | 4 + .../src/app/core/http/iot-hub-api.service.ts | 17 +- .../iot-hub-install-dialog.component.html | 108 +++++++- .../iot-hub-install-dialog.component.scss | 89 ++++++ .../iot-hub-install-dialog.component.ts | 172 +++++++++--- .../iot-hub/iot-hub-installed-item.models.ts | 31 +++ .../models/iot-hub/iot-hub-version.models.ts | 2 + .../assets/locale/locale.constant-en_US.json | 10 + 14 files changed, 755 insertions(+), 82 deletions(-) create mode 100644 application/src/main/java/org/thingsboard/server/service/iot_hub/InstallPlan.java create mode 100644 application/src/main/java/org/thingsboard/server/service/iot_hub/InstallPlanEntry.java create mode 100644 application/src/main/java/org/thingsboard/server/service/iot_hub/InstallPlanResult.java 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 5c08b4f12b..ace31effbe 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 @@ -103,48 +106,61 @@ public class DefaultIotHubService implements IotHubService { 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); - 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); - - 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 = 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. + */ + private 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("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); + + 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 { @@ -654,4 +670,170 @@ public class DefaultIotHubService implements IotHubService { iotHubInstalledItemService.deleteById(tenantId, installedItemId); 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); + } + + Set alreadyInstalledItemIds = new HashSet<>(iotHubInstalledItemService.findInstalledItemIdsByTenantId(tenantId)); + + // LinkedHashMap preserves insertion order; we add deps before the root so a single + // forward iteration yields the correct topological install sequence. + LinkedHashMap entries = new LinkedHashMap<>(); + Set visiting = new HashSet<>(); + + collectDependencies(rootVersion, alreadyInstalledItemIds, entries, visiting, true); + + return new InstallPlan(versionId, new ArrayList<>(entries.values())); + } + + /** + * Depth-first walk over {@code relatedItems}. Children are added BEFORE the current + * version so the resulting LinkedHashMap iterates deps-first, root-last. + */ + private void collectDependencies(JsonNode versionInfo, + Set alreadyInstalledItemIds, + LinkedHashMap entries, + Set visiting, + boolean root) { + String itemId = versionInfo.get("itemId").asText(); + if (entries.containsKey(itemId)) { + return; + } + if (!visiting.add(itemId)) { + log.warn("Dependency cycle detected involving IoT Hub item {} — breaking", itemId); + return; + } + + JsonNode related = versionInfo.get("relatedItems"); + if (related != null && related.isArray()) { + for (JsonNode relatedNode : related) { + String relatedItemId = relatedNode.asText(); + if (relatedItemId == null || relatedItemId.isEmpty() || 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; + } + collectDependencies(relatedVersion, alreadyInstalledItemIds, entries, visiting, false); + } + } + + visiting.remove(itemId); + + InstallPlanEntry entry = new InstallPlanEntry(); + entry.setItemId(itemId); + entry.setVersionId(versionInfo.get("id").asText()); + entry.setName(versionInfo.hasNonNull("name") ? versionInfo.get("name").asText() : null); + entry.setType(versionInfo.hasNonNull("type") ? versionInfo.get("type").asText() : null); + entry.setVersion(versionInfo.hasNonNull("version") ? versionInfo.get("version").asText() : null); + entry.setRoot(root); + boolean alreadyInstalled; + try { + alreadyInstalled = alreadyInstalledItemIds.contains(UUID.fromString(itemId)); + } catch (IllegalArgumentException ex) { + alreadyInstalled = false; + } + entry.setStatus(alreadyInstalled + ? 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; + } + + @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<>()); + } + + 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 -> { + 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()); + 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); + rollbackInstalledItems(user, rollbackIds); + result.setSuccess(false); + result.setRolledBack(true); + 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; + } + + private void rollbackInstalledItems(SecurityUser user, List installedIds) { + for (int i = installedIds.size() - 1; i >= 0; i--) { + IotHubInstalledItemId id = installedIds.get(i); + try { + deleteInstalledItem(user, id); + } catch (Exception e) { + log.error("[{}] Failed to roll back installed item {}: {}", user.getTenantId(), id, e.getMessage(), e); + } + } + } + + 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()); + } } 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 1155bcf0ad..2de1fc8635 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 @@ -18,8 +18,10 @@ package org.thingsboard.server.service.iot_hub; import com.fasterxml.jackson.databind.JsonNode; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Component; +import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.RestTemplate; import org.thingsboard.server.queue.util.TbCoreComponent; @@ -39,6 +41,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/ui-ngx/src/app/core/http/iot-hub-api.service.ts b/ui-ngx/src/app/core/http/iot-hub-api.service.ts index 12878ac0fc..3a98526b60 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'; @@ -162,6 +162,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 19a43fcb30..46e7f08264 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 @@ -48,6 +48,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 }}

@@ -56,6 +95,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 } }}

@@ -72,39 +125,66 @@ @switch (state) { @case ('confirm') { - + @if (item.type === ItemType.RULE_CHAIN) { - - + } @else { - + } } @case ('select-entity') { - + @if (item.type === ItemType.RULE_CHAIN) { - + } @else { } } @case ('confirm-overwrite') { - - + } + @case ('plan') { + + + } @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 be37e6e1bb..161e8ca909 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 @@ -22,7 +22,14 @@ import { AppState } from '@core/core.state'; import { DialogComponent } from '@shared/components/dialog.component'; import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; import { ItemType, itemTypeTranslations } from '@shared/models/iot-hub/iot-hub-item.models'; -import { SolutionTemplateInstalledItemDescriptor } from '@shared/models/iot-hub/iot-hub-installed-item.models'; +import { + 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'; import { TranslateService } from '@ngx-translate/core'; import { EntityType } from '@shared/models/entity-type.models'; @@ -57,8 +64,10 @@ export type InstallState = | 'select-entity' | 'confirm-overwrite' | 'confirm' + | 'plan' | 'installing' | 'success' + | 'partial' | 'error'; @Component({ @@ -70,6 +79,7 @@ export type InstallState = export class TbIotHubInstallDialogComponent extends DialogComponent { ItemType = ItemType; + PlanStatus = InstallPlanEntryStatus; item: MpItemVersionView; typeTranslations = itemTypeTranslations; @@ -80,6 +90,15 @@ export class TbIotHubInstallDialogComponent extends DialogComponent> = { [ItemType.CALCULATED_FIELD]: { allowed: [EntityType.DEVICE, EntityType.ASSET, EntityType.DEVICE_PROFILE, EntityType.ASSET_PROFILE], @@ -155,11 +174,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) }); } @@ -199,44 +214,130 @@ export class TbIotHubInstallDialogComponent extends DialogComponent { + this.resolvingPlan = false; + const summary = this.summarizePlan(plan); + const hasDependencies = plan.entries.length > 1 + || summary.alreadyInstalled > 0 + || summary.missing > 0; + + if (!hasDependencies && summary.willInstall === 1) { + // Single root version, no deps, nothing to skip — install directly without showing the plan. + this.legacyInstall(); + 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); + } + }); + } + + confirmPlan(): 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 legacyInstall(): 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 = resolveEntityDetailsUrl(result.descriptor, this.item.type); - } - } 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'; + this.errorMessage = result.errorMessage || this.translate.instant('iot-hub.install-error', { name: this.item.name }); + return; + } + if (this.installPlan) { + this.installPlan = { ...this.installPlan, entries: result.entries ?? this.installPlan.entries }; + this.missingEntries = (result.entries ?? []).filter(e => e.status === InstallPlanEntryStatus.MISSING); + } + if (result.rootDescriptor) { + this.handleInstalledDescriptor(result.rootDescriptor, result.missingItemIds?.length > 0); + } else { + this.state = (result.missingItemIds?.length ?? 0) > 0 ? '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 = resolveEntityDetailsUrl(descriptor, this.item.type); + this.state = partial ? 'partial' : 'success'; + } + + private installData(): any | undefined { + return this.selectedEntityId ? { entityId: this.selectedEntityId } : undefined; + } + + cancelPlan(): void { + this.installPlan = null; + this.state = 'confirm'; + } + openEntityDetails(): void { if (this.entityDetailsUrl) { this.dialogRef.close('installed'); @@ -245,7 +346,8 @@ export class TbIotHubInstallDialogComponent extends DialogComponent Date: Thu, 11 Jun 2026 08:45:20 +0300 Subject: [PATCH 2/5] avoid recursion to find relatedItems and change method names --- .../service/iot_hub/DefaultIotHubService.java | 57 +++++++++---------- .../iot-hub-install-dialog.component.html | 2 +- .../iot-hub-install-dialog.component.ts | 8 +-- 3 files changed, 32 insertions(+), 35 deletions(-) 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 ace31effbe..b7e4cb2a5d 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 @@ -683,39 +683,19 @@ public class DefaultIotHubService implements IotHubService { Set alreadyInstalledItemIds = new HashSet<>(iotHubInstalledItemService.findInstalledItemIdsByTenantId(tenantId)); - // LinkedHashMap preserves insertion order; we add deps before the root so a single - // forward iteration yields the correct topological install sequence. + // 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<>(); - Set visiting = new HashSet<>(); - collectDependencies(rootVersion, alreadyInstalledItemIds, entries, visiting, true); - - return new InstallPlan(versionId, new ArrayList<>(entries.values())); - } - - /** - * Depth-first walk over {@code relatedItems}. Children are added BEFORE the current - * version so the resulting LinkedHashMap iterates deps-first, root-last. - */ - private void collectDependencies(JsonNode versionInfo, - Set alreadyInstalledItemIds, - LinkedHashMap entries, - Set visiting, - boolean root) { - String itemId = versionInfo.get("itemId").asText(); - if (entries.containsKey(itemId)) { - return; - } - if (!visiting.add(itemId)) { - log.warn("Dependency cycle detected involving IoT Hub item {} — breaking", itemId); - return; - } - - JsonNode related = versionInfo.get("relatedItems"); + String rootItemId = rootVersion.get("itemId").asText(); + JsonNode related = rootVersion.get("relatedItems"); if (related != null && related.isArray()) { for (JsonNode relatedNode : related) { String relatedItemId = relatedNode.asText(); - if (relatedItemId == null || relatedItemId.isEmpty() || entries.containsKey(relatedItemId)) { + if (relatedItemId == null || relatedItemId.isEmpty() + || relatedItemId.equals(rootItemId) || entries.containsKey(relatedItemId)) { continue; } JsonNode relatedVersion; @@ -731,11 +711,28 @@ public class DefaultIotHubService implements IotHubService { entries.put(relatedItemId, missingEntry(relatedItemId, "Item not found or not published")); continue; } - collectDependencies(relatedVersion, alreadyInstalledItemIds, entries, visiting, false); + addPlanEntry(relatedVersion, alreadyInstalledItemIds, entries, false); } } - visiting.remove(itemId); + 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 = versionInfo.get("itemId").asText(); + if (entries.containsKey(itemId)) { + return; + } InstallPlanEntry entry = new InstallPlanEntry(); entry.setItemId(itemId); 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 3f2b79fbfc..354399e5c8 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 @@ -197,7 +197,7 @@ } 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 9c64353ef3..ccd8731ec8 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 @@ -259,9 +259,9 @@ export class TbIotHubInstallDialogComponent extends DialogComponent 0 || summary.missing > 0; - if (!hasDependencies && summary.willInstall === 1) { + if (!hasDependencies) { // Single root version, no deps, nothing to skip — install directly without showing the plan. - this.legacyInstall(); + this.installItem(); return; } @@ -277,7 +277,7 @@ export class TbIotHubInstallDialogComponent extends DialogComponent Date: Thu, 11 Jun 2026 09:39:55 +0300 Subject: [PATCH 3/5] refactor + add tests --- .../service/iot_hub/DefaultIotHubService.java | 94 ++++++-- .../iot_hub/DefaultIotHubServiceTest.java | 215 ++++++++++++++++++ .../iot-hub-install-dialog.component.html | 10 +- .../iot-hub-install-dialog.component.ts | 13 +- .../assets/locale/locale.constant-en_US.json | 4 +- 5 files changed, 303 insertions(+), 33 deletions(-) create mode 100644 application/src/test/java/org/thingsboard/server/service/iot_hub/DefaultIotHubServiceTest.java 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 b7e4cb2a5d..a96f95c2c3 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 @@ -102,6 +102,15 @@ 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(); @@ -122,10 +131,10 @@ public class DefaultIotHubService implements IotHubService { private 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("type").asText(); - String itemName = versionInfo.get("name").asText(); - UUID itemId = UUID.fromString(versionInfo.get("itemId").asText()); - String version = versionInfo.get("version").asText(); + 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); @@ -689,8 +698,8 @@ public class DefaultIotHubService implements IotHubService { // level deep, so there is no need to walk a related item's own related items. LinkedHashMap entries = new LinkedHashMap<>(); - String rootItemId = rootVersion.get("itemId").asText(); - JsonNode related = rootVersion.get("relatedItems"); + String rootItemId = rootVersion.get(FIELD_ITEM_ID).asText(); + JsonNode related = rootVersion.get(FIELD_RELATED_ITEMS); if (related != null && related.isArray()) { for (JsonNode relatedNode : related) { String relatedItemId = relatedNode.asText(); @@ -729,25 +738,34 @@ public class DefaultIotHubService implements IotHubService { Set alreadyInstalledItemIds, LinkedHashMap entries, boolean root) { - String itemId = versionInfo.get("itemId").asText(); + 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(versionInfo.get("id").asText()); - entry.setName(versionInfo.hasNonNull("name") ? versionInfo.get("name").asText() : null); - entry.setType(versionInfo.hasNonNull("type") ? versionInfo.get("type").asText() : null); - entry.setVersion(versionInfo.hasNonNull("version") ? versionInfo.get("version").asText() : null); + entry.setVersionId(versionId); + entry.setName(optText(versionInfo, FIELD_NAME)); + entry.setType(optText(versionInfo, FIELD_TYPE)); + entry.setVersion(optText(versionInfo, FIELD_VERSION)); entry.setRoot(root); - boolean alreadyInstalled; - try { - alreadyInstalled = alreadyInstalledItemIds.contains(UUID.fromString(itemId)); - } catch (IllegalArgumentException ex) { - alreadyInstalled = false; - } - entry.setStatus(alreadyInstalled + entry.setStatus(isAlreadyInstalled(itemId, alreadyInstalledItemIds) ? InstallPlanEntry.Status.ALREADY_INSTALLED : InstallPlanEntry.Status.WILL_INSTALL); entries.put(itemId, entry); @@ -761,6 +779,10 @@ public class DefaultIotHubService implements IotHubService { 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(); @@ -768,6 +790,11 @@ public class DefaultIotHubService implements IotHubService { 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 against the current state + // so we never install the same item twice. + Set alreadyInstalledItemIds = new HashSet<>(iotHubInstalledItemService.findInstalledItemIdsByTenantId(tenantId)); + InstallPlanResult result = new InstallPlanResult(); List resultEntries = new ArrayList<>(); List missingItemIds = new ArrayList<>(); @@ -783,6 +810,12 @@ public class DefaultIotHubService implements IotHubService { } 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. @@ -798,9 +831,9 @@ public class DefaultIotHubService implements IotHubService { entry.getName(), entry.getVersionId(), e.getMessage(), e); resultEntry.setErrorMessage(e.getMessage()); resultEntries.add(resultEntry); - rollbackInstalledItems(user, rollbackIds); + boolean rolledBack = rollbackInstalledItems(user, rollbackIds); result.setSuccess(false); - result.setRolledBack(true); + result.setRolledBack(rolledBack); result.setErrorMessage("Failed to install '" + entry.getName() + "': " + e.getMessage()); result.setEntries(resultEntries); result.setMissingItemIds(missingItemIds); @@ -818,15 +851,34 @@ public class DefaultIotHubService implements IotHubService { return result; } - private void rollbackInstalledItems(SecurityUser user, List installedIds) { + /** + * 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 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) { 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..5c0559c4d4 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/iot_hub/DefaultIotHubServiceTest.java @@ -0,0 +1,215 @@ +/** + * 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 org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.id.TenantId; +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.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; + + @InjectMocks + private DefaultIotHubService service; + + private void mockTenant() { + when(user.getTenantId()).thenReturn(tenantId); + } + + 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.findInstalledItemIdsByTenantId(tenantId)).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.findInstalledItemIdsByTenantId(tenantId)).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.findInstalledItemIdsByTenantId(tenantId)).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.findInstalledItemIdsByTenantId(tenantId)).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.findInstalledItemIdsByTenantId(tenantId)).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()).findInstalledItemIdsByTenantId(any()); + } + + @Test + void installPlan_missingEntry_collectedAndNotInstalled() { + mockTenant(); + InstallPlanEntry missing = new InstallPlanEntry(); + missing.setItemId(UUID.randomUUID().toString()); + missing.setStatus(InstallPlanEntry.Status.MISSING); + when(iotHubInstalledItemService.findInstalledItemIdsByTenantId(tenantId)).thenReturn(List.of()); + + 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()); + } + + @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.findInstalledItemIdsByTenantId(tenantId)).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()); + } +} 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 354399e5c8..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 @@ -195,11 +195,11 @@ } @case ('plan') { - + @if (planSummary.willInstall > 0) { + + } } @case ('installing') { 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 ccd8731ec8..33bb2af0f7 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 @@ -255,12 +255,14 @@ export class TbIotHubInstallDialogComponent extends DialogComponent { this.resolvingPlan = false; const summary = this.summarizePlan(plan); - const hasDependencies = plan.entries.length > 1 + // 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 (!hasDependencies) { - // Single root version, no deps, nothing to skip — install directly without showing the plan. + if (!shouldShowPlan) { this.installItem(); return; } @@ -332,10 +334,11 @@ export class TbIotHubInstallDialogComponent extends DialogComponent e.status === InstallPlanEntryStatus.MISSING); } + const hasMissing = (result.missingItemIds?.length ?? 0) > 0; if (result.rootDescriptor) { - this.handleInstalledDescriptor(result.rootDescriptor, result.missingItemIds?.length > 0); + this.handleInstalledDescriptor(result.rootDescriptor, hasMissing); } else { - this.state = (result.missingItemIds?.length ?? 0) > 0 ? 'partial' : 'success'; + this.state = hasMissing ? 'partial' : 'success'; } } diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index becae3d7be..664ab07b91 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -3845,10 +3845,10 @@ "install-error-message": "Failed to install '{{name}}'.", "install-plan-title": "Install '{{name}}' and its dependencies", "install-plan-will-install": "{{count}} item(s) will be installed.", - "install-plan-will-update": "{{count}} item(s) will be updated.", + "install-plan-will-update": "{{count}} item(s) already installed and will be skipped.", "install-plan-missing-warning": "{{count}} required item(s) could not be found on the marketplace. '{{ name }}' may not work fully without them.", "install-plan-status-will": "Install", - "install-plan-status-installed": "Update", + "install-plan-status-installed": "Already installed", "install-plan-status-missing": "Missing", "install-plan-confirm": "Install", "install-partial-title": "Installed with warnings", From 65e005fe97c282beef791c60846ec29d0326d941 Mon Sep 17 00:00:00 2001 From: dpinkevych Date: Thu, 11 Jun 2026 11:02:48 +0300 Subject: [PATCH 4/5] add tests, refactor translation map --- .../service/iot_hub/DefaultIotHubService.java | 7 +- .../iot_hub/DefaultIotHubServiceTest.java | 117 ++++++++++++++++++ .../iot-hub-install-dialog.component.ts | 10 +- .../assets/locale/locale.constant-en_US.json | 1 + 4 files changed, 132 insertions(+), 3 deletions(-) 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 a96f95c2c3..6b61c05a7a 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 @@ -127,8 +127,9 @@ public class DefaultIotHubService implements IotHubService { /** * 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. */ - private IotHubInstalledItem doInstallVersion(SecurityUser user, String versionId, JsonNode data, HttpServletRequest request) throws Exception { + 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(); @@ -698,7 +699,9 @@ public class DefaultIotHubService implements IotHubService { // level deep, so there is no need to walk a related item's own related items. LinkedHashMap entries = new LinkedHashMap<>(); - String rootItemId = rootVersion.get(FIELD_ITEM_ID).asText(); + // 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); if (related != null && related.isArray()) { for (JsonNode relatedNode : related) { 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 index 5c0559c4d4..9631129a81 100644 --- 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 @@ -17,13 +17,19 @@ 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; @@ -34,6 +40,12 @@ 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; @@ -50,6 +62,7 @@ class DefaultIotHubServiceTest { @Mock private IotHubInstalledItemService iotHubInstalledItemService; + @Spy @InjectMocks private DefaultIotHubService service; @@ -57,6 +70,23 @@ class DefaultIotHubServiceTest { 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); @@ -212,4 +242,91 @@ class DefaultIotHubServiceTest { // No marketplace fetch happened — the duplicate install was avoided. verify(iotHubRestClient, never()).getVersionInfo(anyString()); } + + @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.findInstalledItemIdsByTenantId(tenantId)).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.findInstalledItemIdsByTenantId(tenantId)).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.findInstalledItemIdsByTenantId(tenantId)).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/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 33bb2af0f7..95de4a289e 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 @@ -327,7 +327,15 @@ export class TbIotHubInstallDialogComponent extends DialogComponent 0) { + message += ' ' + this.translate.instant('iot-hub.install-rollback-partial'); + } + this.errorMessage = message; return; } if (this.installPlan) { diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 664ab07b91..7b63f52b97 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -3853,6 +3853,7 @@ "install-plan-confirm": "Install", "install-partial-title": "Installed with warnings", "install-partial-desc": "'{{name}}' was installed, but some required items were missing.", + "install-rollback-partial": "Some of the items installed before the failure could not be removed automatically and may need to be deleted manually.", "check-for-updates": "Check for updates", "updates": "Updates", "up-to-date": "Up to date", From ea70386a7a8d9a4dd47c267ab7e777b41d66bd5f Mon Sep 17 00:00:00 2001 From: dpinkevych Date: Mon, 15 Jun 2026 13:18:06 +0300 Subject: [PATCH 5/5] fix alreadInstalledItems to searchByTenantAndItemId instead of only tenantId --- .../service/iot_hub/DefaultIotHubService.java | 56 +++++++++++++++--- .../iot_hub/DefaultIotHubServiceTest.java | 58 +++++++++++++++---- .../dao/iot_hub/IotHubInstalledItemDao.java | 3 + .../iot_hub/IotHubInstalledItemService.java | 3 + .../IotHubInstalledItemServiceImpl.java | 6 ++ .../IotHubInstalledItemRepository.java | 6 ++ .../iot_hub/JpaIotHubInstalledItemDao.java | 6 ++ 7 files changed, 119 insertions(+), 19 deletions(-) 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 201e48001e..4ac2cd4244 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 @@ -713,7 +713,24 @@ public class DefaultIotHubService implements IotHubService { throw new IllegalArgumentException("Marketplace version not found: " + versionId); } - Set alreadyInstalledItemIds = new HashSet<>(iotHubInstalledItemService.findInstalledItemIdsByTenantId(tenantId)); + // 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, @@ -721,10 +738,6 @@ public class DefaultIotHubService implements IotHubService { // level deep, so there is no need to walk a related item's own related items. LinkedHashMap entries = new LinkedHashMap<>(); - // 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); if (related != null && related.isArray()) { for (JsonNode relatedNode : related) { String relatedItemId = relatedNode.asText(); @@ -816,9 +829,22 @@ public class DefaultIotHubService implements IotHubService { } // 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 against the current state - // so we never install the same item twice. - Set alreadyInstalledItemIds = new HashSet<>(iotHubInstalledItemService.findInstalledItemIdsByTenantId(tenantId)); + // 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<>(); @@ -847,6 +873,9 @@ public class DefaultIotHubService implements IotHubService { 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(); } @@ -895,6 +924,17 @@ public class DefaultIotHubService implements IotHubService { 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; 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 index 9631129a81..d505509e4a 100644 --- 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 @@ -103,7 +103,7 @@ class DefaultIotHubServiceTest { String versionId = UUID.randomUUID().toString(); String itemId = UUID.randomUUID().toString(); when(iotHubRestClient.getVersionInfo(versionId)).thenReturn(version(versionId, itemId, "DASHBOARD", "Root", "1.0")); - when(iotHubInstalledItemService.findInstalledItemIdsByTenantId(tenantId)).thenReturn(List.of()); + when(iotHubInstalledItemService.findInstalledItemIdsByTenantIdAndItemIdIn(eq(tenantId), any())).thenReturn(List.of()); InstallPlan plan = service.resolveInstallPlan(user, versionId); @@ -125,7 +125,7 @@ class DefaultIotHubServiceTest { when(iotHubRestClient.getVersionInfo(versionId)).thenReturn(root); when(iotHubRestClient.getPublishedVersionByItemId(relatedItemId)) .thenReturn(version(UUID.randomUUID().toString(), relatedItemId, "WIDGET", "Dep", "2.0")); - when(iotHubInstalledItemService.findInstalledItemIdsByTenantId(tenantId)).thenReturn(List.of()); + when(iotHubInstalledItemService.findInstalledItemIdsByTenantIdAndItemIdIn(eq(tenantId), any())).thenReturn(List.of()); InstallPlan plan = service.resolveInstallPlan(user, versionId); @@ -146,7 +146,7 @@ class DefaultIotHubServiceTest { root.putArray("relatedItems").add(relatedItemId); when(iotHubRestClient.getVersionInfo(versionId)).thenReturn(root); when(iotHubRestClient.getPublishedVersionByItemId(relatedItemId)).thenReturn(null); - when(iotHubInstalledItemService.findInstalledItemIdsByTenantId(tenantId)).thenReturn(List.of()); + when(iotHubInstalledItemService.findInstalledItemIdsByTenantIdAndItemIdIn(eq(tenantId), any())).thenReturn(List.of()); InstallPlan plan = service.resolveInstallPlan(user, versionId); @@ -166,7 +166,7 @@ class DefaultIotHubServiceTest { root.putArray("relatedItems").add(relatedItemId); when(iotHubRestClient.getVersionInfo(versionId)).thenReturn(root); when(iotHubRestClient.getPublishedVersionByItemId(relatedItemId)).thenThrow(new RuntimeException("boom")); - when(iotHubInstalledItemService.findInstalledItemIdsByTenantId(tenantId)).thenReturn(List.of()); + when(iotHubInstalledItemService.findInstalledItemIdsByTenantIdAndItemIdIn(eq(tenantId), any())).thenReturn(List.of()); InstallPlan plan = service.resolveInstallPlan(user, versionId); @@ -181,7 +181,7 @@ class DefaultIotHubServiceTest { UUID rootItemId = UUID.randomUUID(); when(iotHubRestClient.getVersionInfo(versionId)) .thenReturn(version(versionId, rootItemId.toString(), "DASHBOARD", "Root", "1.0")); - when(iotHubInstalledItemService.findInstalledItemIdsByTenantId(tenantId)).thenReturn(List.of(rootItemId)); + when(iotHubInstalledItemService.findInstalledItemIdsByTenantIdAndItemIdIn(eq(tenantId), any())).thenReturn(List.of(rootItemId)); InstallPlan plan = service.resolveInstallPlan(user, versionId); @@ -204,7 +204,7 @@ class DefaultIotHubServiceTest { assertThat(result.isSuccess()).isFalse(); assertThat(result.getErrorMessage()).contains("empty"); - verify(iotHubInstalledItemService, never()).findInstalledItemIdsByTenantId(any()); + verify(iotHubInstalledItemService, never()).findInstalledItemIdsByTenantIdAndItemIdIn(any(), any()); } @Test @@ -213,7 +213,6 @@ class DefaultIotHubServiceTest { InstallPlanEntry missing = new InstallPlanEntry(); missing.setItemId(UUID.randomUUID().toString()); missing.setStatus(InstallPlanEntry.Status.MISSING); - when(iotHubInstalledItemService.findInstalledItemIdsByTenantId(tenantId)).thenReturn(List.of()); InstallPlanResult result = service.installPlan(user, new InstallPlan(null, List.of(missing)), null, null); @@ -221,6 +220,8 @@ class DefaultIotHubServiceTest { 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 @@ -233,7 +234,7 @@ class DefaultIotHubServiceTest { 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.findInstalledItemIdsByTenantId(tenantId)).thenReturn(List.of(itemId)); + when(iotHubInstalledItemService.findInstalledItemIdsByTenantIdAndItemIdIn(eq(tenantId), any())).thenReturn(List.of(itemId)); InstallPlanResult result = service.installPlan(user, new InstallPlan(null, List.of(entry)), null, null); @@ -243,6 +244,41 @@ class DefaultIotHubServiceTest { 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(); @@ -253,7 +289,7 @@ class DefaultIotHubServiceTest { InstallPlanEntry dep = willInstall(depVersionId, false); InstallPlanEntry root = willInstall(rootVersionId, true); - when(iotHubInstalledItemService.findInstalledItemIdsByTenantId(tenantId)).thenReturn(List.of()); + when(iotHubInstalledItemService.findInstalledItemIdsByTenantIdAndItemIdIn(eq(tenantId), any())).thenReturn(List.of()); IotHubInstalledItem depItem = installedItem(UUID.randomUUID()); IotHubInstalledItem rootItem = installedItem(UUID.randomUUID()); DashboardInstalledItemDescriptor rootDescriptor = new DashboardInstalledItemDescriptor(); @@ -287,7 +323,7 @@ class DefaultIotHubServiceTest { InstallPlanEntry dep2 = willInstall(dep2VersionId, false); InstallPlanEntry root = willInstall(rootVersionId, true); - when(iotHubInstalledItemService.findInstalledItemIdsByTenantId(tenantId)).thenReturn(List.of()); + 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()); @@ -316,7 +352,7 @@ class DefaultIotHubServiceTest { InstallPlanEntry dep = willInstall(depVersionId, false); InstallPlanEntry root = willInstall(rootVersionId, true); - when(iotHubInstalledItemService.findInstalledItemIdsByTenantId(tenantId)).thenReturn(List.of()); + 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()); 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);