From cf58c36777fec9d60b6441634f44650e68852386 Mon Sep 17 00:00:00 2001 From: Maksym Tsymbarov Date: Wed, 3 Jun 2026 12:57:55 +0200 Subject: [PATCH 01/12] Fixed CVE-2026-44705 --- ui-ngx/yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ui-ngx/yarn.lock b/ui-ngx/yarn.lock index 6b5f48283b..6b9765e59c 100644 --- a/ui-ngx/yarn.lock +++ b/ui-ngx/yarn.lock @@ -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" From 26e05aeaf1c730197502226f7423528c254a1a91 Mon Sep 17 00:00:00 2001 From: Maksym Tsymbarov Date: Wed, 3 Jun 2026 12:58:26 +0200 Subject: [PATCH 02/12] Fixed CVE-2026-46625 --- ui-ngx/yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ui-ngx/yarn.lock b/ui-ngx/yarn.lock index 6b9765e59c..ed3de52ba4 100644 --- a/ui-ngx/yarn.lock +++ b/ui-ngx/yarn.lock @@ -7232,9 +7232,9 @@ js-beautify@1.15.4: nopt "^7.2.1" js-cookie@^3.0.5: - version "3.0.5" - resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-3.0.5.tgz#0b7e2fd0c01552c58ba86e0841f94dc2557dcdbc" - integrity sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw== + version "3.0.8" + resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-3.0.8.tgz#444e6f4b27a5d844594fef61c9d6bca5f0787688" + integrity sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw== js-tokens@^4.0.0: version "4.0.0" From ea7ff63c11e19b8ad7313a062807cea824b1c989 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Thu, 11 Jun 2026 14:34:37 +0300 Subject: [PATCH 03/12] Bump com.squareup.wire:wire-schema from 3.7.1 to 6.3.0 to fix CVE-2026-45799 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 9fb6c66d0f..a1fc5bff0e 100755 --- a/pom.xml +++ b/pom.xml @@ -136,7 +136,7 @@ 1.11.0 4.4 1.0.4TB - 3.7.1 + 6.3.0 10.1.3 3.7.4 4.0.2 From 166292f3ef36c6bbdf9a6754e2502e50de15affb Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Thu, 11 Jun 2026 14:35:48 +0300 Subject: [PATCH 04/12] Bump spring-boot from 3.5.14 to 3.5.15 and drop redundant postgresql and tomcat version overrides now provided by the BOM --- pom.xml | 26 +------------------------- 1 file changed, 1 insertion(+), 25 deletions(-) diff --git a/pom.xml b/pom.xml index a1fc5bff0e..ab5328ec7e 100755 --- a/pom.xml +++ b/pom.xml @@ -62,15 +62,13 @@ ${project.name} /var/log/${pkg.name} /usr/share/${pkg.name} - 3.5.14 + 3.5.15 3.5.13 3.18.0 - 42.7.11 4.1.134.Final - 10.1.55 2.4.0-b180830.0359 0.12.5 0.10 @@ -1015,23 +1013,6 @@ import - - - org.apache.tomcat.embed - tomcat-embed-core - ${tomcat.version} - - - org.apache.tomcat.embed - tomcat-embed-el - ${tomcat.version} - - - org.apache.tomcat.embed - tomcat-embed-websocket - ${tomcat.version} - - org.springframework.boot spring-boot-dependencies @@ -1369,11 +1350,6 @@ commons-lang3 ${commons-lang3.version} - - org.postgresql - postgresql - ${postgresql.version} - commons-io commons-io From 2bb249f86f41ab0ba6cd45ec50a0ac354564abc0 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Thu, 11 Jun 2026 16:34:59 +0300 Subject: [PATCH 05/12] Use wire-schema-jvm coordinate so wire 6.3.0 resolves under Maven --- common/data/pom.xml | 5 ++++- pom.xml | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/common/data/pom.xml b/common/data/pom.xml index a9ca14a2d6..450a0826b9 100644 --- a/common/data/pom.xml +++ b/common/data/pom.xml @@ -84,8 +84,11 @@ test + com.squareup.wire - wire-schema + wire-schema-jvm org.thingsboard diff --git a/pom.xml b/pom.xml index ab5328ec7e..73e61092f3 100755 --- a/pom.xml +++ b/pom.xml @@ -1770,7 +1770,7 @@ com.squareup.wire - wire-schema + wire-schema-jvm ${wire-schema.version} From fadad06407b9ba3946a15a42a59237646f715efc Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Fri, 12 Jun 2026 12:39:05 +0300 Subject: [PATCH 06/12] Clarify spring-boot-test and wire-schema pin comments per PR review --- pom.xml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/pom.xml b/pom.xml index 73e61092f3..2a56db5e8f 100755 --- a/pom.xml +++ b/pom.xml @@ -63,9 +63,11 @@ /var/log/${pkg.name} /usr/share/${pkg.name} 3.5.15 - + 3.5.13 3.18.0 4.1.134.Final @@ -134,7 +136,7 @@ 1.11.0 4.4 1.0.4TB - 6.3.0 + 6.3.0 10.1.3 3.7.4 4.0.2 From 4e2bffa559e1dd117239525b3288a1e4a74272a2 Mon Sep 17 00:00:00 2001 From: dpinkevych Date: Fri, 12 Jun 2026 08:54:02 +0300 Subject: [PATCH 07/12] add help pages for html container --- .../html-container-settings.component.html | 3 +- .../html_container/container_angular_fn.md | 135 ++++++++++++ .../lib/html_container/container_plain_fn.md | 124 +++++++++++ .../examples/angular_drawer_css.md | 201 ++++++++++++++++++ .../examples/angular_drawer_html.md | 66 ++++++ .../examples/angular_drawer_js.md | 100 +++++++++ .../examples/angular_split_css.md | 142 +++++++++++++ .../examples/angular_split_html.md | 38 ++++ .../examples/angular_split_js.md | 73 +++++++ .../examples/angular_tabs_css.md | 66 ++++++ .../examples/angular_tabs_html.md | 22 ++ .../examples/angular_tabs_js.md | 35 +++ .../examples/plain_analytics_css.md | 38 ++++ .../examples/plain_analytics_html.md | 36 ++++ .../examples/plain_analytics_js.md | 185 ++++++++++++++++ .../examples/plain_analytics_resources.md | 12 ++ .../examples/plain_kanban_css.md | 45 ++++ .../examples/plain_kanban_html.md | 10 + .../examples/plain_kanban_js.md | 89 ++++++++ .../examples/plain_kanban_resources.md | 12 ++ 20 files changed, 1431 insertions(+), 1 deletion(-) create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/html_container/container_angular_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/html_container/container_plain_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_drawer_css.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_drawer_html.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_drawer_js.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_split_css.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_split_html.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_split_js.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_tabs_css.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_tabs_html.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_tabs_js.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_analytics_css.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_analytics_html.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_analytics_js.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_analytics_resources.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_kanban_css.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_kanban_html.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_kanban_js.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_kanban_resources.md diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.html index a72f28e70e..4f891f1617 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.html @@ -122,7 +122,8 @@ [editorCompleter]="containerFunctionEditorCompleter" [functionArgs]="htmlContainerSettingsForm.get('type').value === HtmlContainerWidgetType.ANGULAR ? ['ctx'] : ['ctx', 'container']" withModules - functionTitle="{{ 'widgets.html-container.js-function' | translate }}"> + functionTitle="{{ 'widgets.html-container.js-function' | translate }}" + [helpId]="htmlContainerSettingsForm.get('type').value === HtmlContainerWidgetType.ANGULAR ? 'widget/lib/html_container/container_angular_fn' : 'widget/lib/html_container/container_plain_fn'"> diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/html_container/container_angular_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/container_angular_fn.md new file mode 100644 index 0000000000..2f739b9770 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/container_angular_fn.md @@ -0,0 +1,135 @@ +#### Container function + +
+
+ +*function (ctx): void* + +A JavaScript function used to expose data and handlers to the Angular template by assigning them to this. Reference them in the template without the this prefix. + +**Parameters:** + +
    +
  • ctx: WidgetContext - A reference to WidgetContext that has all necessary API + and data used by widget instance.
    + Provides access to send RPC, switch dashboard states, create data subscriptions, read settings and access platform services. +
  • +
