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})); 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 bf46d0b603..5875afe1a1 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 @@ -336,7 +336,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/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 @@ 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:** + + + +**Returns:** + +This function does not return any value. Expose values and handlers to the template by assigning them to this. + +
+ +##### Notes + + + +
+ +##### 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:** + + + +**Returns:** + +This function does not return any value. + +
+ +##### Notes + + + +
+ +##### 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} +``` + +
+