Browse Source

Merge branch 'master' into feature/4.4-iot-hub

pull/15801/head
Igor Kulikov 1 month ago
parent
commit
400aab24ee
  1. 1
      application/src/main/data/json/system/widget_types/unread_notifications.json
  2. 21
      application/src/main/java/org/thingsboard/server/service/edge/rpc/session/manager/KafkaBasedEdgeGrpcSessionManager.java
  3. 52
      application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java
  4. 315
      application/src/test/java/org/thingsboard/server/service/edge/rpc/session/manager/KafkaBasedEdgeGrpcSessionManagerTest.java
  5. 95
      application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/AbstractMqttV5ClientSparkplugTest.java
  6. 2
      application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/attributes/MqttV5ClientSparkplugBAttributesInProfileTest.java
  7. 2
      application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/attributes/MqttV5ClientSparkplugBAttributesTest.java
  8. 3
      application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/connection/MqttV5ClientSparkplugBConnectionDevicesCreatingBeforeTest.java
  9. 2
      application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/connection/MqttV5ClientSparkplugBConnectionTest.java
  10. 2
      application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/rpc/MqttV5RpcSparkplugTest.java
  11. 2
      application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/timeseries/MqttV5ClientSparkplugBTelemetryTest.java
  12. 5
      common/data/pom.xml
  13. 2
      common/data/src/main/java/org/thingsboard/server/common/data/security/DeviceCredentialsFilter.java
  14. 31
      common/queue/src/main/java/org/thingsboard/server/queue/common/consumer/QueueConsumerManager.java
  15. 251
      common/queue/src/test/java/org/thingsboard/server/queue/common/consumer/QueueConsumerManagerTest.java
  16. 8
      common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java
  17. 35
      common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/SparkplugNodeSessionHandler.java
  18. 42
      pom.xml
  19. 3
      ui-ngx/src/app/core/api/widget-subscription.ts
  20. 2
      ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts
  21. 3
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.html
  22. 4
      ui-ngx/src/app/modules/home/components/widget/widget-config.component.html
  23. 4
      ui-ngx/src/app/modules/home/components/widget/widget-config.component.scss
  24. 2
      ui-ngx/src/app/modules/home/pages/security/security.component.html
  25. 2
      ui-ngx/src/app/shared/components/string-pattern-autocomplete.component.html
  26. 49
      ui-ngx/src/app/shared/components/string-pattern-autocomplete.component.ts
  27. 40
      ui-ngx/src/app/shared/import-export/import-export.service.ts
  28. 135
      ui-ngx/src/assets/help/en_US/widget/lib/html_container/container_angular_fn.md
  29. 124
      ui-ngx/src/assets/help/en_US/widget/lib/html_container/container_plain_fn.md
  30. 201
      ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_drawer_css.md
  31. 66
      ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_drawer_html.md
  32. 100
      ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_drawer_js.md
  33. 142
      ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_split_css.md
  34. 38
      ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_split_html.md
  35. 73
      ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_split_js.md
  36. 66
      ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_tabs_css.md
  37. 22
      ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_tabs_html.md
  38. 35
      ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_tabs_js.md
  39. 38
      ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_analytics_css.md
  40. 36
      ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_analytics_html.md
  41. 185
      ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_analytics_js.md
  42. 12
      ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_analytics_resources.md
  43. 45
      ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_kanban_css.md
  44. 10
      ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_kanban_html.md
  45. 89
      ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_kanban_js.md
  46. 12
      ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_kanban_resources.md
  47. 1
      ui-ngx/src/assets/locale/locale.constant-en_US.json
  48. 12
      ui-ngx/yarn.lock

1
application/src/main/data/json/system/widget_types/unread_notifications.json