+ +**Returns:** + +This function does not return any value. Expose values and handlers to the template by assigning them to this. + +
+ +##### Notes + +
    +
  • There is no container parameter in Angular mode. Access the root element via ctx.$container[0] or event.currentTarget.closest(...) from a handler.
  • +
  • Define helper functions as arrow functions to preserve this.
  • +
  • Call ctx.detectChanges() after changing template-bound values so the template re-renders.
  • +
  • This widget does not bind widget datasources automatically. There is no ctx.data populated from the widget configuration. To read live data create an explicit subscription via ctx.subscriptionApi.createSubscription(...) and release it from ctx.registerDestroyCallback(...).
  • +
+ +
+ +##### Examples + +###### Resizable split master–detail with embedded dashboard state + +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +###### Slide-over device detail with two-way RPC + +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +###### Composite dashboard with tab navigation + +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+
diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/html_container/container_plain_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/container_plain_fn.md new file mode 100644 index 0000000000..5874a6e657 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/container_plain_fn.md @@ -0,0 +1,124 @@ +#### Container function + +
+
+ +*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:** + +
    +
  • ctx: WidgetContext - A reference to WidgetContext that has all necessary API + and data used by widget instance.
    + Provides access to send RPC, switch dashboard states, create data subscriptions, read settings and access platform services. +
  • +
  • container: HTMLElement - The widget's content element, a native DOM node (<div class="tb-absolute-fill">) wrapping the rendered template.
    + Query it with container.querySelector(...). +
  • +
+ +**Returns:** + +This function does not return any value. + +
+ +##### Notes + +
    +
  • This widget does not bind widget datasources automatically. There is no ctx.data populated from the widget configuration. To read live data create an explicit subscription via ctx.subscriptionApi.createSubscription(...).
  • +
  • Release every subscription, timer and global event listener you create from ctx.registerDestroyCallback(...) so the widget cleans up correctly.
  • +
