From ab578a5f87e5f90595937f85960ab9e142c77806 Mon Sep 17 00:00:00 2001 From: Oleksandra Matviienko Date: Tue, 5 May 2026 13:09:32 +0200 Subject: [PATCH 1/2] Added support for new system widgets in patch upgrades SystemPatchApplier now creates widget types missing from the DB instead of throwing, and merges new fqns into existing system bundles. Bundle creation and inline widgetTypes in bundle JSONs are explicitly rejected as out-of-scope for patch upgrades. --- .../service/install/InstallScripts.java | 6 +- .../service/system/SystemPatchApplier.java | 128 ++++++++- .../server/system/SystemPatchApplierTest.java | 269 +++++++++++++++++- 3 files changed, 376 insertions(+), 27 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java b/application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java index 364fcb47dd..c7203a0a07 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java +++ b/application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java @@ -135,6 +135,10 @@ public class InstallScripts { return Paths.get(getDataDir(), JSON_DIR, SYSTEM_DIR, WIDGET_TYPES_DIR); } + public Path getWidgetBundlesDir() { + return Paths.get(getDataDir(), JSON_DIR, SYSTEM_DIR, WIDGET_BUNDLES_DIR); + } + public String getDataDir() { if (!StringUtils.isEmpty(dataDir)) { if (!Paths.get(this.dataDir).toFile().isDirectory()) { @@ -207,7 +211,7 @@ public class InstallScripts { public void loadSystemWidgets() { log.info("Loading system widgets"); Map widgetsBundlesMap = new HashMap<>(); - Path widgetBundlesDir = Paths.get(getDataDir(), JSON_DIR, SYSTEM_DIR, WIDGET_BUNDLES_DIR); + Path widgetBundlesDir = getWidgetBundlesDir(); try (Stream dirStream = listDir(widgetBundlesDir).filter(path -> path.toString().endsWith(JSON_EXT))) { dirStream.forEach( path -> { diff --git a/application/src/main/java/org/thingsboard/server/service/system/SystemPatchApplier.java b/application/src/main/java/org/thingsboard/server/service/system/SystemPatchApplier.java index bb5052fd76..e5bc65a047 100644 --- a/application/src/main/java/org/thingsboard/server/service/system/SystemPatchApplier.java +++ b/application/src/main/java/org/thingsboard/server/service/system/SystemPatchApplier.java @@ -28,8 +28,10 @@ import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.ThingsBoardThreadFactory; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.widget.WidgetTypeDetails; +import org.thingsboard.server.common.data.widget.WidgetsBundle; import org.thingsboard.server.dao.resource.ImageService; import org.thingsboard.server.dao.widget.WidgetTypeService; +import org.thingsboard.server.dao.widget.WidgetsBundleService; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.install.DatabaseSchemaSettingsService; import org.thingsboard.server.service.install.InstallScripts; @@ -42,6 +44,9 @@ import java.nio.file.Files; import java.nio.file.NoSuchFileException; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; import java.util.Objects; import java.util.Set; import java.util.concurrent.ExecutorService; @@ -67,6 +72,7 @@ public class SystemPatchApplier { private final InstallScripts installScripts; private final DatabaseSchemaSettingsService schemaSettingsService; private final WidgetTypeService widgetTypeService; + private final WidgetsBundleService widgetsBundleService; private final ImageService imageService; @PostConstruct @@ -100,8 +106,11 @@ public class SystemPatchApplier { updateSqlViews(); log.info("Updated sql database views"); - int updated = updateWidgetTypes(); - log.info("Updated {} widget types", updated); + WidgetTypeStats widgetStats = updateWidgetTypes(); + log.info("System widget types: {} created, {} updated", widgetStats.created(), widgetStats.updated()); + + int updatedBundles = updateWidgetBundles(); + log.info("System widget bundles: {} updated", updatedBundles); int createdImages = createMissingSystemImages(); log.info("Created {} new system images", createdImages); @@ -171,20 +180,24 @@ public class SystemPatchApplier { } } - private int updateWidgetTypes() { + private WidgetTypeStats updateWidgetTypes() { + AtomicInteger created = new AtomicInteger(); AtomicInteger updated = new AtomicInteger(); Path widgetTypesDir = installScripts.getWidgetTypesDir(); if (!Files.exists(widgetTypesDir)) { log.trace("Widget types directory does not exist: {}", widgetTypesDir); - return 0; + return new WidgetTypeStats(0, 0); } try (Stream dirStream = listDir(widgetTypesDir).filter(path -> path.toString().endsWith(InstallScripts.JSON_EXT))) { dirStream.forEach( path -> { try { - if (updateWidgetTypeFromFile(path)) { + WidgetTypeChange change = updateWidgetTypeFromFile(path); + if (change == WidgetTypeChange.CREATED) { + created.incrementAndGet(); + } else if (change == WidgetTypeChange.UPDATED) { updated.incrementAndGet(); } } catch (Exception e) { @@ -195,18 +208,26 @@ public class SystemPatchApplier { ); } - return updated.get(); + return new WidgetTypeStats(created.get(), updated.get()); } - private boolean updateWidgetTypeFromFile(Path filePath) { + private WidgetTypeChange updateWidgetTypeFromFile(Path filePath) { JsonNode json = JacksonUtil.toJsonNode(filePath.toFile()); WidgetTypeDetails fileWidgetType = JacksonUtil.treeToValue(json, WidgetTypeDetails.class); + return saveOrUpdateSystemWidgetType(fileWidgetType); + } + + private WidgetTypeChange saveOrUpdateSystemWidgetType(WidgetTypeDetails fileWidgetType) { String fqn = fileWidgetType.getFqn(); + if (fqn == null || fqn.isBlank()) { + throw new RuntimeException("Widget type fqn is missing or blank: " + fileWidgetType.getName()); + } WidgetTypeDetails existingWidgetType = widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, fqn); if (existingWidgetType == null) { - // We expect only update here, so it's probably never happening, but for test purpose leave it like this: - throw new RuntimeException("Widget type not found: " + fqn); + widgetTypeService.saveWidgetType(fileWidgetType); + log.trace("Created widget type: {}", fqn); + return WidgetTypeChange.CREATED; } if (isWidgetTypeChanged(existingWidgetType, fileWidgetType)) { existingWidgetType.setDescription(fileWidgetType.getDescription()); @@ -214,11 +235,92 @@ public class SystemPatchApplier { existingWidgetType.setDescriptor(fileWidgetType.getDescriptor()); widgetTypeService.saveWidgetType(existingWidgetType); log.trace("Updated widget type: {}", fqn); - return true; + return WidgetTypeChange.UPDATED; } log.trace("Widget type unchanged: {}", fqn); - return false; + return WidgetTypeChange.UNCHANGED; + } + + private int updateWidgetBundles() { + AtomicInteger updated = new AtomicInteger(); + Path widgetBundlesDir = installScripts.getWidgetBundlesDir(); + + if (!Files.exists(widgetBundlesDir)) { + log.trace("Widget bundles directory does not exist: {}", widgetBundlesDir); + return 0; + } + + try (Stream dirStream = listDir(widgetBundlesDir).filter(path -> path.toString().endsWith(InstallScripts.JSON_EXT))) { + dirStream.forEach(path -> { + try { + if (processWidgetBundleFile(path)) { + updated.incrementAndGet(); + } + } catch (Exception e) { + log.error("Unable to process widgets bundle from json: [{}]", path); + throw new RuntimeException("Unable to process widgets bundle from json", e); + } + }); + } + + return updated.get(); + } + + private boolean processWidgetBundleFile(Path filePath) { + JsonNode bundleJson = JacksonUtil.toJsonNode(filePath.toFile()); + if (bundleJson == null || !bundleJson.has("widgetsBundle")) { + throw new RuntimeException("Invalid widgets bundle json: " + filePath); + } + WidgetsBundle fileBundle = JacksonUtil.treeToValue(bundleJson.get("widgetsBundle"), WidgetsBundle.class); + String alias = fileBundle.getAlias(); + if (alias == null || alias.isBlank()) { + throw new RuntimeException("Widgets bundle alias is missing or blank: " + filePath); + } + + WidgetsBundle existingBundle = widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, alias); + if (existingBundle == null) { + log.warn("Widgets bundle '{}' not found in DB; bundle creation is not supported by the patch applier, skipping.", alias); + return false; + } + + if (bundleJson.has("widgetTypes")) { + throw new RuntimeException("Inline widgetTypes in bundle JSON are not supported by the patch applier; " + + "place widget definitions in widget_types/*.json and reference them via widgetTypeFqns: " + filePath); + } + List fileWidgetFqns = new ArrayList<>(); + if (bundleJson.has("widgetTypeFqns")) { + bundleJson.get("widgetTypeFqns").forEach(fqnJson -> fileWidgetFqns.add(fqnJson.asText())); + } + + boolean changed = false; + if (isWidgetsBundleChanged(existingBundle, fileBundle)) { + existingBundle.setTitle(fileBundle.getTitle()); + existingBundle.setDescription(fileBundle.getDescription()); + existingBundle.setImage(fileBundle.getImage()); + existingBundle.setOrder(fileBundle.getOrder()); + existingBundle.setScada(fileBundle.isScada()); + widgetsBundleService.saveWidgetsBundle(existingBundle); + log.trace("Updated widgets bundle metadata: {}", alias); + changed = true; + } + + List existingFqns = widgetTypeService.findWidgetFqnsByWidgetsBundleId(TenantId.SYS_TENANT_ID, existingBundle.getId()); + LinkedHashSet mergedFqns = new LinkedHashSet<>(existingFqns); + if (mergedFqns.addAll(fileWidgetFqns)) { + widgetTypeService.updateWidgetsBundleWidgetFqns(TenantId.SYS_TENANT_ID, existingBundle.getId(), new ArrayList<>(mergedFqns)); + log.trace("Linked {} new widget fqn(s) to bundle: {}", mergedFqns.size() - existingFqns.size(), alias); + changed = true; + } + return changed; + } + + private boolean isWidgetsBundleChanged(WidgetsBundle existing, WidgetsBundle file) { + return !Objects.equals(existing.getTitle(), file.getTitle()) + || !Objects.equals(existing.getDescription(), file.getDescription()) + || !Objects.equals(existing.getImage(), file.getImage()) + || !Objects.equals(existing.getOrder(), file.getOrder()) + || existing.isScada() != file.isScada(); } private int createMissingSystemImages() { @@ -349,4 +451,8 @@ public class SystemPatchApplier { public record VersionInfo(int major, int minor, int maintenance, int patch) {} + public record WidgetTypeStats(int created, int updated) {} + + private enum WidgetTypeChange { CREATED, UPDATED, UNCHANGED } + } diff --git a/application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java b/application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java index 51e35bd999..4275b27560 100644 --- a/application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java +++ b/application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java @@ -31,9 +31,12 @@ import org.springframework.test.util.ReflectionTestUtils; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.WidgetTypeId; +import org.thingsboard.server.common.data.id.WidgetsBundleId; import org.thingsboard.server.common.data.widget.WidgetTypeDetails; +import org.thingsboard.server.common.data.widget.WidgetsBundle; import org.thingsboard.server.dao.resource.ImageService; import org.thingsboard.server.dao.widget.WidgetTypeService; +import org.thingsboard.server.dao.widget.WidgetsBundleService; import org.thingsboard.server.service.install.DatabaseSchemaSettingsService; import org.thingsboard.server.service.install.InstallScripts; import org.thingsboard.server.service.system.SystemPatchApplier; @@ -41,6 +44,7 @@ import org.thingsboard.server.service.system.SystemPatchApplier; import java.nio.file.Files; import java.nio.file.Path; import java.util.Collections; +import java.util.List; import java.util.Set; import java.util.UUID; import java.util.concurrent.CountDownLatch; @@ -81,6 +85,9 @@ public class SystemPatchApplierTest { @Mock private WidgetTypeService widgetTypeService; + @Mock + private WidgetsBundleService widgetsBundleService; + @Mock private ImageService imageService; @@ -155,19 +162,72 @@ public class SystemPatchApplierTest { } @Test - void whenWidgetNotFound_thenThrowException() throws Exception { + void whenWidgetNotFound_thenCreateNewWidget() throws Exception { Path widgetTypesDir = tempDir.resolve("widget_types"); Files.createDirectories(widgetTypesDir); when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir); - WidgetTypeDetails testWidget = createTestWidgetType("test_widget", "Test Widget"); - String json = JacksonUtil.toString(testWidget); + WidgetTypeDetails fileWidget = createTestWidgetType("new_widget", "New Widget"); + String json = JacksonUtil.toString(fileWidget); assertNotNull(json); - Files.writeString(widgetTypesDir.resolve("test_widget.json"), json); + Files.writeString(widgetTypesDir.resolve("new_widget.json"), json); + + when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "new_widget")).thenReturn(null); + + SystemPatchApplier.WidgetTypeStats stats = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"); - when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "test_widget")).thenReturn(null); + assertNotNull(stats); + assertEquals(1, stats.created()); + assertEquals(0, stats.updated()); + verify(widgetTypeService).saveWidgetType(argThat(w -> "new_widget".equals(w.getFqn()))); + } + + @Test + void whenFqnIsBlank_thenThrowException() throws Exception { + Path widgetTypesDir = tempDir.resolve("widget_types"); + Files.createDirectories(widgetTypesDir); + when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir); + + WidgetTypeDetails brokenWidget = createTestWidgetType("", "Broken Widget"); + String json = JacksonUtil.toString(brokenWidget); + assertNotNull(json); + Files.writeString(widgetTypesDir.resolve("broken.json"), json); assertThrows(RuntimeException.class, () -> ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes")); + verify(widgetTypeService, never()).saveWidgetType(any()); + } + + @Test + void whenMixOfCreatedAndUpdated_thenStatsAreCorrect() throws Exception { + Path widgetTypesDir = tempDir.resolve("widget_types"); + Files.createDirectories(widgetTypesDir); + when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir); + + WidgetTypeDetails newFileWidget = createTestWidgetType("widget_new", "Widget New"); + Files.writeString(widgetTypesDir.resolve("widget_new.json"), JacksonUtil.toString(newFileWidget)); + + WidgetTypeDetails changedFileWidget = createTestWidgetType("widget_changed", "Widget Changed New Name"); + Files.writeString(widgetTypesDir.resolve("widget_changed.json"), JacksonUtil.toString(changedFileWidget)); + + WidgetTypeDetails sameFileWidget = createTestWidgetType("widget_same", "Widget Same"); + Files.writeString(widgetTypesDir.resolve("widget_same.json"), JacksonUtil.toString(sameFileWidget)); + + WidgetTypeDetails existingChanged = createTestWidgetType("widget_changed", "Widget Changed Old Name"); + existingChanged.setId(new WidgetTypeId(UUID.randomUUID())); + + WidgetTypeDetails existingSame = createTestWidgetType("widget_same", "Widget Same"); + existingSame.setId(new WidgetTypeId(UUID.randomUUID())); + + when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "widget_new")).thenReturn(null); + when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "widget_changed")).thenReturn(existingChanged); + when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "widget_same")).thenReturn(existingSame); + + SystemPatchApplier.WidgetTypeStats stats = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"); + + assertNotNull(stats); + assertEquals(1, stats.created()); + assertEquals(1, stats.updated()); + verify(widgetTypeService, times(2)).saveWidgetType(any()); } @Test @@ -189,9 +249,11 @@ public class SystemPatchApplierTest { when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "test_widget")) .thenReturn(existingWidget); - Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"); + SystemPatchApplier.WidgetTypeStats stats = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"); - assertEquals(1, updated); + assertNotNull(stats); + assertEquals(0, stats.created()); + assertEquals(1, stats.updated()); verify(widgetTypeService).saveWidgetType(argThat(w -> w.getDescriptor().get("version").asInt() == 2 )); @@ -214,9 +276,11 @@ public class SystemPatchApplierTest { when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "test_widget")) .thenReturn(existingWidget); - Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"); + SystemPatchApplier.WidgetTypeStats stats = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"); - assertEquals(1, updated); + assertNotNull(stats); + assertEquals(0, stats.created()); + assertEquals(1, stats.updated()); verify(widgetTypeService).saveWidgetType(argThat(w -> "New Name".equals(w.getName()))); } @@ -237,9 +301,11 @@ public class SystemPatchApplierTest { when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "test_widget")) .thenReturn(existingWidget); - Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"); + SystemPatchApplier.WidgetTypeStats stats = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"); - assertEquals(0, updated); + assertNotNull(stats); + assertEquals(0, stats.created()); + assertEquals(0, stats.updated()); verify(widgetTypeService, never()).saveWidgetType(any()); } @@ -339,8 +405,8 @@ public class SystemPatchApplierTest { // Simulate work while holding lock Thread.sleep(100); - Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"); - firstThreadSavedWidget.set(updated != null && updated > 0); + SystemPatchApplier.WidgetTypeStats stats = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"); + firstThreadSavedWidget.set(stats != null && stats.updated() > 0); ReflectionTestUtils.invokeMethod(reconciler, "releaseAdvisoryLock"); } @@ -360,8 +426,8 @@ public class SystemPatchApplierTest { secondThreadAcquiredLock.set(Boolean.TRUE.equals(acquired)); if (secondThreadAcquiredLock.get()) { - Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"); - secondThreadSavedWidget.set(updated != null && updated > 0); + SystemPatchApplier.WidgetTypeStats stats = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"); + secondThreadSavedWidget.set(stats != null && stats.updated() > 0); ReflectionTestUtils.invokeMethod(reconciler, "releaseAdvisoryLock"); } @@ -560,6 +626,7 @@ public class SystemPatchApplierTest { Path widgetTypesDir = tempDir.resolve("widget_types"); Files.createDirectories(widgetTypesDir); when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir); + when(installScripts.getWidgetBundlesDir()).thenReturn(tempDir.resolve("widget_bundles_missing")); ReflectionTestUtils.invokeMethod(reconciler, "applyPatchIfNeeded"); @@ -607,6 +674,7 @@ public class SystemPatchApplierTest { Path widgetTypesDir = tempDir.resolve("widget_types"); Files.createDirectories(widgetTypesDir); when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir); + when(installScripts.getWidgetBundlesDir()).thenReturn(tempDir.resolve("widget_bundles_missing")); ReflectionTestUtils.invokeMethod(reconciler, "applyPatchIfNeeded"); @@ -834,6 +902,7 @@ public class SystemPatchApplierTest { Path widgetTypesDir = tempDir.resolve("widget_types"); Files.createDirectories(widgetTypesDir); when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir); + when(installScripts.getWidgetBundlesDir()).thenReturn(tempDir.resolve("widget_bundles_missing")); when(imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID)).thenReturn(Collections.emptySet()); @@ -854,4 +923,174 @@ public class SystemPatchApplierTest { verify(imageService, never()).createOrUpdateSystemImage(anyString(), any(byte[].class)); } + // --- updateWidgetBundles tests --- + + @Test + void whenWidgetBundlesDirDoesNotExist_thenReturnsZero() { + when(installScripts.getWidgetBundlesDir()).thenReturn(tempDir.resolve("missing_bundles")); + + Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles"); + + assertEquals(0, updated); + verify(widgetsBundleService, never()).saveWidgetsBundle(any()); + verify(widgetTypeService, never()).updateWidgetsBundleWidgetFqns(any(), any(), any()); + } + + @Test + void whenBundleNotInDb_thenSkipWithoutCreation() throws Exception { + Path bundlesDir = tempDir.resolve("widget_bundles"); + Files.createDirectories(bundlesDir); + when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir); + + Files.writeString(bundlesDir.resolve("charts.json"), + "{\"widgetsBundle\":{\"alias\":\"charts\",\"title\":\"Charts\",\"order\":10}," + + "\"widgetTypeFqns\":[\"line_chart\"]}"); + + when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "charts")).thenReturn(null); + + Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles"); + + assertEquals(0, updated); + verify(widgetsBundleService, never()).saveWidgetsBundle(any()); + verify(widgetTypeService, never()).updateWidgetsBundleWidgetFqns(any(), any(), any()); + } + + @Test + void whenBundleExistsAndHasNewFqn_thenMergeFqns() throws Exception { + Path bundlesDir = tempDir.resolve("widget_bundles"); + Files.createDirectories(bundlesDir); + when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir); + + Files.writeString(bundlesDir.resolve("charts.json"), + "{\"widgetsBundle\":{\"alias\":\"charts\",\"title\":\"Charts\",\"description\":\"d\",\"order\":10}," + + "\"widgetTypeFqns\":[\"line_chart\",\"bar_chart\",\"new_chart\"]}"); + + WidgetsBundle existingBundle = createTestBundle("charts", "Charts"); + existingBundle.setDescription("d"); + existingBundle.setOrder(10); + when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "charts")).thenReturn(existingBundle); + when(widgetTypeService.findWidgetFqnsByWidgetsBundleId(TenantId.SYS_TENANT_ID, existingBundle.getId())) + .thenReturn(List.of("line_chart", "bar_chart")); + + Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles"); + + assertEquals(1, updated); + verify(widgetsBundleService, never()).saveWidgetsBundle(any()); + verify(widgetTypeService).updateWidgetsBundleWidgetFqns( + eq(TenantId.SYS_TENANT_ID), + eq(existingBundle.getId()), + argThat(fqns -> fqns.size() == 3 + && fqns.get(0).equals("line_chart") + && fqns.get(1).equals("bar_chart") + && fqns.get(2).equals("new_chart")) + ); + } + + @Test + void whenBundleExistsAndAllFqnsAlreadyLinked_thenNoLinkUpdate() throws Exception { + Path bundlesDir = tempDir.resolve("widget_bundles"); + Files.createDirectories(bundlesDir); + when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir); + + Files.writeString(bundlesDir.resolve("charts.json"), + "{\"widgetsBundle\":{\"alias\":\"charts\",\"title\":\"Charts\",\"description\":\"d\",\"order\":10}," + + "\"widgetTypeFqns\":[\"line_chart\",\"bar_chart\"]}"); + + WidgetsBundle existingBundle = createTestBundle("charts", "Charts"); + existingBundle.setDescription("d"); + existingBundle.setOrder(10); + when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "charts")).thenReturn(existingBundle); + when(widgetTypeService.findWidgetFqnsByWidgetsBundleId(TenantId.SYS_TENANT_ID, existingBundle.getId())) + .thenReturn(List.of("line_chart", "bar_chart")); + + Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles"); + + assertEquals(0, updated); + verify(widgetsBundleService, never()).saveWidgetsBundle(any()); + verify(widgetTypeService, never()).updateWidgetsBundleWidgetFqns(any(), any(), any()); + } + + @Test + void whenBundleMetadataChanged_thenUpdateBundle() throws Exception { + Path bundlesDir = tempDir.resolve("widget_bundles"); + Files.createDirectories(bundlesDir); + when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir); + + Files.writeString(bundlesDir.resolve("charts.json"), + "{\"widgetsBundle\":{\"alias\":\"charts\",\"title\":\"New Title\",\"description\":\"new\",\"order\":20}," + + "\"widgetTypeFqns\":[\"line_chart\"]}"); + + WidgetsBundle existingBundle = createTestBundle("charts", "Old Title"); + existingBundle.setDescription("old"); + existingBundle.setOrder(10); + when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "charts")).thenReturn(existingBundle); + when(widgetTypeService.findWidgetFqnsByWidgetsBundleId(TenantId.SYS_TENANT_ID, existingBundle.getId())) + .thenReturn(List.of("line_chart")); + + Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles"); + + assertEquals(1, updated); + verify(widgetsBundleService).saveWidgetsBundle(argThat(b -> + "New Title".equals(b.getTitle()) && "new".equals(b.getDescription()) && b.getOrder() == 20 + )); + verify(widgetTypeService, never()).updateWidgetsBundleWidgetFqns(any(), any(), any()); + } + + @Test + void whenBundleAliasIsBlank_thenThrowException() throws Exception { + Path bundlesDir = tempDir.resolve("widget_bundles"); + Files.createDirectories(bundlesDir); + when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir); + + Files.writeString(bundlesDir.resolve("broken.json"), + "{\"widgetsBundle\":{\"alias\":\"\",\"title\":\"Broken\"}}"); + + assertThrows(RuntimeException.class, () -> ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles")); + verify(widgetsBundleService, never()).saveWidgetsBundle(any()); + } + + @Test + void whenBundleJsonMissingWidgetsBundleField_thenThrowException() throws Exception { + Path bundlesDir = tempDir.resolve("widget_bundles"); + Files.createDirectories(bundlesDir); + when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir); + + Files.writeString(bundlesDir.resolve("broken.json"), "{\"foo\":\"bar\"}"); + + assertThrows(RuntimeException.class, () -> ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles")); + verify(widgetsBundleService, never()).saveWidgetsBundle(any()); + } + + @Test + void whenBundleHasInlineWidgetTypes_thenThrowException() throws Exception { + Path bundlesDir = tempDir.resolve("widget_bundles"); + Files.createDirectories(bundlesDir); + when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir); + + Files.writeString(bundlesDir.resolve("charts.json"), + "{\"widgetsBundle\":{\"alias\":\"charts\",\"title\":\"Charts\",\"description\":\"d\",\"order\":10}," + + "\"widgetTypes\":[" + + "{\"fqn\":\"inline_chart\",\"name\":\"Inline\",\"descriptor\":{\"type\":\"latest\"}}" + + "]}"); + + WidgetsBundle existingBundle = createTestBundle("charts", "Charts"); + existingBundle.setDescription("d"); + existingBundle.setOrder(10); + when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "charts")).thenReturn(existingBundle); + + assertThrows(RuntimeException.class, () -> ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles")); + verify(widgetTypeService, never()).saveWidgetType(any()); + verify(widgetTypeService, never()).updateWidgetsBundleWidgetFqns(any(), any(), any()); + verify(widgetsBundleService, never()).saveWidgetsBundle(any()); + } + + private WidgetsBundle createTestBundle(String alias, String title) { + WidgetsBundle bundle = new WidgetsBundle(); + bundle.setId(new WidgetsBundleId(UUID.randomUUID())); + bundle.setAlias(alias); + bundle.setTitle(title); + bundle.setTenantId(TenantId.SYS_TENANT_ID); + return bundle; + } + } From ee541633527b6146d7145009ad5e71c9e9c1b962 Mon Sep 17 00:00:00 2001 From: Oleksandra Matviienko Date: Tue, 5 May 2026 15:05:57 +0200 Subject: [PATCH 2/2] Skipped image field when comparing widgets bundle metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ImageService.replaceBase64WithImageUrl rewrites base64 data URIs into system-image URLs at save time. After install, the DB image is a 'tb-image;...' URL while the JSON file still carries a base64 data URI — naive string compare always reported a diff and caused every system bundle to be re-saved on every patch run. --- .../service/system/SystemPatchApplier.java | 5 +++- .../server/system/SystemPatchApplierTest.java | 27 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/application/src/main/java/org/thingsboard/server/service/system/SystemPatchApplier.java b/application/src/main/java/org/thingsboard/server/service/system/SystemPatchApplier.java index e5bc65a047..a256888107 100644 --- a/application/src/main/java/org/thingsboard/server/service/system/SystemPatchApplier.java +++ b/application/src/main/java/org/thingsboard/server/service/system/SystemPatchApplier.java @@ -316,9 +316,12 @@ public class SystemPatchApplier { } private boolean isWidgetsBundleChanged(WidgetsBundle existing, WidgetsBundle file) { + // Image is intentionally NOT compared: the file always carries a base64 data URI, while the DB stores + // the system-image URL produced by ImageService.replaceBase64WithImageUrl on save. A naive string compare + // would always report a diff and re-save every system bundle on every patch run. Image content changes + // are out of scope for the patch applier — full reinstall covers them. return !Objects.equals(existing.getTitle(), file.getTitle()) || !Objects.equals(existing.getDescription(), file.getDescription()) - || !Objects.equals(existing.getImage(), file.getImage()) || !Objects.equals(existing.getOrder(), file.getOrder()) || existing.isScada() != file.isScada(); } diff --git a/application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java b/application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java index 4275b27560..f2485dc7d5 100644 --- a/application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java +++ b/application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java @@ -1010,6 +1010,33 @@ public class SystemPatchApplierTest { verify(widgetTypeService, never()).updateWidgetsBundleWidgetFqns(any(), any(), any()); } + @Test + void whenOnlyBundleImageFormatDiffers_thenNoUpdate() throws Exception { + Path bundlesDir = tempDir.resolve("widget_bundles"); + Files.createDirectories(bundlesDir); + when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir); + + // File carries a base64 data URI; DB has the resolved system-image URL — same content, different format. + Files.writeString(bundlesDir.resolve("charts.json"), + "{\"widgetsBundle\":{\"alias\":\"charts\",\"title\":\"Charts\",\"description\":\"d\",\"order\":10," + + "\"image\":\"data:image/png;base64,iVBORw0KGgo\"}," + + "\"widgetTypeFqns\":[]}"); + + WidgetsBundle existingBundle = createTestBundle("charts", "Charts"); + existingBundle.setDescription("d"); + existingBundle.setOrder(10); + existingBundle.setImage("tb-image;/api/images/system/charts.png"); + when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "charts")).thenReturn(existingBundle); + when(widgetTypeService.findWidgetFqnsByWidgetsBundleId(TenantId.SYS_TENANT_ID, existingBundle.getId())) + .thenReturn(List.of()); + + Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles"); + + assertEquals(0, updated); + verify(widgetsBundleService, never()).saveWidgetsBundle(any()); + verify(widgetTypeService, never()).updateWidgetsBundleWidgetFqns(any(), any(), any()); + } + @Test void whenBundleMetadataChanged_thenUpdateBundle() throws Exception { Path bundlesDir = tempDir.resolve("widget_bundles");