48 changed files with 2258 additions and 164 deletions
@ -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(); |
|||
} |
|||
|
|||
} |
|||
|
|||
} |
|||
@ -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(); |
|||
} |
|||
|
|||
} |
|||
|
|||
} |
|||
@ -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> |
|||
@ -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><div class="tb-absolute-fill"></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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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) => |
|||
`<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> |
|||
@ -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> |
|||
Loading…
Reference in new issue