diff --git a/application/src/main/data/json/system/widget_bundles/cards.json b/application/src/main/data/json/system/widget_bundles/cards.json index 1c96d7168e..8f115009d0 100644 --- a/application/src/main/data/json/system/widget_bundles/cards.json +++ b/application/src/main/data/json/system/widget_bundles/cards.json @@ -21,22 +21,6 @@ "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Random\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"var value = prevValue + Math.random() * 100 - 50;\\nvar multiplier = Math.pow(10, 2 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < -1000) {\\n\\tvalue = -1000;\\n} else if (value > 1000) {\\n\\tvalue = 1000;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{},\"title\":\"Attributes card\"}" } }, - { - "alias": "entities_table", - "name": "Entities table", - "descriptor": { - "type": "latest", - "sizeX": 7.5, - "sizeY": 6.5, - "resources": [], - "templateHtml": "\n", - "templateCss": "", - "controllerScript": "self.onInit = function() {\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.entitiesTableWidget.onDataUpdated();\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n dataKeysOptional: true\n };\n}\n\nself.actionSources = function() {\n return {\n 'actionCellButton': {\n name: 'widget-action.action-cell-button',\n multiple: true\n },\n 'rowClick': {\n name: 'widget-action.row-click',\n multiple: false\n },\n 'rowDoubleClick': {\n name: 'widget-action.row-double-click',\n multiple: false\n }\n };\n}\n\nself.onDestroy = function() {\n}\n", - "settingsSchema": "{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"EntitiesTableSettings\",\n \"properties\": {\n \"entitiesTitle\": {\n \"title\": \"Entities table title\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"enableSearch\": {\n \"title\": \"Enable entities search\",\n \"type\": \"boolean\",\n \"default\": true\n },\n \"enableSelectColumnDisplay\": {\n \"title\": \"Enable select columns to display\",\n \"type\": \"boolean\",\n \"default\": true\n },\n \"displayEntityName\": {\n \"title\": \"Display entity name column\",\n \"type\": \"boolean\",\n \"default\": true\n },\n \"entityNameColumnTitle\": {\n \"title\": \"Entity name column title\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"displayEntityLabel\": {\n \"title\": \"Display entity label column\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"entityLabelColumnTitle\": {\n \"title\": \"Entity label column title\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"displayEntityType\": {\n \"title\": \"Display entity type column\",\n \"type\": \"boolean\",\n \"default\": true\n },\n \"displayPagination\": {\n \"title\": \"Display pagination\",\n \"type\": \"boolean\",\n \"default\": true\n },\n \"defaultPageSize\": {\n \"title\": \"Default page size\",\n \"type\": \"number\",\n \"default\": 10\n },\n \"defaultSortOrder\": {\n \"title\": \"Default sort order\",\n \"type\": \"string\",\n \"default\": \"entityName\"\n }\n },\n \"required\": []\n },\n \"form\": [\n \"entitiesTitle\",\n \"enableSearch\",\n \"enableSelectColumnDisplay\",\n \"displayEntityName\",\n \"entityNameColumnTitle\",\n \"displayEntityLabel\",\n \"entityLabelColumnTitle\",\n \"displayEntityType\",\n \"displayPagination\",\n \"defaultPageSize\",\n \"defaultSortOrder\"\n ]\n}", - "dataKeySettingsSchema": "{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"DataKeySettings\",\n \"properties\": {\n \"columnWidth\": {\n \"title\": \"Column width (px or %)\",\n \"type\": \"string\",\n \"default\": \"0px\"\n },\n \"useCellStyleFunction\": {\n \"title\": \"Use cell style function\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"cellStyleFunction\": {\n \"title\": \"Cell style function: f(value)\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"useCellContentFunction\": {\n \"title\": \"Use cell content function\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"cellContentFunction\": {\n \"title\": \"Cell content function: f(value, entity, ctx)\",\n \"type\": \"string\",\n \"default\": \"\"\n }\n },\n \"required\": []\n },\n \"form\": [\n \"columnWidth\",\n \"useCellStyleFunction\",\n {\n \"key\": \"cellStyleFunction\",\n \"type\": \"javascript\"\n },\n \"useCellContentFunction\",\n {\n \"key\": \"cellContentFunction\",\n \"type\": \"javascript\"\n }\n ]\n}", - "defaultConfig": "{\"timewindow\":{\"realtime\":{\"interval\":1000,\"timewindowMs\":86400000},\"aggregation\":{\"type\":\"NONE\",\"limit\":200}},\"showTitle\":true,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"4px\",\"settings\":{\"enableSelection\":true,\"enableSearch\":true,\"displayDetails\":true,\"displayPagination\":true,\"defaultPageSize\":10,\"defaultSortOrder\":\"entityName\",\"displayEntityName\":true,\"displayEntityType\":true},\"title\":\"Entities table\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400,\"padding\":\"5px 10px 5px 10px\"},\"useDashboardTimewindow\":false,\"showLegend\":false,\"datasources\":[{\"type\":\"function\",\"name\":\"Simulated\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Sin\",\"color\":\"#2196f3\",\"settings\":{\"columnWidth\":\"0px\",\"useCellStyleFunction\":false,\"cellStyleFunction\":\"\",\"useCellContentFunction\":false,\"cellContentFunction\":\"\"},\"_hash\":0.472295003170325,\"funcBody\":\"return Math.round(1000*Math.sin(time/5000));\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Cos\",\"color\":\"#4caf50\",\"settings\":{\"columnWidth\":\"0px\",\"useCellStyleFunction\":false,\"cellStyleFunction\":\"\",\"useCellContentFunction\":false,\"cellContentFunction\":\"\"},\"_hash\":0.8926244886945558,\"funcBody\":\"return Math.round(1000*Math.cos(time/5000));\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Random\",\"color\":\"#f44336\",\"settings\":{\"columnWidth\":\"0px\",\"useCellStyleFunction\":false,\"cellStyleFunction\":\"\",\"useCellContentFunction\":false,\"cellContentFunction\":\"\"},\"_hash\":0.6401141393938932,\"funcBody\":\"var value = prevValue + Math.random() * 100 - 50;\\nvar multiplier = Math.pow(10, 2 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < -1000) {\\n\\tvalue = -1000;\\n} else if (value > 1000) {\\n\\tvalue = 1000;\\n}\\nreturn value;\"}]}]}" - } - }, { "alias": "html_card", "name": "HTML Card", @@ -132,6 +116,22 @@ "dataKeySettingsSchema": "{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"DataKeySettings\",\n \"properties\": {},\n \"required\": []\n },\n \"form\": []\n}", "defaultConfig": "{\"timewindow\":{\"realtime\":{\"interval\":1000,\"timewindowMs\":86400000},\"aggregation\":{\"type\":\"NONE\",\"limit\":200}},\"showTitle\":true,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"4px\",\"settings\":{\"nodeRelationQueryFunction\":\"/**\\n\\n// Function should return relations query object for current node used to fetch entity children.\\n// Function can return 'default' string value. In this case default relations query will be used.\\n\\n// The following example code will construct simple relations query that will fetch relations of type 'Contains'\\n// from the current entity.\\n\\nvar entity = nodeCtx.entity;\\nvar query = {\\n parameters: {\\n rootId: entity.id.id,\\n rootType: entity.id.entityType,\\n direction: types.entitySearchDirection.from,\\n relationTypeGroup: \\\"COMMON\\\",\\n maxLevel: 1\\n },\\n filters: [{\\n relationType: \\\"Contains\\\",\\n entityTypes: []\\n }]\\n};\\nreturn query;\\n\\n**/\\n\",\"nodeHasChildrenFunction\":\"/**\\n\\n// Function should return boolean value indicating whether current node has children (whether it can be expanded).\\n\\n// The following example code will restrict entities hierarchy expansion up to third level.\\n\\nreturn nodeCtx.level <= 2;\\n\\n// The next example code will restrict entities expansion according to the value of example 'nodeHasChildren' attribute.\\n\\nvar data = nodeCtx.data;\\nif (data.hasOwnProperty('nodeHasChildren') && data['nodeHasChildren'] !== null) {\\n return data['nodeHasChildren'] === 'true';\\n} else {\\n return true;\\n}\\n \\n**/\\n \",\"nodeTextFunction\":\"/**\\n\\n// Function should return text (can be HTML code) for the current node.\\n\\n// The following example code will generate node text consisting of entity name and temperature if temperature value is present in entity attributes/timeseries.\\n\\nvar data = nodeCtx.data;\\nvar entity = nodeCtx.entity;\\nvar text = entity.name;\\nif (data.hasOwnProperty('temperature') && data['temperature'] !== null) {\\n text += \\\" \\\"+ data['temperature'] +\\\" °C\\\";\\n}\\nreturn text;\\n\\n**/\",\"nodeIconFunction\":\"/** \\n\\n// Function should return node icon info object.\\n// Resulting object should contain either 'materialIcon' or 'iconUrl' property. \\n// Where:\\n - 'materialIcon' - name of the material icon to be used from the Material Icons Library (https://material.io/tools/icons);\\n - 'iconUrl' - url of the external image to be used as node icon.\\n// Function can return 'default' string value. In this case default icons according to entity type will be used.\\n\\n// The following example code shows how to use external image for devices which name starts with 'Test' and use \\n// default icons for the rest of entities.\\n\\nvar entity = nodeCtx.entity;\\nif (entity.id.entityType === 'DEVICE' && entity.name.startsWith('Test')) {\\n return {iconUrl: 'https://avatars1.githubusercontent.com/u/14793288?v=4&s=117'};\\n} else {\\n return 'default';\\n}\\n \\n**/\",\"nodeDisabledFunction\":\"/**\\n\\n// Function should return boolean value indicating whether current node should be disabled (not selectable).\\n\\n// The following example code will disable current node according to the value of example 'nodeDisabled' attribute.\\n\\nvar data = nodeCtx.data;\\nif (data.hasOwnProperty('nodeDisabled') && data['nodeDisabled'] !== null) {\\n return data['nodeDisabled'] === 'true';\\n} else {\\n return false;\\n}\\n \\n**/\\n\",\"nodesSortFunction\":\"/**\\n\\n// This function is used to sort nodes of the same level. Function should compare two nodes and return \\n// integer value: \\n// - less than 0 - sort nodeCtx1 to an index lower than nodeCtx2\\n// - 0 - leave nodeCtx1 and nodeCtx2 unchanged with respect to each other\\n// - greater than 0 - sort nodeCtx2 to an index lower than nodeCtx1\\n\\n// The following example code will sort entities first by entity type in alphabetical order then\\n// by entity name in alphabetical order.\\n\\nvar result = nodeCtx1.entity.id.entityType.localeCompare(nodeCtx2.entity.id.entityType);\\nif (result === 0) {\\n result = nodeCtx1.entity.name.localeCompare(nodeCtx2.entity.name);\\n}\\nreturn result;\\n \\n**/\",\"nodeOpenedFunction\":\"/**\\n\\n// Function should return boolean value indicating whether current node should be opened (expanded) when it first loaded.\\n\\n// The following example code will open by default nodes up to third level.\\n\\nreturn nodeCtx.level <= 2;\\n\\n**/\\n \"},\"title\":\"Entities hierarchy\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400,\"padding\":\"5px 10px 5px 10px\"},\"useDashboardTimewindow\":false,\"showLegend\":false,\"datasources\":[{\"type\":\"function\",\"name\":\"Simulated\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Sin\",\"color\":\"#2196f3\",\"settings\":{\"columnWidth\":\"0px\",\"useCellStyleFunction\":false,\"cellStyleFunction\":\"\",\"useCellContentFunction\":false,\"cellContentFunction\":\"\"},\"_hash\":0.472295003170325,\"funcBody\":\"return Math.round(1000*Math.sin(time/5000));\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Cos\",\"color\":\"#4caf50\",\"settings\":{\"columnWidth\":\"0px\",\"useCellStyleFunction\":false,\"cellStyleFunction\":\"\",\"useCellContentFunction\":false,\"cellContentFunction\":\"\"},\"_hash\":0.8926244886945558,\"funcBody\":\"return Math.round(1000*Math.cos(time/5000));\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Random\",\"color\":\"#f44336\",\"settings\":{\"columnWidth\":\"0px\",\"useCellStyleFunction\":false,\"cellStyleFunction\":\"\",\"useCellContentFunction\":false,\"cellContentFunction\":\"\"},\"_hash\":0.6401141393938932,\"funcBody\":\"var value = prevValue + Math.random() * 100 - 50;\\nvar multiplier = Math.pow(10, 2 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < -1000) {\\n\\tvalue = -1000;\\n} else if (value > 1000) {\\n\\tvalue = 1000;\\n}\\nreturn value;\"}]}],\"widgetStyle\":{},\"actions\":{}}" } + }, + { + "alias": "entities_table", + "name": "Entities table", + "descriptor": { + "type": "latest", + "sizeX": 7.5, + "sizeY": 6.5, + "resources": [], + "templateHtml": "\n", + "templateCss": "", + "controllerScript": "self.onInit = function() {\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.entitiesTableWidget.onDataUpdated();\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n hasDataPageLink: true,\n dataKeysOptional: true\n };\n}\n\nself.actionSources = function() {\n return {\n 'actionCellButton': {\n name: 'widget-action.action-cell-button',\n multiple: true\n },\n 'rowClick': {\n name: 'widget-action.row-click',\n multiple: false\n },\n 'rowDoubleClick': {\n name: 'widget-action.row-double-click',\n multiple: false\n }\n };\n}\n\nself.onDestroy = function() {\n}\n", + "settingsSchema": "{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"EntitiesTableSettings\",\n \"properties\": {\n \"entitiesTitle\": {\n \"title\": \"Entities table title\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"enableSearch\": {\n \"title\": \"Enable entities search\",\n \"type\": \"boolean\",\n \"default\": true\n },\n \"enableSelectColumnDisplay\": {\n \"title\": \"Enable select columns to display\",\n \"type\": \"boolean\",\n \"default\": true\n },\n \"displayEntityName\": {\n \"title\": \"Display entity name column\",\n \"type\": \"boolean\",\n \"default\": true\n },\n \"entityNameColumnTitle\": {\n \"title\": \"Entity name column title\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"displayEntityLabel\": {\n \"title\": \"Display entity label column\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"entityLabelColumnTitle\": {\n \"title\": \"Entity label column title\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"displayEntityType\": {\n \"title\": \"Display entity type column\",\n \"type\": \"boolean\",\n \"default\": true\n },\n \"displayPagination\": {\n \"title\": \"Display pagination\",\n \"type\": \"boolean\",\n \"default\": true\n },\n \"defaultPageSize\": {\n \"title\": \"Default page size\",\n \"type\": \"number\",\n \"default\": 10\n },\n \"defaultSortOrder\": {\n \"title\": \"Default sort order\",\n \"type\": \"string\",\n \"default\": \"entityName\"\n }\n },\n \"required\": []\n },\n \"form\": [\n \"entitiesTitle\",\n \"enableSearch\",\n \"enableSelectColumnDisplay\",\n \"displayEntityName\",\n \"entityNameColumnTitle\",\n \"displayEntityLabel\",\n \"entityLabelColumnTitle\",\n \"displayEntityType\",\n \"displayPagination\",\n \"defaultPageSize\",\n \"defaultSortOrder\"\n ]\n}", + "dataKeySettingsSchema": "{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"DataKeySettings\",\n \"properties\": {\n \"columnWidth\": {\n \"title\": \"Column width (px or %)\",\n \"type\": \"string\",\n \"default\": \"0px\"\n },\n \"useCellStyleFunction\": {\n \"title\": \"Use cell style function\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"cellStyleFunction\": {\n \"title\": \"Cell style function: f(value)\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"useCellContentFunction\": {\n \"title\": \"Use cell content function\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"cellContentFunction\": {\n \"title\": \"Cell content function: f(value, entity, ctx)\",\n \"type\": \"string\",\n \"default\": \"\"\n }\n },\n \"required\": []\n },\n \"form\": [\n \"columnWidth\",\n \"useCellStyleFunction\",\n {\n \"key\": \"cellStyleFunction\",\n \"type\": \"javascript\"\n },\n \"useCellContentFunction\",\n {\n \"key\": \"cellContentFunction\",\n \"type\": \"javascript\"\n }\n ]\n}", + "defaultConfig": "{\"timewindow\":{\"realtime\":{\"interval\":1000,\"timewindowMs\":86400000},\"aggregation\":{\"type\":\"NONE\",\"limit\":200}},\"showTitle\":true,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"4px\",\"settings\":{\"enableSelection\":true,\"enableSearch\":true,\"displayDetails\":true,\"displayPagination\":true,\"defaultPageSize\":10,\"defaultSortOrder\":\"entityName\",\"displayEntityName\":true,\"displayEntityType\":true},\"title\":\"Entities table\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400,\"padding\":\"5px 10px 5px 10px\"},\"useDashboardTimewindow\":false,\"showLegend\":false,\"datasources\":[{\"type\":\"function\",\"name\":\"Simulated\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Sin\",\"color\":\"#2196f3\",\"settings\":{\"columnWidth\":\"0px\",\"useCellStyleFunction\":false,\"cellStyleFunction\":\"\",\"useCellContentFunction\":false,\"cellContentFunction\":\"\"},\"_hash\":0.472295003170325,\"funcBody\":\"return Math.round(1000*Math.sin(time/5000));\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Cos\",\"color\":\"#4caf50\",\"settings\":{\"columnWidth\":\"0px\",\"useCellStyleFunction\":false,\"cellStyleFunction\":\"\",\"useCellContentFunction\":false,\"cellContentFunction\":\"\"},\"_hash\":0.8926244886945558,\"funcBody\":\"return Math.round(1000*Math.cos(time/5000));\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Random\",\"color\":\"#f44336\",\"settings\":{\"columnWidth\":\"0px\",\"useCellStyleFunction\":false,\"cellStyleFunction\":\"\",\"useCellContentFunction\":false,\"cellContentFunction\":\"\"},\"_hash\":0.6401141393938932,\"funcBody\":\"var value = prevValue + Math.random() * 100 - 50;\\nvar multiplier = Math.pow(10, 2 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < -1000) {\\n\\tvalue = -1000;\\n} else if (value > 1000) {\\n\\tvalue = 1000;\\n}\\nreturn value;\"}]}]}" + } } ] } \ No newline at end of file diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbEntityDataSubscriptionService.java b/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbEntityDataSubscriptionService.java index d9e8c043c5..4fd46195d0 100644 --- a/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbEntityDataSubscriptionService.java +++ b/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbEntityDataSubscriptionService.java @@ -5,7 +5,7 @@ * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, @@ -128,7 +128,6 @@ public class DefaultTbEntityDataSubscriptionService implements TbEntityDataSubsc serviceId = serviceInfoProvider.getServiceId(); wsCallBackExecutor = Executors.newSingleThreadExecutor(ThingsBoardThreadFactory.forName("ws-entity-sub-callback")); tsInSqlDB = databaseTsType.equalsIgnoreCase("sql") || databaseTsType.equalsIgnoreCase("timescale"); - } @PreDestroy diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbLocalSubscriptionService.java b/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbLocalSubscriptionService.java index ee30fefe9a..dbd92ae2a0 100644 --- a/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbLocalSubscriptionService.java +++ b/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbLocalSubscriptionService.java @@ -5,7 +5,7 @@ * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/TbEntityDataSubCtx.java b/application/src/main/java/org/thingsboard/server/service/subscription/TbEntityDataSubCtx.java index 5f695d14de..98ba5c954f 100644 --- a/application/src/main/java/org/thingsboard/server/service/subscription/TbEntityDataSubCtx.java +++ b/application/src/main/java/org/thingsboard/server/service/subscription/TbEntityDataSubCtx.java @@ -1,3 +1,18 @@ +/** + * Copyright © 2016-2020 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package org.thingsboard.server.service.subscription; import lombok.Data; diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/TbTimeseriesSubscription.java b/application/src/main/java/org/thingsboard/server/service/subscription/TbTimeseriesSubscription.java index ee4bd2c8a0..371fd23c5c 100644 --- a/application/src/main/java/org/thingsboard/server/service/subscription/TbTimeseriesSubscription.java +++ b/application/src/main/java/org/thingsboard/server/service/subscription/TbTimeseriesSubscription.java @@ -5,7 +5,7 @@ * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseWebsocketApiTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseWebsocketApiTest.java index 939ea18ab1..8fb6f59fd1 100644 --- a/application/src/test/java/org/thingsboard/server/controller/BaseWebsocketApiTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/BaseWebsocketApiTest.java @@ -5,7 +5,7 @@ * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, @@ -213,13 +213,16 @@ public class BaseWebsocketApiTest extends AbstractWebsocketTest { cmd = new EntityDataCmd(1, edq, null, latestCmd, null); + wrapper = new TelemetryPluginCmdsWrapper(); wrapper.setEntityDataCmds(Collections.singletonList(cmd)); wsClient.send(mapper.writeValueAsString(wrapper)); msg = wsClient.waitForReply(); update = mapper.readValue(msg, EntityDataUpdate.class); + Assert.assertEquals(1, update.getCmdId()); + pageData = update.getData(); Assert.assertNotNull(pageData); Assert.assertEquals(1, pageData.getData().size()); @@ -243,6 +246,7 @@ public class BaseWebsocketApiTest extends AbstractWebsocketTest { Assert.assertNotNull(eData.get(0).getLatest().get(EntityKeyType.TIME_SERIES)); tsValue = eData.get(0).getLatest().get(EntityKeyType.TIME_SERIES).get("temperature"); Assert.assertEquals(new TsValue(dataPoint2.getTs(), dataPoint2.getValueAsString()), tsValue); + } } diff --git a/application/src/test/java/org/thingsboard/server/controller/TbTestWebSocketClient.java b/application/src/test/java/org/thingsboard/server/controller/TbTestWebSocketClient.java index 62dfdbb107..cf1c31c851 100644 --- a/application/src/test/java/org/thingsboard/server/controller/TbTestWebSocketClient.java +++ b/application/src/test/java/org/thingsboard/server/controller/TbTestWebSocketClient.java @@ -5,7 +5,7 @@ * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/query/EntityKeyMapping.java b/dao/src/main/java/org/thingsboard/server/dao/sql/query/EntityKeyMapping.java index 6fa9daf01f..b7cb331237 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/query/EntityKeyMapping.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/query/EntityKeyMapping.java @@ -48,6 +48,7 @@ public class EntityKeyMapping { static { entityFieldColumnMap.put("createdTime", "id"); + entityFieldColumnMap.put("entityType", "entity_type"); entityFieldColumnMap.put("name", "name"); entityFieldColumnMap.put("type", "type"); entityFieldColumnMap.put("label", "label"); diff --git a/ui-ngx/src/app/core/api/alias-controller.ts b/ui-ngx/src/app/core/api/alias-controller.ts index e0afe96c42..f0beea751e 100644 --- a/ui-ngx/src/app/core/api/alias-controller.ts +++ b/ui-ngx/src/app/core/api/alias-controller.ts @@ -22,8 +22,8 @@ import { EntityService } from '@core/http/entity.service'; import { UtilsService } from '@core/services/utils.service'; import { AliasFilterType, EntityAliases } from '@shared/models/alias.models'; import { EntityInfo } from '@shared/models/entity.models'; -import { map } from 'rxjs/operators'; -import { defaultEntityDataPageLink } from '@shared/models/query/query.models'; +import { map, mergeMap } from 'rxjs/operators'; +import { createDefaultEntityDataPageLink, defaultEntityDataPageLink } from '@shared/models/query/query.models'; export class AliasController implements IAliasController { @@ -169,7 +169,24 @@ export class AliasController implements IAliasController { } } - private resolveDatasource(datasource: Datasource, isSingle?: boolean): Observable> { + resolveSingleEntityInfo(aliasId: string): Observable { + return this.getAliasInfo(aliasId).pipe( + mergeMap((aliasInfo) => { + if (aliasInfo.resolveMultiple) { + if (aliasInfo.entityFilter) { + return this.entityService.findSingleEntityInfoByEntityFilter(aliasInfo.entityFilter, + {ignoreLoading: true, ignoreErrors: true}); + } else { + return of(null); + } + } else { + return of(aliasInfo.currentEntity); + } + }) + ); + } + + private resolveDatasource(datasource: Datasource, isSingle?: boolean): Observable { if (datasource.type === DatasourceType.entity) { if (datasource.entityAliasId) { return this.getAliasInfo(datasource.entityAliasId).pipe( @@ -200,14 +217,14 @@ export class AliasController implements IAliasController { datasources.push(newDatasource); } return datasources;*/ - return [newDatasource]; + return newDatasource; } else { if (aliasInfo.stateEntity) { newDatasource = deepClone(datasource); newDatasource.unresolvedStateEntity = true; - return [newDatasource]; + return newDatasource; } else { - return []; + return null; // throw new Error('Unable to resolve datasource.'); } } @@ -232,13 +249,13 @@ export class AliasController implements IAliasController { entityType: entity.entityType } }; - return [datasource]; + return datasource; } else { if (aliasInfo.stateEntity) { datasource.unresolvedStateEntity = true; - return [datasource]; + return datasource; } else { - return []; + return null; // throw new Error('Unable to resolve datasource.'); } } @@ -248,10 +265,10 @@ export class AliasController implements IAliasController { } else { datasource.aliasName = datasource.entityName; datasource.name = datasource.entityName; - return of([datasource]); + return of(datasource); } } else { - return of([datasource]); + return of(datasource); } } @@ -354,18 +371,14 @@ export class AliasController implements IAliasController { ); } - resolveDatasources(datasources: Array): Observable> { - const newDatasources = deepClone(datasources); - const observables = new Array>>(); + resolveDatasources(datasources: Array, singleEntity?: boolean): Observable> { + const newDatasources = deepClone(singleEntity ? [datasources[0]] : datasources); + const observables = new Array>(); newDatasources.forEach((datasource) => { observables.push(this.resolveDatasource(datasource)); }); return forkJoin(observables).pipe( - map((arrayOfDatasources) => { - const result = new Array(); - arrayOfDatasources.forEach((datasourcesArray) => { - result.push(...datasourcesArray); - }); + map((result) => { let functionIndex = 0; result.forEach((datasource) => { if (datasource.type === DatasourceType.function) { @@ -386,6 +399,9 @@ export class AliasController implements IAliasController { datasource.name = 'Unresolved'; datasource.entityName = 'Unresolved'; } else if (datasource.type === DatasourceType.entity) { + if (singleEntity) { + datasource.pageLink = createDefaultEntityDataPageLink(1); + } if (!datasource.pageLink) { datasource.pageLink = deepClone(defaultEntityDataPageLink); } diff --git a/ui-ngx/src/app/core/api/entity-data-subscription.ts b/ui-ngx/src/app/core/api/entity-data-subscription.ts index d700672d53..591a5436d4 100644 --- a/ui-ngx/src/app/core/api/entity-data-subscription.ts +++ b/ui-ngx/src/app/core/api/entity-data-subscription.ts @@ -35,19 +35,21 @@ import { TelemetrySubscriber } from '@shared/models/telemetry/telemetry.models'; import { UtilsService } from '@core/services/utils.service'; -import { EntityDataListener } from '@core/api/entity-data.service'; +import { EntityDataListener, EntityDataLoadResult } from '@core/api/entity-data.service'; import { deepClone, isDefinedAndNotNull, isObject, objectHashCode } from '@core/utils'; import { PageData } from '@shared/models/page/page-data'; import { DataAggregator } from '@core/api/data-aggregator'; import { NULL_UUID } from '@shared/models/id/has-uuid'; import { EntityType } from '@shared/models/entity-type.models'; import Timeout = NodeJS.Timeout; +import { Observable, of, ReplaySubject, Subject } from 'rxjs'; export interface EntityDataSubscriptionOptions { datasourceType: DatasourceType; dataKeys: Array; type: widgetType; entityFilter?: EntityFilter; + isLatestDataSubscription?: boolean; pageLink?: EntityDataPageLink; keyFilters?: Array; subscriptionTimewindow?: SubscriptionTimewindow; @@ -59,21 +61,19 @@ declare type DataUpdatedCb = (data: DataSetHolder, dataIndex: number, dataKeyInd export class EntityDataSubscription { - private listeners: Array = []; private datasourceType: DatasourceType = this.entityDataSubscriptionOptions.datasourceType; - - private history = this.entityDataSubscriptionOptions.subscriptionTimewindow && - isObject(this.entityDataSubscriptionOptions.subscriptionTimewindow.fixedWindow); - - private realtime = this.entityDataSubscriptionOptions.subscriptionTimewindow && - isDefinedAndNotNull(this.entityDataSubscriptionOptions.subscriptionTimewindow.realtimeWindowMs); + private history: boolean; + private realtime: boolean; private subscriber: TelemetrySubscriber; + private dataCommand: EntityDataCmd; + private subsCommand: EntityDataCmd; private attrFields: Array; private tsFields: Array; private latestValues: Array; + private entityDataResolveSubject: Subject; private pageData: PageData; private subsTw: SubscriptionTimewindow; private dataAggregators: Array; @@ -87,7 +87,11 @@ export class EntityDataSubscription { private tickElapsed = 0; private timer: Timeout; - constructor(private entityDataSubscriptionOptions: EntityDataSubscriptionOptions, + private dataResolved = false; + private started = false; + + constructor(public entityDataSubscriptionOptions: EntityDataSubscriptionOptions, + private listener: EntityDataListener, private telemetryService: TelemetryService, private utils: UtilsService) { this.initializeSubscription(); @@ -126,50 +130,6 @@ export class EntityDataSubscription { } dataKey.key = key; } - if (this.datasourceType === DatasourceType.function) { - this.frequency = 1000; - if (this.entityDataSubscriptionOptions.type === widgetType.timeseries) { - this.frequency = Math.min(this.entityDataSubscriptionOptions.subscriptionTimewindow.aggregation.interval, 5000); - } - } - } - - public addListener(listener: EntityDataListener) { - this.listeners.push(listener); - } - - public hasListeners(): boolean { - return this.listeners.length > 0; - } - - public removeListener(listener: EntityDataListener) { - this.listeners.splice(this.listeners.indexOf(listener), 1); - } - - public syncListener(listener: EntityDataListener) { - if (this.pageData) { - let key: string; - let dataKey: SubscriptionDataKey; - const data: Array> = []; - for (let dataIndex = 0; dataIndex < this.pageData.data.length; dataIndex++) { - data[dataIndex] = []; - for (key of Object.keys(this.dataKeys)) { - if (this.datasourceType === DatasourceType.entity || this.entityDataSubscriptionOptions.type === widgetType.timeseries) { - const dataKeysList = this.dataKeys[key] as Array; - for (let i = 0; i < dataKeysList.length; i++) { - dataKey = dataKeysList[i]; - const datasourceKey = `${key}_${i}`; - data[dataIndex][dataKey.index] = this.datasourceData[dataIndex][datasourceKey]; - } - } else { - dataKey = this.dataKeys[key] as SubscriptionDataKey; - data[dataIndex][dataKey.index] = this.datasourceData[dataIndex][key]; - } - } - } - listener.dataLoaded(this.pageData, data, listener.configDatasourceIndex); - } - this.listeners.push(listener); } public unsubscribe() { @@ -192,19 +152,30 @@ export class EntityDataSubscription { this.pageData = null; } - public start() { - this.subsTw = this.entityDataSubscriptionOptions.subscriptionTimewindow; + public subscribe(): Observable { + if (!this.entityDataSubscriptionOptions.isLatestDataSubscription) { + this.entityDataResolveSubject = new ReplaySubject(1); + } else { + this.started = true; + this.dataResolved = true; + } if (this.datasourceType === DatasourceType.entity) { const entityFields: Array = this.entityDataSubscriptionOptions.dataKeys.filter(dataKey => dataKey.type === DataKeyType.entityField).map( - dataKey => ({ type: EntityKeyType.ENTITY_FIELD, key: dataKey.name }) - ); + dataKey => ({ type: EntityKeyType.ENTITY_FIELD, key: dataKey.name }) + ); if (!entityFields.find(key => key.key === 'name')) { entityFields.push({ type: EntityKeyType.ENTITY_FIELD, key: 'name' }); } + if (!entityFields.find(key => key.key === 'label')) { + entityFields.push({ + type: EntityKeyType.ENTITY_FIELD, + key: 'label' + }); + } this.attrFields = this.entityDataSubscriptionOptions.dataKeys.filter(dataKey => dataKey.type === DataKeyType.attribute).map( dataKey => ({ type: EntityKeyType.ATTRIBUTE, key: dataKey.name }) @@ -217,9 +188,9 @@ export class EntityDataSubscription { this.latestValues = this.attrFields.concat(this.tsFields); this.subscriber = new TelemetrySubscriber(this.telemetryService); - const command = new EntityDataCmd(); + this.dataCommand = new EntityDataCmd(); - command.query = { + this.dataCommand.query = { entityFilter: this.entityDataSubscriptionOptions.entityFilter, pageLink: this.entityDataSubscriptionOptions.pageLink, keyFilters: this.entityDataSubscriptionOptions.keyFilters, @@ -227,72 +198,17 @@ export class EntityDataSubscription { latestValues: this.latestValues }; - if (this.entityDataSubscriptionOptions.type === widgetType.timeseries) { - if (this.tsFields.length > 0) { - if (this.history) { - command.historyCmd = { - keys: this.tsFields.map(key => key.key), - startTs: this.subsTw.fixedWindow.startTimeMs, - endTs: this.subsTw.fixedWindow.endTimeMs, - interval: this.subsTw.aggregation.interval, - limit: this.subsTw.aggregation.limit, - agg: this.subsTw.aggregation.type + if (this.entityDataSubscriptionOptions.isLatestDataSubscription) { + if (this.entityDataSubscriptionOptions.type === widgetType.latest) { + if (this.latestValues.length > 0) { + this.dataCommand.latestCmd = { + keys: this.latestValues }; - if (this.subsTw.aggregation.stateData) { - command.historyCmd.startTs -= YEAR; - } - } else { - command.tsCmd = { - keys: this.tsFields.map(key => key.key), - startTs: this.subsTw.startTs, - timeWindow: this.subsTw.aggregation.timeWindow, - interval: this.subsTw.aggregation.interval, - limit: this.subsTw.aggregation.limit, - agg: this.subsTw.aggregation.type - } - if (this.subsTw.aggregation.stateData) { - command.historyCmd = { - keys: this.tsFields.map(key => key.key), - startTs: this.subsTw.startTs - YEAR, - endTs: this.subsTw.startTs, - interval: this.subsTw.aggregation.interval, - limit: this.subsTw.aggregation.limit, - agg: this.subsTw.aggregation.type - }; - } - this.subscriber.reconnect$.subscribe(() => { - let newSubsTw: SubscriptionTimewindow = null; - this.listeners.forEach((listener) => { - if (!newSubsTw) { - newSubsTw = listener.updateRealtimeSubscription(); - } else { - listener.setRealtimeSubscription(newSubsTw); - } - }); - this.subsTw = newSubsTw; - command.tsCmd.startTs = this.subsTw.startTs; - command.tsCmd.timeWindow = this.subsTw.aggregation.timeWindow; - command.tsCmd.interval = this.subsTw.aggregation.interval; - command.tsCmd.limit = this.subsTw.aggregation.limit; - command.tsCmd.agg = this.subsTw.aggregation.type; - if (this.subsTw.aggregation.stateData) { - command.historyCmd.startTs = this.subsTw.startTs - YEAR; - command.historyCmd.endTs = this.subsTw.startTs; - command.historyCmd.interval = this.subsTw.aggregation.interval; - command.historyCmd.limit = this.subsTw.aggregation.limit; - command.historyCmd.agg = this.subsTw.aggregation.type; - } - }); } } - } else if (this.entityDataSubscriptionOptions.type === widgetType.latest) { - if (this.latestValues.length > 0) { - command.latestCmd = { - keys: this.latestValues.map(key => key.key) - }; - } } - this.subscriber.subscriptionCommands.push(command); + + this.subscriber.subscriptionCommands.push(this.dataCommand); this.subscriber.entityData$.subscribe( (entityDataUpdate) => { @@ -304,6 +220,30 @@ export class EntityDataSubscription { } ); + this.subscriber.reconnect$.subscribe(() => { + const newSubsTw: SubscriptionTimewindow = this.listener.updateRealtimeSubscription(); + this.listener.setRealtimeSubscription(newSubsTw); + this.subsTw = newSubsTw; + if (this.started && !this.entityDataSubscriptionOptions.isLatestDataSubscription) { + this.subsCommand.tsCmd.startTs = this.subsTw.startTs; + this.subsCommand.tsCmd.timeWindow = this.subsTw.aggregation.timeWindow; + this.subsCommand.tsCmd.interval = this.subsTw.aggregation.interval; + this.subsCommand.tsCmd.limit = this.subsTw.aggregation.limit; + this.subsCommand.tsCmd.agg = this.subsTw.aggregation.type; + if (this.subsTw.aggregation.stateData) { + this.subsCommand.historyCmd.startTs = this.subsTw.startTs - YEAR; + this.subsCommand.historyCmd.endTs = this.subsTw.startTs; + this.subsCommand.historyCmd.interval = this.subsTw.aggregation.interval; + this.subsCommand.historyCmd.limit = this.subsTw.aggregation.limit; + this.subsCommand.historyCmd.agg = this.subsTw.aggregation.type; + } + this.subsCommand.query = this.dataCommand.query; + this.subscriber.subscriptionCommands = [this.subsCommand]; + } else { + this.subscriber.subscriptionCommands = [this.dataCommand]; + } + }); + this.subscriber.subscribe(); } else if (this.datasourceType === DatasourceType.function) { const entityData: EntityData = { @@ -325,29 +265,46 @@ export class EntityDataSubscription { totalPages: 1 }; this.onPageData(pageData); - this.tickScheduledTime = this.utils.currentPerfTime(); - if (this.history) { - this.onTick(true); - } else { - this.timer = setTimeout(this.onTick.bind(this, true), 0); + if (this.entityDataSubscriptionOptions.isLatestDataSubscription) { + if (this.entityDataSubscriptionOptions.type === widgetType.latest) { + this.frequency = 1000; + this.timer = setTimeout(this.onTick.bind(this, true), 0); + } } } + if (this.entityDataSubscriptionOptions.isLatestDataSubscription) { + return of(null); + } else { + return this.entityDataResolveSubject.asObservable(); + } } - private onPageData(pageData: PageData) { + public start() { + if (this.entityDataSubscriptionOptions.isLatestDataSubscription) { + return; + } + this.subsTw = this.entityDataSubscriptionOptions.subscriptionTimewindow; + this.history = this.entityDataSubscriptionOptions.subscriptionTimewindow && + isObject(this.entityDataSubscriptionOptions.subscriptionTimewindow.fixedWindow); + this.realtime = this.entityDataSubscriptionOptions.subscriptionTimewindow && + isDefinedAndNotNull(this.entityDataSubscriptionOptions.subscriptionTimewindow.realtimeWindowMs); + + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + if (this.dataAggregators) { this.dataAggregators.forEach((aggregator) => { aggregator.destroy(); }) - this.dataAggregators = null; } - this.datasourceData = []; this.dataAggregators = []; - this.entityIdToDataIndex = {}; - let tsKeyNames; + this.resetData(); + if (this.entityDataSubscriptionOptions.type === widgetType.timeseries) { + let tsKeyNames = []; if (this.datasourceType === DatasourceType.function) { - tsKeyNames = []; for (const key of Object.keys(this.dataKeys)) { const dataKeysList = this.dataKeys[key] as Array; dataKeysList.forEach((subscriptionDataKey) => { @@ -357,20 +314,85 @@ export class EntityDataSubscription { } else { tsKeyNames = this.tsFields ? this.tsFields.map(field => field.key) : []; } - } - for (let dataIndex = 0; dataIndex < pageData.data.length; dataIndex++) { - const entityData = pageData.data[dataIndex]; - this.entityIdToDataIndex[entityData.entityId.id] = dataIndex; - this.datasourceData[dataIndex] = {}; - if (this.entityDataSubscriptionOptions.type === widgetType.timeseries) { + for (let dataIndex = 0; dataIndex < this.pageData.data.length; dataIndex++) { if (this.datasourceType === DatasourceType.function) { this.dataAggregators[dataIndex] = this.createRealtimeDataAggregator(this.subsTw, tsKeyNames, - DataKeyType.function, dataIndex, this.notifyListeners.bind(this)); + DataKeyType.function, dataIndex, this.notifyListener.bind(this)); } else if (!this.history && tsKeyNames.length) { this.dataAggregators[dataIndex] = this.createRealtimeDataAggregator(this.subsTw, tsKeyNames, - DataKeyType.timeseries, dataIndex, this.notifyListeners.bind(this)); + DataKeyType.timeseries, dataIndex, this.notifyListener.bind(this)); } } + } + if (this.datasourceType === DatasourceType.entity) { + this.subsCommand = new EntityDataCmd(); + this.subsCommand.cmdId = this.dataCommand.cmdId; + if (this.entityDataSubscriptionOptions.type === widgetType.timeseries) { + if (this.tsFields.length > 0) { + if (this.history) { + this.subsCommand.historyCmd = { + keys: this.tsFields.map(key => key.key), + startTs: this.subsTw.fixedWindow.startTimeMs, + endTs: this.subsTw.fixedWindow.endTimeMs, + interval: this.subsTw.aggregation.interval, + limit: this.subsTw.aggregation.limit, + agg: this.subsTw.aggregation.type + }; + if (this.subsTw.aggregation.stateData) { + this.subsCommand.historyCmd.startTs -= YEAR; + } + } else { + this.subsCommand.tsCmd = { + keys: this.tsFields.map(key => key.key), + startTs: this.subsTw.startTs, + timeWindow: this.subsTw.aggregation.timeWindow, + interval: this.subsTw.aggregation.interval, + limit: this.subsTw.aggregation.limit, + agg: this.subsTw.aggregation.type + } + if (this.subsTw.aggregation.stateData) { + this.subsCommand.historyCmd = { + keys: this.tsFields.map(key => key.key), + startTs: this.subsTw.startTs - YEAR, + endTs: this.subsTw.startTs, + interval: this.subsTw.aggregation.interval, + limit: this.subsTw.aggregation.limit, + agg: this.subsTw.aggregation.type + }; + } + } + } + } else if (this.entityDataSubscriptionOptions.type === widgetType.latest) { + if (this.latestValues.length > 0) { + this.subsCommand.latestCmd = { + keys: this.latestValues + }; + } + } + this.subscriber.subscriptionCommands = [this.subsCommand]; + this.subscriber.update(); + } else if (this.datasourceType === DatasourceType.function) { + this.frequency = 1000; + if (this.entityDataSubscriptionOptions.type === widgetType.timeseries) { + this.frequency = Math.min(this.entityDataSubscriptionOptions.subscriptionTimewindow.aggregation.interval, 5000); + } + this.tickScheduledTime = this.utils.currentPerfTime(); + if (this.history) { + this.onTick(true); + } else { + this.timer = setTimeout(this.onTick.bind(this, true), 0); + } + } + this.started = true; + } + + private resetData() { + this.datasourceData = []; + this.entityIdToDataIndex = {}; + for (let dataIndex = 0; dataIndex < this.pageData.data.length; dataIndex++) { + const entityData = this.pageData.data[dataIndex]; + this.entityIdToDataIndex[entityData.entityId.id] = dataIndex; + this.datasourceData[dataIndex] = {}; for (const key of Object.keys(this.dataKeys)) { const dataKey = this.dataKeys[key]; if (this.datasourceType === DatasourceType.entity || this.entityDataSubscriptionOptions.type === widgetType.timeseries) { @@ -388,7 +410,23 @@ export class EntityDataSubscription { } } this.datasourceOrigData = deepClone(this.datasourceData); + if (this.entityDataSubscriptionOptions.type === widgetType.timeseries) { + for (const key of Object.keys(this.dataKeys)) { + const dataKeyList = this.dataKeys[key] as Array; + dataKeyList.forEach((dataKey) => { + delete dataKey.lastUpdateTime; + }); + } + } else if (this.entityDataSubscriptionOptions.type === widgetType.latest) { + for (const key of Object.keys(this.dataKeys)) { + delete (this.dataKeys[key] as SubscriptionDataKey).lastUpdateTime; + } + } + } + private onPageData(pageData: PageData) { + this.pageData = pageData; + this.resetData(); const data: Array> = []; for (let dataIndex = 0; dataIndex < pageData.data.length; dataIndex++) { const entityData = pageData.data[dataIndex]; @@ -401,28 +439,33 @@ export class EntityDataSubscription { } ); } - - this.pageData = pageData; - - this.listeners.forEach((listener) => { - listener.dataLoaded(pageData, data, - listener.configDatasourceIndex); - }); + if (!this.dataResolved) { + this.dataResolved = true; + this.entityDataResolveSubject.next( + { + pageData, + data, + datasourceIndex: this.listener.configDatasourceIndex + } + ); + this.entityDataResolveSubject.complete(); + } else { + this.listener.dataLoaded(pageData, data, + this.listener.configDatasourceIndex); + } } private onDataUpdate(update: Array) { for (const entityData of update) { const dataIndex = this.entityIdToDataIndex[entityData.entityId.id]; - this.processEntityData(entityData, dataIndex, true, this.notifyListeners.bind(this)); + this.processEntityData(entityData, dataIndex, true, this.notifyListener.bind(this)); } } - private notifyListeners(data: DataSetHolder, dataIndex: number, dataKeyIndex: number, detectChanges: boolean) { - this.listeners.forEach((listener) => { - listener.dataUpdated(data, - listener.configDatasourceIndex, + private notifyListener(data: DataSetHolder, dataIndex: number, dataKeyIndex: number, detectChanges: boolean) { + this.listener.dataUpdated(data, + this.listener.configDatasourceIndex, dataIndex, dataKeyIndex, detectChanges); - }); } private processEntityData(entityData: EntityData, dataIndex: number, aggregate: boolean, @@ -596,14 +639,10 @@ export class EntityDataSubscription { const value = dataKey.func(time, prevSeries[1]); const series: [number, any] = [time, value]; this.datasourceData[0][dataKey.key].data = [series]; - this.listeners.forEach( - (listener) => { - listener.dataUpdated(this.datasourceData[0][dataKey.key], - listener.configDatasourceIndex, - 0, - dataKey.index, detectChanges); - } - ); + this.listener.dataUpdated(this.datasourceData[0][dataKey.key], + this.listener.configDatasourceIndex, + 0, + dataKey.index, detectChanges); } private onTick(detectChanges: boolean) { diff --git a/ui-ngx/src/app/core/api/entity-data.service.ts b/ui-ngx/src/app/core/api/entity-data.service.ts index b17e5f7b82..cddc9bcba0 100644 --- a/ui-ngx/src/app/core/api/entity-data.service.ts +++ b/ui-ngx/src/app/core/api/entity-data.service.ts @@ -24,17 +24,24 @@ import { UtilsService } from '@core/services/utils.service'; import { SubscriptionDataKey } from '@core/api/datasource-subcription'; import { deepClone, objectHashCode } from '@core/utils'; import { EntityDataSubscription, EntityDataSubscriptionOptions } from '@core/api/entity-data-subscription'; +import { Observable, of } from 'rxjs'; export interface EntityDataListener { subscriptionType: widgetType; - subscriptionTimewindow: SubscriptionTimewindow; + subscriptionTimewindow?: SubscriptionTimewindow; configDatasource: Datasource; configDatasourceIndex: number; dataLoaded: (pageData: PageData, data: Array>, datasourceIndex: number) => void; dataUpdated: (data: DataSetHolder, datasourceIndex: number, dataIndex: number, dataKeyIndex: number, detectChanges: boolean) => void; - updateRealtimeSubscription: () => SubscriptionTimewindow; - setRealtimeSubscription: (subscriptionTimewindow: SubscriptionTimewindow) => void; - entityDataSubscriptionKey?: number; + updateRealtimeSubscription?: () => SubscriptionTimewindow; + setRealtimeSubscription?: (subscriptionTimewindow: SubscriptionTimewindow) => void; + subscription?: EntityDataSubscription; +} + +export interface EntityDataLoadResult { + pageData: PageData; + data: Array>; + datasourceIndex: number; } @Injectable({ @@ -42,16 +49,48 @@ export interface EntityDataListener { }) export class EntityDataService { - private subscriptions: {[entityDataSubscriptionKey: string]: EntityDataSubscription} = {}; - constructor(private telemetryService: TelemetryWebsocketService, private utils: UtilsService) {} - public subscribeToEntityData(listener: EntityDataListener) { + public prepareSubscription(listener: EntityDataListener): Observable { const datasource = listener.configDatasource; if (datasource.type === DatasourceType.entity && (!datasource.entityFilter || !datasource.pageLink)) { + return of(null); + } + listener.subscription = this.createSubscription(listener, + datasource.pageLink, datasource.keyFilters, + false); + return listener.subscription.subscribe(); + } + + public startSubscription(listener: EntityDataListener) { + if (listener.subscriptionType === widgetType.timeseries) { + listener.subscription.entityDataSubscriptionOptions.subscriptionTimewindow = deepClone(listener.subscriptionTimewindow); + } + listener.subscription.start(); + } + + public subscribeForLatestData(listener: EntityDataListener, + pageLink: EntityDataPageLink, + keyFilters: KeyFilter[]) { + const datasource = listener.configDatasource; + if (datasource.type === DatasourceType.entity && (!datasource.entityFilter || !pageLink)) { return; } + listener.subscription = this.createSubscription(listener, + pageLink, keyFilters, true); + listener.subscription.subscribe(); + } + + public stopSubscription(listener: EntityDataListener) { + listener.subscription.unsubscribe(); + } + + private createSubscription(listener: EntityDataListener, + pageLink: EntityDataPageLink, + keyFilters: KeyFilter[], + isLatestDataSubscription: boolean): EntityDataSubscription { + const datasource = listener.configDatasource; const subscriptionDataKeys: Array = []; datasource.dataKeys.forEach((dataKey) => { const subscriptionDataKey: SubscriptionDataKey = { @@ -62,47 +101,19 @@ export class EntityDataService { }; subscriptionDataKeys.push(subscriptionDataKey); }); - const entityDataSubscriptionOptions: EntityDataSubscriptionOptions = { datasourceType: datasource.type, dataKeys: subscriptionDataKeys, type: listener.subscriptionType }; - - if (listener.subscriptionType === widgetType.timeseries) { - entityDataSubscriptionOptions.subscriptionTimewindow = deepClone(listener.subscriptionTimewindow); - } if (entityDataSubscriptionOptions.datasourceType === DatasourceType.entity) { entityDataSubscriptionOptions.entityFilter = datasource.entityFilter; - entityDataSubscriptionOptions.pageLink = datasource.pageLink; - entityDataSubscriptionOptions.keyFilters = datasource.keyFilters; - } - listener.entityDataSubscriptionKey = objectHashCode(entityDataSubscriptionOptions); - let subscription: EntityDataSubscription; - if (this.subscriptions[listener.entityDataSubscriptionKey]) { - subscription = this.subscriptions[listener.entityDataSubscriptionKey]; - subscription.syncListener(listener); - } else { - subscription = new EntityDataSubscription(entityDataSubscriptionOptions, - this.telemetryService, this.utils); - this.subscriptions[listener.entityDataSubscriptionKey] = subscription; - subscription.addListener(listener); - subscription.start(); - } - } - - public unsubscribeFromDatasource(listener: EntityDataListener) { - if (listener.entityDataSubscriptionKey) { - const subscription = this.subscriptions[listener.entityDataSubscriptionKey]; - if (subscription) { - subscription.removeListener(listener); - if (!subscription.hasListeners()) { - subscription.unsubscribe(); - delete this.subscriptions[listener.entityDataSubscriptionKey]; - } - } - listener.entityDataSubscriptionKey = null; + entityDataSubscriptionOptions.pageLink = pageLink; + entityDataSubscriptionOptions.keyFilters = keyFilters; } + entityDataSubscriptionOptions.isLatestDataSubscription = isLatestDataSubscription; + return new EntityDataSubscription(entityDataSubscriptionOptions, + listener, this.telemetryService, this.utils); } } diff --git a/ui-ngx/src/app/core/api/widget-api.models.ts b/ui-ngx/src/app/core/api/widget-api.models.ts index 5ab06505b7..c6a39e726b 100644 --- a/ui-ngx/src/app/core/api/widget-api.models.ts +++ b/ui-ngx/src/app/core/api/widget-api.models.ts @@ -98,7 +98,8 @@ export interface IAliasController { getAliasInfo(aliasId: string): Observable; getEntityAliasId(aliasName: string): string; getInstantAliasInfo(aliasId: string): AliasInfo; - resolveDatasources(datasources: Array): Observable>; + resolveSingleEntityInfo(aliasId: string): Observable; + resolveDatasources(datasources: Array, singleEntity?: boolean): Observable>; resolveAlarmSource(alarmSource: Datasource): Observable; getEntityAliases(): EntityAliases; updateCurrentAliasEntity(aliasId: string, currentEntity: EntityInfo); @@ -202,8 +203,8 @@ export interface WidgetSubscriptionOptions { alarmsMaxCountLoad?: number; alarmsFetchSize?: number; datasources?: Array; - keyFilters?: Array; - pageLink?: EntityDataPageLink; + hasDataPageLink?: boolean; + singleEntity?: boolean; targetDeviceAliasIds?: Array; targetDeviceIds?: Array; useDashboardTimewindow?: boolean; @@ -264,7 +265,7 @@ export interface IWidgetSubscription { onAliasesChanged(aliasIds: Array): boolean; - onDashboardTimewindowChanged(dashboardTimewindow: Timewindow): boolean; + onDashboardTimewindowChanged(dashboardTimewindow: Timewindow): void; updateDataVisibility(index: number): void; @@ -278,6 +279,10 @@ export interface IWidgetSubscription { subscribe(): void; + subscribeForLatestData(datasourceIndex: number, + pageLink: EntityDataPageLink, + keyFilters: KeyFilter[]): void; + isDataResolved(): boolean; destroy(): void; diff --git a/ui-ngx/src/app/core/api/widget-subscription.ts b/ui-ngx/src/app/core/api/widget-subscription.ts index 924f161d8f..e4cfee4f3e 100644 --- a/ui-ngx/src/app/core/api/widget-subscription.ts +++ b/ui-ngx/src/app/core/api/widget-subscription.ts @@ -22,7 +22,6 @@ import { WidgetSubscriptionOptions } from '@core/api/widget-api.models'; import { - DataKey, DataSet, DataSetHolder, Datasource, @@ -43,20 +42,18 @@ import { toHistoryTimewindow, WidgetTimewindow } from '@app/shared/models/time/time.models'; -import { Observable, ReplaySubject, Subject, throwError } from 'rxjs'; +import { forkJoin, Observable, of, ReplaySubject, Subject, throwError } from 'rxjs'; import { CancelAnimationFrame } from '@core/services/raf.service'; import { EntityType } from '@shared/models/entity-type.models'; import { AlarmInfo, AlarmSearchStatus } from '@shared/models/alarm.models'; import { createLabelFromDatasource, deepClone, isDefined, isEqual } from '@core/utils'; import { AlarmSourceListener } from '@core/http/alarm.service'; -import { DatasourceListener } from '@core/api/datasource.service'; import { EntityId } from '@app/shared/models/id/entity-id'; -import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; -import { entityFields } from '@shared/models/entity.models'; import * as moment_ from 'moment'; import { PageData } from '@shared/models/page/page-data'; import { EntityDataListener } from '@core/api/entity-data.service'; -import { EntityData, EntityDataPageLink, EntityKeyType } from '@shared/models/query/query.models'; +import { EntityData, EntityDataPageLink, EntityKeyType, KeyFilter } from '@shared/models/query/query.models'; +import { map } from 'rxjs/operators'; const moment = moment_; @@ -73,12 +70,14 @@ export class WidgetSubscription implements IWidgetSubscription { subscriptionTimewindow: SubscriptionTimewindow; useDashboardTimewindow: boolean; + hasDataPageLink: boolean; + singleEntity: boolean; + datasourcePages: PageData[]; dataPages: PageData>[]; entityDataListeners: Array; configuredDatasources: Array; - initDataSubscriptionSubject: Subject; data: Array; datasources: Array; // datasourceListeners: Array; @@ -211,6 +210,8 @@ export class WidgetSubscription implements IWidgetSubscription { // this.datasources = this.ctx.utils.validateDatasources(options.datasources); this.configuredDatasources = this.ctx.utils.validateDatasources(options.datasources); this.entityDataListeners = []; + this.hasDataPageLink = options.hasDataPageLink; + this.singleEntity = options.singleEntity; // this.datasourceListeners = []; this.datasourcePages = []; this.datasources = []; @@ -271,11 +272,11 @@ export class WidgetSubscription implements IWidgetSubscription { const initRpcSubject = new ReplaySubject(); if (this.targetDeviceAliasIds && this.targetDeviceAliasIds.length > 0) { this.targetDeviceAliasId = this.targetDeviceAliasIds[0]; - this.ctx.aliasController.getAliasInfo(this.targetDeviceAliasId).subscribe( - (aliasInfo) => { - if (aliasInfo.currentEntity && aliasInfo.currentEntity.entityType === EntityType.DEVICE) { - this.targetDeviceId = aliasInfo.currentEntity.id; - this.targetDeviceName = aliasInfo.currentEntity.name; + this.ctx.aliasController.resolveSingleEntityInfo(this.targetDeviceAliasId).subscribe( + (entityInfo) => { + if (entityInfo && entityInfo.entityType === EntityType.DEVICE) { + this.targetDeviceId = entityInfo.id; + this.targetDeviceName = entityInfo.name; if (this.targetDeviceId) { this.rpcEnabled = true; } else { @@ -348,34 +349,72 @@ export class WidgetSubscription implements IWidgetSubscription { } private initDataSubscription(): Observable { - this.initDataSubscriptionSubject = new ReplaySubject(1); + const initDataSubscriptionSubject = new ReplaySubject(1); this.loadStDiff().subscribe(() => { if (!this.ctx.aliasController) { this.hasResolvedData = true; - // this.configureData(); - // initDataSubscriptionSubject.next(); - // initDataSubscriptionSubject.complete(); - this.subscribe(); + this.prepareDataSubscriptions().subscribe( + () => { + initDataSubscriptionSubject.next(); + initDataSubscriptionSubject.complete(); + } + ); } else { - this.ctx.aliasController.resolveDatasources(this.configuredDatasources).subscribe( + this.ctx.aliasController.resolveDatasources(this.configuredDatasources, this.singleEntity).subscribe( (datasources) => { this.configuredDatasources = datasources; - /* if (datasources && datasources.length) { - this.hasResolvedData = true; - }*/ - this.subscribe(); - // this.configureData(); - // initDataSubscriptionSubject.next(); - // initDataSubscriptionSubject.complete(); + this.prepareDataSubscriptions().subscribe( + () => { + initDataSubscriptionSubject.next(); + initDataSubscriptionSubject.complete(); + } + ); }, (err) => { this.notifyDataLoaded(); - this.initDataSubscriptionSubject.error(err); + initDataSubscriptionSubject.error(err); } ); } }); - return this.initDataSubscriptionSubject.asObservable(); + return initDataSubscriptionSubject.asObservable(); + } + + private prepareDataSubscriptions(): Observable { + if (this.hasDataPageLink) { + this.hasResolvedData = true; + return of(null); + } + const resolveResultObservables = this.configuredDatasources.map((datasource, index) => { + const listener: EntityDataListener = { + subscriptionType: this.type, + configDatasource: datasource, + configDatasourceIndex: index, + dataLoaded: (pageData, data1, datasourceIndex) => { + this.dataLoaded(pageData, data1, datasourceIndex, true) + }, + dataUpdated: this.dataUpdated.bind(this), + updateRealtimeSubscription: () => { + this.subscriptionTimewindow = this.updateRealtimeSubscription(); + return this.subscriptionTimewindow; + }, + setRealtimeSubscription: (subscriptionTimewindow) => { + this.updateRealtimeSubscription(deepClone(subscriptionTimewindow)); + } + }; + this.entityDataListeners.push(listener); + return this.ctx.entityDataService.prepareSubscription(listener); + }); + return forkJoin(resolveResultObservables).pipe( + map((resolveResults) => { + resolveResults.forEach((resolveResult) => { + this.dataLoaded(resolveResult.pageData, resolveResult.data, resolveResult.datasourceIndex, false); + }); + this.configureLoadedData(); + this.hasResolvedData = true; + this.notifyDataLoaded(); + }) + ); } /* private initDataSubscriptionOld(): Observable { @@ -592,13 +631,12 @@ export class WidgetSubscription implements IWidgetSubscription { }); } - onDashboardTimewindowChanged(newDashboardTimewindow: Timewindow): boolean { + onDashboardTimewindowChanged(newDashboardTimewindow: Timewindow) { if (this.type === widgetType.timeseries || this.type === widgetType.alarm) { if (this.useDashboardTimewindow) { if (!isEqual(this.timeWindowConfig, newDashboardTimewindow) && newDashboardTimewindow) { - // this.timeWindowConfig = deepClone(newDashboardTimewindow); - // this.update(); - // TODO: + this.timeWindowConfig = deepClone(newDashboardTimewindow); + this.update(); return true; } } @@ -785,8 +823,12 @@ export class WidgetSubscription implements IWidgetSubscription { } update() { - this.unsubscribe(); - this.subscribe(); + if (this.type === widgetType.rpc || this.type === widgetType.alarm) { + this.unsubscribe(); + this.subscribe(); + } else { + this.dataSubscribe(); + } } subscribe(): void { @@ -802,6 +844,29 @@ export class WidgetSubscription implements IWidgetSubscription { } } + subscribeForLatestData(datasourceIndex: number, + pageLink: EntityDataPageLink, + keyFilters: KeyFilter[]): void { + let entityDataListener = this.entityDataListeners[datasourceIndex]; + if (entityDataListener) { + this.ctx.entityDataService.stopSubscription(entityDataListener); + } + const datasource = this.configuredDatasources[datasourceIndex]; + if (datasource) { + entityDataListener = { + subscriptionType: this.type, + configDatasource: datasource, + configDatasourceIndex: datasourceIndex, + dataLoaded: (pageData, data1, datasourceIndex1) => { + this.dataLoaded(pageData, data1, datasourceIndex1, true) + }, + dataUpdated: this.dataUpdated.bind(this) + }; + this.entityDataListeners[datasourceIndex] = entityDataListener; + this.ctx.entityDataService.subscribeForLatestData(entityDataListener, pageLink, keyFilters); + } + } + private doSubscribe() { if (this.type === widgetType.rpc) { return; @@ -809,6 +874,12 @@ export class WidgetSubscription implements IWidgetSubscription { if (this.type === widgetType.alarm) { this.alarmsSubscribe(); } else { + this.dataSubscribe(); + } + } + + private dataSubscribe() { + if (!this.hasDataPageLink) { this.notifyDataLoading(); if (this.type === widgetType.timeseries && this.timeWindowConfig) { this.updateRealtimeSubscription(); @@ -819,62 +890,10 @@ export class WidgetSubscription implements IWidgetSubscription { this.onDataUpdated(); } } - // let index = 0; const forceUpdate = !this.datasources.length; - this.configuredDatasources.forEach((datasource, index) => { - const listener: EntityDataListener = { - subscriptionType: this.type, - subscriptionTimewindow: this.subscriptionTimewindow, - configDatasource: datasource, - configDatasourceIndex: index, - dataLoaded: this.dataLoaded.bind(this), - dataUpdated: this.dataUpdated.bind(this), - updateRealtimeSubscription: () => { - this.subscriptionTimewindow = this.updateRealtimeSubscription(); - return this.subscriptionTimewindow; - }, - setRealtimeSubscription: (subscriptionTimewindow) => { - this.updateRealtimeSubscription(deepClone(subscriptionTimewindow)); - } - }; - - /*if (this.comparisonEnabled && datasource.isAdditional) { - listener.subscriptionTimewindow = this.timewindowForComparison; - listener.updateRealtimeSubscription = () => { - this.subscriptionTimewindow = this.updateSubscriptionForComparison(); - return this.subscriptionTimewindow; - }; - listener.setRealtimeSubscription = () => { - this.updateSubscriptionForComparison(); - }; - }*/ - -/* let entityFieldKey = false; - - for (let a = 0; a < datasource.dataKeys.length; a++) { - if (datasource.dataKeys[a].type !== DataKeyType.entityField) { - this.data[index + a].data = []; - } else { - entityFieldKey = true; - } - } - index += datasource.dataKeys.length;*/ - - this.entityDataListeners.push(listener); - // this.datasourceListeners.push(listener); - - // if (datasource.dataKeys.length) { - // this.ctx.datasourceService.subscribeToDatasource(listener); - // } - - this.ctx.entityDataService.subscribeToEntityData(listener); - - /* if (datasource.unresolvedStateEntity || entityFieldKey || - !datasource.dataKeys.length || - (datasource.type === DatasourceType.entity && !datasource.entityId) - ) { - forceUpdate = true; - }*/ + this.entityDataListeners.forEach((listener) => { + listener.subscriptionTimewindow = this.subscriptionTimewindow; + this.ctx.entityDataService.startSubscription(listener); }); if (forceUpdate) { this.notifyDataLoaded(); @@ -1000,7 +1019,9 @@ export class WidgetSubscription implements IWidgetSubscription { this.alarmsUnsubscribe(); } else { this.entityDataListeners.forEach((listener) => { - this.ctx.entityDataService.unsubscribeFromDatasource(listener); + if (listener != null) { + this.ctx.entityDataService.stopSubscription(listener); + } }); this.entityDataListeners.length = 0; this.resetData(); @@ -1129,7 +1150,9 @@ export class WidgetSubscription implements IWidgetSubscription { return this.timewindowForComparison; } - private dataLoaded(pageData: PageData, data: Array>, datasourceIndex: number) { + private dataLoaded(pageData: PageData, + data: Array>, + datasourceIndex: number, isUpdate: boolean) { const datasource = this.configuredDatasources[datasourceIndex]; datasource.dataReceived = true; const datasources = pageData.data.map((entityData, index) => @@ -1152,14 +1175,8 @@ export class WidgetSubscription implements IWidgetSubscription { totalPages: pageData.totalPages }; this.dataPages[datasourceIndex] = datasourceDataPage; - this.configureLoadedData(); - const readyCount = this.configuredDatasources.filter(d => d.dataReceived).length; - if (this.configuredDatasources.length === readyCount) { - this.hasResolvedData = true; - this.initDataSubscriptionSubject.next(); - this.initDataSubscriptionSubject.complete(); + if (isUpdate) { this.configureLoadedData(); - this.notifyDataLoaded(); this.onDataUpdated(true); } } @@ -1238,6 +1255,9 @@ export class WidgetSubscription implements IWidgetSubscription { dataKey, data: [] }; + if (data && data[keyIndex] && data[keyIndex].data) { + datasourceData.data = data[keyIndex].data; + } return datasourceData; }); } @@ -1275,6 +1295,7 @@ export class WidgetSubscription implements IWidgetSubscription { const startIndex = configuredDatasource.dataKeyStartIndex; const dataKeysCount = configuredDatasource.dataKeys.length; const index = startIndex + dataIndex*dataKeysCount + dataKeyIndex; + this.notifyDataLoaded(); let update = true; let currentData: DataSetHolder; if (this.displayLegend && this.legendData.keys[index].dataKey.hidden) { diff --git a/ui-ngx/src/app/core/http/entity.service.ts b/ui-ngx/src/app/core/http/entity.service.ts index 076f132cb8..ff1e4e193d 100644 --- a/ui-ngx/src/app/core/http/entity.service.ts +++ b/ui-ngx/src/app/core/http/entity.service.ts @@ -54,9 +54,15 @@ import { import { EntityRelationService } from '@core/http/entity-relation.service'; import { deepClone, isDefined, isDefinedAndNotNull } from '@core/utils'; import { Asset, AssetSearchQuery } from '@shared/models/asset.models'; -import { Device, DeviceCredentialsType, DeviceSearchQuery } from '@shared/models/device.models'; +import { ClaimResult, Device, DeviceCredentialsType, DeviceSearchQuery } from '@shared/models/device.models'; import { EntityViewSearchQuery } from '@shared/models/entity-view.models'; import { AttributeService } from '@core/http/attribute.service'; +import { + createDefaultEntityDataPageLink, + EntityData, + EntityDataQuery, + EntityFilter, EntityKeyType +} from '@shared/models/query/query.models'; @Injectable({ providedIn: 'root' @@ -360,6 +366,54 @@ export class EntityService { } } + public findEntityDataByQuery(query: EntityDataQuery, config?: RequestConfig): Observable> { + return this.http.post>('/api/entitiesQuery/find', query, defaultHttpOptionsFromConfig(config)); + } + + private entityDataToEntityInfo(entityData: EntityData): EntityInfo { + const entityInfo: EntityInfo = { + id: entityData.entityId.id, + entityType: entityData.entityId.entityType as EntityType + }; + if (entityData.latest && entityData.latest[EntityKeyType.ENTITY_FIELD]) { + const fields = entityData.latest[EntityKeyType.ENTITY_FIELD]; + if (fields.name) { + entityInfo.name = fields.name.value; + } + if (fields.label) { + entityInfo.label = fields.label.value; + } + } + return entityInfo; + } + + public findSingleEntityInfoByEntityFilter(filter: EntityFilter, config?: RequestConfig): Observable { + const query: EntityDataQuery = { + entityFilter: filter, + pageLink: createDefaultEntityDataPageLink(1), + entityFields: [ + { + type: EntityKeyType.ENTITY_FIELD, + key: 'name' + }, + { + type: EntityKeyType.ENTITY_FIELD, + key: 'label' + } + ] + }; + return this.findEntityDataByQuery(query, config).pipe( + map((data) => { + if (data.data.length) { + const entityData = data.data[0]; + return this.entityDataToEntityInfo(entityData); + } else { + return null; + } + }) + ); + } + public getAliasFilterTypesByEntityTypes(entityTypes: Array): Array { const allAliasFilterTypes: Array = Object.keys(AliasFilterType).map((key) => AliasFilterType[key]); if (!entityTypes || !entityTypes.length) { @@ -605,7 +659,7 @@ export class EntityService { public resolveAlias(entityAlias: EntityAlias, stateParams: StateParams): Observable { const filter = entityAlias.filter; return this.resolveAliasFilter(filter, stateParams).pipe( - map((result) => { + mergeMap((result) => { const aliasInfo: AliasInfo = { alias: entityAlias.alias, entityFilter: result.entityFilter, @@ -615,30 +669,19 @@ export class EntityService { }; aliasInfo.resolvedEntities = result.entities; aliasInfo.currentEntity = null; - if (aliasInfo.resolvedEntities.length) { - aliasInfo.currentEntity = aliasInfo.resolvedEntities[0]; + if (!aliasInfo.resolveMultiple && aliasInfo.entityFilter) { + return this.findSingleEntityInfoByEntityFilter(aliasInfo.entityFilter, + {ignoreLoading: true, ignoreErrors: true}).pipe( + map((entity) => { + aliasInfo.currentEntity = entity; + return aliasInfo; + }) + ); } - return aliasInfo; + return of(aliasInfo); }) ); } -/* - public resolveEntityFilter(filter: EntityAliasFilter, stateParams: StateParams): EntityFilter { - const stateEntityInfo = this.getStateEntityInfo(filter, stateParams); - let result: EntityFilter = filter; - const stateEntityId = stateEntityInfo.entityId; - if (filter.type === AliasFilterType.stateEntity) { - result = { - singleEntity: stateEntityId, - type: AliasFilterType.singleEntity - }; - } else if (filter.rootStateEntity) { - let rootEntityType; - let rootEntityId; - - } - return result; - }*/ public resolveAliasFilter(filter: EntityAliasFilter, stateParams: StateParams): Observable { const result: EntityAliasFilterResult = { diff --git a/ui-ngx/src/app/core/ws/telemetry-websocket.service.ts b/ui-ngx/src/app/core/ws/telemetry-websocket.service.ts index 5d3daa9467..5610551d20 100644 --- a/ui-ngx/src/app/core/ws/telemetry-websocket.service.ts +++ b/ui-ngx/src/app/core/ws/telemetry-websocket.service.ts @@ -114,6 +114,17 @@ export class TelemetryWebsocketService implements TelemetryService { this.publishCommands(); } + public update(subscriber: TelemetrySubscriber) { + subscriber.subscriptionCommands.forEach( + (subscriptionCommand) => { + if (subscriptionCommand.cmdId && subscriptionCommand instanceof EntityDataCmd) { + this.cmdsWrapper.entityDataCmds.push(subscriptionCommand); + } + } + ); + this.publishCommands(); + } + public unsubscribe(subscriber: TelemetrySubscriber) { if (this.isActive) { subscriber.subscriptionCommands.forEach( diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/entities-table-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/entities-table-widget.component.html index 6a87650526..a7f9d8e678 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/entities-table-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/entities-table-widget.component.html @@ -39,7 +39,7 @@
+ matSort [matSortActive]="sortOrderProperty" [matSortDirection]="pageLinkSortDirection()" matSortDisableClear> {{ column.title }} = []; @@ -150,8 +152,13 @@ export class EntitiesTableWidgetComponent extends PageComponent implements OnIni private domSanitizer: DomSanitizer) { super(store); - const sortOrder: SortOrder = sortOrderFromString(this.defaultSortOrder); - this.pageLink = new PageLink(this.defaultPageSize, 0, null, sortOrder); + // const sortOrder: EntityDataSortOrder = sortOrderFromString(this.defaultSortOrder); + this.pageLink = { + page: 0, + pageSize: this.defaultPageSize, + textSearch: null + }; + // new PageLink(this.defaultPageSize, 0, null, sortOrder); } ngOnInit(): void { @@ -191,11 +198,15 @@ export class EntitiesTableWidgetComponent extends PageComponent implements OnIni public onDataUpdated() { this.ngZone.run(() => { - this.entityDatasource.updateEntitiesData(this.subscription.data); + this.entityDatasource.dataUpdated(); // .updateEntitiesData(this.subscription.data); this.ctx.detectChanges(); }); } + public pageLinkSortDirection(): SortDirection { + return entityDataPageLinkSortDirection(this.pageLink); + } + private initializeConfig() { this.ctx.widgetActions = [this.searchAction, this.columnDisplayAction]; @@ -256,7 +267,11 @@ export class EntitiesTableWidgetComponent extends PageComponent implements OnIni name: 'entityName', label: 'entityName', def: 'entityName', - title: entityNameColumnTitle + title: entityNameColumnTitle, + entityKey: { + key: 'name', + type: EntityKeyType.ENTITY_FIELD + } } as EntityColumn ); this.contentsInfo.entityName = { @@ -273,7 +288,11 @@ export class EntitiesTableWidgetComponent extends PageComponent implements OnIni name: 'entityLabel', label: 'entityLabel', def: 'entityLabel', - title: entityLabelColumnTitle + title: entityLabelColumnTitle, + entityKey: { + key: 'label', + type: EntityKeyType.ENTITY_FIELD + } } as EntityColumn ); this.contentsInfo.entityLabel = { @@ -291,6 +310,10 @@ export class EntitiesTableWidgetComponent extends PageComponent implements OnIni label: 'entityType', def: 'entityType', title: this.translate.instant('entity.entity-type'), + entityKey: { + key: 'entityType', + type: EntityKeyType.ENTITY_FIELD + } } as EntityColumn ); this.contentsInfo.entityType = { @@ -309,8 +332,19 @@ export class EntitiesTableWidgetComponent extends PageComponent implements OnIni if (datasource) { datasource.dataKeys.forEach((entityDataKey) => { const dataKey: EntityColumn = deepClone(entityDataKey) as EntityColumn; + dataKey.entityKey = { + key: dataKey.name, + type: null + }; if (dataKey.type === DataKeyType.function) { dataKey.name = dataKey.label; + dataKey.entityKey.type = EntityKeyType.ENTITY_FIELD; + } else if (dataKey.type === DataKeyType.entityField) { + dataKey.entityKey.type = EntityKeyType.ENTITY_FIELD; + } else if (dataKey.type === DataKeyType.attribute) { + dataKey.entityKey.type = EntityKeyType.ATTRIBUTE; + } else if (dataKey.type === DataKeyType.timeseries) { + dataKey.entityKey.type = EntityKeyType.TIME_SERIES; } dataKeys.push(dataKey); @@ -331,14 +365,19 @@ export class EntitiesTableWidgetComponent extends PageComponent implements OnIni if (this.settings.defaultSortOrder && this.settings.defaultSortOrder.length) { this.defaultSortOrder = this.settings.defaultSortOrder; } - this.pageLink.sortOrder = sortOrderFromString(this.defaultSortOrder); - this.sortOrderProperty = toEntityColumnDef(this.pageLink.sortOrder.property, this.columns); + + this.pageLink.sortOrder = entityDataSortOrderFromString(this.defaultSortOrder, this.columns); + let sortColumn: EntityColumn; + if (this.pageLink.sortOrder) { + sortColumn = findColumnByEntityKey(this.pageLink.sortOrder.key, this.columns); + } + this.sortOrderProperty = sortColumn ? sortColumn.def : null; if (this.actionCellDescriptors.length) { this.displayedColumns.push('actions'); } this.entityDatasource = new EntityDatasource( - this.translate, dataKeys, this.subscription.datasources); + this.translate, dataKeys, this.subscription); } private editColumnsToDisplay($event: Event) { @@ -416,9 +455,12 @@ export class EntitiesTableWidgetComponent extends PageComponent implements OnIni } else { this.pageLink.page = 0; } - this.pageLink.sortOrder.property = fromEntityColumnDef(this.sort.active, this.columns); - this.pageLink.sortOrder.direction = Direction[this.sort.direction.toUpperCase()]; - this.entityDatasource.loadEntities(this.pageLink); + this.pageLink.sortOrder = { + key: findEntityKeyByColumnDef(this.sort.active, this.columns), + direction: Direction[this.sort.direction.toUpperCase()] + }; + const keyFilters: KeyFilter[] = null; // TODO: + this.entityDatasource.loadEntities(this.pageLink, keyFilters); this.ctx.detectChanges(); } @@ -523,18 +565,19 @@ class EntityDatasource implements DataSource { private entitiesSubject = new BehaviorSubject([]); private pageDataSubject = new BehaviorSubject>(emptyPageData()); - private allEntities: Array = []; - private allEntitiesSubject = new BehaviorSubject([]); - private allEntities$: Observable> = this.allEntitiesSubject.asObservable(); +// private allEntities: Array = []; +// private allEntitiesSubject = new BehaviorSubject([]); +// private allEntities$: Observable> = this.allEntitiesSubject.asObservable(); private currentEntity: EntityData = null; constructor( private translate: TranslateService, private dataKeys: Array, - datasources: Array + private subscription: IWidgetSubscription + // datasources: Array ) { - +/* for (const datasource of datasources) { if (datasource.type === DatasourceType.entity && !datasource.entityId) { continue; @@ -558,7 +601,7 @@ class EntityDatasource implements DataSource { }); this.allEntities.push(entity); } - this.allEntitiesSubject.next(this.allEntities); + this.allEntitiesSubject.next(this.allEntities);*/ } connect(collectionViewer: CollectionViewer): Observable> { @@ -570,18 +613,63 @@ class EntityDatasource implements DataSource { this.pageDataSubject.complete(); } - loadEntities(pageLink: PageLink) { - this.fetchEntities(pageLink).pipe( + loadEntities(pageLink: EntityDataPageLink, keyFilters: KeyFilter[]) { + this.subscription.subscribeForLatestData(0, pageLink, keyFilters); +/* this.fetchEntities(pageLink).pipe( catchError(() => of(emptyPageData())), ).subscribe( (pageData) => { this.entitiesSubject.next(pageData.data); this.pageDataSubject.next(pageData); } - ); + );*/ } - updateEntitiesData(data: DatasourceData[]) { + dataUpdated() { + const datasourcesPageData = this.subscription.datasourcePages[0]; + const dataPageData = this.subscription.dataPages[0]; + const entities = new Array(); + datasourcesPageData.data.forEach((datasource, index) => { + entities.push(this.datasourceToEntityData(datasource, dataPageData.data[index])); + }); + const entitiesPageData: PageData = { + data: entities, + totalPages: datasourcesPageData.totalPages, + totalElements: datasourcesPageData.totalElements, + hasNext: datasourcesPageData.hasNext + }; + this.entitiesSubject.next(entities); + this.pageDataSubject.next(entitiesPageData); + } + + private datasourceToEntityData(datasource: Datasource, data: DatasourceData[]): EntityData { + const entity: EntityData = { + id: {} as EntityId, + entityName: datasource.entityName, + entityLabel: datasource.entityLabel ? datasource.entityLabel : datasource.entityName + }; + if (datasource.entityId) { + entity.id.id = datasource.entityId; + } + if (datasource.entityType) { + entity.id.entityType = datasource.entityType; + entity.entityType = this.translate.instant(entityTypeTranslations.get(datasource.entityType).type); + } else { + entity.entityType = ''; + } + this.dataKeys.forEach((dataKey, index) => { + const keyData = data[index].data; + if (keyData && keyData.length && keyData[0].length > 1) { + const value = keyData[0][1]; + entity[dataKey.label] = value; + } else { + entity[dataKey.label] = ''; + } + }); + return entity; + } + +/* updateEntitiesData(data: DatasourceData[]) { for (let i = 0; i < this.allEntities.length; i++) { const entity = this.allEntities[i]; for (let a = 0; a < this.dataKeys.length; a++) { @@ -597,7 +685,7 @@ class EntityDatasource implements DataSource { } } this.allEntitiesSubject.next(this.allEntities); - } + }*/ isEmpty(): Observable { return this.entitiesSubject.pipe( @@ -625,9 +713,9 @@ class EntityDatasource implements DataSource { (this.currentEntity.id.id === entity.id.id); } - private fetchEntities(pageLink: PageLink): Observable> { + /* private fetchEntities(pageLink: PageLink): Observable> { return this.allEntities$.pipe( map((data) => pageLink.filterData(data)) ); - } + }*/ } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts index 29f9e4265a..77acf524df 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts @@ -19,6 +19,7 @@ import { DataKey, WidgetConfig } from '@shared/models/widget.models'; import { getDescendantProp, isDefined } from '@core/utils'; import { alarmFields, AlarmInfo } from '@shared/models/alarm.models'; import * as tinycolor_ from 'tinycolor2'; +import { Direction, EntityDataSortOrder, EntityKey } from '@shared/models/query/query.models'; const tinycolor = tinycolor_; @@ -49,6 +50,7 @@ export interface EntityData { export interface EntityColumn extends DataKey { def: string; title: string; + entityKey?: EntityKey; } export interface DisplayColumn { @@ -73,6 +75,58 @@ export interface CellStyleInfo { cellStyleFunction?: CellStyleFunction; } + +export function entityDataSortOrderFromString(strSortOrder: string, columns: EntityColumn[]): EntityDataSortOrder { + if (!strSortOrder && !strSortOrder.length) { + return null; + } + let property: string; + let direction = Direction.ASC; + if (strSortOrder.startsWith('-')) { + direction = Direction.DESC; + property = strSortOrder.substring(1); + } else { + if (strSortOrder.startsWith('+')) { + property = strSortOrder.substring(1); + } else { + property = strSortOrder; + } + } + if (!property && !property.length) { + return null; + } + const column = findColumnByLabel(property, columns); + if (column && column.entityKey) { + return {key: column.entityKey, direction}; + } + return null; +} + +export function findColumnByEntityKey(key: EntityKey, columns: EntityColumn[]): EntityColumn { + if (key) { + return columns.find(theColumn => theColumn.entityKey && + theColumn.entityKey.type === key.type && theColumn.entityKey.key === key.key); + } else { + return null; + } +} + +export function findEntityKeyByColumnDef(def: string, columns: EntityColumn[]): EntityKey { + return findColumnByDef(def, columns).entityKey; +} + +export function findColumn(searchProperty: string, searchValue: string, columns: EntityColumn[]): EntityColumn { + return columns.find(theColumn => theColumn[searchProperty] === searchValue); +} + +export function findColumnByLabel(label: string, columns: EntityColumn[]): EntityColumn { + return findColumn('label', label, columns); +} + +export function findColumnByDef(def: string, columns: EntityColumn[]): EntityColumn { + return findColumn('def', def, columns); +} + export function findColumnProperty(searchProperty: string, searchValue: string, columnProperty: string, columns: EntityColumn[]): string { let res = searchValue; const column = columns.find(theColumn => theColumn[searchProperty] === searchValue); @@ -82,6 +136,10 @@ export function findColumnProperty(searchProperty: string, searchValue: string, return res; } +export function toEntityKey(def: string, columns: EntityColumn[]): string { + return findColumnProperty('def', def, 'label', columns); +} + export function toEntityColumnDef(label: string, columns: EntityColumn[]): string { return findColumnProperty('label', label, 'def', columns); } diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-component.service.ts b/ui-ngx/src/app/modules/home/components/widget/widget-component.service.ts index 3587201415..a9d7e332f4 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-component.service.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget-component.service.ts @@ -346,12 +346,19 @@ export class WidgetComponentService { } else { result.typeParameters.useCustomDatasources = false; } + if (isUndefined(result.typeParameters.hasDataPageLink)) { + result.typeParameters.hasDataPageLink = false; + } if (isUndefined(result.typeParameters.maxDatasources)) { result.typeParameters.maxDatasources = -1; } if (isUndefined(result.typeParameters.maxDataKeys)) { result.typeParameters.maxDataKeys = -1; } + if (isUndefined(result.typeParameters.singleEntity)) { + result.typeParameters.singleEntity = result.typeParameters.maxDatasources === 1 && + result.typeParameters.maxDataKeys === 1; + } if (isUndefined(result.typeParameters.dataKeysOptional)) { result.typeParameters.dataKeysOptional = false; } diff --git a/ui-ngx/src/app/modules/home/components/widget/widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/widget.component.ts index 1091b4c550..800ccab847 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget.component.ts @@ -620,14 +620,9 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI this.rxSubscriptions.push(this.widgetContext.dashboard.dashboardTimewindowChanged.subscribe( (dashboardTimewindow) => { - // TODO: - let subscriptionChanged = false; for (const id of Object.keys(this.widgetContext.subscriptions)) { const subscription = this.widgetContext.subscriptions[id]; - subscriptionChanged = subscriptionChanged || subscription.onDashboardTimewindowChanged(dashboardTimewindow); - } - if (subscriptionChanged && !this.typeParameters.useCustomDatasources) { - this.reInit(); + subscription.onDashboardTimewindowChanged(dashboardTimewindow); } } )); @@ -845,6 +840,8 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI options = { type: this.widget.type, stateData: this.typeParameters.stateData, + hasDataPageLink: this.typeParameters.hasDataPageLink, + singleEntity: this.typeParameters.singleEntity, comparisonEnabled: comparisonSettings.comparisonEnabled, timeForComparison: comparisonSettings.timeForComparison }; diff --git a/ui-ngx/src/app/shared/models/query/query.models.ts b/ui-ngx/src/app/shared/models/query/query.models.ts index 304968841e..b2eaa52d53 100644 --- a/ui-ngx/src/app/shared/models/query/query.models.ts +++ b/ui-ngx/src/app/shared/models/query/query.models.ts @@ -16,6 +16,7 @@ import { AliasFilterType, EntityFilters } from '@shared/models/alias.models'; import { EntityId } from '@shared/models/id/entity-id'; +import { SortDirection } from '@angular/material/sort'; export enum EntityKeyType { ATTRIBUTE = 'ATTRIBUTE', @@ -122,18 +123,30 @@ export interface EntityDataPageLink { sortOrder?: EntityDataSortOrder; } -export const defaultEntityDataPageLink: EntityDataPageLink = { - pageSize: 1024, - page: 0, - sortOrder: { - key: { - type: EntityKeyType.ENTITY_FIELD, - key: 'createdTime' - }, - direction: Direction.DESC +export function entityDataPageLinkSortDirection(pageLink: EntityDataPageLink): SortDirection { + if (pageLink.sortOrder) { + return (pageLink.sortOrder.direction + '').toLowerCase() as SortDirection; + } else { + return '' as SortDirection; } } +export function createDefaultEntityDataPageLink(pageSize: number): EntityDataPageLink { + return { + pageSize, + page: 0, + sortOrder: { + key: { + type: EntityKeyType.ENTITY_FIELD, + key: 'createdTime' + }, + direction: Direction.DESC + } + } +} + +export const defaultEntityDataPageLink: EntityDataPageLink = createDefaultEntityDataPageLink(1024); + export interface EntityCountQuery { entityFilter: EntityFilter; } diff --git a/ui-ngx/src/app/shared/models/telemetry/telemetry.models.ts b/ui-ngx/src/app/shared/models/telemetry/telemetry.models.ts index e3108004c8..c0ad2c80a0 100644 --- a/ui-ngx/src/app/shared/models/telemetry/telemetry.models.ts +++ b/ui-ngx/src/app/shared/models/telemetry/telemetry.models.ts @@ -21,7 +21,7 @@ import { Observable, ReplaySubject, Subject } from 'rxjs'; import { EntityId } from '@shared/models/id/entity-id'; import { map } from 'rxjs/operators'; import { NgZone } from '@angular/core'; -import { EntityData, EntityDataQuery } from '@shared/models/query/query.models'; +import { EntityData, EntityDataQuery, EntityKey } from '@shared/models/query/query.models'; import { PageData } from '@shared/models/page/page-data'; export enum DataKeyType { @@ -139,7 +139,7 @@ export interface EntityHistoryCmd { } export interface LatestValueCmd { - keys: Array; + keys: Array; } export interface TimeSeriesCmd { @@ -153,7 +153,7 @@ export interface TimeSeriesCmd { export class EntityDataCmd implements WebsocketCmd { cmdId: number; - query: EntityDataQuery; + query?: EntityDataQuery; historyCmd?: EntityHistoryCmd; latestCmd?: LatestValueCmd; tsCmd?: TimeSeriesCmd; @@ -314,6 +314,7 @@ export class EntityDataUpdate implements EntityDataUpdateMsg { export interface TelemetryService { subscribe(subscriber: TelemetrySubscriber); + update(subscriber: TelemetrySubscriber); unsubscribe(subscriber: TelemetrySubscriber); } @@ -360,6 +361,10 @@ export class TelemetrySubscriber { this.telemetryService.subscribe(this); } + public update() { + this.telemetryService.update(this); + } + public unsubscribe() { this.telemetryService.unsubscribe(this); this.complete(); diff --git a/ui-ngx/src/app/shared/models/widget.models.ts b/ui-ngx/src/app/shared/models/widget.models.ts index 3b3c29f44d..3a6937974b 100644 --- a/ui-ngx/src/app/shared/models/widget.models.ts +++ b/ui-ngx/src/app/shared/models/widget.models.ts @@ -150,6 +150,8 @@ export interface WidgetTypeParameters { maxDataKeys?: number; dataKeysOptional?: boolean; stateData?: boolean; + hasDataPageLink?: boolean; + singleEntity?: boolean; } export interface WidgetControllerDescriptor {