Browse Source

Merge branch 'lts-4.2' into feature/iot-hub

pull/15615/head
Igor Kulikov 3 months ago
parent
commit
ab2f474b51
  1. 3
      application/src/main/data/json/system/widget_bundles/cards.json
  2. 3
      application/src/main/data/json/system/widget_bundles/html_widgets.json
  3. 52
      application/src/main/data/json/system/widget_types/html_container.json
  4. 4
      common/message/src/main/java/org/thingsboard/server/common/msg/EncryptionUtil.java
  5. 4
      common/queue/pom.xml
  6. 28
      pom.xml
  7. 4
      rule-engine/rule-engine-components/pom.xml
  8. 4
      tools/pom.xml
  9. 9
      ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts
  10. 20
      ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.html
  11. 62
      ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.ts
  12. 318
      ui-ngx/src/app/modules/home/components/widget/lib/html/html-container-widget.component.ts
  13. 63
      ui-ngx/src/app/modules/home/components/widget/lib/html/html-container-widget.models.ts
  14. 98
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.html
  15. 28
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.scss
  16. 186
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.ts
  17. 9
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts
  18. 2
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget/widget-settings.component.html
  19. 20
      ui-ngx/src/app/modules/home/components/widget/lib/settings/html/html-container-widget-settings.component.html
  20. 65
      ui-ngx/src/app/modules/home/components/widget/lib/settings/html/html-container-widget-settings.component.ts
  21. 9
      ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts
  22. 7
      ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts
  23. 2
      ui-ngx/src/app/modules/home/components/widget/widget-config.component.html
  24. 1
      ui-ngx/src/app/modules/home/components/widget/widget-config.component.scss
  25. 12
      ui-ngx/src/assets/locale/locale.constant-en_US.json

3
application/src/main/data/json/system/widget_bundles/cards.json

@ -24,6 +24,7 @@
"cards.html_value_card",
"cards.markdown_card",
"cards.simple_card",
"unread_notifications"
"unread_notifications",
"html_container"
]
}

3
application/src/main/data/json/system/widget_bundles/html_widgets.json

@ -11,6 +11,7 @@
"widgetTypeFqns": [
"cards.html_card",
"cards.html_value_card",
"cards.markdown_card"
"cards.markdown_card",
"html_container"
]
}

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

File diff suppressed because one or more lines are too long

4
common/message/src/main/java/org/thingsboard/server/common/msg/EncryptionUtil.java