+ +
+ +##### Examples + +###### Kanban board with a CDN drag-and-drop library + +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +###### Visitor analytics with a custom range and working hours + +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+
diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_drawer_css.md b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_drawer_css.md new file mode 100644 index 0000000000..8bd68b8197 --- /dev/null +++ b/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} +``` + +
+
diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_drawer_html.md b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_drawer_html.md new file mode 100644 index 0000000000..eeabbf94ae --- /dev/null +++ b/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;"} + + +

Devices

+
+ + + Device + {{ d.name }} + + + People + {{ d.lastValue }} + + + + +
+
+ + +
+
+
+
Device
+

{{ selected.name }}

+
+ +
+ +
+
People{{ selected.lastValue }}
+
Entity type{{ selected.entityType }}
+
Last update{{ selected.lastSeen }}
+
+ +
+
+ + +
+
+ + +
+
+ +
+

Alarms

+
+
+
+

Charts

+
+
+
+
+
+{:copy-code} +``` + +
+
diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_drawer_js.md b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_drawer_js.md new file mode 100644 index 0000000000..78c8ecae0b --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_drawer_js.md @@ -0,0 +1,100 @@ +#### JavaScript function of slide-over device detail + +```javascript +{:code-style="max-height: 400px;"} +this.ctx = ctx; +this.devices = []; +this.columns = ['name', 'people']; +this.selected = null; +this.selectedId = null; +this.drawerOpen = false; + +this.openDevice = (d) => { + this.selected = d; + this.selectedId = d.entityId; + this.drawerOpen = true; + ctx.stateController.updateState(null, { + entityId: { entityType: d.entityType, id: d.entityId }, + entityName: d.name, + }); + ctx.detectChanges(); +}; + +this.rebooting = false; +this.resetting = false; + +this.reboot = () => { + if (!this.selected || this.rebooting) return; + this.rebooting = true; + ctx.detectChanges(); + ctx.http.post('/api/rpc/twoway/' + this.selected.entityId, { method: 'reboot', params: {} }).subscribe({ + error: (e) => { + console.error(e); + this.rebooting = false; + ctx.detectChanges(); + }, + complete: () => { + this.rebooting = false; + ctx.detectChanges(); + }, + }); +}; + +this.resetCounter = () => { + if (!this.selected || this.resetting) return; + this.resetting = true; + ctx.detectChanges(); + ctx.http.post('/api/rpc/twoway/' + this.selected.entityId, { method: 'resetCounter', params: {} }).subscribe({ + error: (e) => { + console.error(e); + this.resetting = false; + ctx.detectChanges(); + }, + complete: () => { + this.resetting = false; + ctx.detectChanges(); + }, + }); +}; + +const subOpts = { + type: 'latest', + datasources: [ + { + type: 'entity', + entityFilter: { type: 'deviceType', deviceTypes: ['peopleCount'], deviceNameFilter: '', resolveMultiple: true }, + dataKeys: [{ type: 'timeseries', name: 'peopleCount', settings: {} }], + }, + ], + callbacks: { + onDataUpdated: (subscription) => { + this.devices = (subscription.data || []).map((entry) => { + const ds = entry.datasource; + const last = entry.data && entry.data.length ? entry.data[entry.data.length - 1] : null; + return { + entityId: ds.entityId, + entityType: ds.entityType, + name: ds.entityName, + lastValue: last ? last[1] : '—', + lastSeen: last ? new Date(last[0]).toLocaleString() : '—', + }; + }); + ctx.detectChanges(); + }, + onDataUpdateError: (subscription, e) => console.error(e), + }, +}; + +let subscriptionId = null; +ctx.subscriptionApi.createSubscription(subOpts, true).subscribe((subscription) => { + subscriptionId = subscription.id; +}); + +ctx.registerDestroyCallback(() => { + if (subscriptionId != null) ctx.subscriptionApi.removeSubscription(subscriptionId); +}); +{:copy-code} +``` + +
+
diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_split_css.md b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_split_css.md new file mode 100644 index 0000000000..2297dfcf75 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_split_css.md @@ -0,0 +1,142 @@ +#### CSS of resizable split master–detail + +```css +{:code-style="max-height: 400px;"} +.split { + display: flex; + height: 100%; + box-sizing: border-box; + font-family: 'Inter', 'Roboto', system-ui, sans-serif; + color: #0f172a; + background: #fff; + border-radius: 16px; + box-shadow: 0 4px 24px rgba(15, 23, 42, 0.06); + overflow: hidden; +} + +.split__list { + flex: 0 0 auto; + min-width: 160px; + overflow: auto; + border-right: 1px solid #eef1f6; +} + +.split__title { + margin: 0; + padding: 16px; + font-family: 'Roboto', sans-serif; + font-size: 20px; + font-weight: 500; +} + +.split__items { + list-style: none; + margin: 0; + padding: 0; +} + +.split__item { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 12px 16px; + cursor: pointer; + border-bottom: 1px solid #f1f5f9; + transition: background 0.15s ease; +} + +.split__item:hover { + background: #f8fafc; +} + +.split__item.is-active { + background: rgba(47, 107, 255, 0.08); + box-shadow: inset 3px 0 0 #2f6bff; +} + +.split__value { + font-weight: 700; + color: #475569; +} + +.split__divider { + flex: 0 0 12px; + display: flex; + align-items: center; + justify-content: center; + cursor: col-resize; + background: #eef1f6; + transition: background 0.15s ease; +} + +.split__divider:hover { + background: #e2e8f0; +} + +.split__divider::before { + content: ''; + width: 4px; + height: 32px; + background-image: radial-gradient(circle, #94a3b8 1.2px, transparent 1.4px); + background-position: center; + background-size: 4px 6px; + background-repeat: repeat-y; +} + +.split--dragging { + user-select: none; +} + +.split--dragging .split__list, +.split--dragging .split__detail { + pointer-events: none; +} + +.split__detail { + flex: 1 1 0; + min-width: 220px; + display: flex; + flex-direction: column; + gap: 16px; + padding: 20px; + overflow: auto; +} + +.split__eyebrow { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #94a3b8; +} + +.split__detail-title { + margin: 2px 0 0; + font-size: 20px; + font-weight: 800; +} + +.split__state { + flex: 1 1 auto; + min-height: 260px; + border: 1px solid #eef1f6; + border-radius: 14px; + overflow: hidden; +} + +.split__state tb-dashboard-state { + display: block; + width: 100%; + height: 100%; +} + +.split__empty { + margin: auto; + color: #94a3b8; + font-size: 14px; +} +{:copy-code} +``` + +
+
diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_split_html.md b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_split_html.md new file mode 100644 index 0000000000..0d4394015d --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_split_html.md @@ -0,0 +1,38 @@ +#### Angular template of resizable split master–detail + +```html +{:code-style="max-height: 400px;"} +
+ + +
+ +
+ +
+
Device
+

