Browse Source

Merge remote-tracking branch 'upstream/rc' into merge/160626/rc

# Conflicts:
#	pom.xml
#	ui-ngx/src/app/modules/home/components/widget/widget-config.component.html
pull/15792/head
Vladyslav_Prykhodko 2 months ago
parent
commit
94ac17c4ea
  1. 5
      common/data/pom.xml
  2. 40
      pom.xml
  3. 3
      ui-ngx/src/app/core/api/widget-subscription.ts
  4. 2
      ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts
  5. 3
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.html
  6. 4
      ui-ngx/src/app/modules/home/components/widget/widget-config.component.html
  7. 4
      ui-ngx/src/app/modules/home/components/widget/widget-config.component.scss
  8. 2
      ui-ngx/src/app/modules/home/pages/security/security.component.html
  9. 2
      ui-ngx/src/app/shared/components/string-pattern-autocomplete.component.html
  10. 49
      ui-ngx/src/app/shared/components/string-pattern-autocomplete.component.ts
  11. 135
      ui-ngx/src/assets/help/en_US/widget/lib/html_container/container_angular_fn.md
  12. 124
      ui-ngx/src/assets/help/en_US/widget/lib/html_container/container_plain_fn.md
  13. 201
      ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_drawer_css.md
  14. 66
      ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_drawer_html.md
  15. 100
      ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_drawer_js.md
  16. 142
      ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_split_css.md
  17. 38
      ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_split_html.md
  18. 73
      ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_split_js.md
  19. 66
      ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_tabs_css.md
  20. 22
      ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_tabs_html.md
  21. 35
      ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_tabs_js.md
  22. 38
      ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_analytics_css.md
  23. 36
      ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_analytics_html.md
  24. 185
      ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_analytics_js.md
  25. 12
      ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_analytics_resources.md
  26. 45
      ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_kanban_css.md
  27. 10
      ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_kanban_html.md
  28. 89
      ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_kanban_js.md
  29. 12
      ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_kanban_resources.md
  30. 12
      ui-ngx/yarn.lock

5
common/data/pom.xml

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

40
pom.xml

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

12
ui-ngx/yarn.lock

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

Loading…
Cancel
Save