@ -17,7 +17,7 @@ package org.thingsboard.server.common.msg;
import lombok.extern.slf4j.Slf4j;
import org.bouncycastle.crypto.digests.SHA3Digest;
import org.bouncycastle.pqc.legacy.math.linearalgebra.ByteUtils;
import org.bouncycastle.util.encoders.Hex;
/**
* @author Valerii Sosliuk
@ -66,7 +66,7 @@ public class EncryptionUtil {
md.update(dataBytes, 0, dataBytes.length);
byte[] hashedBytes = new byte[256 / 8];
md.doFinal(hashedBytes, 0);
String sha3Hash = ByteUtils.toHexString(hashedBytes);
String sha3Hash = Hex.toHexString(hashedBytes);
return sha3Hash;
}

4
common/queue/pom.xml

@ -68,10 +68,6 @@
<groupId>org.apache.kafka</groupId>
<artifactId>kafka-clients</artifactId>
</dependency>
<dependency>
<groupId>at.yawk.lz4</groupId>
<artifactId>lz4-java</artifactId>
</dependency>
<dependency>
<groupId>com.google.cloud</groupId>
<artifactId>google-cloud-pubsub</artifactId>

28
pom.xml

@ -63,15 +63,15 @@
<pkg.unixLogFolder>/var/log/${pkg.name}</pkg.unixLogFolder>
<pkg.installFolder>/usr/share/${pkg.name}</pkg.installFolder>
<spring-boot.version>3.5.13</spring-boot.version>
<tomcat.version>10.1.54</tomcat.version> <!-- to fix CVE-2026-34487, CVE-2026-34486, CVE-2026-34483. TODO: remove when fixed in spring-boot-dependencies -->
<commons-lang3.version>3.18.0</commons-lang3.version> <!-- to fix CVE-2025-48924. TODO: remove when fixed in spring-boot-dependencies -->
<javax.xml.bind-api.version>2.4.0-b180830.0359</javax.xml.bind-api.version>
<jjwt.version>0.12.5</jjwt.version>
<rat.version>0.10</rat.version> <!-- unused -->
<cassandra.version>4.17.0</cassandra.version>
<metrics.version>4.2.25</metrics.version>
<cassandra-all.version>5.0.4</cassandra-all.version> <!-- tools -->
<cassandra-all.version>5.0.7</cassandra-all.version> <!-- tools; 5.0.7 fixes CVE-2026-27314 -->
<guava.version>33.1.0-jre</guava.version>
<tomcat.version>10.1.54</tomcat.version> <!-- to fix CVE-2026-34487, CVE-2026-34486, CVE-2026-34483. TODO: remove when fixed in spring-boot-dependencies -->
<commons-lang3.version>3.18.0</commons-lang3.version> <!-- to fix CVE-2025-48924. TODO: remove when fixed in spring-boot-dependencies -->
<commons-io.version>2.16.1</commons-io.version>
<commons-logging.version>1.3.1</commons-logging.version>
<commons-csv.version>1.10.0</commons-csv.version>
@ -102,7 +102,7 @@
<swagger-annotations.version>2.2.30</swagger-annotations.version>
<spatial4j.version>0.8</spatial4j.version>
<jts.version>1.19.0</jts.version>
<bouncycastle.version>1.78.1</bouncycastle.version>
<bouncycastle.version>1.84</bouncycastle.version> <!-- 1.84 fixes CVE-2026-5588, CVE-2026-5598, CVE-2025-14813 -->
<winsw.version>2.0.1</winsw.version>
<sonar.exclusions>org/thingsboard/server/gen/**/*,
org/thingsboard/server/extensions/core/plugin/telemetry/gen/**/*
@ -112,8 +112,7 @@
<!-- IMPORTANT: If you change the version of the kafka client, make sure to synchronize our overwritten implementation of the
org.apache.kafka.common.network.NetworkReceive class in the application module. It addresses the issue https://issues.apache.org/jira/browse/KAFKA-4090.
Here is the source to track https://github.com/apache/kafka/tree/trunk/clients/src/main/java/org/apache/kafka/common/network -->
<kafka.version>3.9.1</kafka.version>
<lz4.version>1.10.1</lz4.version> <!-- to fix CVE-2025-12183 and CVE-2025-66566 introduced through kafka-clients 3.9.1 TODO: remove when kafka-clients is bumped -->
<kafka.version>3.9.2</kafka.version> <!-- to fix CVE-2026-35554 -->
<bucket4j.version>8.10.1</bucket4j.version>
<antlr.version>3.5.3</antlr.version>
<aws.sdk.version>1.12.701</aws.sdk.version>
@ -1271,17 +1270,6 @@
<groupId>org.apache.kafka</groupId>
<artifactId>kafka-clients</artifactId>
<version>${kafka.version}</version>
<exclusions>
<exclusion>
<groupId>org.lz4</groupId>
<artifactId>lz4-java</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>at.yawk.lz4</groupId>
<artifactId>lz4-java</artifactId>
<version>${lz4.version}</version> <!-- to fix CVE introduced through kafka-clients 3.9.1 -->
</dependency>
<dependency>
<groupId>com.github.springtestdbunit</groupId>
@ -1557,12 +1545,6 @@
<groupId>org.apache.cassandra</groupId>
<artifactId>cassandra-all</artifactId>
<version>${cassandra-all.version}</version>
<exclusions>
<exclusion>
<groupId>org.lz4</groupId>
<artifactId>lz4-java</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.testng</groupId>

4
rule-engine/rule-engine-components/pom.xml

@ -96,10 +96,6 @@
<groupId>org.apache.kafka</groupId>
<artifactId>kafka-clients</artifactId>
</dependency>
<dependency>
<groupId>at.yawk.lz4</groupId>
<artifactId>lz4-java</artifactId>
</dependency>
<dependency>
<groupId>com.amazonaws</groupId>
<artifactId>aws-java-sdk-sns</artifactId>

4
tools/pom.xml

@ -73,10 +73,6 @@
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>at.yawk.lz4</groupId>
<artifactId>lz4-java</artifactId>
</dependency>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>

9
ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts

