diff --git a/common/data/pom.xml b/common/data/pom.xml index 0647e706eb..7a521ac9d5 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 01e1dfe9ee..8642ce998a 100755 --- a/pom.xml +++ b/pom.xml @@ -63,15 +63,15 @@ /var/log/${pkg.name} /usr/share/${pkg.name} 4.4.0 - 3.5.14 - + 3.5.15 + 3.5.13 3.18.0 - 42.7.11 - 4.1.133.Final - 10.1.55 + 4.1.134.Final 2.4.0-b180830.0359 0.12.5 0.10 @@ -137,7 +137,7 @@ 1.11.0 4.4 1.0.4TB - 3.7.1 + 6.3.0 10.1.3 3.7.4 4.0.2 @@ -1018,23 +1018,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 @@ -1388,11 +1371,6 @@ commons-lang3 ${commons-lang3.version} - - org.postgresql - postgresql - ${postgresql.version} - commons-io commons-io @@ -1813,7 +1791,7 @@ com.squareup.wire - wire-schema + wire-schema-jvm ${wire-schema.version} diff --git a/ui-ngx/src/app/core/api/widget-subscription.ts b/ui-ngx/src/app/core/api/widget-subscription.ts index 2194835dfe..1243a69ec8 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})); 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 3948af744f..7b9ef5e5e3 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 @@ -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) { 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/app/modules/home/components/widget/widget-config.component.html b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html index e194b48ae9..1ed4d777ef 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 @@ -36,7 +36,7 @@ } -
+
@@ -243,7 +243,7 @@ } @else {
@if (hasBasicModeDirective) { -
+
@if (basicModeDirectiveError) {
{{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; 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 548160ec32..cb9785897f 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 @@ -174,7 +174,7 @@
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 1f10bc4974..caef81b9d0 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 @@ -26,7 +26,7 @@ [formControl]="selectionFormControl" [placeholder]="placeholderText" (focusin)="onFocus()" - (scroll)="onInputScroll($event)"> + (focusout)="onBlur()"> @if (predefinedValuesButton) { + + +
+
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} +``` + +
+
diff --git a/ui-ngx/yarn.lock b/ui-ngx/yarn.lock index 6b349cfd2f..e301f795fc 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" @@ -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"