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",