@ -150,6 +150,9 @@ import {
ValueStepperBasicConfigComponent
} from '@home/components/widget/config/basic/rpc/value-stepper-basic-config.component';
import { MapBasicConfigComponent } from '@home/components/widget/config/basic/map/map-basic-config.component';
import {
HtmlContainerBasicConfigComponent
} from '@home/components/widget/config/basic/html/html-container-basic-config.component';
@NgModule({
declarations: [
@ -201,7 +204,8 @@ import { MapBasicConfigComponent } from '@home/components/widget/config/basic/ma
LabelValueCardBasicConfigComponent,
UnreadNotificationBasicConfigComponent,
ScadaSymbolBasicConfigComponent,
MapBasicConfigComponent
MapBasicConfigComponent,
HtmlContainerBasicConfigComponent
],
imports: [
CommonModule,
@ -255,7 +259,8 @@ import { MapBasicConfigComponent } from '@home/components/widget/config/basic/ma
LabelCardBasicConfigComponent,
LabelValueCardBasicConfigComponent,
UnreadNotificationBasicConfigComponent,
MapBasicConfigComponent
MapBasicConfigComponent,
HtmlContainerBasicConfigComponent
]
})
export class BasicWidgetConfigModule {

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

@ -0,0 +1,20 @@
<!--
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.
-->
<ng-container [formGroup]="htmlContainerWidgetConfigForm">
<tb-html-container-settings formControlName="settings"></tb-html-container-settings>
</ng-container>

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

@ -0,0 +1,62 @@
///
/// 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.
///
import { Component, HostBinding } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { BasicWidgetConfigComponent } from '@home/components/widget/config/widget-config.component.models';
import { WidgetConfigComponentData } from '@home/models/widget-component.models';
import { WidgetConfigComponent } from '@home/components/widget/widget-config.component';
import {
htmlContainerDefaultSettings,
HtmlContainerWidgetSettings
} from '@home/components/widget/lib/html/html-container-widget.models';
@Component({
selector: 'tb-html-container-basic-config',
templateUrl: './html-container-basic-config.component.html',
styleUrls: ['../basic-config.scss'],
standalone: false
})
export class HtmlContainerBasicConfigComponent extends BasicWidgetConfigComponent {
@HostBinding('style.height') height = '100%';
htmlContainerWidgetConfigForm: UntypedFormGroup;
constructor(protected store: Store<AppState>,
protected widgetConfigComponent: WidgetConfigComponent,
private fb: UntypedFormBuilder) {
super(store, widgetConfigComponent);
}
protected configForm(): UntypedFormGroup {
return this.htmlContainerWidgetConfigForm;
}
protected onConfigSet(configData: WidgetConfigComponentData) {
const settings: HtmlContainerWidgetSettings = {...htmlContainerDefaultSettings, ...(configData.config.settings || {})};
this.htmlContainerWidgetConfigForm = this.fb.group({
settings: [settings, []]
});
}
protected prepareOutputConfig(config: any): WidgetConfigComponentData {
this.widgetConfig.config.settings = {...(this.widgetConfig.config.settings || {}), ...config.settings};
return this.widgetConfig;
}
}

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

@ -0,0 +1,318 @@
///
/// 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.
///
import {
Component,
ElementRef,
Inject,
Injector,
Input,
OnInit,
Optional,
Type,
ViewChild,
ViewEncapsulation
} from '@angular/core';
import { WidgetContext } from '@home/models/widget-component.models';
import {
htmlContainerDefaultSettings,
HtmlContainerWidgetSettings,
HtmlContainerWidgetType,
WidgetContainerAngularFunction,
WidgetContainerPlainFunction
} from '@home/components/widget/lib/html/html-container-widget.models';
import { hashCode, isNotEmptyStr, parseTbFunction } from '@core/utils';
import { CompiledTbFunction, isNotEmptyTbFunction } from '@shared/models/js-function.models';
import { catchError, forkJoin, map, Observable, of, switchMap, throwError } from 'rxjs';
import cssjs from '@core/css/css';
import { SHARED_MODULE_TOKEN } from '@shared/components/tokens';
import { DynamicComponentFactoryService } from '@core/services/dynamic-component-factory.service';
import { HOME_COMPONENTS_MODULE_TOKEN, WIDGET_COMPONENTS_MODULE_TOKEN } from '@home/components/tokens';
import { ExceptionData } from '@shared/models/error.models';
import { UtilsService } from '@core/services/utils.service';
import {
flatModulesWithComponents,
ModulesWithComponents,
modulesWithComponentsToTypes,
ResourcesService
} from '@core/services/resources.service';
import { MODULES_MAP } from '@shared/models/constants';
import { IModulesMap } from '@modules/common/modules-map.models';
import { TbAnchorComponent } from '@shared/components/tb-anchor.component';
@Component({
selector: 'tb-html-container-widget',
template: '<div #container class="tb-absolute-fill"><tb-anchor #angularContainer></tb-anchor></div>' +
'@if (widgetErrorData) { <div class="tb-absolute-fill tb-widget-error">\n' +
' <span [innerHtml]="(\'Widget Error:<br/><br/>\' + widgetErrorData.message) | safe:\'html\'"></span>\n' +
'</div> }',
styles: '.tb-widget-error {\n' +
' display: flex;\n' +
' align-items: center;\n' +
' justify-content: center;\n' +
' background: rgba(255, 255, 255, .5);\n' +
'\n' +
' span {\n' +
' color: #f00;\n' +
' }\n' +
' }',
encapsulation: ViewEncapsulation.None,
standalone: false
})
export class HtmlContainerWidgetComponent implements OnInit {
@ViewChild('container', {static: true})
containerElmRef: ElementRef<HTMLElement>;
@ViewChild('angularContainer', {static: true})
angularContainer: TbAnchorComponent;
@Input()
ctx: WidgetContext;
private containerInstanceComponentType: Type<any>;
private settings: HtmlContainerWidgetSettings;
widgetErrorData: ExceptionData;
constructor(private elementRef: ElementRef<HTMLElement>,
@Optional() @Inject(MODULES_MAP) private modulesMap: IModulesMap,
@Inject(SHARED_MODULE_TOKEN) private sharedModule: Type<any>,
@Inject(WIDGET_COMPONENTS_MODULE_TOKEN) private widgetComponentsModule: Type<any>,
@Inject(HOME_COMPONENTS_MODULE_TOKEN) private homeComponentsModule: Type<any>,
private dynamicComponentFactoryService: DynamicComponentFactoryService,
private utils: UtilsService,
private resources: ResourcesService) {}
ngOnInit(): void {
this.settings = {...htmlContainerDefaultSettings, ...(this.ctx.settings || {})};
this.loadWidgetResources().subscribe(
{
next: () => {
if (this.settings.type === HtmlContainerWidgetType.PLAIN) {
this.initPlain();
} else if (this.settings.type === HtmlContainerWidgetType.ANGULAR) {
this.initAngular();
}
},
error: (e) => {
this.handleWidgetException(e);
}
}
);
}
private initPlain(): void {
try {
if (isNotEmptyStr(this.settings.css)) {
const cssParser = new cssjs();
cssParser.testMode = false;
const namespace = 'html-container-' + hashCode(this.settings.css);
cssParser.cssPreviewNamespace = namespace;
cssParser.createStyleElement(namespace, this.settings.css);
$(this.elementRef.nativeElement).addClass(namespace);
}
if (isNotEmptyStr(this.settings.html)) {
$(this.containerElmRef.nativeElement).html(this.settings.html);
}
this.compileAndExecutePlainFunction();
} catch (e) {
this.handleWidgetException(e);
}
}
private compileAndExecutePlainFunction(): void {
if (isNotEmptyTbFunction(this.settings.js)) {
const jsFunction: Observable<CompiledTbFunction<WidgetContainerPlainFunction>> = parseTbFunction(this.ctx.http, this.settings.js, ['ctx', 'container']);
jsFunction.subscribe({
next: (containerFunction) => {
try {
containerFunction.execute(this.ctx, this.containerElmRef.nativeElement);
} catch (e) {
this.handleWidgetException(e);
}
},
error: (e) => {
this.handleWidgetException(e);
}
});
}
}
private initAngular(): void {
this.loadAngularModules().subscribe(
{
next: (imports) => {
this.compileAngularFunction().subscribe(
{
next: (containerFunction) => {
try {
this.initAngularComponent(imports, containerFunction);
} catch (e) {
this.handleWidgetException(e);
}
},
error: (e) => {
this.handleWidgetException(e);
}
}
);
},
error: (e) => {
this.handleWidgetException(e);
}
}
);
}
private compileAngularFunction(): Observable<CompiledTbFunction<WidgetContainerAngularFunction>> {
if (isNotEmptyTbFunction(this.settings.js)) {
return parseTbFunction(this.ctx.http, this.settings.js, ['ctx']);
} else {
return of(null);
}
}
private initAngularComponent(imports?: Type<any>[], containerFunction?: CompiledTbFunction<WidgetContainerAngularFunction>): void {
this.angularContainer.viewContainerRef.clear();
const destroyContainerInstanceResources = this.destroyContainerInstanceResources.bind(this);
const template = this.settings.html || '';
const styles: string[] = [];
if (isNotEmptyStr(this.settings.css)) {
styles.push(this.settings.css);
}
let compileModules = [this.sharedModule, this.widgetComponentsModule, this.homeComponentsModule];
if (imports && imports.length) {
compileModules = compileModules.concat(imports);
}
const self = () => this;
this.dynamicComponentFactoryService.createDynamicComponent(
class TbContainerInstance {
ngOnInit(): void {
if (containerFunction) {
const instance = self();
try {
containerFunction.apply(this, [instance.ctx]);
} catch (e) {
instance.handleWidgetException(e);
}
}
}
ngOnDestroy(): void {
destroyContainerInstanceResources();
}
},
template,
compileModules,
true, styles
).subscribe({
next: (componentType) => {
this.containerInstanceComponentType = componentType;
const injector: Injector = Injector.create({providers: [], parent: this.angularContainer.viewContainerRef.injector});
try {
this.angularContainer.viewContainerRef.createComponent(this.containerInstanceComponentType,
{index: 0, injector});
} catch (error) {
this.handleWidgetException(error);
}
},
error: (e) => {
this.handleWidgetException(e);
}
});
}
private destroyContainerInstanceResources() {
if (this.containerInstanceComponentType) {
this.dynamicComponentFactoryService.destroyDynamicComponent(this.containerInstanceComponentType);
this.containerInstanceComponentType = null;
}
}
private handleWidgetException(e: any) {
console.error(e);
this.widgetErrorData = this.utils.processWidgetException(e);
this.ctx.detectChanges();
}
private loadWidgetResources(): Observable<any> {
const resourceTasks: Observable<string>[] = [];
this.settings.resources.filter(r => !r.isModule).forEach(
(resource) => {
resourceTasks.push(
this.resources.loadResource(resource.url).pipe(
catchError(() => of(`Failed to load widget resource: '${resource.url}'`))
)
);
}
);
if (resourceTasks.length) {
return forkJoin(resourceTasks).pipe(
switchMap(msgs => {
let errors: string[];
if (msgs && msgs.length) {
errors = msgs.filter(msg => msg && msg.length > 0);
}
if (errors && errors.length) {
return throwError(() => new Error(errors.join('<br/>')));
} else {
return of(null);
}
}
));
} else {
return of(null);
}
}
private loadAngularModules(): Observable<Type<any>[]> {
const modulesTasks: Observable<ModulesWithComponents | string>[] = [];
this.settings.resources.filter(r => r.isModule).forEach(
(resource) => {
modulesTasks.push(
this.resources.loadModulesWithComponents(resource.url, this.modulesMap).pipe(
catchError((e: Error) => of(e?.message ? e.message : `Failed to load widget resource module: '${resource.url}'`))
)
);
}
);
if (modulesTasks.length) {
return forkJoin(modulesTasks).pipe(
map(res => {
const msg = res.find(r => typeof r === 'string');
if (msg) {
return msg as string;
} else {
const modulesWithComponentsList = res as ModulesWithComponents[];
return flatModulesWithComponents(modulesWithComponentsList);
}
}),
switchMap(modulesWithComponentsList => {
if (typeof modulesWithComponentsList === 'string') {
return throwError(() => new Error(modulesWithComponentsList));
} else {
const modules = modulesWithComponentsToTypes(modulesWithComponentsList);
return of(modules);
}
})
);
} else {
return of(null);
}
}
}

63
ui-ngx/src/app/modules/home/components/widget/lib/html/html-container-widget.models.ts

@ -0,0 +1,63 @@
///
/// 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.
///
import { TbFunction } from '@shared/models/js-function.models';
import { WidgetContext } from '@home/models/widget-component.models';
import { TbEditorCompleter, TbEditorCompletions } from '@shared/models/ace/completion.models';
import { widgetContextCompletions } from '@shared/models/ace/widget-completion.models';
import { WidgetResource } from '@shared/models/widget.models';
export enum HtmlContainerWidgetType {
PLAIN = 'PLAIN',
ANGULAR = 'ANGULAR'
}
export interface HtmlContainerWidgetSettings {
type: HtmlContainerWidgetType;
html: string;
css: string;
js: TbFunction;
resources: WidgetResource[];
}
export const htmlContainerDefaultSettings: HtmlContainerWidgetSettings = {
type: HtmlContainerWidgetType.PLAIN,
html: '',
css: '',
js: '',
resources: [],
};
export type WidgetContainerPlainFunction = (ctx: WidgetContext, container: HTMLElement) => void;
export type WidgetContainerAngularFunction = (ctx: WidgetContext) => void;
const containerFunctionCompletions: TbEditorCompletions = {
...{
ctx: {
meta: 'argument',
type: widgetContextCompletions.ctx.type,
description: widgetContextCompletions.ctx.description,
children: widgetContextCompletions.ctx.children
},
container: {
meta: 'argument',
type: 'HTMLElement',
description: 'Container element of the widget'
},
}
};
export const ContainerFunctionEditorCompleter = new TbEditorCompleter(containerFunctionCompletions);

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

@ -0,0 +1,98 @@
<!--
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.
-->
<ng-container [formGroup]="htmlContainerSettingsForm">
<div class="tb-form-panel no-padding no-border relative h-full">
<div class="flex flex-row items-center gap-4">
<div class="tb-form-panel-title" translate>widgets.html-container.container-type</div>
<tb-toggle-select formControlName="type">
<tb-toggle-option [value]="HtmlContainerWidgetType.PLAIN">{{ 'widgets.html-container.type-plain' | translate }}</tb-toggle-option>
<tb-toggle-option [value]="HtmlContainerWidgetType.ANGULAR">{{ 'widgets.html-container.type-angular' | translate }}</tb-toggle-option>
</tb-toggle-select>
</div>
<mat-tab-group [mat-stretch-tabs]="false" selectedIndex="3" class="flex-1">
<mat-tab #resourceTab="matTab">
<ng-template mat-tab-label>
<div [matBadge]="resourcesFormArray.length" [matBadgeHidden]="resourceTab.isActive || !resourcesFormArray.length"
matBadgeSize="small">{{ 'widgets.html-container.resources' | translate }}</div>
</ng-template>
<div class="flex flex-col gap-2 pt-4">
@if (resourcesFormArray.length) {
@for (resourceControl of resourcesControls; track resourceControl; let i = $index) {
<div class="tb-form-row no-border no-padding" [formGroup]="resourceControl">
<tb-resource-autocomplete class="flex-1"
formControlName="url"
inlineField
hideRequiredMarker required
[allowAutocomplete]="resourceControl.get('isModule').value && htmlContainerSettingsForm.get('type').value === HtmlContainerWidgetType.ANGULAR"
placeholder="{{ 'widget.resource-url' | translate }}">
</tb-resource-autocomplete>
@if (htmlContainerSettingsForm.get('type').value === HtmlContainerWidgetType.ANGULAR) {
<mat-checkbox formControlName="isModule">
{{ 'widget.resource-is-extension' | translate }}
</mat-checkbox>
}
<button mat-icon-button color="primary"
(click)="removeResource(i)"
matTooltip="{{'widget.remove-resource' | translate}}"
matTooltipPosition="above">
<mat-icon>delete</mat-icon>
</button>
</div>
}
} @else {
<span translate
class="tb-prompt flex items-center justify-center">widgets.html-container.no-resources</span>
}
<div>
<button mat-raised-button color="primary"
(click)="addResource()"
matTooltip="{{'widget.add-resource' | translate}}"
matTooltipPosition="above">
<span translate>action.add</span>
</button>
</div>
</div>
</mat-tab>
<mat-tab label="{{ (htmlContainerSettingsForm.get('type').value === HtmlContainerWidgetType.ANGULAR ? 'widgets.html-container.angular-html-template' : 'widgets.html-container.html') | translate }}">
<tb-html class="flex-1"
[fillHeight]="true"
formControlName="html"
label="{{ (htmlContainerSettingsForm.get('type').value === HtmlContainerWidgetType.ANGULAR ? 'widgets.html-container.angular-html-template' : 'widgets.html-container.html') | translate }}">
</tb-html>
</mat-tab>
<mat-tab label="{{ 'widgets.html-container.css' | translate }}">
<tb-css class="flex-1"
[fillHeight]="true"
formControlName="css"
label="{{ 'widgets.html-container.css' | translate }}">
</tb-css>
</mat-tab>
<mat-tab label="{{ 'widgets.html-container.java-script' | translate }}">
<tb-js-func class="flex-1"
[fillHeight]="true"
formControlName="js"
[globalVariables]="functionScopeVariables"
[editorCompleter]="containerFunctionEditorCompleter"
[functionArgs]="htmlContainerSettingsForm.get('type').value === HtmlContainerWidgetType.ANGULAR ? ['ctx'] : ['ctx', 'container']"
withModules
functionTitle="{{ 'widgets.html-container.js-function' | translate }}">
</tb-js-func>
</mat-tab>
</mat-tab-group>
</div>
</ng-container>

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

@ -0,0 +1,28 @@
/**
* 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.
*/
:host {
&.tb-html-container-settings {
height: 100%;
::ng-deep {
.mat-mdc-tab-body-wrapper {
position: relative;
top: 0;
flex: 1;
}
}
}
}

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

@ -0,0 +1,186 @@
///
/// 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.
///
import { Component, DestroyRef, forwardRef, HostBinding, Input, OnInit } from '@angular/core';
import { WidgetResource } from '@shared/models/widget.models';
import {
ControlValueAccessor,
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
UntypedFormArray,
UntypedFormBuilder,
UntypedFormControl,
UntypedFormGroup,
Validator,
Validators
} from '@angular/forms';
import {
ContainerFunctionEditorCompleter,
HtmlContainerWidgetSettings,
HtmlContainerWidgetType
} from '@home/components/widget/lib/html/html-container-widget.models';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { isJSResource } from '@shared/models/resource.models';
import { WidgetService } from '@core/http/widget.service';
@Component({
selector: 'tb-html-container-settings',
templateUrl: './html-container-settings.component.html',
styleUrls: ['./html-container-settings.component.scss'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => HtmlContainerSettingsComponent),
multi: true
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => HtmlContainerSettingsComponent),
multi: true,
}
],
standalone: false
})
export class HtmlContainerSettingsComponent implements OnInit, ControlValueAccessor, Validator {
HtmlContainerWidgetType = HtmlContainerWidgetType;
functionScopeVariables = this.widgetService.getWidgetScopeVariables();
containerFunctionEditorCompleter = ContainerFunctionEditorCompleter;
@HostBinding('class')
hostClass = 'tb-html-container-settings';
@Input()
disabled: boolean;
htmlContainerSettingsForm: UntypedFormGroup;
private modelValue: HtmlContainerWidgetSettings;
constructor(private fb: UntypedFormBuilder,
private widgetService: WidgetService,
private destroyRef: DestroyRef) {
}
get resourcesFormArray(): UntypedFormArray {
return this.htmlContainerSettingsForm.get('resources') as UntypedFormArray;
}
get resourcesControls(): UntypedFormGroup[] {
return this.resourcesFormArray.controls as UntypedFormGroup[];
}
ngOnInit(): void {
this.htmlContainerSettingsForm = this.fb.group({
type: [null, []],
html: [null, []],
css: [null, []],
js: [null, []],
resources: this.fb.array([])
});
this.htmlContainerSettingsForm.get('type').valueChanges.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe(() => this.updateResources());
this.htmlContainerSettingsForm.valueChanges.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe(() => {
this.updateModel();
});
}
registerOnChange(fn: any): void {
this.propagateChange = fn;
}
registerOnTouched(fn: any): void {
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
if (isDisabled) {
this.htmlContainerSettingsForm.disable({emitEvent: false});
} else {
this.htmlContainerSettingsForm.enable({emitEvent: false});
}
}
writeValue(value: HtmlContainerWidgetSettings): void {
this.modelValue = value;
this.htmlContainerSettingsForm.get('type').patchValue(value.type, {emitEvent: false});
this.htmlContainerSettingsForm.get('html').patchValue(value.html, {emitEvent: false});
this.htmlContainerSettingsForm.get('css').patchValue(value.css, {emitEvent: false});
this.htmlContainerSettingsForm.get('js').patchValue(value.js, {emitEvent: false});
this.resourcesFormArray.clear({emitEvent: false});
value.resources.forEach(r => {
this.resourcesFormArray.push(this.buildResourceFormGroup(r), {emitEvent: false});
});
}
validate(_c: UntypedFormControl) {
return this.htmlContainerSettingsForm.valid ? null : {
htmlContainerSettings: {
valid: false,
}
};
}
addResource() {
const newResource: WidgetResource = {
url: '',
isModule: false
};
this.resourcesFormArray.push(this.buildResourceFormGroup(newResource));
}
removeResource(index: number) {
this.resourcesFormArray.removeAt(index);
}
private propagateChange = (v: any) => { };
private updateModel() {
this.modelValue = this.htmlContainerSettingsForm.value;
this.propagateChange(this.modelValue);
}
private updateResources() {
if (this.htmlContainerSettingsForm.get('type').value === HtmlContainerWidgetType.PLAIN) {
const resources: WidgetResource[] = this.resourcesFormArray.value;
const filtered = resources.filter(r => !isJSResource(r.url));
let updated = filtered.length !== resources.length;
filtered.forEach((r) => {
if (r.isModule) {
r.isModule = false;
updated = true;
}
});
if (updated) {
this.resourcesFormArray.clear();
filtered.forEach(r => {
this.resourcesFormArray.push(this.buildResourceFormGroup(r));
});
}
}
}
private buildResourceFormGroup(resource: WidgetResource): UntypedFormGroup {
return this.fb.group({
url: [resource.url, [Validators.required]],
isModule: [resource.isModule]
});
}
}

