Browse Source

Merge pull request #15735 from thingsboard/rc

Merge rc into master
pull/15750/head
Viacheslav Klimov 1 month ago
committed by GitHub
parent
commit
a5ccc0b7c7
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 2
      application/src/main/data/json/system/widget_types/html_container.json
  2. 2
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java
  3. 5
      application/src/main/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueService.java
  4. 4
      application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java
  5. 141
      application/src/test/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueServiceTest.java
  6. 6
      common/util/src/main/java/org/thingsboard/common/util/NumberUtils.java
  7. 11
      common/util/src/test/java/org/thingsboard/common/util/NumberUtilsTest.java
  8. 1
      ui-ngx/src/app/core/api/widget-api.models.ts
  9. 2
      ui-ngx/src/app/modules/home/components/widget/config/basic/common/widget-actions-panel.component.html
  10. 5
      ui-ngx/src/app/modules/home/components/widget/config/basic/common/widget-actions-panel.component.ts
  11. 4
      ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.html
  12. 4
      ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.ts
  13. 35
      ui-ngx/src/app/modules/home/components/widget/lib/html/html-container-widget.component.ts
  14. 11
      ui-ngx/src/app/modules/home/components/widget/widget.component.ts
  15. 15
      ui-ngx/src/app/modules/home/models/widget-component.models.ts
  16. 33
      ui-ngx/src/app/shared/models/ace/widget-completion.models.ts

2
application/src/main/data/json/system/widget_types/html_container.json

@ -11,7 +11,7 @@
"resources": [],
"templateHtml": "<tb-html-container-widget \n [ctx]=\"ctx\">\n</tb-html-container-widget>",
"templateCss": "",
"controllerScript": "self.onInit = function() {\n \n}\n\nself.typeParameters = function() {\n return {\n previewWidth: '100%',\n previewHeight: '100%',\n overflowVisible: true\n };\n};\n",
"controllerScript": "self.onInit = function() {\n \n}\n\nself.typeParameters = function() {\n return {\n previewWidth: '100%',\n previewHeight: '100%',\n overflowVisible: true\n };\n};\n\nself.actionSources = function() {\n return {\n 'javaScript': {\n name: 'JavaScript',\n multiple: true\n }\n };\n}",
"settingsDirective": "tb-html-container-widget-settings",
"hasBasicMode": true,
"basicModeDirective": "tb-html-container-basic-config",

2
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java