@ -3,6 +3,7 @@
"name": "Unread notifications",
"deprecated": false,
"image": "tb-image;/api/images/system/unread_notification_system_widget_image.png",
"description": "Displays the latest unread notifications and alarms for the current user.",
"descriptor": {
"type": "static",
"sizeX": 5.5,

21
application/src/main/java/org/thingsboard/server/service/edge/rpc/session/manager/KafkaBasedEdgeGrpcSessionManager.java

@ -35,7 +35,6 @@ import org.thingsboard.server.queue.discovery.TopicService;
import org.thingsboard.server.queue.kafka.KafkaAdmin;
import org.thingsboard.server.queue.provider.TbCoreQueueFactory;
import org.thingsboard.server.queue.util.TbKafkaComponent;
import org.thingsboard.server.service.edge.rpc.EdgeSessionState;
import org.thingsboard.server.service.edge.rpc.processor.PostgresGeneralEdgeEventsDispatcher;
import org.thingsboard.server.service.edge.rpc.session.EdgeSessionsHolder;
@ -143,8 +142,7 @@ public class KafkaBasedEdgeGrpcSessionManager extends AbstractEdgeGrpcSessionMan
}
} catch (Exception e) {
log.warn("[{}] Failed to process edge events for edge [{}]!", tenantId, edgeId, e);
}
finally {
} finally {
initLock.unlock();
}
}, ctx.getEdgeEventStorageSettings().getNoRecordsSleepInterval(), TimeUnit.MILLISECONDS);
@ -176,7 +174,7 @@ public class KafkaBasedEdgeGrpcSessionManager extends AbstractEdgeGrpcSessionMan
}
private boolean initAndLaunchConsumer(TenantId tenantId, EdgeId edgeId, EdgeSessionState state) {
if (!state.isConnected() || state.isSyncInProgress() || isHighPriorityProcessing) {
if (!isSessionReady(state)) {
log.warn("[{}][{}] Session is not ready (connected={}, syncInProgress={}, highPriority={}), skip starting edge event consumer",
tenantId, edgeId, state.isConnected(), state.isSyncInProgress(), isHighPriorityProcessing);
return false;
@ -205,6 +203,7 @@ public class KafkaBasedEdgeGrpcSessionManager extends AbstractEdgeGrpcSessionMan
.consumerCreator(() -> tbCoreQueueFactory.createEdgeEventMsgConsumer(tenantId, edgeId))
.consumerExecutor(consumerExecutor)
.threadPrefix("edge-events-" + edgeId)
.readinessCheck(this::isReadyToProcessGeneralEvents)
.build();
consumer.subscribe();
consumer.launch();
@ -224,7 +223,9 @@ public class KafkaBasedEdgeGrpcSessionManager extends AbstractEdgeGrpcSessionMan
EdgeId edgeId = state.getEdgeId();
log.trace("[{}][{}] starting processing edge events", tenantId, edgeId);
if (!state.isConnected() || state.isSyncInProgress() || isHighPriorityProcessing) {
// Defensive backstop: the loop already gates polling on readiness; this only fires on the narrow race
// where readiness flips during poll(), and that already-polled batch is dropped here (can't rewind).
if (!isSessionReady(state)) {
log.debug("[{}][{}] edge not connected, edge sync is not completed or high priority processing in progress, " +
"connected = {}, sync in progress = {}, high priority in progress = {}. Skipping iteration",
tenantId, edgeId, state.isConnected(), state.isSyncInProgress(), isHighPriorityProcessing);
@ -250,6 +251,15 @@ public class KafkaBasedEdgeGrpcSessionManager extends AbstractEdgeGrpcSessionMan
}
}
private boolean isReadyToProcessGeneralEvents() {
return isSessionReady(getState());
}
/** Single source of truth for "the session may process general edge events" - see {@link #isReadyToProcessGeneralEvents}. */
private boolean isSessionReady(EdgeSessionState state) {
return state != null && state.isConnected() && !state.isSyncInProgress() && !isHighPriorityProcessing;
}
private void cancelMigrationAndProcessingInit() {
EdgeSessionState state = getState();
log.trace("[{}] cancelling edge migration & processing init for edge", state != null ? state.getEdgeId() : "unknown");
@ -299,4 +309,5 @@ public class KafkaBasedEdgeGrpcSessionManager extends AbstractEdgeGrpcSessionMan
Thread.currentThread().interrupt();
}
}
}

52
application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java

@ -141,6 +141,7 @@ public class DefaultTransportApiService implements TransportApiService {
private final OtaPackageService otaPackageService;
private final OtaPackageDataCache otaPackageDataCache;
private final QueueService queueService;
public static final String GATEWAY_CREATED_RELATION = "Created";
private final ConcurrentMap<String, ReentrantLock> deviceCreationLocks = new ConcurrentReferenceHashMap<>(16, ConcurrentReferenceHashMap.ReferenceType.WEAK);
@ -395,47 +396,32 @@ public class DefaultTransportApiService implements TransportApiService {
// Security check: verify that the device was created by this gateway
boolean isRelated = false;
try {
// Security check: verify that the device was originally created by this gateway
isRelated = relationService.checkRelation(
gateway.getTenantId(),
isRelated = relationService.checkRelation(
gateway.getTenantId(),
gateway.getId(),
existingDevice.getId(),
GATEWAY_CREATED_RELATION,
RelationTypeGroup.COMMON
);
// If the device is found but not related to this gateway
if (!isRelated) {
log.debug("[{}] Device [{}] exists but is not related to gateway. " +
"Skipping rename and allowing creation of Sparkplug device [{}].",
gateway.getId(),
existingDevice.getId(),
"Created",
RelationTypeGroup.COMMON
);
} catch (Exception e) {
// Log the error from the relation service but return null to allow potential recovery
log.error("[{}] Error checking relation for device {}", gateway.getId(), existingDevice.getId(), e);
return null;
}
requestMsg.getDeviceName());
// If the device is found but not related to this gateway, it's a security breach
if (!isRelated) {
log.error("[{}] Security breach attempt! Gateway tried to rename device [{}] without 'Created' relation.",
gateway.getId(), existingDevice.getId());
// Throwing exception to halt the entire connection process
throw new RuntimeException("Security breach attempt! Unauthorized device rename.");
return null;
}
// Logic for renaming the device if it's related and no naming conflicts exist
boolean changed = false;
String newName = requestMsg.getDeviceName();
if (!newName.equals(existingDevice.getName())) {
// Check if the new name is already taken by another device
Device conflictDevice = deviceService.findDeviceByTenantIdAndName(gateway.getTenantId(), newName);
if (conflictDevice != null) {
log.warn("[{}] Cannot rename device [{}] to [{}]: name already exists!",
gateway.getId(), existingDevice.getId(), newName);
return existingDevice;
}
existingDevice.setName(newName);
// Update label only if it's empty to avoid overwriting user changes
if (existingDevice.getLabel() == null || existingDevice.getLabel().isEmpty()) {
if (StringUtils.isEmpty(existingDevice.getLabel())) {
existingDevice.setLabel(deviceId);
}
@ -452,8 +438,8 @@ public class DefaultTransportApiService implements TransportApiService {
Device device = new Device();
device.setTenantId(tenantId);
device.setName(requestMsg.getDeviceName());
if (requestMsg.getIsSparkplug()) {
if (topicPath.length == 3) device.setLabel(topicPath[2]);
if (requestMsg.getIsSparkplug() && topicPath.length == 3) {
device.setLabel(topicPath[2]);
}
device.setType(requestMsg.getDeviceType());
device.setCustomerId(gateway.getCustomerId());
@ -466,7 +452,7 @@ public class DefaultTransportApiService implements TransportApiService {
device = deviceService.saveDevice(device);
relationService.saveRelation(
tenantId,
new EntityRelation(gateway.getId(), device.getId(), "Created")
new EntityRelation(gateway.getId(), device.getId(), GATEWAY_CREATED_RELATION)
);
return device;
}

315
application/src/test/java/org/thingsboard/server/service/edge/rpc/session/manager/KafkaBasedEdgeGrpcSessionManagerTest.java

@ -0,0 +1,315 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.edge.rpc.session.manager;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.id.EdgeId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.msg.queue.TopicPartitionInfo;
import org.thingsboard.server.gen.transport.TransportProtos.ToEdgeEventNotificationMsg;
import org.thingsboard.server.queue.TbQueueConsumer;
import org.thingsboard.server.queue.common.TbProtoQueueMsg;
import org.thingsboard.server.queue.common.consumer.QueueConsumerManager;
import org.thingsboard.server.queue.discovery.TopicService;
import org.thingsboard.server.queue.kafka.KafkaAdmin;
import org.thingsboard.server.queue.provider.TbCoreQueueFactory;
import org.thingsboard.server.service.edge.EdgeContextComponent;
import org.thingsboard.server.service.edge.rpc.DownlinkMessageMapper;
import org.thingsboard.server.service.edge.rpc.EdgeEventStorageSettings;
import org.thingsboard.server.service.edge.rpc.EdgeSessionState;
import org.thingsboard.server.service.edge.rpc.session.EdgeSession;
import org.thingsboard.server.service.edge.rpc.session.EdgeSessionsHolder;
import java.util.Collections;
import java.util.List;
import java.util.Queue;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.BooleanSupplier;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class KafkaBasedEdgeGrpcSessionManagerTest {
private static final long POLL_INTERVAL_MS = 20L;
private EdgeContextComponent ctx;
private TbCoreQueueFactory tbCoreQueueFactory;
private EdgeSessionState state;
private KafkaBasedEdgeGrpcSessionManager manager;
@BeforeEach
void setUp() {
ctx = mock(EdgeContextComponent.class);
tbCoreQueueFactory = mock(TbCoreQueueFactory.class);
TopicService topicService = mock(TopicService.class);
KafkaAdmin kafkaAdmin = mock(KafkaAdmin.class);
EdgeSessionsHolder sessions = mock(EdgeSessionsHolder.class);
manager = new KafkaBasedEdgeGrpcSessionManager(tbCoreQueueFactory, topicService, kafkaAdmin, sessions);
Edge edge = new Edge(new EdgeId(UUID.randomUUID()));
edge.setTenantId(TenantId.fromUUID(UUID.randomUUID()));
state = new EdgeSessionState();
state.setEdge(edge);
EdgeSession session = mock(EdgeSession.class);
when(session.getState()).thenReturn(state);
ReflectionTestUtils.setField(manager, "session", session);
ReflectionTestUtils.setField(manager, "ctx", ctx);
ReflectionTestUtils.setField(manager, "downlinkMessageMapper", mock(DownlinkMessageMapper.class));
}
@AfterEach
void tearDown() {
if (manager != null) {
manager.destroy();
}
}
@Test
void readyOnlyWhenConnectedNotSyncingNotHighPriority() {
setReadiness(true, false, false);
assertThat(isReadyToProcessGeneralEvents())
.as("connected, not syncing, no high-priority work -> ready")
.isTrue();
}
@Test
void notReadyWhenDisconnected() {
setReadiness(false, false, false);
assertThat(isReadyToProcessGeneralEvents())
.as("disconnected -> not ready")
.isFalse();
}
@Test
void notReadyWhileSyncInProgress() {
setReadiness(true, true, false);
assertThat(isReadyToProcessGeneralEvents())
.as("sync in progress -> not ready (this is the window where events were being dropped)")
.isFalse();
}
@Test
void notReadyWhileHighPriorityProcessing() {
setReadiness(true, false, true);
assertThat(isReadyToProcessGeneralEvents())
.as("high-priority processing -> not ready")
.isFalse();
}
@Test
void initConsumerWiresReadinessPredicateIntoConsumerGate() {
stubStorageSettings();
@SuppressWarnings("unchecked")
TbQueueConsumer<TbProtoQueueMsg<ToEdgeEventNotificationMsg>> queueConsumer = mock(TbQueueConsumer.class);
// Report stopped so the launched consumer loop exits immediately - this test asserts on wiring, not polling.
when(queueConsumer.isStopped()).thenReturn(true);
when(tbCoreQueueFactory.createEdgeEventMsgConsumer(any(), any())).thenReturn(queueConsumer);
setReadiness(true, false, false);
initConsumer();
QueueConsumerManager<TbProtoQueueMsg<ToEdgeEventNotificationMsg>> consumer = manager.getConsumer();
assertThat(consumer).as("consumer must be built").isNotNull();
BooleanSupplier readinessCheck = (BooleanSupplier) ReflectionTestUtils.getField(consumer, "readinessCheck");
assertThat(readinessCheck)
.as("the edge consumer must be wired with a readinessCheck (the .readinessCheck(...) builder line)")
.isNotNull();
// It must be the live predicate, not a snapshot: flipping the session state must flip the gate.
assertThat(readinessCheck.getAsBoolean()).as("ready session -> gate open").isTrue();
state.tryStartSync();
assertThat(readinessCheck.getAsBoolean()).as("sync starts -> gate closes, consumer pauses polling").isFalse();
}
@Test
void eventArrivingDuringSyncIsHeldByTheEdgeConsumerUntilSyncCompletes() {
stubStorageSettings();
RecordingEdgeEventConsumer queueConsumer = new RecordingEdgeEventConsumer();
when(tbCoreQueueFactory.createEdgeEventMsgConsumer(any(), any())).thenReturn(queueConsumer);
// The consumer is launched only while the session is ready.
setReadiness(true, false, false);
initConsumer();
// Sync starts: the gate closes. Wait until the loop has actually parked on it (poll count stops advancing)
// before enqueuing - otherwise we would race an in-flight poll() and the test would be non-deterministic.
state.tryStartSync();
awaitParkedOnClosedGate(queueConsumer);
// An event lands in the edge-event topic during the sync window - exactly the case that used to be dropped.
@SuppressWarnings("unchecked")
TbProtoQueueMsg<ToEdgeEventNotificationMsg> event = mock(TbProtoQueueMsg.class);
int pollsBeforeEvent = queueConsumer.getPollCount();
queueConsumer.enqueue(List.of(event));
// While sync is in progress the consumer stays parked: it neither polls nor consumes the event.
sleepQuietly(POLL_INTERVAL_MS * 5);
assertThat(queueConsumer.getPolledEvents())
.as("event must not be polled while sync is in progress (it must stay queued, not be dropped)")
.isEmpty();
assertThat(queueConsumer.getPollCount())
.as("consumer must not poll at all while the gate is closed")
.isEqualTo(pollsBeforeEvent);
// Sync completes: the gate opens and the held event is finally picked up by the consumer.
state.finishSync();
await().atMost(5, TimeUnit.SECONDS)
.untilAsserted(() -> assertThat(queueConsumer.getPolledEvents())
.as("event held during sync must be picked up once sync completes, not lost")
.hasSize(1));
}
private void stubStorageSettings() {
EdgeEventStorageSettings storageSettings = mock(EdgeEventStorageSettings.class);
when(storageSettings.getNoRecordsSleepInterval()).thenReturn(POLL_INTERVAL_MS);
when(ctx.getEdgeEventStorageSettings()).thenReturn(storageSettings);
}
private void initConsumer() {
ReflectionTestUtils.invokeMethod(manager, "initConsumerAndExecutor", state.getTenantId(), state.getEdgeId(), state);
}
private boolean isReadyToProcessGeneralEvents() {
return Boolean.TRUE.equals(ReflectionTestUtils.invokeMethod(manager, "isReadyToProcessGeneralEvents"));
}
private void setReadiness(boolean connected, boolean syncInProgress, boolean highPriorityProcessing) {
state.setConnected(connected);
if (syncInProgress) {
state.tryStartSync();
} else {
state.finishSync();
}
ReflectionTestUtils.setField(manager, "isHighPriorityProcessing", highPriorityProcessing);
}
private static void awaitParkedOnClosedGate(RecordingEdgeEventConsumer consumer) {
await().atMost(5, TimeUnit.SECONDS).until(() -> {
int before = consumer.getPollCount();
// Several poll intervals with no new poll means the loop is parked on the readiness gate rather than
// blocked inside poll(), so it is safe to enqueue without racing an in-flight read.
sleepQuietly(POLL_INTERVAL_MS * 5);
return consumer.getPollCount() == before;
});
}
private static void sleepQuietly(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
/**
* Fake edge-event consumer that records what it was actually polled, so a test can prove an event stayed in the
* queue while the readiness gate was closed and was only read once it opened.
*/
private static class RecordingEdgeEventConsumer implements TbQueueConsumer<TbProtoQueueMsg<ToEdgeEventNotificationMsg>> {
private final Queue<List<TbProtoQueueMsg<ToEdgeEventNotificationMsg>>> pending = new ConcurrentLinkedQueue<>();
private final List<TbProtoQueueMsg<ToEdgeEventNotificationMsg>> polledEvents = new CopyOnWriteArrayList<>();
private final AtomicInteger pollCount = new AtomicInteger();
private volatile boolean stopped;
void enqueue(List<TbProtoQueueMsg<ToEdgeEventNotificationMsg>> batch) {
pending.add(batch);
}
int getPollCount() {
return pollCount.get();
}
List<TbProtoQueueMsg<ToEdgeEventNotificationMsg>> getPolledEvents() {
return polledEvents;
}
@Override
public List<TbProtoQueueMsg<ToEdgeEventNotificationMsg>> poll(long durationInMillis) {
pollCount.incrementAndGet();
List<TbProtoQueueMsg<ToEdgeEventNotificationMsg>> batch = pending.poll();
if (batch != null) {
polledEvents.addAll(batch);
return batch;
}
sleepQuietly(durationInMillis);
return Collections.emptyList();
}
@Override
public String getTopic() {
return "test-edge-event-topic";
}
@Override
public void subscribe() {
}
@Override
public void subscribe(Set<TopicPartitionInfo> partitions) {
}
@Override
public void stop() {
stopped = true;
}
@Override
public void unsubscribe() {
stopped = true;
}
@Override
public void commit() {
}
@Override
public boolean isStopped() {
return stopped;
}
@Override
public List<String> getFullTopicNames() {
return Collections.emptyList();
}
@Override
public Set<TopicPartitionInfo> getPartitions() {
return Collections.emptySet();
}
}
}

95
application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/AbstractMqttV5ClientSparkplugTest.java

@ -31,7 +31,6 @@ import org.junit.Assert;
import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.TransportPayloadType;
import org.thingsboard.server.common.data.asset.AssetInfo;
import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.kv.BasicTsKvEntry;
@ -54,14 +53,19 @@ import java.util.Calendar;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import static java.util.concurrent.Executors.newFixedThreadPool;
import static org.awaitility.Awaitility.await;
import static org.eclipse.paho.mqttv5.common.packet.MqttWireMessage.MESSAGE_TYPE_CONNACK;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.thingsboard.common.util.JacksonUtil.newArrayNode;
import static org.thingsboard.server.service.transport.DefaultTransportApiService.GATEWAY_CREATED_RELATION;
import static org.thingsboard.server.transport.mqtt.util.sparkplug.MetricDataType.Bytes;
import static org.thingsboard.server.transport.mqtt.util.sparkplug.MetricDataType.Int16;
import static org.thingsboard.server.transport.mqtt.util.sparkplug.MetricDataType.Int32;
@ -108,7 +112,8 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte
protected static final String metricBirthName_Int32 = "Device Metric int32";
protected Set<String> sparkplugAttributesMetricNames;
public void beforeSparkplugTest(boolean isCreateDevices) throws Exception {
public void beforeSparkplugTest() throws Exception {
MqttTestConfigProperties configProperties = MqttTestConfigProperties.builder()
.gatewayName(edgeNodeDeviceName)
.isSparkplug(true)
@ -116,24 +121,25 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte
.transportPayloadType(TransportPayloadType.PROTOBUF)
.build();
processBeforeTest(configProperties);
if (isCreateDevices) {
// 1. Create the first device with a short name (legacy style)
String deviceName1 = deviceId + "_1";
Device device1 = createDevice(deviceName1, deviceProfile.getName(), false);
// 2. Establish 'Created' relation so the transport identifies this gateway as the owner
String relationType = "Created";
EntityRelation relation1 = createFromRelation(savedGateway, device1, relationType);
doPost("/api/relation", relation1).andExpect(status().isOk());
// 3. Create the second device with a full-path name
String deviceName2 = groupId + DEVICE_NAME_SPLIT_SEPARATOR + edgeNode + DEVICE_NAME_SPLIT_SEPARATOR + deviceId + "_2";
Device device2 = createDevice(deviceName2, deviceProfile.getName(), false);
// 4. Establish 'Created' relation for the second device as well
EntityRelation relation2 = createFromRelation(savedGateway, device2, relationType);
doPost("/api/relation", relation2).andExpect(status().isOk());
}
}
public void seedLegacyAndFullPathDevices() throws Exception {
// 1. Create the first device with a short name (legacy style)
String deviceName1 = deviceId + "_1";
Device device1 = createDevice(deviceName1, deviceProfile.getName(), false);
// 2. Establish 'Created' relation so the transport identifies this gateway as the owner
EntityRelation relation1 = createFromRelation(savedGateway, device1, GATEWAY_CREATED_RELATION);
doPost("/api/relation", relation1).andExpect(status().isOk());
// 3. Create the second device with a full-path name
String deviceName2 = groupId + DEVICE_NAME_SPLIT_SEPARATOR + edgeNode + DEVICE_NAME_SPLIT_SEPARATOR + deviceId + "_2";
Device device2 = createDevice(deviceName2, deviceProfile.getName(), false);
// 4. Establish 'Created' relation for the second device as well
EntityRelation relation2 = createFromRelation(savedGateway, device2, GATEWAY_CREATED_RELATION);
doPost("/api/relation", relation2).andExpect(status().isOk());
}
public void clientWithCorrectNodeAccessTokenWithNDEATH() throws Exception {
@ -150,9 +156,9 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte
public void clientWithCorrectNodeAccessTokenWithNDEATH(long ts, long value) throws Exception {
IMqttToken connectionResult = clientMqttV5ConnectWithNDEATH(ts, value, -1L);
MqttWireMessage response = connectionResult.getResponse();
Assert.assertEquals(MESSAGE_TYPE_CONNACK, response.getType());
assertEquals(MESSAGE_TYPE_CONNACK, response.getType());
MqttConnAck connAckMsg = (MqttConnAck) response;
Assert.assertEquals(MqttReturnCode.RETURN_CODE_SUCCESS, connAckMsg.getReturnCode());
assertEquals(MqttReturnCode.RETURN_CODE_SUCCESS, connAckMsg.getReturnCode());
}
public IMqttToken clientMqttV5ConnectWithNDEATH(long ts, long value, Long alias, String... nameSpaceBad) throws Exception {
@ -208,7 +214,7 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte
.setTimestamp(ts)
.setSeq(getSeqNum());
String deviceIdName = deviceId + "_" + i;
String deviceName = groupId + ":" + edgeNode + ":" + deviceIdName;
String deviceName = groupId + DEVICE_NAME_SPLIT_SEPARATOR + edgeNode + DEVICE_NAME_SPLIT_SEPARATOR + deviceIdName;
payloadBirthDevice.addMetrics(metric);
if (client.isConnected()) {
client.publish(TOPIC_ROOT_SPB_V_1_0 + TOPIC_SPLIT_SEPARATOR + groupId + TOPIC_SPLIT_SEPARATOR + SparkplugMessageType.DBIRTH.name() + TOPIC_SPLIT_SEPARATOR + edgeNode + TOPIC_SPLIT_SEPARATOR + deviceIdName,
@ -225,7 +231,7 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte
}
}
Assert.assertEquals(cntDevices, devices.size());
assertEquals(cntDevices, devices.size());
return devices;
}
@ -290,10 +296,10 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte
return device2.get() != null;
});
devices.add(device2.get());
Assert.assertEquals(cntDevices, devices.size());
assertEquals(cntDevices, devices.size());
state_ONLINE_ALL (devices, calendar.getTimeInMillis());
// Without full topic: as it was in the old version. When deviceId is updated to full theme, Label is also updated to old deviceId
Assert.assertEquals(deviceIdNameLabel1, device1.get().getLabel());
assertEquals(deviceIdNameLabel1, device1.get().getLabel());
// // With a full topic: if new. When creating a device by a client to a full topic, if the Label was not filled in - we do not touch it.
Assert.assertNull(device2.get().getLabel());
}
@ -304,7 +310,7 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte
protected void renameCollisionWhenTargetNameAlreadyExists_Test() throws Exception {
long ts = calendar.getTimeInMillis();
String shortName = deviceId + "_1"; // Created in beforeTest
String fullPathName = groupId + ":" + edgeNode + ":" + shortName;
String fullPathName = groupId + DEVICE_NAME_SPLIT_SEPARATOR + edgeNode + DEVICE_NAME_SPLIT_SEPARATOR + shortName;
// Manually create a device that already has the "new" full-path name to trigger a collision
createDevice(fullPathName, deviceProfile.getName(), false);
@ -318,7 +324,7 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte
// Gateway sends DBIRTH for the short name.
// Transport will try to rename it but should find a conflict and handle it gracefully.
client.publish(TOPIC_ROOT_SPB_V_1_0 + "/" + groupId + "/DBIRTH/" + edgeNode + "/" + shortName,
client.publish(TOPIC_ROOT_SPB_V_1_0 + TOPIC_SPLIT_SEPARATOR + groupId + "/DBIRTH/" + edgeNode + TOPIC_SPLIT_SEPARATOR + shortName,
payload.build().toByteArray(), 0, false);
await("Checking stability after collision")
@ -352,10 +358,10 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte
.setTimestamp(ts).setSeq(getSeqNum());
// 2. Unauthorized gateway attempts to rename this device via Sparkplug topic path
client.publish(TOPIC_ROOT_SPB_V_1_0 + "/" + groupId + "/DBIRTH/" + edgeNode + "/" + strangerName,
client.publish(TOPIC_ROOT_SPB_V_1_0 + TOPIC_SPLIT_SEPARATOR + groupId + "/DBIRTH/" + edgeNode + TOPIC_SPLIT_SEPARATOR + strangerName,
payload.build().toByteArray(), 0, false);
String expectedFullPath = groupId + ":" + edgeNode + ":" + strangerName;
String expectedFullPath = groupId + DEVICE_NAME_SPLIT_SEPARATOR + edgeNode + DEVICE_NAME_SPLIT_SEPARATOR + strangerName;
// 3. Verify security: the original device must still be linked to its short name with the same ID
await("Verify original device was not hijacked")
@ -364,8 +370,8 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte
.untilAsserted(() -> {
// Check if the original device still exists with its original ID
Device currentStranger = doGet("/api/tenant/devices?deviceName=" + strangerName, Device.class);
Assert.assertNotNull("Original device disappeared!", currentStranger);
Assert.assertEquals("Security breach: Original device ID changed!", originalStrangerId, currentStranger.getId());
assertNotNull("Original device disappeared!", currentStranger);
assertEquals("Security breach: Original device ID changed!", originalStrangerId, currentStranger.getId());
// Even if the gateway created a NEW device with a full path, it must have a different ID
Device newDevice = doGet("/api/tenant/devices?deviceName=" + expectedFullPath, Device.class);
@ -387,6 +393,8 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte
stranger.setName(strangerName);
stranger.setType("default");
doPost("/api/device", stranger);
Device originalStrangerDevice =
doGet("/api/tenant/devices?deviceName=" + strangerName, Device.class);
clientWithCorrectNodeAccessTokenWithNDEATH();
@ -394,13 +402,18 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte
.setTimestamp(ts).setSeq(getSeqNum());
// Unauthorized gateway attempts to rename the device via Sparkplug topic
client.publish(TOPIC_ROOT_SPB_V_1_0 + "/" + groupId + "/DBIRTH/" + edgeNode + "/" + strangerName,
client.publish(TOPIC_ROOT_SPB_V_1_0 + TOPIC_SPLIT_SEPARATOR + groupId + "/DBIRTH/" + edgeNode + TOPIC_SPLIT_SEPARATOR + strangerName,
payload.build().toByteArray(), 0, false);
String expectedFullPath = groupId + ":" + edgeNode + ":" + strangerName;
String expectedFullPath = groupId + DEVICE_NAME_SPLIT_SEPARATOR + edgeNode + DEVICE_NAME_SPLIT_SEPARATOR + strangerName;
await().atMost(30, TimeUnit.SECONDS).untilAsserted(() ->
doGet("/api/tenant/devices?deviceName=" + expectedFullPath, Device.class, status().isNotFound())
doGet("/api/tenant/devices?deviceName=" + expectedFullPath, Device.class, status().isOk())
);
Device strangerDevice =
doGet("/api/tenant/devices?deviceName=" + strangerName, Device.class);
assertNotNull(strangerDevice);
assertEquals(originalStrangerDevice.getId(), strangerDevice.getId());
}
protected void state_ONLINE_ALL (List<Device> devices, long ts) {
@ -438,7 +451,7 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte
String concurrentDeviceName = "concurrent_device";
clientWithCorrectNodeAccessTokenWithNDEATH();
java.util.concurrent.ExecutorService executor = java.util.concurrent.Executors.newFixedThreadPool(threadCount);
ExecutorService executor = newFixedThreadPool(threadCount);
long ts = calendar.getTimeInMillis();
for (int i = 0; i < threadCount; i++) {
@ -446,7 +459,7 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte
try {
SparkplugBProto.Payload.Builder payload = SparkplugBProto.Payload.newBuilder()
.setTimestamp(ts).setSeq(0);
client.publish(TOPIC_ROOT_SPB_V_1_0 + "/" + groupId + "/DBIRTH/" + edgeNode + "/" + concurrentDeviceName,
client.publish(TOPIC_ROOT_SPB_V_1_0 + TOPIC_SPLIT_SEPARATOR + groupId + "/DBIRTH/" + edgeNode + TOPIC_SPLIT_SEPARATOR + concurrentDeviceName,
payload.build().toByteArray(), 0, false);
} catch (Exception e) {
log.error("Concurrent publish failed", e);
@ -454,9 +467,9 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte
});
}
String expectedName = groupId + ":" + edgeNode + ":" + concurrentDeviceName;
String expectedName = groupId + DEVICE_NAME_SPLIT_SEPARATOR + edgeNode + DEVICE_NAME_SPLIT_SEPARATOR + concurrentDeviceName;
await("Wait for concurrent registration result")
.atMost(40, TimeUnit.SECONDS) // Restored to 40s as requested
.atMost(40, TimeUnit.SECONDS)
.until(() -> doGet("/api/tenant/devices?deviceName=" + expectedName, Device.class) != null);
executor.shutdown();
@ -506,7 +519,7 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte
.setTimestamp(ts)
.setSeq(getSeqNum());
String deviceIdName = deviceId + "_1";
String deviceName = groupId + ":" + edgeNode + ":" + deviceIdName;
String deviceName = groupId + DEVICE_NAME_SPLIT_SEPARATOR + edgeNode + DEVICE_NAME_SPLIT_SEPARATOR + deviceIdName;
payloadBirthDevice.addMetrics(metric);
if (client.isConnected()) {
@ -523,7 +536,7 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte
devices.add(device.get());
}
Assert.assertEquals(1, devices.size());
assertEquals(1, devices.size());
return devices;
}

2
application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/attributes/MqttV5ClientSparkplugBAttributesInProfileTest.java

@ -33,7 +33,7 @@ public class MqttV5ClientSparkplugBAttributesInProfileTest extends AbstractMqttV
public void beforeTest() throws Exception {
sparkplugAttributesMetricNames = new HashSet<>();
sparkplugAttributesMetricNames.add(metricBirthName_Int32);
beforeSparkplugTest(false);
beforeSparkplugTest();
}
@After

2
application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/attributes/MqttV5ClientSparkplugBAttributesTest.java

@ -29,7 +29,7 @@ public class MqttV5ClientSparkplugBAttributesTest extends AbstractMqttV5ClientSp
@Before
public void beforeTest() throws Exception {
beforeSparkplugTest(false);
beforeSparkplugTest();
}
@After

3
application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/connection/MqttV5ClientSparkplugBConnectionDevicesCreatingBeforeTest.java

@ -34,7 +34,8 @@ public class MqttV5ClientSparkplugBConnectionDevicesCreatingBeforeTest extends A
*/
@Before
public void beforeTest() throws Exception {
beforeSparkplugTest(true);
beforeSparkplugTest();
seedLegacyAndFullPathDevices();
}
@After

2
application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/connection/MqttV5ClientSparkplugBConnectionTest.java

@ -29,7 +29,7 @@ public class MqttV5ClientSparkplugBConnectionTest extends AbstractMqttV5ClientSp
@Before
public void beforeTest() throws Exception {
beforeSparkplugTest(false);
beforeSparkplugTest();
}
@After

2
application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/rpc/MqttV5RpcSparkplugTest.java

@ -28,7 +28,7 @@ public class MqttV5RpcSparkplugTest extends AbstractMqttV5RpcSparkplugTest {
@Before
public void beforeTest() throws Exception {
beforeSparkplugTest(false);
beforeSparkplugTest();
}
@After

2
application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/timeseries/MqttV5ClientSparkplugBTelemetryTest.java

@ -29,7 +29,7 @@ public class MqttV5ClientSparkplugBTelemetryTest extends AbstractMqttV5ClientSpa
@Before
public void beforeTest() throws Exception {
beforeSparkplugTest(false);
beforeSparkplugTest();
}
@After

5
common/data/pom.xml

@ -84,8 +84,11 @@
<scope>test</scope>
</dependency>
<dependency>
<!-- wire-schema-jvm (not wire-schema): from Wire 4.x the plain wire-schema artifact is an empty
Kotlin-Multiplatform pointer whose Maven pom carries no classes, so the -jvm variant must be
referenced explicitly. It transitively pulls wire-runtime-jvm. See CVE-2026-45799 fix. -->
<groupId>com.squareup.wire</groupId>
<artifactId>wire-schema</artifactId>
<artifactId>wire-schema-jvm</artifactId>
</dependency>
<dependency>
<groupId>org.thingsboard</groupId>

2
common/data/src/main/java/org/thingsboard/server/common/data/security/DeviceCredentialsFilter.java

@ -16,7 +16,7 @@
package org.thingsboard.server.common.data.security;
/**
* TODO: This is a temporary name. DeviceCredentialsId is resereved in dao layer
* TODO: This is a temporary name. DeviceCredentialsId is reserved in dao layer
*/
public interface DeviceCredentialsFilter {

31
common/queue/src/main/java/org/thingsboard/server/queue/common/consumer/QueueConsumerManager.java

@ -30,6 +30,7 @@ import java.util.concurrent.ExecutorService;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.function.BooleanSupplier;
import java.util.function.Supplier;
@Slf4j
@ -40,6 +41,8 @@ public class QueueConsumerManager<M extends TbQueueMsg> {
private final long pollInterval;
private final ExecutorService consumerExecutor;
private final String threadPrefix;
/** Optional poll gate: while {@code false} the loop skips polling so the position doesn't advance; {@code null} = always ready (default). */
private final BooleanSupplier readinessCheck;
@Getter
private final TbQueueConsumer<M> consumer;
@ -49,12 +52,13 @@ public class QueueConsumerManager<M extends TbQueueMsg> {
@Builder
public QueueConsumerManager(String name, MsgPackProcessor<M> msgPackProcessor,
long pollInterval, Supplier<TbQueueConsumer<M>> consumerCreator,
ExecutorService consumerExecutor, String threadPrefix) {
ExecutorService consumerExecutor, String threadPrefix, BooleanSupplier readinessCheck) {
this.name = name;
this.pollInterval = pollInterval;
this.msgPackProcessor = msgPackProcessor;
this.consumerExecutor = consumerExecutor;
this.threadPrefix = threadPrefix;
this.readinessCheck = readinessCheck;
this.consumer = consumerCreator.get();
}
@ -84,6 +88,12 @@ public class QueueConsumerManager<M extends TbQueueMsg> {
private void consumerLoop(TbQueueConsumer<M> consumer) {
while (!stopped && !consumer.isStopped()) {
try {
if (!isReadyToProcess()) {
if (!awaitNextReadinessCheck()) {
return;
}
continue;
}
List<M> msgs = consumer.poll(pollInterval);
if (msgs.isEmpty()) {
continue;
@ -102,6 +112,25 @@ public class QueueConsumerManager<M extends TbQueueMsg> {
}
}
private boolean isReadyToProcess() {
return readinessCheck == null || readinessCheck.getAsBoolean();
}
/**
* Waits one poll interval before readiness is re-checked. Returns {@code false} if interrupted, which is treated as
* a stop signal so the consumer loop exits.
*/
private boolean awaitNextReadinessCheck() {
log.trace("[{}] Consumer is not ready to process messages yet, skipping poll iteration", name);
try {
Thread.sleep(pollInterval);
return true;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}
public void stop() {
log.debug("[{}] Stopping consumer", name);
stopped = true;

251
common/queue/src/test/java/org/thingsboard/server/queue/common/consumer/QueueConsumerManagerTest.java

@ -0,0 +1,251 @@
/**
* 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.queue.common.consumer;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.thingsboard.server.common.msg.queue.TopicPartitionInfo;
import org.thingsboard.server.queue.TbQueueConsumer;
import org.thingsboard.server.queue.TbQueueMsg;
import java.util.Collections;
import java.util.List;
import java.util.Queue;
import java.util.Set;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.BooleanSupplier;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
import static org.mockito.Mockito.mock;
@Slf4j
class QueueConsumerManagerTest {
private static final long POLL_INTERVAL_MS = 20L;
// Before asserting the consumer never polled we wait until the loop has evaluated the readiness gate at least
// this many times. That proves the consumer thread is actually running and deciding not to poll, rather than the
// assertion passing vacuously because the thread simply has not started yet.
private static final int MIN_READINESS_CHECKS = 3;
private final AtomicBoolean readyToProcess = new AtomicBoolean(false);
private final AtomicInteger readinessChecks = new AtomicInteger();
private final TestQueueConsumer consumer = new TestQueueConsumer();
private ExecutorService consumerExecutor;
private QueueConsumerManager<TbQueueMsg> manager;
@AfterEach
void tearDown() {
if (manager != null) {
manager.stop();
}
if (consumerExecutor != null) {
consumerExecutor.shutdownNow();
}
}
@Test
void eventQueuedWhileNotReadyIsDeliveredAfterReadinessGateOpensInsteadOfBeingDropped() {
List<TbQueueMsg> delivered = new CopyOnWriteArrayList<>();
consumer.enqueue(List.of(mock(TbQueueMsg.class)));
// The processor is unconditional: only the readiness gate may hold the event back, so delivery proves the
// gate (not the processor) is what kept the event queued while not ready.
manager = launchManager(consumer, countingReadiness(readyToProcess, readinessChecks), (msgs, c) -> {
delivered.addAll(msgs);
c.commit();
});
// The loop is running and repeatedly evaluating the gate during the not-ready (sync) window...
awaitReadinessGateEvaluated(readinessChecks);
// ...yet the queued event is neither polled nor delivered - it stays in the queue rather than being dropped.
assertThat(consumer.getPollCount())
.as("consumer must not poll while not ready")
.isZero();
assertThat(delivered)
.as("event must not be delivered while not ready")
.isEmpty();
// Sync completes - the processor becomes ready.
readyToProcess.set(true);
await().atMost(5, TimeUnit.SECONDS)
.untilAsserted(() -> assertThat(delivered)
.as("event queued during the not-ready window must be delivered, not dropped")
.hasSize(1));
}
@Test
void consumerIsNotPolledWhileNotReadyToProcess() {
manager = launchManager(consumer, countingReadiness(readyToProcess, readinessChecks), (msgs, c) -> c.commit());
awaitReadinessGateEvaluated(readinessChecks);
assertThat(consumer.getPollCount())
.as("consumer must not be polled while not ready to process")
.isZero();
readyToProcess.set(true);
await().atMost(5, TimeUnit.SECONDS)
.untilAsserted(() -> assertThat(consumer.getPollCount())
.as("consumer resumes polling once ready")
.isPositive());
}
@Test
void consumerWithoutReadinessCheckPollsAndDeliversImmediately() {
List<TbQueueMsg> delivered = new CopyOnWriteArrayList<>();
consumer.enqueue(List.of(mock(TbQueueMsg.class)));
// No readiness gate configured - the consumer must default to "always ready", preserving the behaviour every
// consumer that does not opt in relies on.
manager = launchManager(consumer, null, (msgs, c) -> {
delivered.addAll(msgs);
c.commit();
});
await().atMost(5, TimeUnit.SECONDS)
.untilAsserted(() -> assertThat(delivered)
.as("consumer without a readiness gate must poll and deliver immediately")
.hasSize(1));
}
@Test
void consumerLoopExitsWhenInterruptedWhileNotReady() throws Exception {
manager = launchManager(consumer, countingReadiness(readyToProcess, readinessChecks), (msgs, c) -> c.commit());
// The loop is parked in the not-ready wait...
awaitReadinessGateEvaluated(readinessChecks);
// ...interrupting the worker (as shutdownNow does on stop) must end the loop, not spin or hang.
consumerExecutor.shutdownNow();
assertThat(consumerExecutor.awaitTermination(5, TimeUnit.SECONDS))
.as("consumer loop must exit when interrupted while waiting to become ready")
.isTrue();
}
private static void awaitReadinessGateEvaluated(AtomicInteger readinessChecks) {
await().atMost(5, TimeUnit.SECONDS)
.untilAsserted(() -> assertThat(readinessChecks.get())
.as("consumer loop must be running and repeatedly evaluating the readiness gate")
.isGreaterThanOrEqualTo(MIN_READINESS_CHECKS));
}
private static BooleanSupplier countingReadiness(AtomicBoolean ready, AtomicInteger readinessChecks) {
return () -> {
readinessChecks.incrementAndGet();
return ready.get();
};
}
private QueueConsumerManager<TbQueueMsg> launchManager(TestQueueConsumer consumer, BooleanSupplier readinessCheck,
QueueConsumerManager.MsgPackProcessor<TbQueueMsg> processor) {
consumerExecutor = Executors.newSingleThreadExecutor();
QueueConsumerManager<TbQueueMsg> queueConsumerManager = QueueConsumerManager.<TbQueueMsg>builder()
.name("test-consumer")
.pollInterval(POLL_INTERVAL_MS)
.consumerCreator(() -> consumer)
.consumerExecutor(consumerExecutor)
.readinessCheck(readinessCheck)
.msgPackProcessor(processor)
.build();
queueConsumerManager.subscribe();
queueConsumerManager.launch();
return queueConsumerManager;
}
private static class TestQueueConsumer implements TbQueueConsumer<TbQueueMsg> {
private final Queue<List<TbQueueMsg>> batches = new ConcurrentLinkedQueue<>();
private final AtomicInteger pollCount = new AtomicInteger();
private volatile boolean stopped;
void enqueue(List<TbQueueMsg> batch) {
batches.add(batch);
}
int getPollCount() {
return pollCount.get();
}
@Override
public List<TbQueueMsg> poll(long durationInMillis) {
pollCount.incrementAndGet();
List<TbQueueMsg> batch = batches.poll();
if (batch != null) {
return batch;
}
try {
Thread.sleep(durationInMillis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return Collections.emptyList();
}
@Override
public String getTopic() {
return "test-topic";
}
@Override
public void subscribe() {
}
@Override
public void subscribe(Set<TopicPartitionInfo> partitions) {
}
@Override
public void stop() {
stopped = true;
}
@Override
public void unsubscribe() {
stopped = true;
}
@Override
public void commit() {
}
@Override
public boolean isStopped() {
return stopped;
}
@Override
public Set<TopicPartitionInfo> getPartitions() {
return Collections.emptySet();
}
@Override
public List<String> getFullTopicNames() {
return Collections.emptyList();
}
}
}

8
common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java

@ -261,7 +261,7 @@ public abstract class AbstractGatewaySessionHandler<T extends AbstractGatewayDev
log.trace("[{}][{}][{}] onDeviceConnect: [{}]", gateway.getTenantId(), gateway.getDeviceId(), sessionId, deviceName);
int msgId = getMsgId(msg);
AtomicBoolean ackSent = new AtomicBoolean(false);
process(onDeviceConnect(deviceName, deviceType, false),
process(onDeviceConnect(deviceName, deviceType),
result -> {
ack(msg, MqttReasonCodes.PubAck.SUCCESS);
log.trace("[{}][{}][{}] onDeviceConnectOk: [{}]", gateway.getTenantId(), gateway.getDeviceId(), sessionId, deviceName);
@ -277,6 +277,10 @@ public abstract class AbstractGatewaySessionHandler<T extends AbstractGatewayDev
}
}
ListenableFuture<T> onDeviceConnect(String deviceName, String deviceType) {
return onDeviceConnect(deviceName, deviceType, false);
}
ListenableFuture<T> onDeviceConnect(String deviceName, String deviceType, boolean isSparkplug) {
T result = devices.get(deviceName);
if (result == null) {
@ -902,7 +906,7 @@ public abstract class AbstractGatewaySessionHandler<T extends AbstractGatewayDev
}
protected void process(String deviceName, Consumer<T> onSuccess, Consumer<Throwable> onFailure) {
ListenableFuture<T> deviceCtxFuture = onDeviceConnect(deviceName, DEFAULT_DEVICE_TYPE, false);
ListenableFuture<T> deviceCtxFuture = onDeviceConnect(deviceName, DEFAULT_DEVICE_TYPE);
process(deviceCtxFuture, onSuccess, onFailure);
}

35
common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/SparkplugNodeSessionHandler.java

@ -115,26 +115,23 @@ public class SparkplugNodeSessionHandler extends AbstractGatewaySessionHandler<S
}
contextListenableFuture = Futures.immediateFuture(this.deviceSessionCtx);
} else {
try {
deviceName = checkDeviceName(topic.getNodeDeviceNameAllPath());
ListenableFuture<SparkplugDeviceSessionContext> deviceCtx = this.onDeviceConnectProto(topic);
String finalDeviceName = deviceName;
contextListenableFuture = Futures.transform(deviceCtx, ctx -> {
if (topic.isType(DBIRTH)) {
sendSparkplugStateOnTelemetry(ctx.getSessionInfo(), finalDeviceName, ONLINE,
sparkplugBProto.getTimestamp());
try {
ctx.setDeviceBirthMetrics(sparkplugBProto.getMetricsList());
} catch (IllegalArgumentException | DuplicateKeyException e) {
log.error("[{}] Failed to set birth metrics", finalDeviceName, e);
throw new RuntimeException(e);
}
deviceName = checkDeviceName(topic.getNodeDeviceNameAllPath());
ListenableFuture<SparkplugDeviceSessionContext> deviceCtx = this.onDeviceConnectProto(topic);
String finalDeviceName = deviceName;
contextListenableFuture = Futures.transform(deviceCtx, ctx -> {
if (topic.isType(DBIRTH)) {
sendSparkplugStateOnTelemetry(ctx.getSessionInfo(), finalDeviceName, ONLINE,
sparkplugBProto.getTimestamp());
try {
ctx.setDeviceBirthMetrics(sparkplugBProto.getMetricsList());
} catch (IllegalArgumentException | DuplicateKeyException e) {
log.error("[{}] Failed to set birth metrics", finalDeviceName, e);
throw new RuntimeException(e);
}
return ctx;
}, MoreExecutors.directExecutor());
} catch (IllegalArgumentException | DuplicateKeyException e) {
throw new RuntimeException(e);
}
}
return ctx;
}, MoreExecutors.directExecutor());
}
Set<String> attributesMetricNames = ((MqttDeviceProfileTransportConfiguration) deviceSessionCtx
.getDeviceProfile().getProfileData().getTransportConfiguration()).getSparkplugAttributesMetricNames();

42
pom.xml

@ -63,15 +63,15 @@
<pkg.unixLogFolder>/var/log/${pkg.name}</pkg.unixLogFolder>
<pkg.installFolder>/usr/share/${pkg.name}</pkg.installFolder>
<thingsboard.client.version>4.4.0</thingsboard.client.version>
<spring-boot.version>3.5.14</spring-boot.version>
<!-- TODO: remove spring-boot-test.version override and the matching dependencyManagement entries below
once Spring Boot 3.5.15+ is released with a fix for the ImportsContextCustomizer regression in 3.5.14
that causes "Duplicate spy definition" failures on legacy @SpyBean fields (see PR #15557). -->
<spring-boot.version>3.5.15</spring-boot.version>
<!-- Pins spring-boot-test to 3.5.13: the ImportsContextCustomizer change in 3.5.14 causes "Duplicate spy
definition" failures on legacy @SpyBean fields (see PR #15557). Spring declined to restore the prior
behavior (spring-projects/spring-boot#50230, closed NOT_PLANNED), so no 3.5.x release fixes this — the
bump to 3.5.15 in this PR does not. TODO: remove this override and the matching dependencyManagement
entries below once TB migrates its tests off the deprecated @SpyBean/@MockBean to @MockitoSpyBean/@MockitoBean. -->
<spring-boot-test.version>3.5.13</spring-boot-test.version>
<commons-lang3.version>3.18.0</commons-lang3.version> <!-- to fix CVE-2025-48924. TODO: remove when fixed in spring-boot-dependencies -->
<postgresql.version>42.7.11</postgresql.version> <!-- to fix CVE-2026-42198. TODO: remove when fixed in spring-boot-dependencies -->
<netty.version>4.1.133.Final</netty.version> <!-- to fix CVE-2026-42579, CVE-2026-42583, CVE-2026-42584, CVE-2026-42587. TODO: remove when fixed in spring-boot-dependencies -->
<tomcat.version>10.1.55</tomcat.version> <!-- to fix CVE-2026-41284, CVE-2026-43512. TODO: remove when fixed in spring-boot-dependencies -->
<netty.version>4.1.134.Final</netty.version> <!-- to fix CVE-2026-42579, CVE-2026-42583, CVE-2026-42584, CVE-2026-42587, and MQTT decoder regression introduced in 4.1.133 by the CVE-2026-44248 fix. TODO: remove when fixed in spring-boot-dependencies -->
<javax.xml.bind-api.version>2.4.0-b180830.0359</javax.xml.bind-api.version>
<jjwt.version>0.12.5</jjwt.version>
<rat.version>0.10</rat.version> <!-- unused -->
@ -93,7 +93,7 @@
<mail.version>2.0.1</mail.version>
<curator.version>5.6.0</curator.version>
<zookeeper.version>3.9.5</zookeeper.version> <!-- to fix CVE-2026-24308 and CVE-2026-24281. TODO: remove override when fixed in curator-client -->
<protobuf.version>3.25.5</protobuf.version> <!-- A Major v4 does not support by the pubsub yet-->
<protobuf.version>3.25.5</protobuf.version> <!-- Protobuf v4 is not yet supported by the pubsub -->
<grpc.version>1.76.0</grpc.version>
<tbel.version>1.2.10</tbel.version>
<lombok.version>1.18.46</lombok.version> <!-- must be in sync with spring-boot-dependencies; needed for maven-compiler-plugin annotationProcessorPaths -->
@ -137,7 +137,7 @@
<commons-beanutils.version>1.11.0</commons-beanutils.version>
<commons-collections.version>4.4</commons-collections.version>
<protobuf-dynamic.version>1.0.4TB</protobuf-dynamic.version>
<wire-schema.version>3.7.1</wire-schema.version>
<wire-schema.version>6.3.0</wire-schema.version> <!-- to fix CVE-2026-45799 (transitive wire-runtime). Not managed by the Spring Boot BOM — this pin is the sole source of the wire version, nothing to defer to. TODO: remove only once a TB dependency pulls wire >= 6.3.0 transitively, making the explicit pin redundant. -->
<twilio.version>10.1.3</twilio.version>
<hypersistence-utils.version>3.7.4</hypersistence-utils.version> <!-- artifact name should be updated with hibernate-core version -->
<jakarta.el.version>4.0.2</jakarta.el.version>
@ -1018,23 +1018,6 @@
<scope>import</scope>
</dependency>
<!-- End of netty-bom version override -->
<!-- Temporary tomcat-embed version override -->
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-core</artifactId>
<version>${tomcat.version}</version>
</dependency>
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-el</artifactId>
<version>${tomcat.version}</version>
</dependency>
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-websocket</artifactId>
<version>${tomcat.version}</version>
</dependency>
<!-- End of tomcat-embed version override -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
@ -1388,11 +1371,6 @@
<artifactId>commons-lang3</artifactId>
<version>${commons-lang3.version}</version>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>${postgresql.version}</version>
</dependency>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
@ -1813,7 +1791,7 @@
</dependency>
<dependency>
<groupId>com.squareup.wire</groupId>
<artifactId>wire-schema</artifactId>
<artifactId>wire-schema-jvm</artifactId>
<version>${wire-schema.version}</version>
</dependency>
<dependency>

3
ui-ngx/src/app/core/api/widget-subscription.ts

@ -871,7 +871,8 @@ export class WidgetSubscription implements IWidgetSubscription {
return timer(initialTimeout, pollingInterval).pipe(
switchMap(() => this.ctx.deviceService.getPersistedRpc(response.rpcId, true)),
filter(persistentRespons =>
persistentRespons.status !== RpcStatus.DELIVERED && persistentRespons.status !== RpcStatus.QUEUED),
(oneWayElseTwoWay && persistentRespons.status === RpcStatus.DELIVERED) ||
(persistentRespons.status !== RpcStatus.DELIVERED && persistentRespons.status !== RpcStatus.QUEUED)),
switchMap(persistentResponse => {
if ([RpcStatus.TIMEOUT, RpcStatus.EXPIRED].includes(persistentResponse.status)) {
return throwError(() => ({status: 504}));

2
ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts

@ -338,7 +338,7 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC
}
get mobileDisplayRightLayoutFirst(): boolean {
return this.isMobile && this.layouts.right.layoutCtx.gridSettings?.mobileDisplayLayoutFirst;
return this.isMobile && this.layouts.right.show && this.layouts.right.layoutCtx.gridSettings?.mobileDisplayLayoutFirst;
}
set mobileDisplayRightLayoutFirst(mobileDisplayRightLayoutFirst: boolean) {

3
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.html

@ -122,7 +122,8 @@
[editorCompleter]="containerFunctionEditorCompleter"
[functionArgs]="htmlContainerSettingsForm.get('type').value === HtmlContainerWidgetType.ANGULAR ? ['ctx'] : ['ctx', 'container']"
withModules
functionTitle="{{ 'widgets.html-container.js-function' | translate }}">
functionTitle="{{ 'widgets.html-container.js-function' | translate }}"
[helpId]="htmlContainerSettingsForm.get('type').value === HtmlContainerWidgetType.ANGULAR ? 'widget/lib/html_container/container_angular_fn' : 'widget/lib/html_container/container_plain_fn'">
</tb-js-func>
</ng-container>
</ng-template>

4
ui-ngx/src/app/modules/home/components/widget/widget-config.component.html

@ -36,7 +36,7 @@
<ng-container *ngTemplateOutlet="data"></ng-container>
</div>
}
<div [class.!hidden]="selectedOption !== 'appearance'" class="mat-content">
<div [class.!hidden]="selectedOption !== 'appearance'" class="mat-content tb-widget-config-fill">
<ng-container *ngTemplateOutlet="appearance"></ng-container>
</div>
<div [class.!hidden]="selectedOption !== 'card'" [formGroup]="widgetSettings" class="mat-content">
@ -243,7 +243,7 @@
} @else {
<div class="tb-widget-config-content">
@if (hasBasicModeDirective) {
<div class="mat-content">
<div class="mat-content tb-widget-config-fill">
<ng-container #basicModeContainer></ng-container>
@if (basicModeDirectiveError) {
<div class="tb-basic-mode-directive-error">{{basicModeDirectiveError}}</div>

4
ui-ngx/src/app/modules/home/components/widget/widget-config.component.scss

@ -43,7 +43,9 @@
}
.tb-widget-config-content {
& > .mat-content {
height: 100%;
&.tb-widget-config-fill {
height: 100%;
}
padding-top: 8px;
@media #{$mat-xs} {
padding-left: 8px;

2
ui-ngx/src/app/modules/home/pages/security/security.component.html

@ -174,7 +174,7 @@
</button>
<button mat-raised-button color="primary"
type="submit"
[disabled]="(isLoading$ | async) || (!changePassword.valid && changePassword.touched)">
[disabled]="(isLoading$ | async) || changePassword.invalid">
{{ 'profile.change-password' | translate }}
</button>
</div>

2
ui-ngx/src/app/shared/components/string-pattern-autocomplete.component.html

@ -26,7 +26,7 @@
[formControl]="selectionFormControl"
[placeholder]="placeholderText"
(focusin)="onFocus()"
(scroll)="onInputScroll($event)">
(focusout)="onBlur()">
@if (predefinedValuesButton) {
<button type="button"
matSuffix

49
ui-ngx/src/app/shared/components/string-pattern-autocomplete.component.ts

@ -45,6 +45,7 @@ import {
PositionStrategy
} from '@angular/cdk/overlay';
import { TemplatePortal } from '@angular/cdk/portal';
import { CancelAnimationFrame, RafService } from '@core/services/raf.service';
@Component({
selector: 'tb-string-pattern-autocomplete',
@ -123,6 +124,8 @@ export class StringPatternAutocompleteComponent implements ControlValueAccessor,
private predefinedValuesButtonMode = false;
private scrollSyncCancel: CancelAnimationFrame | null = null;
private propagateChange = (_val: any) => {
};
@ -131,7 +134,8 @@ export class StringPatternAutocompleteComponent implements ControlValueAccessor,
private translate: TranslateService,
private viewContainerRef: ViewContainerRef,
private destroyRef: DestroyRef,
private cd: ChangeDetectorRef) {
private cd: ChangeDetectorRef,
private raf: RafService) {
}
ngOnInit() {
@ -150,6 +154,8 @@ export class StringPatternAutocompleteComponent implements ControlValueAccessor,
fromEvent<KeyboardEvent>(this.inputRef.nativeElement, 'keydown')
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(event => this.handleKeydown(event));
this.destroyRef.onDestroy(() => this.stopScrollSync());
}
ngOnChanges(changes: SimpleChanges) {
@ -180,6 +186,11 @@ export class StringPatternAutocompleteComponent implements ControlValueAccessor,
onFocus() {
this.onSelectionChange();
this.startScrollSync();
}
onBlur() {
this.stopScrollSync();
}
registerOnChange(fn: any): void {
@ -199,12 +210,36 @@ export class StringPatternAutocompleteComponent implements ControlValueAccessor,
}
}
onInputScroll(event: Event) {
const scrollLeft = (event.target as HTMLInputElement).scrollLeft;
const scrollTop = (event.target as HTMLInputElement).scrollTop;
if (this.highlightTextRef && this.highlightTextRef.nativeElement) {
this.highlightTextRef.nativeElement.scrollLeft = scrollLeft;
this.highlightTextRef.nativeElement.scrollTop = scrollTop;
// Continuous per-frame poll instead of a 'scroll' event handler: Safari does not reliably
// fire 'scroll' on this input, so the highlight overlay would drift out of sync. The poll
// also keeps the overlay synced frame-by-frame during drag-selection / auto-scroll, where an
// event-driven sync stutters. The loop runs only while the input is focused (one at a time),
// so its cost is bounded - do not revert this to a 'scroll' listener.
private startScrollSync() {
if (this.scrollSyncCancel !== null) {
return;
}
let lastLeft = -1;
let lastTop = -1;
const tick = () => {
const input = this.inputRef?.nativeElement as HTMLInputElement;
const overlay = this.highlightTextRef?.nativeElement as HTMLElement;
if (input && overlay && (input.scrollLeft !== lastLeft || input.scrollTop !== lastTop)) {
lastLeft = input.scrollLeft;
lastTop = input.scrollTop;
overlay.scrollLeft = lastLeft;
overlay.scrollTop = lastTop;
}
this.scrollSyncCancel = this.raf.raf(tick);
};
this.scrollSyncCancel = this.raf.raf(tick);
}
private stopScrollSync() {
if (this.scrollSyncCancel !== null) {
this.scrollSyncCancel();
this.scrollSyncCancel = null;
}
}

40
ui-ngx/src/app/shared/import-export/import-export.service.ts

@ -89,12 +89,16 @@ import {
import { FormProperty, propertyValid } from '@shared/models/dynamic-form.models';
import { CalculatedFieldsService } from '@core/http/calculated-fields.service';
import { CalculatedField } from '@shared/models/calculated-field.models';
import { RuleChainId } from '@shared/models/id/rule-chain-id';
export type editMissingAliasesFunction = (widgets: Array<Widget>, isSingleWidget: boolean,
customTitle: string, missingEntityAliases: EntityAliases) => Observable<EntityAliases>;
type SupportEntityResources = 'includeResourcesInExportWidgetTypes' | 'includeResourcesInExportDashboard' | 'includeBundleWidgetsInExport';
const profileRuleChainFields = ['defaultRuleChainId', 'defaultEdgeRuleChainId'] as const;
type ProfileRuleChainField = typeof profileRuleChainFields[number];
// @dynamic
@Injectable()
export class ImportExportService {
@ -749,7 +753,8 @@ export class ImportExportService {
type: 'error'}));
throw new Error('Invalid device profile file');
} else {
return this.deviceProfileService.saveDeviceProfile(this.prepareImport(deviceProfile));
return this.clearMissingRuleChainsAndSave(deviceProfile,
profile => this.deviceProfileService.saveDeviceProfile(profile));
}
}),
catchError(() => of(null))
@ -776,13 +781,44 @@ export class ImportExportService {
type: 'error'}));
throw new Error('Invalid asset profile file');
} else {
return this.assetProfileService.saveAssetProfile(this.prepareImport(assetProfile));
return this.clearMissingRuleChainsAndSave(assetProfile,
profile => this.assetProfileService.saveAssetProfile(profile));
}
}),
catchError(() => of(null))
);
}
private clearMissingRuleChainsAndSave<T extends ExportableEntity<EntityId> & Partial<Record<ProfileRuleChainField, RuleChainId>>>(
profile: T, save: (profile: T) => Observable<T>): Observable<T> {
return this.clearMissingProfileRuleChains(profile).pipe(
mergeMap(cleared => save(this.prepareImport(cleared)))
);
}
private clearMissingProfileRuleChains<T extends Partial<Record<ProfileRuleChainField, RuleChainId>>>(profile: T): Observable<T> {
const fieldsToCheck = profileRuleChainFields.filter(field => profile[field]?.id);
if (!fieldsToCheck.length) {
return of(profile);
}
const ruleChainIds = fieldsToCheck.map(field => profile[field].id);
return this.ruleChainService.getRuleChainsByIds(ruleChainIds, {ignoreErrors: true, ignoreLoading: true}).pipe(
map(ruleChains => {
const existingIds = new Set(ruleChains.map(ruleChain => ruleChain.id.id));
const missingFields = fieldsToCheck.filter(field => !existingIds.has(profile[field].id));
if (!missingFields.length) {
return profile;
}
this.store.dispatch(new ActionNotificationShow(
{message: this.translate.instant('rulechain.rulechain-not-found-warning'),
type: 'warn'}));
missingFields.forEach(field => profile[field] = null);
return profile;
})
);
}
public exportTenantProfile(tenantProfileId: string) {
this.tenantProfileService.getTenantProfile(tenantProfileId).subscribe({
next: (tenantProfile) => {

135
ui-ngx/src/assets/help/en_US/widget/lib/html_container/container_angular_fn.md

@ -0,0 +1,135 @@
#### Container function
<div class="divider"></div>
<br/>
*function (ctx): void*
A JavaScript function used to expose data and handlers to the Angular template by assigning them to <code>this</code>. Reference them in the template without the <code>this</code> prefix.
**Parameters:**
<ul>
<li><b>ctx:</b> <code><a href="https://github.com/thingsboard/thingsboard/blob/5bb6403407aa4898084832d6698aa9ea6d484889/ui-ngx/src/app/modules/home/models/widget-component.models.ts#L107" target="_blank">WidgetContext</a></code> - A reference to <a href="https://github.com/thingsboard/thingsboard/blob/5bb6403407aa4898084832d6698aa9ea6d484889/ui-ngx/src/app/modules/home/models/widget-component.models.ts#L107" target="_blank">WidgetContext</a> that has all necessary API
and data used by widget instance.<br/>
Provides access to send RPC, switch dashboard states, create data subscriptions, read settings and access platform services.
</li>
</ul>
**Returns:**
This function does not return any value. Expose values and handlers to the template by assigning them to <code>this</code>.
<div class="divider"></div>
##### Notes
<ul>
<li>There is no <code>container</code> parameter in Angular mode. Access the root element via <code>ctx.$container[0]</code> or <code>event.currentTarget.closest(...)</code> from a handler.</li>
<li>Define helper functions as arrow functions to preserve <code>this</code>.</li>
<li>Call <code>ctx.detectChanges()</code> after changing template-bound values so the template re-renders.</li>
<li>This widget does <b>not</b> bind widget datasources automatically. There is no <code>ctx.data</code> populated from the widget configuration. To read live data create an explicit subscription via <code>ctx.subscriptionApi.createSubscription(...)</code> and release it from <code>ctx.registerDestroyCallback(...)</code>.</li>
</ul>
<div class="divider"></div>
##### Examples
###### Resizable split master–detail with embedded dashboard state
<br>
<div style="padding-left: 64px;"
tb-help-popup="widget/lib/html_container/examples/angular_split_html"
tb-help-popup-placement="top"
[tb-help-popup-style]="{maxHeight: '50vh', maxWidth: '50vw'}"
trigger-style="font-size: 16px;"
trigger-text="HTML template">
</div>
<br>
<div style="padding-left: 64px;"
tb-help-popup="widget/lib/html_container/examples/angular_split_css"
tb-help-popup-placement="top"
[tb-help-popup-style]="{maxHeight: '50vh', maxWidth: '50vw'}"
trigger-style="font-size: 16px;"
trigger-text="CSS">
</div>
<br>
<div style="padding-left: 64px;"
tb-help-popup="widget/lib/html_container/examples/angular_split_js"
tb-help-popup-placement="top"
[tb-help-popup-style]="{maxHeight: '50vh', maxWidth: '50vw'}"
trigger-style="font-size: 16px;"
trigger-text="JavaScript function">
</div>
###### Slide-over device detail with two-way RPC
<br>
<div style="padding-left: 64px;"
tb-help-popup="widget/lib/html_container/examples/angular_drawer_html"
tb-help-popup-placement="top"
[tb-help-popup-style]="{maxHeight: '50vh', maxWidth: '50vw'}"
trigger-style="font-size: 16px;"
trigger-text="HTML template">
</div>
<br>
<div style="padding-left: 64px;"
tb-help-popup="widget/lib/html_container/examples/angular_drawer_css"
tb-help-popup-placement="top"
[tb-help-popup-style]="{maxHeight: '50vh', maxWidth: '50vw'}"
trigger-style="font-size: 16px;"
trigger-text="CSS">
</div>
<br>
<div style="padding-left: 64px;"
tb-help-popup="widget/lib/html_container/examples/angular_drawer_js"
tb-help-popup-placement="top"
[tb-help-popup-style]="{maxHeight: '50vh', maxWidth: '50vw'}"
trigger-style="font-size: 16px;"
trigger-text="JavaScript function">
</div>
###### Composite dashboard with tab navigation
<br>
<div style="padding-left: 64px;"
tb-help-popup="widget/lib/html_container/examples/angular_tabs_html"
tb-help-popup-placement="top"
[tb-help-popup-style]="{maxHeight: '50vh', maxWidth: '50vw'}"
trigger-style="font-size: 16px;"
trigger-text="HTML template">
</div>
<br>
<div style="padding-left: 64px;"
tb-help-popup="widget/lib/html_container/examples/angular_tabs_css"
tb-help-popup-placement="top"
[tb-help-popup-style]="{maxHeight: '50vh', maxWidth: '50vw'}"
trigger-style="font-size: 16px;"
trigger-text="CSS">
</div>
<br>
<div style="padding-left: 64px;"
tb-help-popup="widget/lib/html_container/examples/angular_tabs_js"
tb-help-popup-placement="top"
[tb-help-popup-style]="{maxHeight: '50vh', maxWidth: '50vw'}"
trigger-style="font-size: 16px;"
trigger-text="JavaScript function">
</div>
<br>
<br>

124
ui-ngx/src/assets/help/en_US/widget/lib/html_container/container_plain_fn.md

@ -0,0 +1,124 @@
#### Container function
<div class="divider"></div>
<br/>
*function (ctx, container): void*
A JavaScript function used to initialize libraries, build or update the DOM inside the widget content element, wire up event handlers and drive the widget through the widget context.
**Parameters:**
<ul>
<li><b>ctx:</b> <code><a href="https://github.com/thingsboard/thingsboard/blob/5bb6403407aa4898084832d6698aa9ea6d484889/ui-ngx/src/app/modules/home/models/widget-component.models.ts#L107" target="_blank">WidgetContext</a></code> - A reference to <a href="https://github.com/thingsboard/thingsboard/blob/5bb6403407aa4898084832d6698aa9ea6d484889/ui-ngx/src/app/modules/home/models/widget-component.models.ts#L107" target="_blank">WidgetContext</a> that has all necessary API
and data used by widget instance.<br/>
Provides access to send RPC, switch dashboard states, create data subscriptions, read settings and access platform services.
</li>
<li><b>container:</b> <code>HTMLElement</code> - The widget's content element, a native DOM node (<code>&lt;div class="tb-absolute-fill"&gt;</code>) wrapping the rendered template.<br/>
Query it with <code>container.querySelector(...)</code>.
</li>
</ul>
**Returns:**
This function does not return any value.
<div class="divider"></div>
##### Notes
<ul>
<li>This widget does <b>not</b> bind widget datasources automatically. There is no <code>ctx.data</code> populated from the widget configuration. To read live data create an explicit subscription via <code>ctx.subscriptionApi.createSubscription(...)</code>.</li>
<li>Release every subscription, timer and global event listener you create from <code>ctx.registerDestroyCallback(...)</code> so the widget cleans up correctly.</li>
</ul>
<div class="divider"></div>
##### Examples
###### Kanban board with a CDN drag-and-drop library
<br>
<div style="padding-left: 64px;"
tb-help-popup="widget/lib/html_container/examples/plain_kanban_resources"
tb-help-popup-placement="top"
[tb-help-popup-style]="{maxHeight: '50vh', maxWidth: '50vw'}"
trigger-style="font-size: 16px;"
trigger-text="Resources">
</div>
<br>
<div style="padding-left: 64px;"
tb-help-popup="widget/lib/html_container/examples/plain_kanban_html"
tb-help-popup-placement="top"
[tb-help-popup-style]="{maxHeight: '50vh', maxWidth: '50vw'}"
trigger-style="font-size: 16px;"
trigger-text="HTML code">
</div>
<br>
<div style="padding-left: 64px;"
tb-help-popup="widget/lib/html_container/examples/plain_kanban_css"
tb-help-popup-placement="top"
[tb-help-popup-style]="{maxHeight: '50vh', maxWidth: '50vw'}"
trigger-style="font-size: 16px;"
trigger-text="CSS">
</div>
<br>
<div style="padding-left: 64px;"
tb-help-popup="widget/lib/html_container/examples/plain_kanban_js"
tb-help-popup-placement="top"
[tb-help-popup-style]="{maxHeight: '50vh', maxWidth: '50vw'}"
trigger-style="font-size: 16px;"
trigger-text="JavaScript function">
</div>
###### Visitor analytics with a custom range and working hours
<br>
<div style="padding-left: 64px;"
tb-help-popup="widget/lib/html_container/examples/plain_analytics_resources"
tb-help-popup-placement="top"
[tb-help-popup-style]="{maxHeight: '50vh', maxWidth: '50vw'}"
trigger-style="font-size: 16px;"
trigger-text="Resources">
</div>
<br>
<div style="padding-left: 64px;"
tb-help-popup="widget/lib/html_container/examples/plain_analytics_html"
tb-help-popup-placement="top"
[tb-help-popup-style]="{maxHeight: '50vh', maxWidth: '50vw'}"
trigger-style="font-size: 16px;"
trigger-text="HTML code">
</div>
<br>
<div style="padding-left: 64px;"
tb-help-popup="widget/lib/html_container/examples/plain_analytics_css"
tb-help-popup-placement="top"
[tb-help-popup-style]="{maxHeight: '50vh', maxWidth: '50vw'}"
trigger-style="font-size: 16px;"
trigger-text="CSS">
</div>
<br>
<div style="padding-left: 64px;"
tb-help-popup="widget/lib/html_container/examples/plain_analytics_js"
tb-help-popup-placement="top"
[tb-help-popup-style]="{maxHeight: '50vh', maxWidth: '50vw'}"
trigger-style="font-size: 16px;"
trigger-text="JavaScript function">
</div>
<br>
<br>

201
ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_drawer_css.md

@ -0,0 +1,201 @@
#### CSS of slide-over device detail
```css
{:code-style="max-height: 400px;"}
.dd {
height: 100%;
font-family: 'Inter', 'Roboto', system-ui, sans-serif;
color: #0f172a;
}
.dd__main {
padding: 20px;
box-sizing: border-box;
}
.dd__title {
margin: 0 0 16px;
font-family: 'Roboto', sans-serif;
font-size: 20px;
font-weight: 500;
}
.dd__card {
background: #fff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08), 0 1px 3px rgba(0, 0, 0, 0.12);
}
.dd__table {
width: 100%;
}
.dd__table .mat-column-name {
flex: 1;
}
.dd__table .mat-column-people {
flex: 0 0 120px;
justify-content: flex-end;
}
.dd__table .mat-mdc-row {
cursor: pointer;
}
.dd__table .mat-mdc-row:hover {
background: rgba(0, 0, 0, 0.04);
}
.dd__table .mat-mdc-row.is-active {
background: rgba(47, 107, 255, 0.08);
}
.dd__drawer {
width: 70%;
box-sizing: border-box;
}
.dd__panel {
display: flex;
flex-direction: column;
gap: 16px;
padding: 24px;
}
.dd__panel-bar {
display: flex;
align-items: flex-start;
justify-content: space-between;
}
.dd__panel-eyebrow {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .08em;
color: #94a3b8;
}
.dd__panel-title {
margin: 2px 0 0;
font-size: 22px;
font-weight: 800;
}
.dd__close {
border: 0;
background: #f1f5f9;
border-radius: 10px;
width: 36px;
height: 36px;
font-size: 16px;
cursor: pointer;
color: #475569;
}
.dd__close:hover {
background: #e2e8f0;
}
.dd__cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.card {
display: flex;
flex-direction: column;
gap: 4px;
padding: 14px 16px;
border-radius: 14px;
background: linear-gradient(180deg, #f8fafc, #eef2f7);
border: 1px solid #e6ebf2;
}
.card__label {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .08em;
color: #94a3b8;
}
.card__value {
font-size: 18px;
font-weight: 800;
}
.dd__actions {
display: flex;
gap: 10px;
}
.dd__action {
display: inline-flex;
flex-direction: column;
gap: 6px;
}
.dd__btn {
padding: 9px 16px;
border: 1px solid #d8dee9;
border-radius: 10px;
background: #fff;
color: #0f172a;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s ease;
}
.dd__btn:hover {
background: #f8fafc;
}
.dd__btn:disabled {
opacity: 0.6;
cursor: default;
}
.dd__btn--warn {
color: #b91c1c;
border-color: #fecaca;
}
.dd__btn--warn:hover {
background: #fef2f2;
}
.dd__section {
display: flex;
flex-direction: column;
gap: 8px;
}
.dd__section-title {
margin: 0;
font-size: 13px;
font-weight: 700;
color: #334155;
}
.dd__state {
height: 280px;
border: 1px solid #eef1f6;
border-radius: 14px;
overflow: hidden;
}
.dd__state tb-dashboard-state {
display: block;
width: 100%;
height: 100%;
}
{:copy-code}
```
<br>
<br>

66
ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_drawer_html.md

@ -0,0 +1,66 @@
#### Angular template of slide-over device detail
```html
{:code-style="max-height: 400px;"}
<mat-drawer-container class="dd">
<mat-drawer-content class="dd__main">
<h3 class="dd__title">Devices</h3>
<div class="dd__card">
<mat-table [dataSource]="devices" class="dd__table">
<ng-container matColumnDef="name">
<mat-header-cell *matHeaderCellDef>Device</mat-header-cell>
<mat-cell *matCellDef="let d">{{ d.name }}</mat-cell>
</ng-container>
<ng-container matColumnDef="people">
<mat-header-cell *matHeaderCellDef>People</mat-header-cell>
<mat-cell *matCellDef="let d">{{ d.lastValue }}</mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="columns"></mat-header-row>
<mat-row *matRowDef="let d; columns: columns" [class.is-active]="d.entityId === selectedId" (click)="openDevice(d)"></mat-row>
</mat-table>
</div>
</mat-drawer-content>
<mat-drawer class="dd__drawer" mode="over" position="end" [opened]="drawerOpen" (openedChange)="drawerOpen = $event">
<div class="dd__panel" *ngIf="selected">
<header class="dd__panel-bar">
<div>
<div class="dd__panel-eyebrow">Device</div>
<h3 class="dd__panel-title">{{ selected.name }}</h3>
</div>
<button class="dd__close" (click)="drawerOpen = false"></button>
</header>
<div class="dd__cards">
<div class="card"><span class="card__label">People</span><span class="card__value">{{ selected.lastValue }}</span></div>
<div class="card"><span class="card__label">Entity type</span><span class="card__value">{{ selected.entityType }}</span></div>
<div class="card"><span class="card__label">Last update</span><span class="card__value">{{ selected.lastSeen }}</span></div>
</div>
<div class="dd__actions">
<div class="dd__action">
<button class="dd__btn" [disabled]="rebooting" (click)="reboot()">Reboot</button>
<mat-progress-bar *ngIf="rebooting" mode="indeterminate"></mat-progress-bar>
</div>
<div class="dd__action">
<button class="dd__btn dd__btn--warn" [disabled]="resetting" (click)="resetCounter()">Reset counter</button>
<mat-progress-bar *ngIf="resetting" mode="indeterminate"></mat-progress-bar>
</div>
</div>
<section class="dd__section">
<h4 class="dd__section-title">Alarms</h4>
<div class="dd__state"><tb-dashboard-state [ctx]="ctx" stateId="deviceAlarms" [syncParentStateParams]="true"></tb-dashboard-state></div>
</section>
<section class="dd__section">
<h4 class="dd__section-title">Charts</h4>
<div class="dd__state"><tb-dashboard-state [ctx]="ctx" stateId="deviceCharts" [syncParentStateParams]="true"></tb-dashboard-state></div>
</section>
</div>
</mat-drawer>
</mat-drawer-container>
{:copy-code}
```
<br>
<br>

100
ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_drawer_js.md

@ -0,0 +1,100 @@
#### JavaScript function of slide-over device detail
```javascript
{:code-style="max-height: 400px;"}
this.ctx = ctx;
this.devices = [];
this.columns = ['name', 'people'];
this.selected = null;
this.selectedId = null;
this.drawerOpen = false;
this.openDevice = (d) => {
this.selected = d;
this.selectedId = d.entityId;
this.drawerOpen = true;
ctx.stateController.updateState(null, {
entityId: { entityType: d.entityType, id: d.entityId },
entityName: d.name,
});
ctx.detectChanges();
};
this.rebooting = false;
this.resetting = false;
this.reboot = () => {
if (!this.selected || this.rebooting) return;
this.rebooting = true;
ctx.detectChanges();
ctx.http.post('/api/rpc/twoway/' + this.selected.entityId, { method: 'reboot', params: {} }).subscribe({
error: (e) => {
console.error(e);
this.rebooting = false;
ctx.detectChanges();
},
complete: () => {
this.rebooting = false;
ctx.detectChanges();
},
});
};
this.resetCounter = () => {
if (!this.selected || this.resetting) return;
this.resetting = true;
ctx.detectChanges();
ctx.http.post('/api/rpc/twoway/' + this.selected.entityId, { method: 'resetCounter', params: {} }).subscribe({
error: (e) => {
console.error(e);
this.resetting = false;
ctx.detectChanges();
},
complete: () => {
this.resetting = false;
ctx.detectChanges();
},
});
};
const subOpts = {
type: 'latest',
datasources: [
{
type: 'entity',
entityFilter: { type: 'deviceType', deviceTypes: ['peopleCount'], deviceNameFilter: '', resolveMultiple: true },
dataKeys: [{ type: 'timeseries', name: 'peopleCount', settings: {} }],
},
],
callbacks: {
onDataUpdated: (subscription) => {
this.devices = (subscription.data || []).map((entry) => {
const ds = entry.datasource;
const last = entry.data && entry.data.length ? entry.data[entry.data.length - 1] : null;
return {
entityId: ds.entityId,
entityType: ds.entityType,
name: ds.entityName,
lastValue: last ? last[1] : '—',
lastSeen: last ? new Date(last[0]).toLocaleString() : '—',
};
});
ctx.detectChanges();
},
onDataUpdateError: (subscription, e) => console.error(e),
},
};
let subscriptionId = null;
ctx.subscriptionApi.createSubscription(subOpts, true).subscribe((subscription) => {
subscriptionId = subscription.id;
});
ctx.registerDestroyCallback(() => {
if (subscriptionId != null) ctx.subscriptionApi.removeSubscription(subscriptionId);
});
{:copy-code}
```
<br>
<br>

142
ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_split_css.md

@ -0,0 +1,142 @@
#### CSS of resizable split master–detail
```css
{:code-style="max-height: 400px;"}
.split {
display: flex;
height: 100%;
box-sizing: border-box;
font-family: 'Inter', 'Roboto', system-ui, sans-serif;
color: #0f172a;
background: #fff;
border-radius: 16px;
box-shadow: 0 4px 24px rgba(15, 23, 42, 0.06);
overflow: hidden;
}
.split__list {
flex: 0 0 auto;
min-width: 160px;
overflow: auto;
border-right: 1px solid #eef1f6;
}
.split__title {
margin: 0;
padding: 16px;
font-family: 'Roboto', sans-serif;
font-size: 20px;
font-weight: 500;
}
.split__items {
list-style: none;
margin: 0;
padding: 0;
}
.split__item {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 12px 16px;
cursor: pointer;
border-bottom: 1px solid #f1f5f9;
transition: background 0.15s ease;
}
.split__item:hover {
background: #f8fafc;
}
.split__item.is-active {
background: rgba(47, 107, 255, 0.08);
box-shadow: inset 3px 0 0 #2f6bff;
}
.split__value {
font-weight: 700;
color: #475569;
}
.split__divider {
flex: 0 0 12px;
display: flex;
align-items: center;
justify-content: center;
cursor: col-resize;
background: #eef1f6;
transition: background 0.15s ease;
}
.split__divider:hover {
background: #e2e8f0;
}
.split__divider::before {
content: '';
width: 4px;
height: 32px;
background-image: radial-gradient(circle, #94a3b8 1.2px, transparent 1.4px);
background-position: center;
background-size: 4px 6px;
background-repeat: repeat-y;
}
.split--dragging {
user-select: none;
}
.split--dragging .split__list,
.split--dragging .split__detail {
pointer-events: none;
}
.split__detail {
flex: 1 1 0;
min-width: 220px;
display: flex;
flex-direction: column;
gap: 16px;
padding: 20px;
overflow: auto;
}
.split__eyebrow {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #94a3b8;
}
.split__detail-title {
margin: 2px 0 0;
font-size: 20px;
font-weight: 800;
}
.split__state {
flex: 1 1 auto;
min-height: 260px;
border: 1px solid #eef1f6;
border-radius: 14px;
overflow: hidden;
}
.split__state tb-dashboard-state {
display: block;
width: 100%;
height: 100%;
}
.split__empty {
margin: auto;
color: #94a3b8;
font-size: 14px;
}
{:copy-code}
```
<br>
<br>

38
ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_split_html.md

@ -0,0 +1,38 @@
#### Angular template of resizable split master–detail
```html
{:code-style="max-height: 400px;"}
<div class="split">
<aside class="split__list" [style.width.px]="listWidth">
<h3 class="split__title">Devices</h3>
<ul class="split__items">
<li *ngFor="let d of devices"
class="split__item"
[class.is-active]="d.entityId === selectedId"
(click)="select(d)">
<span class="split__name">{{ d.name }}</span>
<span class="split__value">{{ d.lastValue }}</span>
</li>
</ul>
</aside>
<div class="split__divider" (mousedown)="startDrag($event)"></div>
<section class="split__detail">
<ng-container *ngIf="selected; else empty">
<header>
<div class="split__eyebrow">Device</div>
<h3 class="split__detail-title">{{ selected.name }}</h3>
</header>
<div class="split__state">
<tb-dashboard-state [ctx]="ctx" stateId="deviceCharts" [syncParentStateParams]="true"></tb-dashboard-state>
</div>
</ng-container>
<ng-template #empty><div class="split__empty">Select a device</div></ng-template>
</section>
</div>
{:copy-code}
```
<br>
<br>

73
ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_split_js.md

@ -0,0 +1,73 @@
#### JavaScript function of resizable split master–detail
```javascript
{:code-style="max-height: 400px;"}
this.ctx = ctx;
this.devices = [];
this.selected = null;
this.selectedId = null;
this.listWidth = 280;
this.select = (d) => {
this.selected = d;
this.selectedId = d.entityId;
ctx.stateController.updateState(null, {
entityId: { entityType: d.entityType, id: d.entityId },
entityName: d.name,
});
ctx.detectChanges();
};
this.startDrag = (event) => {
event.preventDefault();
const root = event.currentTarget.closest('.split');
const left = root.getBoundingClientRect().left;
root.classList.add('split--dragging');
const onMove = (e) => {
this.listWidth = Math.max(160, Math.min(e.clientX - left, root.clientWidth - 220));
ctx.detectChanges();
};
const onUp = () => {
root.classList.remove('split--dragging');
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
};
const subOpts = {
type: 'latest',
datasources: [
{
type: 'entity',
entityFilter: { type: 'deviceType', deviceTypes: ['peopleCount'], deviceNameFilter: '', resolveMultiple: true },
dataKeys: [{ type: 'timeseries', name: 'peopleCount', settings: {} }],
},
],
callbacks: {
onDataUpdated: (subscription) => {
this.devices = (subscription.data || []).map((entry) => {
const ds = entry.datasource;
const last = entry.data && entry.data.length ? entry.data[entry.data.length - 1] : null;
return { entityId: ds.entityId, entityType: ds.entityType, name: ds.entityName, lastValue: last ? last[1] : '—' };
});
ctx.detectChanges();
},
onDataUpdateError: (subscription, e) => console.error(e),
},
};
let subscriptionId = null;
ctx.subscriptionApi.createSubscription(subOpts, true).subscribe((subscription) => {
subscriptionId = subscription.id;
});
ctx.registerDestroyCallback(() => {
if (subscriptionId != null) ctx.subscriptionApi.removeSubscription(subscriptionId);
});
{:copy-code}
```
<br>
<br>

66
ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_tabs_css.md

@ -0,0 +1,66 @@
#### CSS of composite dashboard with tab navigation
```css
{:code-style="max-height: 400px;"}
.composite {
display: flex;
flex-direction: column;
gap: 12px;
height: 100%;
box-sizing: border-box;
padding: 12px;
font-family: 'Roboto', system-ui, sans-serif;
}
.tabs {
display: inline-flex;
gap: 4px;
padding: 4px;
align-self: flex-start;
background: #eef1f6;
border-radius: 10px;
}
.tab {
border: 0;
background: transparent;
padding: 8px 16px;
border-radius: 7px;
font-size: 13px;
font-weight: 600;
color: #5b6472;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
}
.tab:hover {
color: #1f2733;
}
.tab.is-active {
background: #fff;
color: #2f6bff;
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.08);
}
.container {
flex-grow: 1;
width: 100%;
display: flex;
padding: 0;
border: 1px solid #e6eaf0;
border-radius: 12px;
overflow: hidden;
}
.state {
flex-grow: 1;
width: 100%;
height: 100%;
padding: 0;
}
{:copy-code}
```
<br>
<br>

22
ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_tabs_html.md

@ -0,0 +1,22 @@
#### Angular template of composite dashboard with tab navigation
```html
{:code-style="max-height: 400px;"}
<div class="composite">
<nav class="tabs">
<button *ngFor="let tab of tabs"
class="tab"
[class.is-active]="tab.id === activeTab"
(click)="selectTab(tab.id)">
{{ tab.label }}
</button>
</nav>
<div tb-toast toastTarget="layout" class="container">
<tb-dashboard-state class="state" [ctx]="ctx" [stateId]="stateId" [syncParentStateParams]="true"></tb-dashboard-state>
</div>
</div>
{:copy-code}
```
<br>
<br>

35
ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_tabs_js.md

@ -0,0 +1,35 @@
#### JavaScript function of composite dashboard with tab navigation
```javascript
{:code-style="max-height: 400px;"}
this.ctx = ctx;
this.tabs = [
{ id: 'assets', label: 'Assets', stateId: 'assetsState' },
{ id: 'devices', label: 'Devices', stateId: 'devicesState' },
{ id: 'customers', label: 'Customers', stateId: 'customersState' },
];
const apply = (subState) => {
const tab = this.tabs.find((t) => t.id === subState) || this.tabs[0];
this.activeTab = tab.id;
this.stateId = tab.stateId;
};
apply(ctx.stateController.getStateParams().subState);
this.selectTab = (subState) => ctx.stateController.updateState(null, { subState });
const stateSub = ctx.stateController.dashboardCtrl.dashboardCtx.stateChanged.subscribe(() => {
apply(ctx.stateController.getStateParams().subState);
ctx.detectChanges();
});
ctx.registerDestroyCallback(() => {
stateSub.unsubscribe();
});
{:copy-code}
```
<br>
<br>

38
ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_analytics_css.md

@ -0,0 +1,38 @@
#### CSS of visitor analytics
```css
{:code-style="max-height: 400px;"}
.vc {
display: flex;
flex-direction: column;
gap: 16px;
height: 100%;
box-sizing: border-box;
padding: 20px;
background: #fff;
font-family: 'Inter', 'Roboto', system-ui, sans-serif;
color: #0f172a;
}
.vc__head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
.vc__title { margin: 0; font-family: 'Roboto', sans-serif; font-size: 20px; font-weight: 500; }
.vc__sub { font-size: 12px; font-weight: 600; color: #94a3b8; }
.vc__controls { display: flex; gap: 10px; flex-wrap: wrap; }
.vc__field { display: flex; flex-direction: column; gap: 4px; font-size: 11px; font-weight: 600; color: #64748b; }
.vc__field input { padding: 7px 10px; border: 1px solid #d8dee9; border-radius: 10px; font-size: 13px; color: #0f172a; }
.vc__field input:focus { outline: none; border-color: #2f6bff; box-shadow: 0 0 0 3px rgba(47, 107, 255, 0.15); }
.vc__kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
.vc-kpi { display: flex; flex-direction: column; gap: 6px; padding: 14px 16px; border-radius: 14px; background: #f8fafc; border: 1px solid #e6ebf2; }
.vc-kpi__label { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: #94a3b8; }
.vc-kpi__value { font-size: 26px; font-weight: 800; line-height: 1; color: #1e293b; }
.vc__card { flex: 1; min-height: 240px; display: flex; flex-direction: column; padding: 16px; border: 1px solid #e6ebf2; border-radius: 14px; }
.vc__card-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.vc__card-title { font-size: 13px; font-weight: 700; color: #334155; }
.vc__back { padding: 6px 12px; border: 1px solid #d8dee9; border-radius: 8px; background: #fff; color: #2f6bff; font-size: 12px; font-weight: 600; cursor: pointer; }
.vc__back[hidden] { display: none; }
.vc__back:hover { background: #f0f5ff; border-color: #2f6bff; }
.vc__chart { flex: 1; min-height: 200px; }
{:copy-code}
```
<br>
<br>

36
ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_analytics_html.md

@ -0,0 +1,36 @@
#### HTML code of visitor analytics
```html
{:code-style="max-height: 400px;"}
<div class="vc">
<header class="vc__head">
<div>
<h3 class="vc__title">Visitor analytics</h3>
<span class="vc__sub">all peopleCount devices</span>
</div>
<div class="vc__controls">
<label class="vc__field"><span>From</span><input type="date" id="vcStart" /></label>
<label class="vc__field"><span>To</span><input type="date" id="vcEnd" /></label>
<label class="vc__field"><span>Open</span><input type="time" id="vcOpen" value="09:00" /></label>
<label class="vc__field"><span>Close</span><input type="time" id="vcClose" value="18:00" /></label>
</div>
</header>
<div class="vc__kpis">
<div class="vc-kpi"><span class="vc-kpi__label">Total visitors</span><span class="vc-kpi__value" id="vcTotal"></span></div>
<div class="vc-kpi"><span class="vc-kpi__label">Busiest day</span><span class="vc-kpi__value" id="vcPeakDay"></span></div>
<div class="vc-kpi"><span class="vc-kpi__label">Peak hour</span><span class="vc-kpi__value" id="vcPeakHour"></span></div>
<div class="vc-kpi"><span class="vc-kpi__label">Daily average</span><span class="vc-kpi__value" id="vcAvg"></span></div>
</div>
<div class="vc__card">
<div class="vc__card-head">
<span class="vc__card-title" id="vcChartTitle">Visitors by day</span>
<button class="vc__back" id="vcBack" hidden>← Back</button>
</div>
<div class="vc__chart" id="vcChart"></div>
</div>
</div>
{:copy-code}
```
<br>
<br>

185
ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_analytics_js.md

@ -0,0 +1,185 @@
#### JavaScript function of visitor analytics
```javascript
{:code-style="max-height: 400px;"}
// Plain HTML mode: `container` is the widget's DOM; ECharts comes from the CDN.
const echarts = window.echarts;
const el = (sel) => container.querySelector(sel);
const pad = (n) => String(n).padStart(2, '0');
const isoDate = (d) => d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate());
const dayLabel = (d) => d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
const minutes = (v) => {
const [h, m] = (v || '0:0').split(':').map(Number);
return h * 60 + m;
};
const palette = ['#2563eb', '#10b981', '#f59e0b', '#8b5cf6', '#ef4444', '#0ea5e9'];
const chart = echarts.init(el('#vcChart'));
chart.setOption({
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
legend: { top: 0 },
grid: { left: 8, right: 16, top: 40, bottom: 8, containLabel: true },
xAxis: { type: 'category', data: [], axisLabel: { hideOverlap: true } },
yAxis: { type: 'value' },
series: [],
});
const resizeObserver = new window.ResizeObserver(() => chart.resize());
resizeObserver.observe(el('#vcChart'));
let lastEntries = [];
let mode = 'daily';
let selectedDate = null;
let dailyDates = []; // iso date per daily x-index, for drill-down
// Click a day → drill into its hourly breakdown.
chart.on('click', (p) => {
if (mode !== 'daily' || p.dataIndex == null || dailyDates[p.dataIndex] == null) return;
selectedDate = dailyDates[p.dataIndex];
mode = 'hourly';
el('#vcBack').hidden = false;
recompute();
});
el('#vcBack').addEventListener('click', () => {
mode = 'daily';
selectedDate = null;
el('#vcBack').hidden = true;
recompute();
});
// One stacked series per device, summing values into the given bucket index.
function buildSeries(labelCount, bucketOf) {
return lastEntries.map((entry, k) => {
const arr = new Array(labelCount).fill(0);
(entry.data || []).forEach(([ts, value]) => {
const i = bucketOf(new Date(Number(ts)));
if (i != null && i >= 0) arr[i] += Number(value);
});
return { name: entry.datasource.entityName, type: 'bar', stack: 'v', data: arr, itemStyle: { color: palette[k % palette.length] } };
});
}
function recompute() {
const open = minutes(el('#vcOpen').value);
const close = minutes(el('#vcClose').value);
const inHours = (d) => {
const m = d.getHours() * 60 + d.getMinutes();
return m >= open && m < close;
};
// KPIs over the whole range (per day, summed across devices).
const start = new Date(el('#vcStart').value + 'T00:00:00');
const end = new Date(el('#vcEnd').value + 'T00:00:00');
const dayIndex = {};
const dayLabels = [];
dailyDates = [];
for (let t = new Date(start); t <= end; t.setDate(t.getDate() + 1)) {
dayIndex[isoDate(t)] = dayLabels.length;
dailyDates.push(isoDate(t));
dayLabels.push(dayLabel(t));
}
const dayTotals = dayLabels.map(() => 0);
const perHour = {};
let total = 0;
lastEntries.forEach((entry) =>
(entry.data || []).forEach(([ts, value]) => {
const d = new Date(Number(ts));
if (!inHours(d)) return;
const i = dayIndex[isoDate(d)];
if (i === undefined) return;
const v = Number(value);
dayTotals[i] += v;
perHour[d.getHours()] = (perHour[d.getHours()] || 0) + v;
total += v;
}),
);
el('#vcTotal').textContent = total;
el('#vcPeakDay').textContent = total ? dayLabels[dayTotals.indexOf(Math.max(...dayTotals))] : '—';
const ph = Object.keys(perHour).sort((a, b) => perHour[b] - perHour[a])[0];
el('#vcPeakHour').textContent = ph != null ? ph + ':00' : '—';
el('#vcAvg').textContent = Math.round(total / (dayTotals.filter((x) => x > 0).length || 1));
// Chart: daily (stacked by device) or the hourly breakdown of the selected day.
if (mode === 'daily') {
el('#vcChartTitle').textContent = 'Visitors by day';
const series = buildSeries(dayLabels.length, (d) => (inHours(d) ? dayIndex[isoDate(d)] : null));
chart.setOption({ xAxis: { data: dayLabels }, series }, { replaceMerge: ['series'] });
} else {
const h0 = Math.floor(open / 60);
const h1 = Math.ceil(close / 60);
const labels = [];
for (let h = h0; h < h1; h++) labels.push(pad(h) + ':00');
el('#vcChartTitle').textContent = 'Visitors by hour — ' + dayLabel(new Date(selectedDate + 'T00:00:00'));
const series = buildSeries(labels.length, (d) =>
isoDate(d) === selectedDate && inHours(d) ? d.getHours() - h0 : null,
);
chart.setOption({ xAxis: { data: labels }, series }, { replaceMerge: ['series'] });
}
}
// (Re)subscribe for the chosen date range (a fixed history window).
let subscriptionId = null;
function subscribe() {
if (subscriptionId != null) {
ctx.subscriptionApi.removeSubscription(subscriptionId);
subscriptionId = null;
}
const startTimeMs = new Date(el('#vcStart').value + 'T00:00:00').getTime();
const endTimeMs = new Date(el('#vcEnd').value + 'T23:59:59').getTime();
const subOpts = {
type: 'timeseries',
useDashboardTimewindow: false,
timeWindowConfig: {
selectedTab: 1,
history: { historyType: 1, fixedTimewindow: { startTimeMs, endTimeMs } },
aggregation: { type: 'NONE', limit: 50000 },
},
datasources: [
{
type: 'entity',
entityFilter: { type: 'deviceType', deviceTypes: ['peopleCount'], deviceNameFilter: '', resolveMultiple: true },
dataKeys: [{ type: 'timeseries', name: 'peopleCount', settings: {} }],
},
],
callbacks: {
onDataUpdated: (subscription) => {
lastEntries = subscription.data || [];
recompute();
},
onDataUpdateError: (subscription, e) => console.error(e),
},
};
ctx.subscriptionApi.createSubscription(subOpts, true).subscribe((subscription) => {
subscriptionId = subscription.id;
});
}
// Changing the range resets the drill-down and re-subscribes; hours just re-filter.
function onRangeChange() {
mode = 'daily';
selectedDate = null;
el('#vcBack').hidden = true;
subscribe();
}
// Defaults: the current month (1st → today).
const now = new Date();
el('#vcEnd').value = isoDate(now);
el('#vcStart').value = isoDate(new Date(now.getFullYear(), now.getMonth(), 1));
el('#vcStart').addEventListener('change', onRangeChange);
el('#vcEnd').addEventListener('change', onRangeChange);
el('#vcOpen').addEventListener('change', recompute);
el('#vcClose').addEventListener('change', recompute);
subscribe();
// The JS re-runs on every reload — release the subscription, observer and chart.
ctx.registerDestroyCallback(() => {
if (subscriptionId != null) ctx.subscriptionApi.removeSubscription(subscriptionId);
resizeObserver.disconnect();
chart.dispose();
});
{:copy-code}
```
<br>
<br>

12
ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_analytics_resources.md

@ -0,0 +1,12 @@
#### Resources of visitor analytics
Add the following JavaScript URL under the <b>Resources</b> tab. It loads <a href="https://echarts.apache.org/" target="_blank">ECharts</a> before your function runs and exposes the <code>echarts</code> global (accessed as <code>window.echarts</code>):
```text
{:code-style="max-height: 400px;"}
https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js
{:copy-code}
```
<br>
<br>

45
ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_kanban_css.md

@ -0,0 +1,45 @@
#### CSS of kanban board
```css
{:code-style="max-height: 400px;"}
.kanban {
display: flex;
gap: 12px;
height: 100%;
box-sizing: border-box;
padding: 16px;
overflow-x: auto;
background: #f9fafb;
font-family: 'Inter', 'Roboto', system-ui, sans-serif;
color: #111827;
font-size: 13px;
}
.kanban__col { flex: 1 1 0; min-width: 220px; display: flex; flex-direction: column; background: #f3f4f6; border-radius: 10px; }
.kanban__head { display: flex; align-items: center; gap: 8px; padding: 12px 14px 8px; }
.kanban__title { font-size: 12px; font-weight: 600; color: #374151; }
.kanban__count { margin-left: auto; min-width: 20px; height: 20px; padding: 0 6px; display: inline-flex; align-items: center; justify-content: center; border-radius: 999px; background: #e5e7eb; color: #6b7280; font-size: 11px; font-weight: 600; }
.kanban__cards { display: flex; flex-direction: column; gap: 8px; padding: 4px 8px 12px; flex: 1; min-height: 24px; overflow-y: auto; }
.kanban__card {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
cursor: grab;
transition: border-color 0.12s ease, box-shadow 0.12s ease;
}
.kanban__card:hover { border-color: #d1d5db; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); }
.kanban__name { font-weight: 500; color: #111827; }
.kanban__dot { width: 8px; height: 8px; border-radius: 50%; flex: none; }
.kanban__dot--idle { background: #9ca3af; }
.kanban__dot--active { background: #22c55e; }
.kanban__dot--maintenance { background: #f59e0b; }
.kanban__card.sortable-ghost { opacity: 0; }
.kanban__card.sortable-chosen { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); }
{:copy-code}
```
<br>
<br>

10
ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_kanban_html.md

@ -0,0 +1,10 @@
#### HTML code of kanban board
```html
{:code-style="max-height: 400px;"}
<div class="kanban" id="board"></div>
{:copy-code}
```
<br>
<br>

89
ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_kanban_js.md

@ -0,0 +1,89 @@
#### JavaScript function of kanban board
```javascript
{:code-style="max-height: 400px;"}
// Plain HTML mode: `container` is the widget's DOM. Build a status board and,
// on drop, persist the card's new column (status) back to the device.
const Sortable = window.Sortable; // loaded from the CDN added under Resources
const statuses = ['idle', 'active', 'maintenance'];
const labels = { idle: 'Idle', active: 'Active', maintenance: 'Maintenance' };
const board = container.querySelector('#board');
let devices = []; // { id, entityType, name, status }
// Escape untrusted strings (e.g. device names) before putting them in innerHTML.
const esc = (s) =>
String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
function render() {
board.innerHTML = statuses
.map((s) => {
const items = devices.filter((d) => d.status === s);
const cards = items
.map(
(d) =>
`<div class="kanban__card" data-id="${esc(d.id)}" data-type="${esc(d.entityType)}"><span class="kanban__dot kanban__dot--${s}"></span><span class="kanban__name">${esc(d.name)}</span></div>`,
)
.join('');
return `<section class="kanban__col">
<header class="kanban__head"><span class="kanban__dot kanban__dot--${s}"></span><span class="kanban__title">${labels[s]}</span><span class="kanban__count">${items.length}</span></header>
<div class="kanban__cards" data-status="${s}">${cards}</div>
</section>`;
})
.join('');
// Make each column a Sortable list; cards share one group so they move between columns.
board.querySelectorAll('.kanban__cards').forEach((listEl) => {
new Sortable(listEl, {
group: 'kanban',
animation: 150,
onAdd: (evt) => {
const id = evt.item.dataset.id;
const entityType = evt.item.dataset.type;
const status = evt.to.dataset.status; // destination column
const dev = devices.find((d) => d.id === id);
if (dev) dev.status = status;
ctx.attributeService
.saveEntityAttributes({ id, entityType }, 'SERVER_SCOPE', [{ key: 'status', value: status }])
.subscribe();
},
});
});
}
// Live data: devices of type "machine" with their "status" attribute.
const subOpts = {
type: 'latest',
datasources: [
{
type: 'entity',
entityFilter: { type: 'deviceType', deviceTypes: ['machine'], deviceNameFilter: '', resolveMultiple: true },
dataKeys: [{ type: 'attribute', name: 'status', settings: {} }],
},
],
callbacks: {
onDataUpdated: (subscription) => {
devices = (subscription.data || []).map((entry) => {
const ds = entry.datasource;
const last = entry.data && entry.data.length ? entry.data[entry.data.length - 1] : null;
const status = last ? String(last[1]) : 'idle';
return { id: ds.entityId, entityType: ds.entityType, name: ds.entityName, status: statuses.includes(status) ? status : 'idle' };
});
render();
},
onDataUpdateError: (subscription, e) => console.error(e),
},
};
// The JS re-runs on every reload — capture the id and remove the subscription on destroy.
let subscriptionId = null;
ctx.subscriptionApi.createSubscription(subOpts, true).subscribe((subscription) => {
subscriptionId = subscription.id;
});
ctx.registerDestroyCallback(() => {
if (subscriptionId != null) ctx.subscriptionApi.removeSubscription(subscriptionId);
});
{:copy-code}
```
<br>
<br>

12
ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_kanban_resources.md

@ -0,0 +1,12 @@
#### Resources of kanban board
Add the following JavaScript URL under the <b>Resources</b> tab. It loads <a href="https://sortablejs.github.io/Sortable/" target="_blank">SortableJS</a> before your function runs and exposes the <code>Sortable</code> global (accessed as <code>window.Sortable</code>):
```text
{:code-style="max-height: 400px;"}
https://cdn.jsdelivr.net/npm/sortablejs@1/Sortable.min.js
{:copy-code}
```
<br>
<br>

1
ui-ngx/src/assets/locale/locale.constant-en_US.json

@ -5392,6 +5392,7 @@
"create-new-rulechain": "Create new rule chain",
"rulechain-file": "Rule chain file",
"invalid-rulechain-file-error": "Unable to import rule chain: Invalid rule chain data structure.",
"rulechain-not-found-warning": "Default rule chain not found. The profile was saved without one — you can assign it in the profile settings.",
"copyId": "Copy rule chain Id",
"idCopiedMessage": "Rule chain Id has been copied to clipboard",
"select-rulechain": "Select rule chain",

12
ui-ngx/yarn.lock

@ -7232,9 +7232,9 @@ js-beautify@1.15.4:
nopt "^7.2.1"
js-cookie@^3.0.5:
version "3.0.5"
resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-3.0.5.tgz#0b7e2fd0c01552c58ba86e0841f94dc2557dcdbc"
integrity sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==
version "3.0.8"
resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-3.0.8.tgz#444e6f4b27a5d844594fef61c9d6bca5f0787688"
integrity sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==
js-tokens@^4.0.0:
version "4.0.0"
@ -10237,9 +10237,9 @@ tinyqueue@^3.0.0:
integrity sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==
tmp@^0.2.4:
version "0.2.5"
resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.5.tgz#b06bcd23f0f3c8357b426891726d16015abfd8f8"
integrity sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==
version "0.2.7"
resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.7.tgz#26f4db11d1601ce8012dcb8a798ece1c06a99059"
integrity sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==
to-regex-range@^5.0.1:
version "5.0.1"

Loading…
Cancel
Save