9
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts

@ -267,6 +267,9 @@ import {
import {
ShapeFillStripeSettingsPanelComponent
} from '@home/components/widget/lib/settings/common/map/shape-fill-stripe-settings-panel.component';
import {
HtmlContainerSettingsComponent
} from '@home/components/widget/lib/settings/common/html/html-container-settings.component';
@NgModule({
declarations: [
@ -372,7 +375,8 @@ import {
DataKeysComponent,
DataKeyConfigDialogComponent,
DataKeyConfigComponent,
WidgetSettingsComponent
WidgetSettingsComponent,
HtmlContainerSettingsComponent
],
imports: [
CommonModule,
@ -453,7 +457,8 @@ import {
DataKeysComponent,
DataKeyConfigDialogComponent,
DataKeyConfigComponent,
WidgetSettingsComponent
WidgetSettingsComponent,
HtmlContainerSettingsComponent
],
providers: [
ColorSettingsComponentService,

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

@ -15,7 +15,7 @@
limitations under the License.
-->
<div [formGroup]="widgetSettingsFormGroup">
<div [formGroup]="widgetSettingsFormGroup" class="h-full">
<ng-container #definedSettingsContent></ng-container>
<div class="tb-settings-directive-error" *ngIf="definedDirectiveError">{{definedDirectiveError}}</div>
<tb-dynamic-form *ngIf="useDynamicForm()"

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

@ -0,0 +1,20 @@
<!--
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.
-->
<ng-container [formGroup]="htmlContainerWidgetSettingsForm">
<tb-html-container-settings formControlName="htmlContainerSettings"></tb-html-container-settings>
</ng-container>

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

@ -0,0 +1,65 @@
///
/// 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.
///
import { Component, HostBinding } from '@angular/core';
import { WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.models';
import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { htmlContainerDefaultSettings } from '@home/components/widget/lib/html/html-container-widget.models';
@Component({
selector: 'tb-html-container-widget-settings',
templateUrl: './html-container-widget-settings.component.html',
styleUrls: [],
standalone: false
})
export class HtmlContainerWidgetSettingsComponent extends WidgetSettingsComponent {
@HostBinding('height')
hostHeight = '100%';
htmlContainerWidgetSettingsForm: UntypedFormGroup;
constructor(protected store: Store<AppState>,
private fb: UntypedFormBuilder) {
super(store);
}
protected settingsForm(): UntypedFormGroup {
return this.htmlContainerWidgetSettingsForm;
}
protected defaultSettings(): WidgetSettings {
return htmlContainerDefaultSettings;
}
protected onSettingsSet(settings: WidgetSettings) {
this.htmlContainerWidgetSettingsForm = this.fb.group({
htmlContainerSettings: [settings.htmlContainerSettings, []]
});
}
protected prepareInputSettings(settings: WidgetSettings): WidgetSettings {
return {
htmlContainerSettings: settings
};
}
protected prepareOutputSettings(settings: any): WidgetSettings {
return settings.htmlContainerSettings;
}
}

9
ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts

@ -375,6 +375,9 @@ import {
ValueStepperWidgetSettingsComponent
} from '@home/components/widget/lib/settings/control/value-stepper-widget-settings.component';
import { MapWidgetSettingsComponent } from '@home/components/widget/lib/settings/map/map-widget-settings.component';
import {
HtmlContainerWidgetSettingsComponent
} from '@home/components/widget/lib/settings/html/html-container-widget-settings.component';
@NgModule({
declarations: [
@ -508,7 +511,8 @@ import { MapWidgetSettingsComponent } from '@home/components/widget/lib/settings
LabelValueCardWidgetSettingsComponent,
UnreadNotificationWidgetSettingsComponent,
ScadaSymbolWidgetSettingsComponent,
MapWidgetSettingsComponent
MapWidgetSettingsComponent,
HtmlContainerWidgetSettingsComponent
],
imports: [
CommonModule,
@ -647,7 +651,8 @@ import { MapWidgetSettingsComponent } from '@home/components/widget/lib/settings
LabelValueCardWidgetSettingsComponent,
UnreadNotificationWidgetSettingsComponent,
ScadaSymbolWidgetSettingsComponent,
MapWidgetSettingsComponent
MapWidgetSettingsComponent,
HtmlContainerWidgetSettingsComponent
]
})
export class WidgetSettingsModule {

7
ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts

@ -94,6 +94,7 @@ import {
SelectMapEntityPanelComponent
} from '@home/components/widget/lib/maps/panels/select-map-entity-panel.component';
import { MapTimelinePanelComponent } from '@home/components/widget/lib/maps/panels/map-timeline-panel.component';
import { HtmlContainerWidgetComponent } from '@home/components/widget/lib/html/html-container-widget.component';
@NgModule({
declarations: [
@ -151,7 +152,8 @@ import { MapTimelinePanelComponent } from '@home/components/widget/lib/maps/pane
ScadaSymbolWidgetComponent,
SelectMapEntityPanelComponent,
MapTimelinePanelComponent,
MapWidgetComponent
MapWidgetComponent,
HtmlContainerWidgetComponent
],
imports: [
CommonModule,
@ -214,7 +216,8 @@ import { MapTimelinePanelComponent } from '@home/components/widget/lib/maps/pane
UnreadNotificationWidgetComponent,
NotificationTypeFilterPanelComponent,
ScadaSymbolWidgetComponent,
MapWidgetComponent
MapWidgetComponent,
HtmlContainerWidgetComponent
],
providers: [
{provide: WIDGET_COMPONENTS_MODULE_TOKEN, useValue: WidgetComponentsModule},

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

@ -308,7 +308,7 @@
</mat-form-field>
</div>
</div>
<div *ngIf="displayAdvancedAppearance" style="height: 100%;" [formGroup]="advancedSettings">
<div *ngIf="displayAdvancedAppearance" class="flex-1" [formGroup]="advancedSettings">
<tb-widget-settings
[aliasController]="aliasController"
[callbacks]="widgetConfigCallbacks"

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

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

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

@ -9764,6 +9764,18 @@
"how-to-create-customer-and-assign-dashboard": "How to create Customer and assign Dashboard"
}
}
},
"html-container": {
"java-script": "JavaScript",
"js-function": "JavaScript function",
"html": "HTML",
"angular-html-template": "Angular HTML template",
"css": "CSS",
"container-type": "Container type",
"type-plain": "Plain HTML",
"type-angular": "Angular",
"resources": "Resources",
"no-resources": "No resources configured"
}
},
"color": {

Loading…
Cancel
Save