@ -67,6 +67,8 @@ public class SimpleCalculatedFieldState extends BaseCalculatedFieldState {
ObjectNode valuesNode = JacksonUtil.newObjectNode();
if (result instanceof Double doubleValue) {
valuesNode.put(outputName, doubleValue);
} else if (result instanceof Long longValue) {
valuesNode.put(outputName, longValue);
} else if (result instanceof Integer integerValue) {
valuesNode.put(outputName, integerValue);
} else {

5
application/src/main/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueService.java

@ -27,6 +27,7 @@ import org.thingsboard.server.common.data.tenant.profile.TenantProfileQueueConfi
import org.thingsboard.server.common.msg.queue.TopicPartitionInfo;
import org.thingsboard.server.dao.queue.QueueService;
import org.thingsboard.server.queue.TbQueueAdmin;
import org.thingsboard.server.queue.discovery.TopicService;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.entitiy.AbstractTbEntityService;
@ -45,6 +46,7 @@ public class DefaultTbQueueService extends AbstractTbEntityService implements Tb
private final QueueService queueService;
private final TbClusterService tbClusterService;
private final TbQueueAdmin tbQueueAdmin;
private final TopicService topicService;
@Override
public Queue saveQueue(Queue queue) {
@ -173,9 +175,10 @@ public class DefaultTbQueueService extends AbstractTbEntityService implements Tb
private void createTopicsIfNeeded(Queue queue, Queue oldQueue) {
int newPartitions = queue.getPartitions();
int oldPartitions = oldQueue != null ? oldQueue.getPartitions() : 0;
String topic = topicService.buildTopicName(queue.getTopic());
for (int i = oldPartitions; i < newPartitions; i++) {
tbQueueAdmin.createTopicIfNotExists(
new TopicPartitionInfo(queue.getTopic(), queue.getTenantId(), i, false).getFullTopicName(),
new TopicPartitionInfo(topic, queue.getTenantId(), i, false).getFullTopicName(),
queue.getCustomProperties(),
true); // forcing topic creation because the topic may still be cached on some nodes
}

4
application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java

@ -154,7 +154,7 @@ public class SimpleCalculatedFieldStateTest {
Output output = getCalculatedFieldConfig().getOutput();
assertThat(result.getType()).isEqualTo(output.getType());
assertThat(result.getScope()).isEqualTo(output.getScope());
assertThat(result.getResult()).isEqualTo(JacksonUtil.valueToTree(Map.of("output", 49)));
assertThat(result.getResult()).isEqualTo(JacksonUtil.valueToTree(Map.of("output", 49L)));
}
@Test
@ -184,7 +184,7 @@ public class SimpleCalculatedFieldStateTest {
Output output = getCalculatedFieldConfig().getOutput();
assertThat(result.getType()).isEqualTo(output.getType());
assertThat(result.getScope()).isEqualTo(output.getScope());
assertThat(result.getResult()).isEqualTo(JacksonUtil.valueToTree(Map.of("output", 35)));
assertThat(result.getResult()).isEqualTo(JacksonUtil.valueToTree(Map.of("output", 35L)));
}
@Test

141
application/src/test/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueServiceTest.java

@ -0,0 +1,141 @@
/**
* 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.entitiy.queue;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.thingsboard.server.cluster.TbClusterService;
import org.thingsboard.server.common.data.id.QueueId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.queue.Queue;
import org.thingsboard.server.dao.queue.QueueService;
import org.thingsboard.server.queue.TbQueueAdmin;
import org.thingsboard.server.queue.discovery.TopicService;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
public class DefaultTbQueueServiceTest {
@Mock
private QueueService queueServiceMock;
@Mock
private TbClusterService tbClusterServiceMock;
@Mock
private TbQueueAdmin tbQueueAdminMock;
private TopicService topicService;
private DefaultTbQueueService tbQueueService;
private final TenantId tenantId = TenantId.SYS_TENANT_ID;
@BeforeEach
public void setUp() {
topicService = new TopicService();
tbQueueService = new DefaultTbQueueService(queueServiceMock, tbClusterServiceMock, tbQueueAdminMock, topicService);
}
private Queue newQueue(int partitions) {
Queue queue = new Queue();
queue.setTenantId(tenantId);
queue.setName("testQueue");
queue.setTopic("tb_rule_engine.testQueue");
queue.setPartitions(partitions);
return queue;
}
@Test
public void givenQueuePrefix_whenSaveQueue_thenCreatesPrefixedTopics() {
// queue.prefix = "thingsboard" (TB_QUEUE_PREFIX set)
ReflectionTestUtils.setField(topicService, "prefix", "thingsboard");
Queue queue = newQueue(2);
when(queueServiceMock.saveQueue(queue)).thenReturn(queue);
tbQueueService.saveQueue(queue);
ArgumentCaptor<String> topicCaptor = ArgumentCaptor.forClass(String.class);
verify(tbQueueAdminMock, times(2)).createTopicIfNotExists(topicCaptor.capture(), any(), anyBoolean());
// All created topics must carry the prefix - this is the fix.
assertThat(topicCaptor.getAllValues())
.containsExactlyInAnyOrder(
"thingsboard.tb_rule_engine.testQueue.0",
"thingsboard.tb_rule_engine.testQueue.1");
// No unprefixed (orphan-prone) topic must ever be created.
assertThat(topicCaptor.getAllValues())
.noneMatch(topic -> topic.equals("tb_rule_engine.testQueue.0")
|| topic.equals("tb_rule_engine.testQueue.1"));
}
@Test
public void givenNoQueuePrefix_whenSaveQueue_thenCreatesUnprefixedTopics() {
// queue.prefix blank (TB_QUEUE_PREFIX not set) - default behavior preserved
ReflectionTestUtils.setField(topicService, "prefix", "");
Queue queue = newQueue(2);
when(queueServiceMock.saveQueue(queue)).thenReturn(queue);
tbQueueService.saveQueue(queue);
ArgumentCaptor<String> topicCaptor = ArgumentCaptor.forClass(String.class);
verify(tbQueueAdminMock, times(2)).createTopicIfNotExists(topicCaptor.capture(), any(), anyBoolean());
assertThat(topicCaptor.getAllValues())
.containsExactlyInAnyOrder(
"tb_rule_engine.testQueue.0",
"tb_rule_engine.testQueue.1");
}
@Test
public void givenQueuePrefix_whenIncreasePartitions_thenOnlyNewPartitionsCreatedPrefixed() {
ReflectionTestUtils.setField(topicService, "prefix", "thingsboard");
Queue oldQueue = newQueue(2);
oldQueue.setId(new QueueId(UUID.randomUUID()));
Queue updatedQueue = newQueue(4);
updatedQueue.setId(oldQueue.getId());
when(queueServiceMock.findQueueById(tenantId, updatedQueue.getId())).thenReturn(oldQueue);
when(queueServiceMock.saveQueue(updatedQueue)).thenReturn(updatedQueue);
tbQueueService.saveQueue(updatedQueue);
ArgumentCaptor<String> topicCaptor = ArgumentCaptor.forClass(String.class);
verify(tbQueueAdminMock, times(2)).createTopicIfNotExists(topicCaptor.capture(), any(), anyBoolean());
assertThat(topicCaptor.getAllValues())
.containsExactlyInAnyOrder(
"thingsboard.tb_rule_engine.testQueue.2",
"thingsboard.tb_rule_engine.testQueue.3");
verify(tbQueueAdminMock, never()).createTopicIfNotExists(eq("thingsboard.tb_rule_engine.testQueue.0"), any(), anyBoolean());
}
}

6
common/util/src/main/java/org/thingsboard/common/util/NumberUtils.java

@ -36,12 +36,16 @@ public class NumberUtils {
return BigDecimal.valueOf(value).setScale(0, RoundingMode.HALF_UP).intValue();
}
public static long toLong(double value) {
return BigDecimal.valueOf(value).setScale(0, RoundingMode.HALF_UP).longValue();
}
public static Object roundResult(double value, Integer precision) {
if (precision == null) {
return value;
}
if (precision.equals(0)) {
return toInt(value);
return toLong(value);
}
return toFixed(value, precision);
}

11
common/util/src/test/java/org/thingsboard/common/util/NumberUtilsTest.java

@ -51,11 +51,20 @@ public class NumberUtilsTest {
assertThat(NumberUtils.toInt(28.0)).isEqualTo(28);
}
@Test
public void toLong() {
assertThat(NumberUtils.toLong(doubleVal)).isEqualTo(1729L);
assertThat(NumberUtils.toLong(12.8)).isEqualTo(13L);
assertThat(NumberUtils.toLong(28.0)).isEqualTo(28L);
assertThat(NumberUtils.toLong(3_980_173_734.0)).isEqualTo(3_980_173_734L);
}
@Test
public void roundResult() {
assertThat(NumberUtils.roundResult(doubleVal, null)).isEqualTo(1729.1729);
assertThat(NumberUtils.roundResult(doubleVal, 0)).isEqualTo(1729);
assertThat(NumberUtils.roundResult(doubleVal, 0)).isEqualTo(1729L);
assertThat(NumberUtils.roundResult(doubleVal, 2)).isEqualTo(1729.17);
assertThat(NumberUtils.roundResult(3_980_173_734.0, 0)).isEqualTo(3_980_173_734L);
}
}

1
ui-ngx/src/app/core/api/widget-api.models.ts

@ -119,6 +119,7 @@ export interface WidgetActionsApi {
elementClick: ($event: Event) => void;
cardClick: ($event: Event) => void;
click: ($event: Event) => void;
invokeAction: ($event: Event, actionName: string, additionalParams?: any) => void;
getActiveEntityInfo: () => SubscriptionEntityInfo;
openDashboardStateInSeparateDialog: (targetDashboardStateId: string, params?: StateParams, dialogTitle?: string,
hideDashboardToolbar?: boolean, dialogWidth?: number, dialogHeight?: number) => MatDialogRef<any>;

2
ui-ngx/src/app/modules/home/components/widget/config/basic/common/widget-actions-panel.component.html

@ -15,7 +15,7 @@
limitations under the License.
-->
<div class="tb-form-panel" (click)="manageWidgetActions()" style="cursor: pointer;">
<div class="tb-form-panel" [class.stroked]="strokedPanel" (click)="manageWidgetActions()" style="cursor: pointer;">
<div class="flex flex-row items-center justify-start gap-4">
<div class="tb-form-panel-title" translate style="padding-right: 48px;">widget-config.actions</div>
<mat-chip-listbox class="flex-1">

5
ui-ngx/src/app/modules/home/components/widget/config/basic/common/widget-actions-panel.component.ts

@ -26,6 +26,7 @@ import {
import { deepClone } from '@core/utils';
import { MatDialog } from '@angular/material/dialog';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { coerceBoolean } from '@shared/decorators/coercion';
@Component({
selector: 'tb-widget-actions-panel',
@ -45,6 +46,10 @@ export class WidgetActionsPanelComponent implements ControlValueAccessor, OnInit
@Input()
disabled: boolean;
@Input()
@coerceBoolean()
strokedPanel = false;
actionsFormGroup: UntypedFormGroup;
private propagateChange = (_val: any) => {};

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

@ -17,4 +17,8 @@
-->
<ng-container [formGroup]="htmlContainerWidgetConfigForm">
<tb-html-container-settings formControlName="settings"></tb-html-container-settings>
<tb-widget-actions-panel
formControlName="actions"
strokedPanel>
</tb-widget-actions-panel>
</ng-container>

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

@ -51,12 +51,14 @@ export class HtmlContainerBasicConfigComponent extends BasicWidgetConfigComponen
protected onConfigSet(configData: WidgetConfigComponentData) {
const settings: HtmlContainerWidgetSettings = {...htmlContainerDefaultSettings, ...(configData.config.settings || {})};
this.htmlContainerWidgetConfigForm = this.fb.group({
settings: [settings, []]
settings: [settings, []],
actions: [configData.config.actions || {}, []]
});
}
protected prepareOutputConfig(config: any): WidgetConfigComponentData {
this.widgetConfig.config.settings = {...(this.widgetConfig.config.settings || {}), ...config.settings};
this.widgetConfig.config.actions = config.actions;
return this.widgetConfig;
}
}

35
ui-ngx/src/app/modules/home/components/widget/lib/html/html-container-widget.component.ts

@ -15,8 +15,10 @@
///
import {
Component,
ChangeDetectorRef,
Component, ComponentRef,
ElementRef,
inject,
Inject,
Injector,
Input,
@ -96,6 +98,7 @@ export class HtmlContainerWidgetComponent implements OnInit {
@Inject(HOME_COMPONENTS_MODULE_TOKEN) private homeComponentsModule: Type<any>,
private dynamicComponentFactoryService: DynamicComponentFactoryService,
private utils: UtilsService,
private cdr: ChangeDetectorRef,
private resources: ResourcesService) {}
ngOnInit(): void {
@ -160,11 +163,13 @@ export class HtmlContainerWidgetComponent implements OnInit {
this.compileAngularFunction().subscribe(
{
next: (containerFunction) => {
try {
this.initAngularComponent(imports, containerFunction);
} catch (e) {
this.handleWidgetException(e);
}
setTimeout(() => {
try {
this.initAngularComponent(imports, containerFunction);
} catch (e) {
this.handleWidgetException(e);
}
});
},
error: (e) => {
this.handleWidgetException(e);
@ -200,9 +205,16 @@ export class HtmlContainerWidgetComponent implements OnInit {
compileModules = compileModules.concat(imports);
}
const self = () => this;
let containerRef: ComponentRef<any>;
this.dynamicComponentFactoryService.createDynamicComponent(
class TbContainerInstance {
private cdr = inject(ChangeDetectorRef);
ngOnInit(): void {
this.cdr.detach();
if (containerFunction) {
const instance = self();
try {
@ -212,6 +224,15 @@ export class HtmlContainerWidgetComponent implements OnInit {
}
}
}
ngDoCheck(): void {
const instance = self();
try {
this.cdr.detectChanges()
} catch (error) {
containerRef.destroy();
instance.handleWidgetException(error)
}
}
ngOnDestroy(): void {
destroyContainerInstanceResources();
}
@ -224,7 +245,7 @@ export class HtmlContainerWidgetComponent implements OnInit {
this.containerInstanceComponentType = componentType;
const injector: Injector = Injector.create({providers: [], parent: this.angularContainer.viewContainerRef.injector});
try {
this.angularContainer.viewContainerRef.createComponent(this.containerInstanceComponentType,
containerRef = this.angularContainer.viewContainerRef.createComponent(this.containerInstanceComponentType,
{index: 0, injector});
} catch (error) {

11
ui-ngx/src/app/modules/home/components/widget/widget.component.ts

@ -278,6 +278,7 @@ export class WidgetComponent extends PageComponent implements OnInit, OnChanges,
elementClick: this.elementClick.bind(this),
cardClick: this.cardClick.bind(this),
click: this.click.bind(this),
invokeAction: this.invokeAction.bind(this),
getActiveEntityInfo: this.getActiveEntityInfo.bind(this),
openDashboardStateInSeparateDialog: this.openDashboardStateInSeparateDialog.bind(this),
openDashboardStateInPopover: this.openDashboardStateInPopover.bind(this),
@ -1617,6 +1618,16 @@ export class WidgetComponent extends PageComponent implements OnInit, OnChanges,
}
}
private invokeAction($event: Event, actionName: string, additionalParams?: any) {
const descriptors = this.getActionDescriptors('javaScript');
if (descriptors?.length) {
const found = descriptors.find(d => d.name === actionName);
if (found) {
this.handleWidgetAction($event, found, null, null, additionalParams);
}
}
}
private onWidgetAction($event: Event, action: WidgetAction) {
if ($event) {
$event.stopPropagation();

15
ui-ngx/src/app/modules/home/models/widget-component.models.ts

@ -159,6 +159,8 @@ export interface IDashboardWidget {
updateParamsFromData(detectChanges?: boolean): void;
}
export type WidgetDestroyCallback = () => void;
export class WidgetContext {
constructor(public dashboard: IDashboardComponent,
@ -363,6 +365,8 @@ export class WidgetContext {
...RxJSOperators
};
private destroyCallbacks: WidgetDestroyCallback[] = [];
registerPopoverComponent(popoverComponent: TbPopoverComponent) {
this.popoverComponents.push(popoverComponent);
popoverComponent.tbDestroy.subscribe(() => {
@ -402,6 +406,10 @@ export class WidgetContext {
}
}
registerDestroyCallback(destroyCallback: WidgetDestroyCallback) {
this.destroyCallbacks.push(destroyCallback);
}
showSuccessToast(message: string, duration: number = 1000,
verticalPosition: NotificationVerticalPosition = 'bottom',
horizontalPosition: NotificationHorizontalPosition = 'left',
@ -518,6 +526,13 @@ export class WidgetContext {
labelPattern.destroy();
}
this.labelPatterns.clear();
this.destroyCallbacks.forEach((destroyCallback) => {
try {
destroyCallback()
} catch (_ignoredError) { /* empty */ }
}
);
this.destroyCallbacks.length = 0;
this.width = undefined;
this.height = undefined;
this.destroyed = true;

33
ui-ngx/src/app/shared/models/ace/widget-completion.models.ts

@ -632,6 +632,28 @@ export const widgetContextCompletionsWithSettings = (settingsCompletions?: TbEdi
optional: true
}
]
},
invokeAction: {
description: 'Invoke action with JavaScript action source.',
meta: 'function',
args: [
{
name: '$event',
description: 'DOM event object associated with action.',
type: 'Event'
},
{
name: 'actionName',
description: 'Name of the configured action with JavaScript action source.',
type: 'string'
},
{
name: 'additionalParams',
description: 'Optional payload merged into the action context and forwarded to the configured JavaScript action function as its <code>additionalParams</code> argument. Use it to pass row data, button state, or any extra values the action handler should react to.',
type: 'object',
optional: true
}
]
}
}
},
@ -736,6 +758,17 @@ export const widgetContextCompletionsWithSettings = (settingsCompletions?: TbEdi
}
}
}
},
registerDestroyCallback: {
description: 'Registers a teardown callback that will be invoked exactly once when the widget is about to be destroyed (dashboard navigation, edit/view switch, layout change, etc.). Use it to release resources acquired during widget setup so they don\'t leak across widget reloads — e.g. unsubscribe RxJS subscriptions, detach DOM/window event listeners, clear <code>setInterval</code> / <code>setTimeout</code> timers, abort outstanding HTTP requests, destroy third-party plugin instances. Multiple callbacks may be registered; they are executed in registration order.',
meta: 'function',
args: [
{
description: 'Zero-argument function executed when the widget is destroyed. Should be idempotent — synchronously dispose of one specific resource (e.g. one subscription or one listener) and avoid throwing; throwing here may prevent later cleanup callbacks from running.',
name: 'destroyCallback',
type: '() => void',
}
]
}
},
...serviceCompletions

Loading…
Cancel
Save