diff --git a/application/src/main/data/json/system/widget_types/unread_notifications.json b/application/src/main/data/json/system/widget_types/unread_notifications.json index 7d699e7018..c4d2f899fb 100644 --- a/application/src/main/data/json/system/widget_types/unread_notifications.json +++ b/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, diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/session/manager/KafkaBasedEdgeGrpcSessionManager.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/session/manager/KafkaBasedEdgeGrpcSessionManager.java index ebc5531320..f42f48421a 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/session/manager/KafkaBasedEdgeGrpcSessionManager.java +++ b/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(); } } + } diff --git a/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java b/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java index ed43c71027..5c21096862 100644 --- a/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java +++ b/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 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; } diff --git a/application/src/test/java/org/thingsboard/server/service/edge/rpc/session/manager/KafkaBasedEdgeGrpcSessionManagerTest.java b/application/src/test/java/org/thingsboard/server/service/edge/rpc/session/manager/KafkaBasedEdgeGrpcSessionManagerTest.java new file mode 100644 index 0000000000..7ddc2af192 --- /dev/null +++ b/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> 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> 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 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> { + + private final Queue>> pending = new ConcurrentLinkedQueue<>(); + private final List> polledEvents = new CopyOnWriteArrayList<>(); + private final AtomicInteger pollCount = new AtomicInteger(); + private volatile boolean stopped; + + void enqueue(List> batch) { + pending.add(batch); + } + + int getPollCount() { + return pollCount.get(); + } + + List> getPolledEvents() { + return polledEvents; + } + + @Override + public List> poll(long durationInMillis) { + pollCount.incrementAndGet(); + List> 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 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 getFullTopicNames() { + return Collections.emptyList(); + } + + @Override + public Set getPartitions() { + return Collections.emptySet(); + } + + } + +} diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/AbstractMqttV5ClientSparkplugTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/AbstractMqttV5ClientSparkplugTest.java index 2adc7b08a5..68237a1839 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/AbstractMqttV5ClientSparkplugTest.java +++ b/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 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 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; } diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/attributes/MqttV5ClientSparkplugBAttributesInProfileTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/attributes/MqttV5ClientSparkplugBAttributesInProfileTest.java index 8cd713ad7d..37abade6e7 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/attributes/MqttV5ClientSparkplugBAttributesInProfileTest.java +++ b/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 diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/attributes/MqttV5ClientSparkplugBAttributesTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/attributes/MqttV5ClientSparkplugBAttributesTest.java index b080cfbff0..3826b59bdc 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/attributes/MqttV5ClientSparkplugBAttributesTest.java +++ b/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 diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/connection/MqttV5ClientSparkplugBConnectionDevicesCreatingBeforeTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/connection/MqttV5ClientSparkplugBConnectionDevicesCreatingBeforeTest.java index 92583e655c..08fc4ea9a5 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/connection/MqttV5ClientSparkplugBConnectionDevicesCreatingBeforeTest.java +++ b/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 diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/connection/MqttV5ClientSparkplugBConnectionTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/connection/MqttV5ClientSparkplugBConnectionTest.java index 0d5e73e2f0..01038c21f1 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/connection/MqttV5ClientSparkplugBConnectionTest.java +++ b/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 diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/rpc/MqttV5RpcSparkplugTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/rpc/MqttV5RpcSparkplugTest.java index 08ade0274d..e90f6e94da 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/rpc/MqttV5RpcSparkplugTest.java +++ b/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 diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/timeseries/MqttV5ClientSparkplugBTelemetryTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/timeseries/MqttV5ClientSparkplugBTelemetryTest.java index 5d9f453415..bafe2d81d2 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/timeseries/MqttV5ClientSparkplugBTelemetryTest.java +++ b/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 diff --git a/common/data/pom.xml b/common/data/pom.xml index 0647e706eb..7a521ac9d5 100644 --- a/common/data/pom.xml +++ b/common/data/pom.xml @@ -84,8 +84,11 @@ test + com.squareup.wire - wire-schema + wire-schema-jvm org.thingsboard diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/DeviceCredentialsFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/DeviceCredentialsFilter.java index 201615eb98..6d7188733e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/DeviceCredentialsFilter.java +++ b/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 { diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/common/consumer/QueueConsumerManager.java b/common/queue/src/main/java/org/thingsboard/server/queue/common/consumer/QueueConsumerManager.java index 4adc0354c4..9ee6bc0b50 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/common/consumer/QueueConsumerManager.java +++ b/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 { 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 consumer; @@ -49,12 +52,13 @@ public class QueueConsumerManager { @Builder public QueueConsumerManager(String name, MsgPackProcessor msgPackProcessor, long pollInterval, Supplier> 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 { private void consumerLoop(TbQueueConsumer consumer) { while (!stopped && !consumer.isStopped()) { try { + if (!isReadyToProcess()) { + if (!awaitNextReadinessCheck()) { + return; + } + continue; + } List msgs = consumer.poll(pollInterval); if (msgs.isEmpty()) { continue; @@ -102,6 +112,25 @@ public class QueueConsumerManager { } } + 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; diff --git a/common/queue/src/test/java/org/thingsboard/server/queue/common/consumer/QueueConsumerManagerTest.java b/common/queue/src/test/java/org/thingsboard/server/queue/common/consumer/QueueConsumerManagerTest.java new file mode 100644 index 0000000000..c987fb502e --- /dev/null +++ b/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 manager; + + @AfterEach + void tearDown() { + if (manager != null) { + manager.stop(); + } + if (consumerExecutor != null) { + consumerExecutor.shutdownNow(); + } + } + + @Test + void eventQueuedWhileNotReadyIsDeliveredAfterReadinessGateOpensInsteadOfBeingDropped() { + List 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 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 launchManager(TestQueueConsumer consumer, BooleanSupplier readinessCheck, + QueueConsumerManager.MsgPackProcessor processor) { + consumerExecutor = Executors.newSingleThreadExecutor(); + QueueConsumerManager queueConsumerManager = QueueConsumerManager.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 { + + private final Queue> batches = new ConcurrentLinkedQueue<>(); + private final AtomicInteger pollCount = new AtomicInteger(); + private volatile boolean stopped; + + void enqueue(List batch) { + batches.add(batch); + } + + int getPollCount() { + return pollCount.get(); + } + + @Override + public List poll(long durationInMillis) { + pollCount.incrementAndGet(); + List 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 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 getPartitions() { + return Collections.emptySet(); + } + + @Override + public List getFullTopicNames() { + return Collections.emptyList(); + } + + } + +} diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java index 94c24f5a1d..99f10eb176 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java @@ -261,7 +261,7 @@ public abstract class AbstractGatewaySessionHandler { ack(msg, MqttReasonCodes.PubAck.SUCCESS); log.trace("[{}][{}][{}] onDeviceConnectOk: [{}]", gateway.getTenantId(), gateway.getDeviceId(), sessionId, deviceName); @@ -277,6 +277,10 @@ public abstract class AbstractGatewaySessionHandler onDeviceConnect(String deviceName, String deviceType) { + return onDeviceConnect(deviceName, deviceType, false); + } + ListenableFuture onDeviceConnect(String deviceName, String deviceType, boolean isSparkplug) { T result = devices.get(deviceName); if (result == null) { @@ -902,7 +906,7 @@ public abstract class AbstractGatewaySessionHandler onSuccess, Consumer onFailure) { - ListenableFuture deviceCtxFuture = onDeviceConnect(deviceName, DEFAULT_DEVICE_TYPE, false); + ListenableFuture deviceCtxFuture = onDeviceConnect(deviceName, DEFAULT_DEVICE_TYPE); process(deviceCtxFuture, onSuccess, onFailure); } diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/SparkplugNodeSessionHandler.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/SparkplugNodeSessionHandler.java index 6cb566d445..1a088344c8 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/SparkplugNodeSessionHandler.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/SparkplugNodeSessionHandler.java @@ -115,26 +115,23 @@ public class SparkplugNodeSessionHandler extends AbstractGatewaySessionHandler 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 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 attributesMetricNames = ((MqttDeviceProfileTransportConfiguration) deviceSessionCtx .getDeviceProfile().getProfileData().getTransportConfiguration()).getSparkplugAttributesMetricNames(); diff --git a/pom.xml b/pom.xml index 70dbf9f43e..8642ce998a 100755 --- a/pom.xml +++ b/pom.xml @@ -63,15 +63,15 @@ /var/log/${pkg.name} /usr/share/${pkg.name} 4.4.0 - 3.5.14 - + 3.5.15 + 3.5.13 3.18.0 - 42.7.11 - 4.1.133.Final - 10.1.55 + 4.1.134.Final 2.4.0-b180830.0359 0.12.5 0.10 @@ -93,7 +93,7 @@ 2.0.1 5.6.0 3.9.5 - 3.25.5 + 3.25.5 1.76.0 1.2.10 1.18.46 @@ -137,7 +137,7 @@ 1.11.0 4.4 1.0.4TB - 3.7.1 + 6.3.0 10.1.3 3.7.4 4.0.2 @@ -1018,23 +1018,6 @@ import - - - org.apache.tomcat.embed - tomcat-embed-core - ${tomcat.version} - - - org.apache.tomcat.embed - tomcat-embed-el - ${tomcat.version} - - - org.apache.tomcat.embed - tomcat-embed-websocket - ${tomcat.version} - - org.springframework.boot spring-boot-dependencies @@ -1388,11 +1371,6 @@ commons-lang3 ${commons-lang3.version} - - org.postgresql - postgresql - ${postgresql.version} - commons-io commons-io @@ -1813,7 +1791,7 @@ com.squareup.wire - wire-schema + wire-schema-jvm ${wire-schema.version} diff --git a/ui-ngx/src/app/core/api/widget-subscription.ts b/ui-ngx/src/app/core/api/widget-subscription.ts index 2194835dfe..1243a69ec8 100644 --- a/ui-ngx/src/app/core/api/widget-subscription.ts +++ b/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})); diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts index cb714700c3..26a0ef2761 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts +++ b/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) { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.html index a72f28e70e..4f891f1617 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.html +++ b/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'"> diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html index e194b48ae9..1ed4d777ef 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html @@ -36,7 +36,7 @@ } -
+
@@ -243,7 +243,7 @@ } @else {
@if (hasBasicModeDirective) { -
+
@if (basicModeDirectiveError) {
{{basicModeDirectiveError}}
diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.scss b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.scss index af7e27f481..8df18b25bc 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.scss +++ b/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; diff --git a/ui-ngx/src/app/modules/home/pages/security/security.component.html b/ui-ngx/src/app/modules/home/pages/security/security.component.html index 548160ec32..cb9785897f 100644 --- a/ui-ngx/src/app/modules/home/pages/security/security.component.html +++ b/ui-ngx/src/app/modules/home/pages/security/security.component.html @@ -174,7 +174,7 @@
diff --git a/ui-ngx/src/app/shared/components/string-pattern-autocomplete.component.html b/ui-ngx/src/app/shared/components/string-pattern-autocomplete.component.html index 1f10bc4974..caef81b9d0 100644 --- a/ui-ngx/src/app/shared/components/string-pattern-autocomplete.component.html +++ b/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) { + + +
+
People{{ selected.lastValue }}
+
Entity type{{ selected.entityType }}
+
Last update{{ selected.lastSeen }}
+
+ +
+
+ + +
+
+ + +
+
+ +
+

Alarms

+
+
+
+

Charts

+
+
+
+ + +{:copy-code} +``` + +
+
diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_drawer_js.md b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_drawer_js.md new file mode 100644 index 0000000000..78c8ecae0b --- /dev/null +++ b/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} +``` + +
+
diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_split_css.md b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_split_css.md new file mode 100644 index 0000000000..2297dfcf75 --- /dev/null +++ b/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} +``` + +
+
diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_split_html.md b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_split_html.md new file mode 100644 index 0000000000..0d4394015d --- /dev/null +++ b/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;"} +
+ + +
+ +
+ +
+
Device
+

{{ selected.name }}

+
+
+ +
+
+
Select a device
+
+
+{:copy-code} +``` + +
+
diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_split_js.md b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_split_js.md new file mode 100644 index 0000000000..7c101ae20e --- /dev/null +++ b/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} +``` + +
+
diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_tabs_css.md b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_tabs_css.md new file mode 100644 index 0000000000..ffb7e4833f --- /dev/null +++ b/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} +``` + +
+
diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_tabs_html.md b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_tabs_html.md new file mode 100644 index 0000000000..7e1648c4ef --- /dev/null +++ b/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;"} +
+ +
+ +
+
+{:copy-code} +``` + +
+
diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_tabs_js.md b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_tabs_js.md new file mode 100644 index 0000000000..1eddefb304 --- /dev/null +++ b/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} +``` + +
+
diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_analytics_css.md b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_analytics_css.md new file mode 100644 index 0000000000..11fb741d53 --- /dev/null +++ b/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} +``` + +
+
diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_analytics_html.md b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_analytics_html.md new file mode 100644 index 0000000000..90d664ed5c --- /dev/null +++ b/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;"} +
+
+
+

Visitor analytics

+ all peopleCount devices +
+
+ + + + +
+
+
+
Total visitors
+
Busiest day
+
Peak hour
+
Daily average
+
+
+
+ Visitors by day + +
+
+
+
+{:copy-code} +``` + +
+
diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_analytics_js.md b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_analytics_js.md new file mode 100644 index 0000000000..1fc2b85b82 --- /dev/null +++ b/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} +``` + +
+
diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_analytics_resources.md b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_analytics_resources.md new file mode 100644 index 0000000000..0b3ae21dd1 --- /dev/null +++ b/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 Resources tab. It loads ECharts before your function runs and exposes the echarts global (accessed as window.echarts): + +```text +{:code-style="max-height: 400px;"} +https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js +{:copy-code} +``` + +
+
diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_kanban_css.md b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_kanban_css.md new file mode 100644 index 0000000000..6f9a857f19 --- /dev/null +++ b/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} +``` + +
+
diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_kanban_html.md b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_kanban_html.md new file mode 100644 index 0000000000..d2ed3008b4 --- /dev/null +++ b/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;"} +
+{:copy-code} +``` + +
+
diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_kanban_js.md b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_kanban_js.md new file mode 100644 index 0000000000..6b04e2551e --- /dev/null +++ b/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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); + +function render() { + board.innerHTML = statuses + .map((s) => { + const items = devices.filter((d) => d.status === s); + const cards = items + .map( + (d) => + `
${esc(d.name)}
`, + ) + .join(''); + return `
+
${labels[s]}${items.length}
+
${cards}
+
`; + }) + .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} +``` + +
+
diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_kanban_resources.md b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_kanban_resources.md new file mode 100644 index 0000000000..a85de9604a --- /dev/null +++ b/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 Resources tab. It loads SortableJS before your function runs and exposes the Sortable global (accessed as window.Sortable): + +```text +{:code-style="max-height: 400px;"} +https://cdn.jsdelivr.net/npm/sortablejs@1/Sortable.min.js +{:copy-code} +``` + +
+
diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 9b62eea979..c298b32eb5 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/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", diff --git a/ui-ngx/yarn.lock b/ui-ngx/yarn.lock index eef5bc1c4d..21dd89c044 100644 --- a/ui-ngx/yarn.lock +++ b/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"