{{ selected.name }}

+
+
+ +
+
+
Select a device
+
+
+{:copy-code} +``` + +
+
diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_split_js.md b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_split_js.md new file mode 100644 index 0000000000..7c101ae20e --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_split_js.md @@ -0,0 +1,73 @@ +#### JavaScript function of resizable split master–detail + +```javascript +{:code-style="max-height: 400px;"} +this.ctx = ctx; +this.devices = []; +this.selected = null; +this.selectedId = null; +this.listWidth = 280; + +this.select = (d) => { + this.selected = d; + this.selectedId = d.entityId; + ctx.stateController.updateState(null, { + entityId: { entityType: d.entityType, id: d.entityId }, + entityName: d.name, + }); + ctx.detectChanges(); +}; + +this.startDrag = (event) => { + event.preventDefault(); + const root = event.currentTarget.closest('.split'); + const left = root.getBoundingClientRect().left; + root.classList.add('split--dragging'); + const onMove = (e) => { + this.listWidth = Math.max(160, Math.min(e.clientX - left, root.clientWidth - 220)); + ctx.detectChanges(); + }; + const onUp = () => { + root.classList.remove('split--dragging'); + window.removeEventListener('mousemove', onMove); + window.removeEventListener('mouseup', onUp); + }; + window.addEventListener('mousemove', onMove); + window.addEventListener('mouseup', onUp); +}; + +const subOpts = { + type: 'latest', + datasources: [ + { + type: 'entity', + entityFilter: { type: 'deviceType', deviceTypes: ['peopleCount'], deviceNameFilter: '', resolveMultiple: true }, + dataKeys: [{ type: 'timeseries', name: 'peopleCount', settings: {} }], + }, + ], + callbacks: { + onDataUpdated: (subscription) => { + this.devices = (subscription.data || []).map((entry) => { + const ds = entry.datasource; + const last = entry.data && entry.data.length ? entry.data[entry.data.length - 1] : null; + return { entityId: ds.entityId, entityType: ds.entityType, name: ds.entityName, lastValue: last ? last[1] : '—' }; + }); + ctx.detectChanges(); + }, + onDataUpdateError: (subscription, e) => console.error(e), + }, +}; + +let subscriptionId = null; +ctx.subscriptionApi.createSubscription(subOpts, true).subscribe((subscription) => { + subscriptionId = subscription.id; +}); + +ctx.registerDestroyCallback(() => { + if (subscriptionId != null) ctx.subscriptionApi.removeSubscription(subscriptionId); +}); +{:copy-code} +``` + +
+
diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_tabs_css.md b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_tabs_css.md new file mode 100644 index 0000000000..ffb7e4833f --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_tabs_css.md @@ -0,0 +1,66 @@ +#### CSS of composite dashboard with tab navigation + +```css +{:code-style="max-height: 400px;"} +.composite { + display: flex; + flex-direction: column; + gap: 12px; + height: 100%; + box-sizing: border-box; + padding: 12px; + font-family: 'Roboto', system-ui, sans-serif; +} + +.tabs { + display: inline-flex; + gap: 4px; + padding: 4px; + align-self: flex-start; + background: #eef1f6; + border-radius: 10px; +} + +.tab { + border: 0; + background: transparent; + padding: 8px 16px; + border-radius: 7px; + font-size: 13px; + font-weight: 600; + color: #5b6472; + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; +} + +.tab:hover { + color: #1f2733; +} + +.tab.is-active { + background: #fff; + color: #2f6bff; + box-shadow: 0 1px 2px rgba(16, 24, 40, 0.08); +} + +.container { + flex-grow: 1; + width: 100%; + display: flex; + padding: 0; + border: 1px solid #e6eaf0; + border-radius: 12px; + overflow: hidden; +} + +.state { + flex-grow: 1; + width: 100%; + height: 100%; + padding: 0; +} +{:copy-code} +``` + +
+
diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_tabs_html.md b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_tabs_html.md new file mode 100644 index 0000000000..7e1648c4ef --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_tabs_html.md @@ -0,0 +1,22 @@ +#### Angular template of composite dashboard with tab navigation + +```html +{:code-style="max-height: 400px;"} +
+ +
+ +
+
+{:copy-code} +``` + +
+
diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_tabs_js.md b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_tabs_js.md new file mode 100644 index 0000000000..1eddefb304 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/angular_tabs_js.md @@ -0,0 +1,35 @@ +#### JavaScript function of composite dashboard with tab navigation + +```javascript +{:code-style="max-height: 400px;"} +this.ctx = ctx; + +this.tabs = [ + { id: 'assets', label: 'Assets', stateId: 'assetsState' }, + { id: 'devices', label: 'Devices', stateId: 'devicesState' }, + { id: 'customers', label: 'Customers', stateId: 'customersState' }, +]; + +const apply = (subState) => { + const tab = this.tabs.find((t) => t.id === subState) || this.tabs[0]; + this.activeTab = tab.id; + this.stateId = tab.stateId; +}; + +apply(ctx.stateController.getStateParams().subState); + +this.selectTab = (subState) => ctx.stateController.updateState(null, { subState }); + +const stateSub = ctx.stateController.dashboardCtrl.dashboardCtx.stateChanged.subscribe(() => { + apply(ctx.stateController.getStateParams().subState); + ctx.detectChanges(); +}); + +ctx.registerDestroyCallback(() => { + stateSub.unsubscribe(); +}); +{:copy-code} +``` + +
+
diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_analytics_css.md b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_analytics_css.md new file mode 100644 index 0000000000..11fb741d53 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_analytics_css.md @@ -0,0 +1,38 @@ +#### CSS of visitor analytics + +```css +{:code-style="max-height: 400px;"} +.vc { + display: flex; + flex-direction: column; + gap: 16px; + height: 100%; + box-sizing: border-box; + padding: 20px; + background: #fff; + font-family: 'Inter', 'Roboto', system-ui, sans-serif; + color: #0f172a; +} +.vc__head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap; } +.vc__title { margin: 0; font-family: 'Roboto', sans-serif; font-size: 20px; font-weight: 500; } +.vc__sub { font-size: 12px; font-weight: 600; color: #94a3b8; } +.vc__controls { display: flex; gap: 10px; flex-wrap: wrap; } +.vc__field { display: flex; flex-direction: column; gap: 4px; font-size: 11px; font-weight: 600; color: #64748b; } +.vc__field input { padding: 7px 10px; border: 1px solid #d8dee9; border-radius: 10px; font-size: 13px; color: #0f172a; } +.vc__field input:focus { outline: none; border-color: #2f6bff; box-shadow: 0 0 0 3px rgba(47, 107, 255, 0.15); } +.vc__kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; } +.vc-kpi { display: flex; flex-direction: column; gap: 6px; padding: 14px 16px; border-radius: 14px; background: #f8fafc; border: 1px solid #e6ebf2; } +.vc-kpi__label { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: #94a3b8; } +.vc-kpi__value { font-size: 26px; font-weight: 800; line-height: 1; color: #1e293b; } +.vc__card { flex: 1; min-height: 240px; display: flex; flex-direction: column; padding: 16px; border: 1px solid #e6ebf2; border-radius: 14px; } +.vc__card-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; } +.vc__card-title { font-size: 13px; font-weight: 700; color: #334155; } +.vc__back { padding: 6px 12px; border: 1px solid #d8dee9; border-radius: 8px; background: #fff; color: #2f6bff; font-size: 12px; font-weight: 600; cursor: pointer; } +.vc__back[hidden] { display: none; } +.vc__back:hover { background: #f0f5ff; border-color: #2f6bff; } +.vc__chart { flex: 1; min-height: 200px; } +{:copy-code} +``` + +
+
diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_analytics_html.md b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_analytics_html.md new file mode 100644 index 0000000000..90d664ed5c --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_analytics_html.md @@ -0,0 +1,36 @@ +#### HTML code of visitor analytics + +```html +{:code-style="max-height: 400px;"} +
+
+
+

Visitor analytics

+ all peopleCount devices +
+
+ + + + +
+
+
+
Total visitors
+
Busiest day
+
Peak hour
+
Daily average
+
+
+
+ Visitors by day + +
+
+
+
+{:copy-code} +``` + +
+
diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_analytics_js.md b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_analytics_js.md new file mode 100644 index 0000000000..1fc2b85b82 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_analytics_js.md @@ -0,0 +1,185 @@ +#### JavaScript function of visitor analytics + +```javascript +{:code-style="max-height: 400px;"} +// Plain HTML mode: `container` is the widget's DOM; ECharts comes from the CDN. +const echarts = window.echarts; +const el = (sel) => container.querySelector(sel); +const pad = (n) => String(n).padStart(2, '0'); +const isoDate = (d) => d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()); +const dayLabel = (d) => d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); +const minutes = (v) => { + const [h, m] = (v || '0:0').split(':').map(Number); + return h * 60 + m; +}; +const palette = ['#2563eb', '#10b981', '#f59e0b', '#8b5cf6', '#ef4444', '#0ea5e9']; + +const chart = echarts.init(el('#vcChart')); +chart.setOption({ + tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } }, + legend: { top: 0 }, + grid: { left: 8, right: 16, top: 40, bottom: 8, containLabel: true }, + xAxis: { type: 'category', data: [], axisLabel: { hideOverlap: true } }, + yAxis: { type: 'value' }, + series: [], +}); +const resizeObserver = new window.ResizeObserver(() => chart.resize()); +resizeObserver.observe(el('#vcChart')); + +let lastEntries = []; +let mode = 'daily'; +let selectedDate = null; +let dailyDates = []; // iso date per daily x-index, for drill-down + +// Click a day → drill into its hourly breakdown. +chart.on('click', (p) => { + if (mode !== 'daily' || p.dataIndex == null || dailyDates[p.dataIndex] == null) return; + selectedDate = dailyDates[p.dataIndex]; + mode = 'hourly'; + el('#vcBack').hidden = false; + recompute(); +}); +el('#vcBack').addEventListener('click', () => { + mode = 'daily'; + selectedDate = null; + el('#vcBack').hidden = true; + recompute(); +}); + +// One stacked series per device, summing values into the given bucket index. +function buildSeries(labelCount, bucketOf) { + return lastEntries.map((entry, k) => { + const arr = new Array(labelCount).fill(0); + (entry.data || []).forEach(([ts, value]) => { + const i = bucketOf(new Date(Number(ts))); + if (i != null && i >= 0) arr[i] += Number(value); + }); + return { name: entry.datasource.entityName, type: 'bar', stack: 'v', data: arr, itemStyle: { color: palette[k % palette.length] } }; + }); +} + +function recompute() { + const open = minutes(el('#vcOpen').value); + const close = minutes(el('#vcClose').value); + const inHours = (d) => { + const m = d.getHours() * 60 + d.getMinutes(); + return m >= open && m < close; + }; + + // KPIs over the whole range (per day, summed across devices). + const start = new Date(el('#vcStart').value + 'T00:00:00'); + const end = new Date(el('#vcEnd').value + 'T00:00:00'); + const dayIndex = {}; + const dayLabels = []; + dailyDates = []; + for (let t = new Date(start); t <= end; t.setDate(t.getDate() + 1)) { + dayIndex[isoDate(t)] = dayLabels.length; + dailyDates.push(isoDate(t)); + dayLabels.push(dayLabel(t)); + } + const dayTotals = dayLabels.map(() => 0); + const perHour = {}; + let total = 0; + lastEntries.forEach((entry) => + (entry.data || []).forEach(([ts, value]) => { + const d = new Date(Number(ts)); + if (!inHours(d)) return; + const i = dayIndex[isoDate(d)]; + if (i === undefined) return; + const v = Number(value); + dayTotals[i] += v; + perHour[d.getHours()] = (perHour[d.getHours()] || 0) + v; + total += v; + }), + ); + el('#vcTotal').textContent = total; + el('#vcPeakDay').textContent = total ? dayLabels[dayTotals.indexOf(Math.max(...dayTotals))] : '—'; + const ph = Object.keys(perHour).sort((a, b) => perHour[b] - perHour[a])[0]; + el('#vcPeakHour').textContent = ph != null ? ph + ':00' : '—'; + el('#vcAvg').textContent = Math.round(total / (dayTotals.filter((x) => x > 0).length || 1)); + + // Chart: daily (stacked by device) or the hourly breakdown of the selected day. + if (mode === 'daily') { + el('#vcChartTitle').textContent = 'Visitors by day'; + const series = buildSeries(dayLabels.length, (d) => (inHours(d) ? dayIndex[isoDate(d)] : null)); + chart.setOption({ xAxis: { data: dayLabels }, series }, { replaceMerge: ['series'] }); + } else { + const h0 = Math.floor(open / 60); + const h1 = Math.ceil(close / 60); + const labels = []; + for (let h = h0; h < h1; h++) labels.push(pad(h) + ':00'); + el('#vcChartTitle').textContent = 'Visitors by hour — ' + dayLabel(new Date(selectedDate + 'T00:00:00')); + const series = buildSeries(labels.length, (d) => + isoDate(d) === selectedDate && inHours(d) ? d.getHours() - h0 : null, + ); + chart.setOption({ xAxis: { data: labels }, series }, { replaceMerge: ['series'] }); + } +} + +// (Re)subscribe for the chosen date range (a fixed history window). +let subscriptionId = null; +function subscribe() { + if (subscriptionId != null) { + ctx.subscriptionApi.removeSubscription(subscriptionId); + subscriptionId = null; + } + const startTimeMs = new Date(el('#vcStart').value + 'T00:00:00').getTime(); + const endTimeMs = new Date(el('#vcEnd').value + 'T23:59:59').getTime(); + const subOpts = { + type: 'timeseries', + useDashboardTimewindow: false, + timeWindowConfig: { + selectedTab: 1, + history: { historyType: 1, fixedTimewindow: { startTimeMs, endTimeMs } }, + aggregation: { type: 'NONE', limit: 50000 }, + }, + datasources: [ + { + type: 'entity', + entityFilter: { type: 'deviceType', deviceTypes: ['peopleCount'], deviceNameFilter: '', resolveMultiple: true }, + dataKeys: [{ type: 'timeseries', name: 'peopleCount', settings: {} }], + }, + ], + callbacks: { + onDataUpdated: (subscription) => { + lastEntries = subscription.data || []; + recompute(); + }, + onDataUpdateError: (subscription, e) => console.error(e), + }, + }; + ctx.subscriptionApi.createSubscription(subOpts, true).subscribe((subscription) => { + subscriptionId = subscription.id; + }); +} + +// Changing the range resets the drill-down and re-subscribes; hours just re-filter. +function onRangeChange() { + mode = 'daily'; + selectedDate = null; + el('#vcBack').hidden = true; + subscribe(); +} + +// Defaults: the current month (1st → today). +const now = new Date(); +el('#vcEnd').value = isoDate(now); +el('#vcStart').value = isoDate(new Date(now.getFullYear(), now.getMonth(), 1)); + +el('#vcStart').addEventListener('change', onRangeChange); +el('#vcEnd').addEventListener('change', onRangeChange); +el('#vcOpen').addEventListener('change', recompute); +el('#vcClose').addEventListener('change', recompute); +subscribe(); + +// The JS re-runs on every reload — release the subscription, observer and chart. +ctx.registerDestroyCallback(() => { + if (subscriptionId != null) ctx.subscriptionApi.removeSubscription(subscriptionId); + resizeObserver.disconnect(); + chart.dispose(); +}); +{:copy-code} +``` + +
+
diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_analytics_resources.md b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_analytics_resources.md new file mode 100644 index 0000000000..0b3ae21dd1 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_analytics_resources.md @@ -0,0 +1,12 @@ +#### Resources of visitor analytics + +Add the following JavaScript URL under the Resources tab. It loads ECharts before your function runs and exposes the echarts global (accessed as window.echarts): + +```text +{:code-style="max-height: 400px;"} +https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js +{:copy-code} +``` + +
+
diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_kanban_css.md b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_kanban_css.md new file mode 100644 index 0000000000..6f9a857f19 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_kanban_css.md @@ -0,0 +1,45 @@ +#### CSS of kanban board + +```css +{:code-style="max-height: 400px;"} +.kanban { + display: flex; + gap: 12px; + height: 100%; + box-sizing: border-box; + padding: 16px; + overflow-x: auto; + background: #f9fafb; + font-family: 'Inter', 'Roboto', system-ui, sans-serif; + color: #111827; + font-size: 13px; +} +.kanban__col { flex: 1 1 0; min-width: 220px; display: flex; flex-direction: column; background: #f3f4f6; border-radius: 10px; } +.kanban__head { display: flex; align-items: center; gap: 8px; padding: 12px 14px 8px; } +.kanban__title { font-size: 12px; font-weight: 600; color: #374151; } +.kanban__count { margin-left: auto; min-width: 20px; height: 20px; padding: 0 6px; display: inline-flex; align-items: center; justify-content: center; border-radius: 999px; background: #e5e7eb; color: #6b7280; font-size: 11px; font-weight: 600; } +.kanban__cards { display: flex; flex-direction: column; gap: 8px; padding: 4px 8px 12px; flex: 1; min-height: 24px; overflow-y: auto; } +.kanban__card { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 8px; + cursor: grab; + transition: border-color 0.12s ease, box-shadow 0.12s ease; +} +.kanban__card:hover { border-color: #d1d5db; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); } +.kanban__name { font-weight: 500; color: #111827; } +.kanban__dot { width: 8px; height: 8px; border-radius: 50%; flex: none; } +.kanban__dot--idle { background: #9ca3af; } +.kanban__dot--active { background: #22c55e; } +.kanban__dot--maintenance { background: #f59e0b; } +.kanban__card.sortable-ghost { opacity: 0; } +.kanban__card.sortable-chosen { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); } +{:copy-code} +``` + +
+
diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_kanban_html.md b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_kanban_html.md new file mode 100644 index 0000000000..d2ed3008b4 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_kanban_html.md @@ -0,0 +1,10 @@ +#### HTML code of kanban board + +```html +{:code-style="max-height: 400px;"} +
+{:copy-code} +``` + +
+
diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_kanban_js.md b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_kanban_js.md new file mode 100644 index 0000000000..6b04e2551e --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_kanban_js.md @@ -0,0 +1,89 @@ +#### JavaScript function of kanban board + +```javascript +{:code-style="max-height: 400px;"} +// Plain HTML mode: `container` is the widget's DOM. Build a status board and, +// on drop, persist the card's new column (status) back to the device. +const Sortable = window.Sortable; // loaded from the CDN added under Resources +const statuses = ['idle', 'active', 'maintenance']; +const labels = { idle: 'Idle', active: 'Active', maintenance: 'Maintenance' }; + +const board = container.querySelector('#board'); +let devices = []; // { id, entityType, name, status } + +// Escape untrusted strings (e.g. device names) before putting them in innerHTML. +const esc = (s) => + String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); + +function render() { + board.innerHTML = statuses + .map((s) => { + const items = devices.filter((d) => d.status === s); + const cards = items + .map( + (d) => + `
${esc(d.name)}
`, + ) + .join(''); + return `
+
${labels[s]}${items.length}
+
${cards}
+
`; + }) + .join(''); + + // Make each column a Sortable list; cards share one group so they move between columns. + board.querySelectorAll('.kanban__cards').forEach((listEl) => { + new Sortable(listEl, { + group: 'kanban', + animation: 150, + onAdd: (evt) => { + const id = evt.item.dataset.id; + const entityType = evt.item.dataset.type; + const status = evt.to.dataset.status; // destination column + const dev = devices.find((d) => d.id === id); + if (dev) dev.status = status; + ctx.attributeService + .saveEntityAttributes({ id, entityType }, 'SERVER_SCOPE', [{ key: 'status', value: status }]) + .subscribe(); + }, + }); + }); +} + +// Live data: devices of type "machine" with their "status" attribute. +const subOpts = { + type: 'latest', + datasources: [ + { + type: 'entity', + entityFilter: { type: 'deviceType', deviceTypes: ['machine'], deviceNameFilter: '', resolveMultiple: true }, + dataKeys: [{ type: 'attribute', name: 'status', settings: {} }], + }, + ], + callbacks: { + onDataUpdated: (subscription) => { + devices = (subscription.data || []).map((entry) => { + const ds = entry.datasource; + const last = entry.data && entry.data.length ? entry.data[entry.data.length - 1] : null; + const status = last ? String(last[1]) : 'idle'; + return { id: ds.entityId, entityType: ds.entityType, name: ds.entityName, status: statuses.includes(status) ? status : 'idle' }; + }); + render(); + }, + onDataUpdateError: (subscription, e) => console.error(e), + }, +}; +// The JS re-runs on every reload — capture the id and remove the subscription on destroy. +let subscriptionId = null; +ctx.subscriptionApi.createSubscription(subOpts, true).subscribe((subscription) => { + subscriptionId = subscription.id; +}); +ctx.registerDestroyCallback(() => { + if (subscriptionId != null) ctx.subscriptionApi.removeSubscription(subscriptionId); +}); +{:copy-code} +``` + +
+
diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_kanban_resources.md b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_kanban_resources.md new file mode 100644 index 0000000000..a85de9604a --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/html_container/examples/plain_kanban_resources.md @@ -0,0 +1,12 @@ +#### Resources of kanban board + +Add the following JavaScript URL under the Resources tab. It loads SortableJS before your function runs and exposes the Sortable global (accessed as window.Sortable): + +```text +{:code-style="max-height: 400px;"} +https://cdn.jsdelivr.net/npm/sortablejs@1/Sortable.min.js +{:copy-code} +``` + +
+
From 8ddfab317ac3f4605d2d5c69ab1a87ed80072b00 Mon Sep 17 00:00:00 2001 From: Maksym Tsymbarov Date: Tue, 16 Jun 2026 12:36:24 +0200 Subject: [PATCH 08/12] Fixed Advanced Widget Style Editor rendering (#15731) * Fixed Advanced Widget Style Editor rendering * Moved styles to class for styles consistency --- .../home/components/widget/widget-config.component.html | 4 ++-- .../home/components/widget/widget-config.component.scss | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html index a321ec81a5..641755b810 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html @@ -29,7 +29,7 @@
-
+
@@ -227,7 +227,7 @@
-
+
{{basicModeDirectiveError}}
diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.scss b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.scss index af7e27f481..8df18b25bc 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.scss +++ b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.scss @@ -43,7 +43,9 @@ } .tb-widget-config-content { & > .mat-content { - height: 100%; + &.tb-widget-config-fill { + height: 100%; + } padding-top: 8px; @media #{$mat-xs} { padding-left: 8px; From 0d476bfaba6fecd3cc105444e0b10bdb9d704506 Mon Sep 17 00:00:00 2001 From: Maksym Tsymbarov Date: Fri, 29 May 2026 12:07:26 +0200 Subject: [PATCH 09/12] Fixed Switch Control widget hanging on one-way persistent RPC --- ui-ngx/src/app/core/api/widget-subscription.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ui-ngx/src/app/core/api/widget-subscription.ts b/ui-ngx/src/app/core/api/widget-subscription.ts index 213b327892..8b513a4da2 100644 --- a/ui-ngx/src/app/core/api/widget-subscription.ts +++ b/ui-ngx/src/app/core/api/widget-subscription.ts @@ -871,7 +871,8 @@ export class WidgetSubscription implements IWidgetSubscription { return timer(initialTimeout, pollingInterval).pipe( switchMap(() => this.ctx.deviceService.getPersistedRpc(response.rpcId, true)), filter(persistentRespons => - persistentRespons.status !== RpcStatus.DELIVERED && persistentRespons.status !== RpcStatus.QUEUED), + (oneWayElseTwoWay && persistentRespons.status === RpcStatus.DELIVERED) || + (persistentRespons.status !== RpcStatus.DELIVERED && persistentRespons.status !== RpcStatus.QUEUED)), switchMap(persistentResponse => { if ([RpcStatus.TIMEOUT, RpcStatus.EXPIRED].includes(persistentResponse.status)) { return throwError(() => ({status: 504})); From 0072074b0eeab53f528de62f0f92cbeeb1dd14d4 Mon Sep 17 00:00:00 2001 From: Maksym Tsymbarov Date: Thu, 28 May 2026 16:54:16 +0200 Subject: [PATCH 10/12] Fix no widgets shown in mobile view after state transition from divided layout --- .../home/components/dashboard-page/dashboard-page.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts index 024282e501..bf8832ad74 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts @@ -346,7 +346,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) { From a94381b7027d831b65b5c4af10cdd42fb33836e2 Mon Sep 17 00:00:00 2001 From: Maksym Tsymbarov Date: Tue, 16 Jun 2026 13:04:08 +0200 Subject: [PATCH 11/12] Fixed change Password button stays disabled when form is filled programmatically (#15701) * Fixed change Password button stays disabled when form is filled programmatically * Minor fixes --- .../src/app/modules/home/pages/security/security.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui-ngx/src/app/modules/home/pages/security/security.component.html b/ui-ngx/src/app/modules/home/pages/security/security.component.html index f2bdd657a8..4d0551f3b9 100644 --- a/ui-ngx/src/app/modules/home/pages/security/security.component.html +++ b/ui-ngx/src/app/modules/home/pages/security/security.component.html @@ -134,7 +134,7 @@
From 9c610c4707841559f183500f312d0a51b7c0e92b Mon Sep 17 00:00:00 2001 From: Maksym Tsymbarov Date: Tue, 16 Jun 2026 15:50:45 +0200 Subject: [PATCH 12/12] Fixed sync highlight overlay scroll on Safari in string-pattern-autocomplete (#15754) * Fixed sync highlight overlay scroll on Safari in string-pattern-autocomplete * Minor adjustment * Code improvements --- ...string-pattern-autocomplete.component.html | 2 +- .../string-pattern-autocomplete.component.ts | 49 ++++++++++++++++--- 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/ui-ngx/src/app/shared/components/string-pattern-autocomplete.component.html b/ui-ngx/src/app/shared/components/string-pattern-autocomplete.component.html index ef2dec83fe..b5d7678ba2 100644 --- a/ui-ngx/src/app/shared/components/string-pattern-autocomplete.component.html +++ b/ui-ngx/src/app/shared/components/string-pattern-autocomplete.component.html @@ -24,7 +24,7 @@ [formControl]="selectionFormControl" [placeholder]="placeholderText" (focusin)="onFocus()" - (scroll)="onInputScroll($event)"> + (focusout)="onBlur()"> @if (predefinedValuesButton) {