From 389d51bbfaea2e3a7feebccaa1ca0eae468f9f25 Mon Sep 17 00:00:00 2001 From: Oleksandra Matviienko Date: Wed, 8 Apr 2026 13:48:47 +0200 Subject: [PATCH 1/4] Add enable/disable support for calculated fields Allow users to temporarily disable calculated fields without deleting them. Disabled CFs are removed from actor processing and persist their disabled state across server restarts. --- .../main/data/upgrade/basic/schema_update.sql | 6 + ...alculatedFieldManagerMessageProcessor.java | 11 +- .../cf/CalculatedFieldIntegrationTest.java | 293 ++++++++++++++++++ .../CalculatedFieldControllerTest.java | 116 +++++++ .../common/data/cf/CalculatedField.java | 3 + .../server/dao/model/ModelConstants.java | 1 + .../dao/model/sql/CalculatedFieldEntity.java | 6 + .../main/resources/sql/schema-entities.sql | 1 + .../server/msa/cf/CalculatedFieldTest.java | 119 +++++++ .../calculated-fields-table-config.ts | 19 ++ .../shared/models/calculated-field.models.ts | 1 + .../assets/locale/locale.constant-en_US.json | 2 + 12 files changed, 576 insertions(+), 2 deletions(-) diff --git a/application/src/main/data/upgrade/basic/schema_update.sql b/application/src/main/data/upgrade/basic/schema_update.sql index 2f9c0cd302..8cb79674bf 100644 --- a/application/src/main/data/upgrade/basic/schema_update.sql +++ b/application/src/main/data/upgrade/basic/schema_update.sql @@ -20,6 +20,12 @@ ALTER TABLE calculated_field ADD COLUMN IF NOT EXISTS additional_info varchar; -- CALCULATED FIELD ADDITIONAL INFO ADDITION END +-- CALCULATED FIELD ENABLED ADDITION START + +ALTER TABLE calculated_field ADD COLUMN IF NOT EXISTS enabled boolean NOT NULL DEFAULT true; + +-- CALCULATED FIELD ENABLED ADDITION END + -- RULE CHAIN NOTES MIGRATION START ALTER TABLE rule_chain ADD COLUMN IF NOT EXISTS notes varchar(1000000); diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java index a1d7477a36..0d87fad694 100644 --- a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java @@ -417,8 +417,8 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware callback.onSuccess(); } else { var cf = cfDaoService.findById(msg.getTenantId(), cfId); - if (cf == null) { - log.debug("[{}] Failed to lookup CF by id [{}]", tenantId, cfId); + if (cf == null || !cf.isEnabled()) { + log.debug("[{}] CF not found or disabled [{}]", tenantId, cfId); callback.onSuccess(); } else { var cfCtx = getCfCtx(cf); @@ -451,6 +451,9 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware if (newCf == null) { log.debug("[{}] Failed to lookup CF by id [{}]", tenantId, cfId); callback.onSuccess(); + } else if (!newCf.isEnabled()) { + log.debug("[{}] CF disabled, treating as delete [{}]", tenantId, cfId); + onCfDeleted(msg, callback); } else { var newCfCtx = getCfCtx(newCf); try { @@ -805,6 +808,10 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware PageDataIterable cfs = new PageDataIterable<>(pageLink -> cfDaoService.findCalculatedFieldsByTenantId(tenantId, pageLink), cfSettings.getInitTenantFetchPackSize()); cfs.forEach(cf -> { log.trace("Processing calculated field record: {}", cf); + if (!cf.isEnabled()) { + log.debug("[{}] Skipping disabled calculated field [{}]", tenantId, cf.getId()); + return; + } try { initCalculatedField(cf); initCalculatedFieldLinks(cf); diff --git a/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java b/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java index ba7d11fee9..67b2b2ccfb 100644 --- a/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java @@ -1481,6 +1481,299 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes }); } + @Test + public void testDisabledCalculatedFieldDoesNotProcess() throws Exception { + Device testDevice = createDevice("Test device", "1234567890"); + + CalculatedField calculatedField = new CalculatedField(); + calculatedField.setEntityId(testDevice.getId()); + calculatedField.setType(CalculatedFieldType.SIMPLE); + calculatedField.setName("enable-disable-test"); + calculatedField.setConfigurationVersion(1); + + SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration(); + Argument argument = new Argument(); + argument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + config.setArguments(Map.of("T", argument)); + config.setExpression("T * 2"); + TimeSeriesOutput output = new TimeSeriesOutput(); + output.setName("doubled"); + config.setOutput(output); + calculatedField.setConfiguration(config); + + // Create CF (enabled by default) and verify it processes + CalculatedField savedCf = doPost("/api/calculatedField", calculatedField, CalculatedField.class); + postTelemetry(testDevice.getId(), "{\"temperature\":10}"); + + await().alias("enabled CF processes telemetry").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode result = getLatestTelemetry(testDevice.getId(), "doubled"); + assertThat(result).isNotNull(); + assertThat(result.get("doubled").get(0).get("value").asText()).isEqualTo("20.0"); + }); + + // Disable CF + savedCf = doGet("/api/calculatedField/" + savedCf.getId().getId(), CalculatedField.class); + savedCf.setEnabled(false); + savedCf = doPost("/api/calculatedField", savedCf, CalculatedField.class); + assertThat(savedCf.isEnabled()).isFalse(); + + // Send new telemetry — disabled CF should NOT recalculate + postTelemetry(testDevice.getId(), "{\"temperature\":50}"); + + await().alias("disabled CF should not recalculate").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .during(3, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode result = getLatestTelemetry(testDevice.getId(), "doubled"); + assertThat(result.get("doubled").get(0).get("value").asText()).isEqualTo("20.0"); + }); + + doDelete("/api/calculatedField/" + savedCf.getId().getId().toString()) + .andExpect(status().isOk()); + } + + @Test + public void testReEnabledCalculatedFieldProcesses() throws Exception { + Device testDevice = createDevice("Test device", "1234567890"); + + CalculatedField calculatedField = new CalculatedField(); + calculatedField.setEntityId(testDevice.getId()); + calculatedField.setType(CalculatedFieldType.SIMPLE); + calculatedField.setName("re-enable-test"); + calculatedField.setConfigurationVersion(1); + + SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration(); + Argument argument = new Argument(); + argument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + config.setArguments(Map.of("T", argument)); + config.setExpression("T + 100"); + TimeSeriesOutput output = new TimeSeriesOutput(); + output.setName("shifted"); + config.setOutput(output); + calculatedField.setConfiguration(config); + + // Create CF, send telemetry, verify + CalculatedField savedCf = doPost("/api/calculatedField", calculatedField, CalculatedField.class); + postTelemetry(testDevice.getId(), "{\"temperature\":5}"); + + await().alias("initial calculation").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode result = getLatestTelemetry(testDevice.getId(), "shifted"); + assertThat(result).isNotNull(); + assertThat(result.get("shifted").get(0).get("value").asText()).isEqualTo("105.0"); + }); + + // Disable + savedCf = doGet("/api/calculatedField/" + savedCf.getId().getId(), CalculatedField.class); + savedCf.setEnabled(false); + savedCf = doPost("/api/calculatedField", savedCf, CalculatedField.class); + + // Re-enable + savedCf = doGet("/api/calculatedField/" + savedCf.getId().getId(), CalculatedField.class); + savedCf.setEnabled(true); + savedCf = doPost("/api/calculatedField", savedCf, CalculatedField.class); + assertThat(savedCf.isEnabled()).isTrue(); + + // Send new telemetry — re-enabled CF should process + postTelemetry(testDevice.getId(), "{\"temperature\":20}"); + + await().alias("re-enabled CF processes telemetry").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode result = getLatestTelemetry(testDevice.getId(), "shifted"); + assertThat(result).isNotNull(); + assertThat(result.get("shifted").get(0).get("value").asText()).isEqualTo("120.0"); + }); + + doDelete("/api/calculatedField/" + savedCf.getId().getId().toString()) + .andExpect(status().isOk()); + } + + @Test + public void testMultipleEnableDisableCyclesWithTelemetry() throws Exception { + Device testDevice = createDevice("Test device", "1234567890"); + + CalculatedField calculatedField = new CalculatedField(); + calculatedField.setEntityId(testDevice.getId()); + calculatedField.setType(CalculatedFieldType.SIMPLE); + calculatedField.setName("multi-cycle-test"); + calculatedField.setConfigurationVersion(1); + + SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration(); + Argument argument = new Argument(); + argument.setRefEntityKey(new ReferencedEntityKey("value", ArgumentType.TS_LATEST, null)); + config.setArguments(Map.of("V", argument)); + config.setExpression("V * 10"); + TimeSeriesOutput output = new TimeSeriesOutput(); + output.setName("result"); + config.setOutput(output); + calculatedField.setConfiguration(config); + + // Create enabled CF + CalculatedField cf = doPost("/api/calculatedField", calculatedField, CalculatedField.class); + + // --- Cycle 1: enabled → process → disable → skip → enable → process --- + + postTelemetry(testDevice.getId(), "{\"value\":1}"); + await().alias("cycle 1: enabled, processes").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode r = getLatestTelemetry(testDevice.getId(), "result"); + assertThat(r.get("result").get(0).get("value").asText()).isEqualTo("10.0"); + }); + + // Disable + cf = doGet("/api/calculatedField/" + cf.getId().getId(), CalculatedField.class); + cf.setEnabled(false); + cf = doPost("/api/calculatedField", cf, CalculatedField.class); + + postTelemetry(testDevice.getId(), "{\"value\":2}"); + await().alias("cycle 1: disabled, should not recalculate").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .during(3, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode r = getLatestTelemetry(testDevice.getId(), "result"); + assertThat(r.get("result").get(0).get("value").asText()).isEqualTo("10.0"); + }); + + // Re-enable + cf = doGet("/api/calculatedField/" + cf.getId().getId(), CalculatedField.class); + cf.setEnabled(true); + cf = doPost("/api/calculatedField", cf, CalculatedField.class); + + postTelemetry(testDevice.getId(), "{\"value\":3}"); + await().alias("cycle 1: re-enabled, processes").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode r = getLatestTelemetry(testDevice.getId(), "result"); + assertThat(r.get("result").get(0).get("value").asText()).isEqualTo("30.0"); + }); + + // --- Cycle 2: disable → skip → enable → process --- + + cf = doGet("/api/calculatedField/" + cf.getId().getId(), CalculatedField.class); + cf.setEnabled(false); + cf = doPost("/api/calculatedField", cf, CalculatedField.class); + + postTelemetry(testDevice.getId(), "{\"value\":4}"); + await().alias("cycle 2: disabled, should not recalculate").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .during(3, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode r = getLatestTelemetry(testDevice.getId(), "result"); + assertThat(r.get("result").get(0).get("value").asText()).isEqualTo("30.0"); + }); + + cf = doGet("/api/calculatedField/" + cf.getId().getId(), CalculatedField.class); + cf.setEnabled(true); + cf = doPost("/api/calculatedField", cf, CalculatedField.class); + + postTelemetry(testDevice.getId(), "{\"value\":5}"); + await().alias("cycle 2: re-enabled, processes").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode r = getLatestTelemetry(testDevice.getId(), "result"); + assertThat(r.get("result").get(0).get("value").asText()).isEqualTo("50.0"); + }); + + // --- Cycle 3: disable → skip → enable → process --- + + cf = doGet("/api/calculatedField/" + cf.getId().getId(), CalculatedField.class); + cf.setEnabled(false); + cf = doPost("/api/calculatedField", cf, CalculatedField.class); + + postTelemetry(testDevice.getId(), "{\"value\":6}"); + await().alias("cycle 3: disabled, should not recalculate").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .during(3, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode r = getLatestTelemetry(testDevice.getId(), "result"); + assertThat(r.get("result").get(0).get("value").asText()).isEqualTo("50.0"); + }); + + cf = doGet("/api/calculatedField/" + cf.getId().getId(), CalculatedField.class); + cf.setEnabled(true); + cf = doPost("/api/calculatedField", cf, CalculatedField.class); + + postTelemetry(testDevice.getId(), "{\"value\":7}"); + await().alias("cycle 3: re-enabled, processes").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode r = getLatestTelemetry(testDevice.getId(), "result"); + assertThat(r.get("result").get(0).get("value").asText()).isEqualTo("70.0"); + }); + + doDelete("/api/calculatedField/" + cf.getId().getId().toString()) + .andExpect(status().isOk()); + } + + @Test + public void testDisabledCalculatedFieldWhenEntityIdIsProfile() throws Exception { + Device testDevice = createDevice("Test device", "1234567890"); + + CalculatedField calculatedField = new CalculatedField(); + calculatedField.setEntityId(testDevice.getDeviceProfileId()); + calculatedField.setType(CalculatedFieldType.SIMPLE); + calculatedField.setName("profile-enable-disable-test"); + calculatedField.setConfigurationVersion(1); + + SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration(); + Argument argument = new Argument(); + argument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + config.setArguments(Map.of("T", argument)); + config.setExpression("T + 1"); + TimeSeriesOutput output = new TimeSeriesOutput(); + output.setName("temp_plus_one"); + config.setOutput(output); + calculatedField.setConfiguration(config); + + CalculatedField cf = doPost("/api/calculatedField", calculatedField, CalculatedField.class); + + postTelemetry(testDevice.getId(), "{\"temperature\":10}"); + await().alias("profile CF processes device telemetry").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode result = getLatestTelemetry(testDevice.getId(), "temp_plus_one"); + assertThat(result).isNotNull(); + assertThat(result.get("temp_plus_one").get(0).get("value").asText()).isEqualTo("11.0"); + }); + + // Disable profile-level CF + cf = doGet("/api/calculatedField/" + cf.getId().getId(), CalculatedField.class); + cf.setEnabled(false); + cf = doPost("/api/calculatedField", cf, CalculatedField.class); + + // Device telemetry should not be processed + postTelemetry(testDevice.getId(), "{\"temperature\":50}"); + + await().alias("disabled profile CF should not process device telemetry").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .during(3, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode result = getLatestTelemetry(testDevice.getId(), "temp_plus_one"); + assertThat(result.get("temp_plus_one").get(0).get("value").asText()).isEqualTo("11.0"); + }); + + // Re-enable + cf = doGet("/api/calculatedField/" + cf.getId().getId(), CalculatedField.class); + cf.setEnabled(true); + cf = doPost("/api/calculatedField", cf, CalculatedField.class); + + postTelemetry(testDevice.getId(), "{\"temperature\":99}"); + await().alias("re-enabled profile CF processes").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode r = getLatestTelemetry(testDevice.getId(), "temp_plus_one"); + assertThat(r.get("temp_plus_one").get(0).get("value").asText()).isEqualTo("100.0"); + }); + + doDelete("/api/calculatedField/" + cf.getId().getId().toString()) + .andExpect(status().isOk()); + } + private ObjectNode getLatestTelemetry(EntityId entityId, String... keys) throws Exception { return doGetAsync("/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() + "/values/timeseries?keys=" + String.join(",", keys), ObjectNode.class); } diff --git a/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java index 18e11faaf9..e723b96fa7 100644 --- a/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java @@ -297,6 +297,122 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest { assertThat(names.getData()).containsOnly(deviceCalculatedField.getName()); } + @Test + public void testCalculatedFieldEnabledByDefault() throws Exception { + Device testDevice = createDevice("Test device", "1234567890"); + CalculatedField calculatedField = getSimpleCalculatedField(testDevice.getId()); + + CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class); + + assertThat(savedCalculatedField.isEnabled()).isTrue(); + + CalculatedField fetchedCalculatedField = doGet("/api/calculatedField/" + savedCalculatedField.getId().getId(), CalculatedField.class); + assertThat(fetchedCalculatedField.isEnabled()).isTrue(); + + doDelete("/api/calculatedField/" + savedCalculatedField.getId().getId().toString()) + .andExpect(status().isOk()); + } + + @Test + public void testDisableCalculatedField() throws Exception { + Device testDevice = createDevice("Test device", "1234567890"); + CalculatedField calculatedField = getSimpleCalculatedField(testDevice.getId()); + + CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class); + assertThat(savedCalculatedField.isEnabled()).isTrue(); + + savedCalculatedField.setEnabled(false); + CalculatedField disabledCalculatedField = doPost("/api/calculatedField", savedCalculatedField, CalculatedField.class); + + assertThat(disabledCalculatedField.isEnabled()).isFalse(); + assertThat(disabledCalculatedField.getVersion()).isEqualTo(savedCalculatedField.getVersion() + 1); + + CalculatedField fetchedCalculatedField = doGet("/api/calculatedField/" + disabledCalculatedField.getId().getId(), CalculatedField.class); + assertThat(fetchedCalculatedField.isEnabled()).isFalse(); + + doDelete("/api/calculatedField/" + savedCalculatedField.getId().getId().toString()) + .andExpect(status().isOk()); + } + + @Test + public void testEnableDisableCycles() throws Exception { + Device testDevice = createDevice("Test device", "1234567890"); + CalculatedField calculatedField = getSimpleCalculatedField(testDevice.getId()); + + CalculatedField cf = doPost("/api/calculatedField", calculatedField, CalculatedField.class); + assertThat(cf.isEnabled()).isTrue(); + long version = cf.getVersion(); + + // Cycle 1: disable + cf.setEnabled(false); + cf = doPost("/api/calculatedField", cf, CalculatedField.class); + assertThat(cf.isEnabled()).isFalse(); + assertThat(cf.getVersion()).isEqualTo(++version); + + // Cycle 1: re-enable + cf.setEnabled(true); + cf = doPost("/api/calculatedField", cf, CalculatedField.class); + assertThat(cf.isEnabled()).isTrue(); + assertThat(cf.getVersion()).isEqualTo(++version); + + // Cycle 2: disable again + cf.setEnabled(false); + cf = doPost("/api/calculatedField", cf, CalculatedField.class); + assertThat(cf.isEnabled()).isFalse(); + assertThat(cf.getVersion()).isEqualTo(++version); + + // Cycle 2: re-enable again + cf.setEnabled(true); + cf = doPost("/api/calculatedField", cf, CalculatedField.class); + assertThat(cf.isEnabled()).isTrue(); + assertThat(cf.getVersion()).isEqualTo(++version); + + // Cycle 3: disable + cf.setEnabled(false); + cf = doPost("/api/calculatedField", cf, CalculatedField.class); + assertThat(cf.isEnabled()).isFalse(); + assertThat(cf.getVersion()).isEqualTo(++version); + + // Verify final state via GET + CalculatedField fetched = doGet("/api/calculatedField/" + cf.getId().getId(), CalculatedField.class); + assertThat(fetched.isEnabled()).isFalse(); + + doDelete("/api/calculatedField/" + cf.getId().getId().toString()) + .andExpect(status().isOk()); + } + + @Test + public void testDisabledCalculatedFieldVisibleInList() throws Exception { + Device testDevice = createDevice("Test device", "1234567890"); + CalculatedField calculatedField = getSimpleCalculatedField(testDevice.getId()); + + CalculatedField cf = doPost("/api/calculatedField", calculatedField, CalculatedField.class); + cf.setEnabled(false); + cf = doPost("/api/calculatedField", cf, CalculatedField.class); + + assertThat(getEntityCalculatedFields(testDevice.getId(), null, new PageLink(10)).getData()) + .singleElement() + .satisfies(found -> assertThat(found.isEnabled()).isFalse()); + + doDelete("/api/calculatedField/" + cf.getId().getId().toString()) + .andExpect(status().isOk()); + } + + @Test + public void testDeleteDisabledCalculatedField() throws Exception { + Device testDevice = createDevice("Test device", "1234567890"); + CalculatedField calculatedField = getSimpleCalculatedField(testDevice.getId()); + + CalculatedField cf = doPost("/api/calculatedField", calculatedField, CalculatedField.class); + cf.setEnabled(false); + cf = doPost("/api/calculatedField", cf, CalculatedField.class); + assertThat(cf.isEnabled()).isFalse(); + + doDelete("/api/calculatedField/" + cf.getId().getId().toString()) + .andExpect(status().isOk()); + doGet("/api/calculatedField/" + cf.getId().getId()).andExpect(status().isNotFound()); + } + @Test public void testDeleteCalculatedField() throws Exception { Device testDevice = createDevice("Test device", "1234567890"); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedField.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedField.java index 2d3928ee88..4f8f699286 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedField.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedField.java @@ -67,6 +67,8 @@ public class CalculatedField extends BaseData implements HasN private TenantId tenantId; private EntityId entityId; + @Schema(description = "Enable/disable the calculated field.", example = "true") + private boolean enabled = true; @NoXss @Length(fieldName = "type") @@ -101,6 +103,7 @@ public class CalculatedField extends BaseData implements HasN super(calculatedField); this.tenantId = calculatedField.tenantId; this.entityId = calculatedField.entityId; + this.enabled = calculatedField.enabled; this.type = calculatedField.type; this.name = calculatedField.name; this.debugMode = calculatedField.debugMode; diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java index 6f971e61f7..194bc4c133 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java @@ -732,6 +732,7 @@ public class ModelConstants { public static final String CALCULATED_FIELD_CONFIGURATION = "configuration"; public static final String CALCULATED_FIELD_VERSION = "version"; public static final String CALCULATED_FIELD_ADDITIONAL_INFO = ADDITIONAL_INFO_PROPERTY; + public static final String CALCULATED_FIELD_ENABLED = "enabled"; /** * Tasks constants. diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/CalculatedFieldEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/CalculatedFieldEntity.java index 47c1520ac0..e7fd204295 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/CalculatedFieldEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/CalculatedFieldEntity.java @@ -46,6 +46,7 @@ import static org.thingsboard.server.dao.model.ModelConstants.CALCULATED_FIELD_T import static org.thingsboard.server.dao.model.ModelConstants.CALCULATED_FIELD_TENANT_ID_COLUMN; import static org.thingsboard.server.dao.model.ModelConstants.CALCULATED_FIELD_TYPE; import static org.thingsboard.server.dao.model.ModelConstants.CALCULATED_FIELD_VERSION; +import static org.thingsboard.server.dao.model.ModelConstants.CALCULATED_FIELD_ENABLED; import static org.thingsboard.server.dao.model.ModelConstants.DEBUG_SETTINGS; @Data @@ -63,6 +64,9 @@ public class CalculatedFieldEntity extends BaseVersionedEntity @Column(name = CALCULATED_FIELD_ENTITY_ID) private UUID entityId; + @Column(name = CALCULATED_FIELD_ENABLED) + private boolean enabled; + @Column(name = CALCULATED_FIELD_TYPE) private String type; @@ -94,6 +98,7 @@ public class CalculatedFieldEntity extends BaseVersionedEntity this.tenantId = calculatedField.getTenantId().getId(); this.entityType = calculatedField.getEntityId().getEntityType().name(); this.entityId = calculatedField.getEntityId().getId(); + this.enabled = calculatedField.isEnabled(); this.type = calculatedField.getType().name(); this.name = calculatedField.getName(); this.configurationVersion = calculatedField.getConfigurationVersion(); @@ -109,6 +114,7 @@ public class CalculatedFieldEntity extends BaseVersionedEntity calculatedField.setCreatedTime(createdTime); calculatedField.setTenantId(TenantId.fromUUID(tenantId)); calculatedField.setEntityId(EntityIdFactory.getByTypeAndUuid(entityType, entityId)); + calculatedField.setEnabled(enabled); calculatedField.setType(CalculatedFieldType.valueOf(type)); calculatedField.setName(name); calculatedField.setConfigurationVersion(configurationVersion); diff --git a/dao/src/main/resources/sql/schema-entities.sql b/dao/src/main/resources/sql/schema-entities.sql index 6a66b203a4..e0a1add9b6 100644 --- a/dao/src/main/resources/sql/schema-entities.sql +++ b/dao/src/main/resources/sql/schema-entities.sql @@ -932,6 +932,7 @@ CREATE TABLE IF NOT EXISTS calculated_field ( entity_id uuid NOT NULL, type varchar(32) NOT NULL, name varchar(255) NOT NULL, + enabled boolean NOT NULL DEFAULT true, configuration_version int DEFAULT 0, configuration varchar(1000000), version BIGINT DEFAULT 1, diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/cf/CalculatedFieldTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/cf/CalculatedFieldTest.java index af4197d6ec..8b1a6158b0 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/cf/CalculatedFieldTest.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/cf/CalculatedFieldTest.java @@ -854,6 +854,125 @@ public class CalculatedFieldTest extends AbstractContainerTest { testRestClient.deleteAsset(asset2.getId()); } + @Test + public void testDisableCalculatedField() { + testRestClient.postTelemetry(deviceToken, JacksonUtil.toJsonNode("{\"temperature\":25}")); + waitInitialTelemetry(device.getId(), "temperature"); + + CalculatedField savedCalculatedField = createSimpleCalculatedField(device.getId()); + + await().alias("create CF -> perform initial calculation").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + JsonNode fahrenheitTemp = testRestClient.getLatestTelemetry(device.getId()); + assertThat(fahrenheitTemp).isNotNull(); + assertThat(fahrenheitTemp.get("fahrenheitTemp")).isNotNull(); + assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("value").asText()).isEqualTo("77.0"); + }); + + // Disable the CF + savedCalculatedField.setEnabled(false); + savedCalculatedField = testRestClient.postCalculatedField(savedCalculatedField); + + // Send new telemetry - should NOT be processed + testRestClient.postTelemetry(deviceToken, JacksonUtil.toJsonNode("{\"temperature\":40}")); + + // Wait a bit and verify the value did NOT change (still 77.0, not 104.0) + await().alias("disabled CF -> telemetry not processed").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .during(10, TimeUnit.SECONDS) + .untilAsserted(() -> { + JsonNode fahrenheitTemp = testRestClient.getLatestTelemetry(device.getId()); + assertThat(fahrenheitTemp).isNotNull(); + assertThat(fahrenheitTemp.get("fahrenheitTemp")).isNotNull(); + assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("value").asText()).isEqualTo("77.0"); + }); + + testRestClient.deleteCalculatedFieldIfExists(savedCalculatedField.getId()); + } + + @Test + public void testEnableDisableCycles() { + testRestClient.postTelemetry(deviceToken, JacksonUtil.toJsonNode("{\"temperature\":25}")); + waitInitialTelemetry(device.getId(), "temperature"); + + CalculatedField savedCalculatedField = createSimpleCalculatedField(device.getId()); + + // Cycle 1: enabled -> verify calculation + await().alias("cycle 1: CF enabled -> initial calculation").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + JsonNode fahrenheitTemp = testRestClient.getLatestTelemetry(device.getId()); + assertThat(fahrenheitTemp).isNotNull(); + assertThat(fahrenheitTemp.get("fahrenheitTemp")).isNotNull(); + assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("value").asText()).isEqualTo("77.0"); + }); + + // Cycle 1: disable + savedCalculatedField.setEnabled(false); + savedCalculatedField = testRestClient.postCalculatedField(savedCalculatedField); + + // Cycle 1: send telemetry while disabled + testRestClient.postTelemetry(deviceToken, JacksonUtil.toJsonNode("{\"temperature\":30}")); + + await().alias("cycle 1: disabled -> no recalculation").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .during(10, TimeUnit.SECONDS) + .untilAsserted(() -> { + JsonNode fahrenheitTemp = testRestClient.getLatestTelemetry(device.getId()); + assertThat(fahrenheitTemp).isNotNull(); + assertThat(fahrenheitTemp.get("fahrenheitTemp")).isNotNull(); + assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("value").asText()).isEqualTo("77.0"); + }); + + // Cycle 2: re-enable + savedCalculatedField.setEnabled(true); + savedCalculatedField = testRestClient.postCalculatedField(savedCalculatedField); + + // After re-enable, CF reinitializes and recalculates with current telemetry (temperature=30) + await().alias("cycle 2: re-enabled -> recalculation with latest telemetry").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + JsonNode fahrenheitTemp = testRestClient.getLatestTelemetry(device.getId()); + assertThat(fahrenheitTemp).isNotNull(); + assertThat(fahrenheitTemp.get("fahrenheitTemp")).isNotNull(); + assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("value").asText()).isEqualTo("86.0"); + }); + + // Cycle 2: disable again + savedCalculatedField.setEnabled(false); + savedCalculatedField = testRestClient.postCalculatedField(savedCalculatedField); + + // Cycle 2: send telemetry while disabled + testRestClient.postTelemetry(deviceToken, JacksonUtil.toJsonNode("{\"temperature\":50}")); + + await().alias("cycle 2: disabled again -> no recalculation").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .during(10, TimeUnit.SECONDS) + .untilAsserted(() -> { + JsonNode fahrenheitTemp = testRestClient.getLatestTelemetry(device.getId()); + assertThat(fahrenheitTemp).isNotNull(); + assertThat(fahrenheitTemp.get("fahrenheitTemp")).isNotNull(); + assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("value").asText()).isEqualTo("86.0"); + }); + + // Cycle 3: re-enable again + savedCalculatedField.setEnabled(true); + savedCalculatedField = testRestClient.postCalculatedField(savedCalculatedField); + + // After re-enable, recalculates with latest telemetry (temperature=50) + await().alias("cycle 3: re-enabled again -> recalculation").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + JsonNode fahrenheitTemp = testRestClient.getLatestTelemetry(device.getId()); + assertThat(fahrenheitTemp).isNotNull(); + assertThat(fahrenheitTemp.get("fahrenheitTemp")).isNotNull(); + assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("value").asText()).isEqualTo("122.0"); + }); + + testRestClient.deleteCalculatedFieldIfExists(savedCalculatedField.getId()); + } + private void waitForDebugEvent(CalculatedField cf, String expectedEventType, int expectedTotalEvents) { await().alias("wait for debug event: " + expectedEventType) .atMost(TIMEOUT, TimeUnit.SECONDS) diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts index aac49a3f37..93434aeaed 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts @@ -161,6 +161,15 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig('type', 'common.type', this.pageMode ? '23%' : '40%', entity => this.translate.instant(CalculatedFieldTypeTranslations.get(entity.type).name), () => ({whiteSpace: 'nowrap' }))); this.cellActionDescriptors.push( + { + name: '', + nameFunction: (entity) => + this.translate.instant(entity.enabled ? 'calculated-fields.disable' : 'calculated-fields.enable'), + icon: 'mdi:toggle-switch', + isEnabled: () => true, + iconFunction: (entity) => entity.enabled ? 'mdi:toggle-switch' : 'mdi:toggle-switch-off-outline', + onAction: ($event, entity) => this.toggleEnabled($event, entity), + }, { name: this.translate.instant('action.copy'), icon: 'content_copy', @@ -378,6 +387,16 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig this.calculatedFieldsService.saveCalculatedField({...cf, enabled: !cf.enabled}, {ignoreLoading: true})) + ).subscribe((saved) => { + entity.enabled = saved.enabled; + this.getTable().detectChanges(); + }); + } + private onDebugConfigChanged(id: string, debugSettings: EntityDebugSettings): void { this.calculatedFieldsService.getCalculatedFieldById(id).pipe( switchMap(field => this.calculatedFieldsService.saveCalculatedField({ ...field, debugSettings })), diff --git a/ui-ngx/src/app/shared/models/calculated-field.models.ts b/ui-ngx/src/app/shared/models/calculated-field.models.ts index 552d2a3a95..87aa13873e 100644 --- a/ui-ngx/src/app/shared/models/calculated-field.models.ts +++ b/ui-ngx/src/app/shared/models/calculated-field.models.ts @@ -38,6 +38,7 @@ export const FORBIDDEN_NAMES = ['ctx', 'e', 'pi']; interface BaseCalculatedField extends Omit, 'label'>, HasVersion, HasEntityDebugSettings, HasTenantId, ExportableEntity { entityId: EntityId; + enabled: boolean; additionalInfo?: any; } 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 b848e5808f..9007000865 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -1159,6 +1159,8 @@ "timeseries": "Time series", "output": "Output", "output-hint": "Defines how output is processed.", + "enable": "Enable", + "disable": "Disable", "create": "Create new calculated field", "file": "Calculated field file", "invalid-file-error": "Invalid file format. Please make sure the file is a valid JSON file.", From a64f4c387df9f8cf44eb192e08ccd3a47d800bc1 Mon Sep 17 00:00:00 2001 From: Oleksandra Matviienko Date: Wed, 8 Apr 2026 14:39:30 +0200 Subject: [PATCH 2/4] Fix review comments: add error handling to toggleEnabled, add enabled to toString --- .../server/common/data/cf/CalculatedField.java | 1 + .../calculated-fields-table-config.ts | 10 +++++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedField.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedField.java index 4f8f699286..f537fc7496 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedField.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedField.java @@ -144,6 +144,7 @@ public class CalculatedField extends BaseData implements HasN .append("CalculatedField[") .append("tenantId=").append(tenantId) .append(", entityId=").append(entityId) + .append(", enabled=").append(enabled) .append(", type='").append(type) .append(", name='").append(name) .append(", configurationVersion=").append(configurationVersion) diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts index 93434aeaed..3aea144af0 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts @@ -390,10 +390,14 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig this.calculatedFieldsService.saveCalculatedField({...cf, enabled: !cf.enabled}, {ignoreLoading: true})) + switchMap(cf => this.calculatedFieldsService.saveCalculatedField({...cf, enabled: !cf.enabled}, {ignoreLoading: true})), + catchError(() => of(null)), + takeUntilDestroyed(this.destroyRef), ).subscribe((saved) => { - entity.enabled = saved.enabled; - this.getTable().detectChanges(); + if (saved) { + entity.enabled = saved.enabled; + this.getTable().detectChanges(); + } }); } From 61ceacc7af4e9c276c35d9a8d05fad980a25d6bb Mon Sep 17 00:00:00 2001 From: Oleksandra Matviienko Date: Thu, 9 Apr 2026 08:22:31 +0200 Subject: [PATCH 3/4] Add enable/disable toggle for alarm rules --- .../thingsboard/server/cf/AlarmRulesTest.java | 155 ++++++++++++++++++ .../common/data/cf/AlarmRuleDefinition.java | 6 + .../alarm-rules/alarm-rules-table-config.ts | 23 +++ 3 files changed, 184 insertions(+) diff --git a/application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java b/application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java index 7041a71086..8a35cd89ac 100644 --- a/application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java +++ b/application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java @@ -858,6 +858,161 @@ public class AlarmRulesTest extends AbstractControllerTest { }); } + @Test + public void testDisabledAlarmRuleDoesNotCreateAlarm() throws Exception { + Argument temperatureArgument = new Argument(); + temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + temperatureArgument.setDefaultValue("0"); + Map arguments = Map.of("temperature", temperatureArgument); + + Map createRules = Map.of( + AlarmSeverity.CRITICAL, new Condition("return temperature >= 50;", null, null) + ); + + AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "Disabled Alarm Test", + arguments, createRules, null); + + // Verify alarm is created when enabled + postTelemetry(deviceId, "{\"temperature\":50}"); + checkAlarmResult(alarmRule, alarmResult -> { + assertThat(alarmResult.isCreated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); + }); + + // Disable the alarm rule + AlarmRuleDefinition fetched = doGet("/api/alarm/rule/" + alarmRule.getId().getId(), AlarmRuleDefinition.class); + fetched.setEnabled(false); + AlarmRuleDefinition disabled = saveAlarmRule(fetched); + assertThat(disabled.isEnabled()).isFalse(); + + // Send telemetry that would trigger alarm — should NOT produce new alarm result + postTelemetry(deviceId, "{\"temperature\":200}"); + + await().alias("disabled alarm rule should not process").atMost(TIMEOUT, TimeUnit.SECONDS) + .during(3, TimeUnit.SECONDS) + .untilAsserted(() -> assertThat(getLatestAlarmResult(alarmRule.getId())).isNull()); + } + + @Test + public void testReEnabledAlarmRuleCreatesAlarm() throws Exception { + Argument temperatureArgument = new Argument(); + temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + temperatureArgument.setDefaultValue("0"); + Map arguments = Map.of("temperature", temperatureArgument); + + Map createRules = Map.of( + AlarmSeverity.CRITICAL, new Condition("return temperature >= 50;", null, null) + ); + Condition clearRule = new Condition("return temperature < 50;", null, null); + + AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "Re-enable Alarm Test", + arguments, createRules, clearRule); + + // Create alarm + postTelemetry(deviceId, "{\"temperature\":60}"); + checkAlarmResult(alarmRule, alarmResult -> { + assertThat(alarmResult.isCreated()).isTrue(); + }); + + // Clear alarm + postTelemetry(deviceId, "{\"temperature\":10}"); + checkAlarmResult(alarmRule, alarmResult -> { + assertThat(alarmResult.isCleared()).isTrue(); + }); + + // Disable + AlarmRuleDefinition fetched = doGet("/api/alarm/rule/" + alarmRule.getId().getId(), AlarmRuleDefinition.class); + fetched.setEnabled(false); + saveAlarmRule(fetched); + + // Re-enable + fetched = doGet("/api/alarm/rule/" + alarmRule.getId().getId(), AlarmRuleDefinition.class); + fetched.setEnabled(true); + saveAlarmRule(fetched); + + // Trigger alarm again — re-enabled rule should process + postTelemetry(deviceId, "{\"temperature\":80}"); + checkAlarmResult(alarmRule, alarmResult -> { + assertThat(alarmResult.isCreated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); + }); + } + + @Test + public void testEnableDisableCycles() throws Exception { + Argument temperatureArgument = new Argument(); + temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + temperatureArgument.setDefaultValue("0"); + Map arguments = Map.of("temperature", temperatureArgument); + + Map createRules = Map.of( + AlarmSeverity.CRITICAL, new Condition("return temperature >= 50;", null, null) + ); + Condition clearRule = new Condition("return temperature < 50;", null, null); + + AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "Cycle Alarm Test", + arguments, createRules, clearRule); + + // --- Cycle 1: enabled → alarm → clear → disable → no alarm → re-enable → alarm --- + + postTelemetry(deviceId, "{\"temperature\":60}"); + checkAlarmResult(alarmRule, alarmResult -> { + assertThat(alarmResult.isCreated()).isTrue(); + }); + + postTelemetry(deviceId, "{\"temperature\":10}"); + checkAlarmResult(alarmRule, alarmResult -> { + assertThat(alarmResult.isCleared()).isTrue(); + }); + + // Disable + AlarmRuleDefinition fetched = doGet("/api/alarm/rule/" + alarmRule.getId().getId(), AlarmRuleDefinition.class); + fetched.setEnabled(false); + saveAlarmRule(fetched); + + postTelemetry(deviceId, "{\"temperature\":70}"); + await().alias("cycle 1: disabled, no alarm").atMost(TIMEOUT, TimeUnit.SECONDS) + .during(3, TimeUnit.SECONDS) + .untilAsserted(() -> assertThat(getLatestAlarmResult(alarmRule.getId())).isNull()); + + // Re-enable + fetched = doGet("/api/alarm/rule/" + alarmRule.getId().getId(), AlarmRuleDefinition.class); + fetched.setEnabled(true); + saveAlarmRule(fetched); + + postTelemetry(deviceId, "{\"temperature\":80}"); + checkAlarmResult(alarmRule, alarmResult -> { + assertThat(alarmResult.isCreated()).isTrue(); + }); + + // Clear for cycle 2 + postTelemetry(deviceId, "{\"temperature\":10}"); + checkAlarmResult(alarmRule, alarmResult -> { + assertThat(alarmResult.isCleared()).isTrue(); + }); + + // --- Cycle 2: disable → no alarm → re-enable → alarm --- + + fetched = doGet("/api/alarm/rule/" + alarmRule.getId().getId(), AlarmRuleDefinition.class); + fetched.setEnabled(false); + saveAlarmRule(fetched); + + postTelemetry(deviceId, "{\"temperature\":90}"); + await().alias("cycle 2: disabled, no alarm").atMost(TIMEOUT, TimeUnit.SECONDS) + .during(3, TimeUnit.SECONDS) + .untilAsserted(() -> assertThat(getLatestAlarmResult(alarmRule.getId())).isNull()); + + fetched = doGet("/api/alarm/rule/" + alarmRule.getId().getId(), AlarmRuleDefinition.class); + fetched.setEnabled(true); + saveAlarmRule(fetched); + + postTelemetry(deviceId, "{\"temperature\":100}"); + checkAlarmResult(alarmRule, alarmResult -> { + assertThat(alarmResult.isCreated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); + }); + } + // TODO: MSA tests private TbAlarmResult checkAlarmResult(AlarmRuleDefinition alarmRule, Consumer assertion) { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/AlarmRuleDefinition.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/AlarmRuleDefinition.java index bd0c76bc05..86c238efc0 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/AlarmRuleDefinition.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/AlarmRuleDefinition.java @@ -44,6 +44,8 @@ public class AlarmRuleDefinition extends BaseData implements private TenantId tenantId; private EntityId entityId; + @Schema(description = "Enable/disable the alarm rule.", example = "true") + private boolean enabled = true; @NoXss @Length(fieldName = "name") @@ -78,6 +80,7 @@ public class AlarmRuleDefinition extends BaseData implements super(alarmRuleDefinition); this.tenantId = alarmRuleDefinition.tenantId; this.entityId = alarmRuleDefinition.entityId; + this.enabled = alarmRuleDefinition.enabled; this.name = alarmRuleDefinition.name; this.debugMode = alarmRuleDefinition.debugMode; this.debugSettings = alarmRuleDefinition.debugSettings; @@ -117,6 +120,7 @@ public class AlarmRuleDefinition extends BaseData implements cf.setCreatedTime(this.createdTime); cf.setTenantId(this.tenantId); cf.setEntityId(this.entityId); + cf.setEnabled(this.enabled); cf.setType(CalculatedFieldType.ALARM); cf.setName(this.name); cf.setDebugMode(this.debugMode); @@ -134,6 +138,7 @@ public class AlarmRuleDefinition extends BaseData implements def.setCreatedTime(cf.getCreatedTime()); def.setTenantId(cf.getTenantId()); def.setEntityId(cf.getEntityId()); + def.setEnabled(cf.isEnabled()); def.setName(cf.getName()); def.setDebugMode(cf.isDebugMode()); def.setDebugSettings(cf.getDebugSettings()); @@ -153,6 +158,7 @@ public class AlarmRuleDefinition extends BaseData implements .append("AlarmRuleDefinition[") .append("tenantId=").append(tenantId) .append(", entityId=").append(entityId) + .append(", enabled=").append(enabled) .append(", name='").append(name) .append(", configurationVersion=").append(configurationVersion) .append(", configuration=").append(configuration) diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table-config.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table-config.ts index f5043bdcee..67ee8e709a 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table-config.ts +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table-config.ts @@ -171,6 +171,15 @@ export class AlarmRulesTableConfig extends EntityTableConfig checkBoxCell(!!entity.configuration.clearRule), ()=> { return {padding: 0, textAlign: 'center'}}, false)); this.cellActionDescriptors.push( + { + name: '', + nameFunction: (entity) => + this.translate.instant(entity.enabled ? 'calculated-fields.disable' : 'calculated-fields.enable'), + icon: 'mdi:toggle-switch', + isEnabled: () => true, + iconFunction: (entity) => entity.enabled ? 'mdi:toggle-switch' : 'mdi:toggle-switch-off-outline', + onAction: ($event, entity) => this.toggleEnabled($event, entity), + }, { name: this.translate.instant('alarm-rule.copy'), icon: 'content_copy', @@ -373,6 +382,20 @@ export class AlarmRulesTableConfig extends EntityTableConfig this.alarmRulesService.saveAlarmRule({...rule, enabled: !rule.enabled}, {ignoreLoading: true})), + catchError(() => of(null)), + takeUntilDestroyed(this.destroyRef), + ).subscribe((saved) => { + if (saved) { + entity.enabled = saved.enabled; + this.getTable().detectChanges(); + } + }); + } + private onDebugConfigChanged(id: string, debugSettings: EntityDebugSettings): void { this.alarmRulesService.getAlarmRuleById(id).pipe( switchMap(field => this.alarmRulesService.saveAlarmRule({ ...field, debugSettings })), From 3742020f6d43de1d22e568218a830817d0aea078 Mon Sep 17 00:00:00 2001 From: Oleksandra Matviienko Date: Wed, 10 Jun 2026 18:20:31 +0200 Subject: [PATCH 4/4] Discard disabled calculated fields before telemetry processing Filter disabled calculated fields in SQL on actor init and skip caching them in DefaultCalculatedFieldCache, so telemetry for disabled fields is discarded before it is pushed to the queue. The native startup query now loads only enabled fields. Add deserialization tests verifying calculated fields and alarm rules without the enabled property default to enabled. --- ...alculatedFieldManagerMessageProcessor.java | 6 +- .../cf/DefaultCalculatedFieldCache.java | 4 ++ .../server/dao/cf/CalculatedFieldService.java | 2 + ...ulatedFieldEnabledDeserializationTest.java | 70 +++++++++++++++++++ .../dao/cf/BaseCalculatedFieldService.java | 8 +++ .../server/dao/cf/CalculatedFieldDao.java | 2 + .../dao/sql/cf/CalculatedFieldRepository.java | 2 + ...efaultNativeCalculatedFieldRepository.java | 5 +- .../dao/sql/cf/JpaCalculatedFieldDao.java | 6 ++ 9 files changed, 98 insertions(+), 7 deletions(-) create mode 100644 common/data/src/test/java/org/thingsboard/server/common/data/cf/CalculatedFieldEnabledDeserializationTest.java diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java index 0d87fad694..710a66c6bb 100644 --- a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java @@ -805,13 +805,9 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware } public void initCalculatedFields() { - PageDataIterable cfs = new PageDataIterable<>(pageLink -> cfDaoService.findCalculatedFieldsByTenantId(tenantId, pageLink), cfSettings.getInitTenantFetchPackSize()); + PageDataIterable cfs = new PageDataIterable<>(pageLink -> cfDaoService.findCalculatedFieldsByTenantIdAndEnabled(tenantId, true, pageLink), cfSettings.getInitTenantFetchPackSize()); cfs.forEach(cf -> { log.trace("Processing calculated field record: {}", cf); - if (!cf.isEnabled()) { - log.debug("[{}] Skipping disabled calculated field [{}]", tenantId, cf.getId()); - return; - } try { initCalculatedField(cf); initCalculatedFieldLinks(cf); diff --git a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java index 8a7d38970f..97f5e3daa4 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java @@ -192,6 +192,10 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache { if (calculatedField == null) { return; } + if (!calculatedField.isEnabled()) { + log.debug("[{}] Skipping disabled calculated field, not caching for processing [{}]", tenantId, calculatedFieldId); + return; + } EntityId cfEntityId = calculatedField.getEntityId(); calculatedFields.put(calculatedFieldId, calculatedField); diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldService.java index 1603385094..1ec9abf310 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldService.java @@ -46,6 +46,8 @@ public interface CalculatedFieldService extends EntityDaoService { PageData findCalculatedFieldsByTenantId(TenantId tenantId, PageLink pageLink); + PageData findCalculatedFieldsByTenantIdAndEnabled(TenantId tenantId, boolean enabled, PageLink pageLink); + PageData findCalculatedFieldsByTenantIdAndFilter(TenantId tenantId, CalculatedFieldFilter filter, PageLink pageLink); PageData findCalculatedFieldNamesByTenantIdAndType(TenantId tenantId, CalculatedFieldType type, PageLink pageLink); diff --git a/common/data/src/test/java/org/thingsboard/server/common/data/cf/CalculatedFieldEnabledDeserializationTest.java b/common/data/src/test/java/org/thingsboard/server/common/data/cf/CalculatedFieldEnabledDeserializationTest.java new file mode 100644 index 0000000000..2e86af826e --- /dev/null +++ b/common/data/src/test/java/org/thingsboard/server/common/data/cf/CalculatedFieldEnabledDeserializationTest.java @@ -0,0 +1,70 @@ +/** + * 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.common.data.cf; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class CalculatedFieldEnabledDeserializationTest { + + private final ObjectMapper mapper = new ObjectMapper(); + + /* + * The 'enabled' field was added together with the enable/disable feature. Calculated fields and alarm rules + * exported before this change have no 'enabled' property in their JSON. Importing such JSON must keep them + * enabled (backward compatible), which relies on the 'enabled = true' field initializer being preserved by + * Jackson when the property is absent. + */ + + @Test + public void givenCalculatedFieldJsonWithoutEnabled_whenDeserialized_thenEnabledIsTrue() throws Exception { + String json = "{\"name\":\"Test CF\",\"type\":\"SIMPLE\",\"configurationVersion\":1}"; + + CalculatedField cf = mapper.readValue(json, CalculatedField.class); + + assertThat(cf.isEnabled()).isTrue(); + } + + @Test + public void givenCalculatedFieldJsonWithEnabledFalse_whenDeserialized_thenEnabledIsFalse() throws Exception { + String json = "{\"name\":\"Test CF\",\"type\":\"SIMPLE\",\"enabled\":false}"; + + CalculatedField cf = mapper.readValue(json, CalculatedField.class); + + assertThat(cf.isEnabled()).isFalse(); + } + + @Test + public void givenAlarmRuleDefinitionJsonWithoutEnabled_whenDeserialized_thenEnabledIsTrue() throws Exception { + String json = "{\"name\":\"Test Alarm Rule\",\"configurationVersion\":1}"; + + AlarmRuleDefinition alarmRule = mapper.readValue(json, AlarmRuleDefinition.class); + + assertThat(alarmRule.isEnabled()).isTrue(); + } + + @Test + public void givenAlarmRuleDefinitionJsonWithEnabledFalse_whenDeserialized_thenEnabledIsFalse() throws Exception { + String json = "{\"name\":\"Test Alarm Rule\",\"enabled\":false}"; + + AlarmRuleDefinition alarmRule = mapper.readValue(json, AlarmRuleDefinition.class); + + assertThat(alarmRule.isEnabled()).isFalse(); + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/cf/BaseCalculatedFieldService.java b/dao/src/main/java/org/thingsboard/server/dao/cf/BaseCalculatedFieldService.java index 075e2d8a9d..2eab63bdc1 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/cf/BaseCalculatedFieldService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/cf/BaseCalculatedFieldService.java @@ -161,6 +161,14 @@ public class BaseCalculatedFieldService extends AbstractEntityService implements return calculatedFieldDao.findAllByTenantId(tenantId, pageLink); } + @Override + public PageData findCalculatedFieldsByTenantIdAndEnabled(TenantId tenantId, boolean enabled, PageLink pageLink) { + log.trace("Executing findCalculatedFieldsByTenantIdAndEnabled, tenantId [{}], enabled [{}], pageLink [{}]", tenantId, enabled, pageLink); + validateId(tenantId, id -> INCORRECT_TENANT_ID + id); + validatePageLink(pageLink); + return calculatedFieldDao.findAllByTenantIdAndEnabled(tenantId, enabled, pageLink); + } + @Override public PageData findCalculatedFieldsByTenantIdAndFilter(TenantId tenantId, CalculatedFieldFilter filter, PageLink pageLink) { PageData calculatedFields = calculatedFieldDao.findByTenantIdAndFilter(tenantId, filter, pageLink); diff --git a/dao/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldDao.java b/dao/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldDao.java index 8da1697c1f..a0991d0869 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldDao.java @@ -44,6 +44,8 @@ public interface CalculatedFieldDao extends Dao { PageData findAllByTenantId(TenantId tenantId, PageLink pageLink); + PageData findAllByTenantIdAndEnabled(TenantId tenantId, boolean enabled, PageLink pageLink); + PageData findByEntityIdAndTypes(TenantId tenantId, EntityId entityId, Set types, PageLink pageLink); List removeAllByEntityId(TenantId tenantId, EntityId entityId); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/cf/CalculatedFieldRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/cf/CalculatedFieldRepository.java index b7b94c5b8c..751d06b9ca 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/cf/CalculatedFieldRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/cf/CalculatedFieldRepository.java @@ -38,6 +38,8 @@ public interface CalculatedFieldRepository extends JpaRepository findAllByTenantId(UUID tenantId, Pageable pageable); + Page findAllByTenantIdAndEnabled(UUID tenantId, boolean enabled, Pageable pageable); + @Query("SELECT cf FROM CalculatedFieldEntity cf WHERE cf.tenantId = :tenantId " + "AND cf.entityId = :entityId AND cf.type IN :types " + "AND (:textSearch IS NULL OR ilike(cf.name, CONCAT('%', :textSearch, '%')) = true)") diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/cf/DefaultNativeCalculatedFieldRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/cf/DefaultNativeCalculatedFieldRepository.java index 0f9dcc20be..c654609f6a 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/cf/DefaultNativeCalculatedFieldRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/cf/DefaultNativeCalculatedFieldRepository.java @@ -44,8 +44,9 @@ import java.util.stream.Collectors; @Slf4j public class DefaultNativeCalculatedFieldRepository implements NativeCalculatedFieldRepository { - private final String CF_COUNT_QUERY = "SELECT count(id) FROM calculated_field;"; - private final String CF_QUERY = "SELECT * FROM calculated_field ORDER BY created_time ASC LIMIT %s OFFSET %s"; + // Only enabled calculated fields are loaded: this query feeds the processing cache, and disabled fields must not be processed. + private final String CF_COUNT_QUERY = "SELECT count(id) FROM calculated_field WHERE enabled = true;"; + private final String CF_QUERY = "SELECT * FROM calculated_field WHERE enabled = true ORDER BY created_time ASC LIMIT %s OFFSET %s"; private final NamedParameterJdbcTemplate jdbcTemplate; private final TransactionTemplate transactionTemplate; diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/cf/JpaCalculatedFieldDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/cf/JpaCalculatedFieldDao.java index a374531a47..5cd5de356b 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/cf/JpaCalculatedFieldDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/cf/JpaCalculatedFieldDao.java @@ -87,6 +87,12 @@ public class JpaCalculatedFieldDao extends JpaAbstractDao findAllByTenantIdAndEnabled(TenantId tenantId, boolean enabled, PageLink pageLink) { + log.debug("Try to find calculated fields by tenantId[{}], enabled [{}] and pageLink [{}]", tenantId, enabled, pageLink); + return DaoUtil.toPageData(calculatedFieldRepository.findAllByTenantIdAndEnabled(tenantId.getId(), enabled, DaoUtil.toPageable(pageLink))); + } + @Override public PageData findByEntityIdAndTypes(TenantId tenantId, EntityId entityId, Set types, PageLink pageLink) { log.debug("Try to find calculated fields by entityId [{}] and type [{}] and pageLink [{}]", entityId, types, pageLink);