From 4982f0e6a4260abb0a4763cdca12834dc4f5e662 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Wed, 22 Jan 2020 20:05:30 +0200 Subject: [PATCH] Finish RuleChain page. Implement missing widget subscription methods. Implement Entities table widget. --- .../json/system/widget_bundles/cards.json | 10 +- .../rule/engine/action/TbCreateAlarmNode.java | 4 + .../TbCreateAlarmNodeConfiguration.java | 6 + ui-ngx/src/app/core/api/alias-controller.ts | 51 +- ui-ngx/src/app/core/api/widget-api.models.ts | 1 - .../src/app/core/api/widget-subscription.ts | 216 +++++- ui-ngx/src/app/core/http/alarm.service.ts | 153 +++- ui-ngx/src/app/core/http/device.service.ts | 8 + .../src/app/core/http/rule-chain.service.ts | 25 +- .../app/core/services/item-buffer.service.ts | 18 + .../attribute/attribute-table.component.ts | 7 +- .../audit-log-details-dialog.component.ts | 13 +- .../dashboard/dashboard.component.ts | 1 + .../entity/entities-table.component.html | 28 +- .../entity/entities-table.component.ts | 51 +- .../event/event-content-dialog.component.html | 40 + .../event/event-content-dialog.component.scss | 23 + .../event/event-content-dialog.component.ts | 116 +++ .../components/event/event-table-config.ts | 75 +- .../home/components/home-components.module.ts | 3 + .../import-export/import-export.models.ts | 1 + .../import-export/import-export.service.ts | 83 +- .../widget/dynamic-widget.component.ts | 22 +- .../lib/display-columns-panel.component.html | 24 + .../lib/display-columns-panel.component.scss | 36 + .../lib/display-columns-panel.component.ts | 43 ++ .../lib/entities-table-widget.component.html | 96 +++ .../lib/entities-table-widget.component.scss | 39 + .../lib/entities-table-widget.component.ts | 710 ++++++++++++++++++ .../widget/lib/table-widget.models.ts | 55 ++ .../widget/widget-components.module.ts | 6 + .../components/widget/widget.component.ts | 65 +- .../home/models/dashboard-component.models.ts | 8 +- .../entity/entities-table-config.models.ts | 4 +- .../home/models/widget-component.models.ts | 32 +- .../rulechain/rule-node-config.component.ts | 5 +- .../rulechain/rulechain-page.component.html | 11 +- .../rulechain/rulechain-page.component.scss | 16 + .../rulechain/rulechain-page.component.ts | 111 ++- .../rulechain/rulechain-routing.module.ts | 45 +- .../rulechains-table-config.resolver.ts | 15 +- .../pages/widget/widget-library.component.ts | 31 +- .../entity/entity-type-list.component.ts | 2 +- ui-ngx/src/app/shared/models/alarm.models.ts | 23 + ui-ngx/src/app/shared/models/event.models.ts | 8 +- .../src/app/shared/models/page/page-link.ts | 11 +- .../src/app/shared/models/page/sort-order.ts | 16 + .../app/shared/models/rule-chain.models.ts | 5 +- .../src/app/shared/models/rule-node.models.ts | 8 +- .../models/telemetry/telemetry.models.ts | 2 +- ui-ngx/src/app/shared/models/widget.models.ts | 2 - ui-ngx/src/styles.scss | 7 +- 52 files changed, 2123 insertions(+), 268 deletions(-) create mode 100644 ui-ngx/src/app/modules/home/components/event/event-content-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/event/event-content-dialog.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/event/event-content-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/display-columns-panel.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/display-columns-panel.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/display-columns-panel.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/entities-table-widget.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/entities-table-widget.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/entities-table-widget.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts 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 60124c0fc3..0a6b0b0d47 100644 --- a/application/src/main/data/json/system/widget_bundles/cards.json +++ b/application/src/main/data/json/system/widget_bundles/cards.json @@ -29,11 +29,11 @@ "sizeX": 7.5, "sizeY": 6.5, "resources": [], - "templateHtml": "\n", + "templateHtml": "\n", "templateCss": "", - "controllerScript": "self.onInit = function() {\n var scope = self.ctx.$scope;\n var id = self.ctx.$scope.$injector.get('utils').guid();\n scope.tableId = \"table-\"+id;\n scope.ctx = self.ctx;\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.$broadcast('entities-table-data-updated', self.ctx.$scope.tableId);\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 };\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 \"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 \"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 \"displayEntityName\",\n \"entityNameColumnTitle\",\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, filter)\",\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}", + "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 };\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 \"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 \"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;\"}]}]}" } }, @@ -134,4 +134,4 @@ } } ] -} +} \ No newline at end of file diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCreateAlarmNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCreateAlarmNode.java index dcf7b6987d..fe409ce7c0 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCreateAlarmNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCreateAlarmNode.java @@ -33,6 +33,7 @@ import org.thingsboard.server.common.data.plugin.ComponentType; import org.thingsboard.server.common.msg.TbMsg; import java.io.IOException; +import java.util.List; @Slf4j @RuleNode( @@ -53,9 +54,12 @@ import java.io.IOException; public class TbCreateAlarmNode extends TbAbstractAlarmNode { private static ObjectMapper mapper = new ObjectMapper(); + private List relationTypes; @Override protected TbCreateAlarmNodeConfiguration loadAlarmNodeConfig(TbNodeConfiguration configuration) throws TbNodeException { + TbCreateAlarmNodeConfiguration nodeConfiguration = TbNodeUtils.convert(configuration, TbCreateAlarmNodeConfiguration.class); + relationTypes = nodeConfiguration.getRelationTypes(); return TbNodeUtils.convert(configuration, TbCreateAlarmNodeConfiguration.class); } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCreateAlarmNodeConfiguration.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCreateAlarmNodeConfiguration.java index f93c879acf..3fb0800e07 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCreateAlarmNodeConfiguration.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCreateAlarmNodeConfiguration.java @@ -19,6 +19,9 @@ import lombok.Data; import org.thingsboard.rule.engine.api.NodeConfiguration; import org.thingsboard.server.common.data.alarm.AlarmSeverity; +import java.util.Collections; +import java.util.List; + @Data public class TbCreateAlarmNodeConfiguration extends TbAbstractAlarmNodeConfiguration implements NodeConfiguration { @@ -26,6 +29,8 @@ public class TbCreateAlarmNodeConfiguration extends TbAbstractAlarmNodeConfigura private boolean propagate; private boolean useMessageAlarmData; + private List relationTypes; + @Override public TbCreateAlarmNodeConfiguration defaultConfiguration() { TbCreateAlarmNodeConfiguration configuration = new TbCreateAlarmNodeConfiguration(); @@ -38,6 +43,7 @@ public class TbCreateAlarmNodeConfiguration extends TbAbstractAlarmNodeConfigura configuration.setSeverity(AlarmSeverity.CRITICAL); configuration.setPropagate(false); configuration.setUseMessageAlarmData(false); + configuration.setRelationTypes(Collections.emptyList()); return configuration; } } diff --git a/ui-ngx/src/app/core/api/alias-controller.ts b/ui-ngx/src/app/core/api/alias-controller.ts index 2f174c80e6..c7fe014d86 100644 --- a/ui-ngx/src/app/core/api/alias-controller.ts +++ b/ui-ngx/src/app/core/api/alias-controller.ts @@ -14,7 +14,7 @@ /// limitations under the License. /// -import { AliasInfo, IAliasController, IStateController, StateEntityInfo, StateControllerHolder } from '@core/api/widget-api.models'; +import { AliasInfo, IAliasController, StateControllerHolder, StateEntityInfo } from '@core/api/widget-api.models'; import { forkJoin, Observable, of, ReplaySubject, Subject } from 'rxjs'; import { DataKey, Datasource, DatasourceType } from '@app/shared/models/widget.models'; import { deepClone } from '@core/utils'; @@ -23,54 +23,7 @@ import { UtilsService } from '@core/services/utils.service'; import { EntityAliases } from '@shared/models/alias.models'; import { EntityInfo } from '@shared/models/entity.models'; import * as equal from 'deep-equal'; -import { map, tap } from 'rxjs/operators'; - -export class DummyAliasController implements IAliasController { - - entityAliasesChanged: Observable>; - entityAliasResolved: Observable; - - [key: string]: any | null; - - constructor() { - this.entityAliasesChanged = new Subject>().asObservable(); - this.entityAliasResolved = new Subject().asObservable(); - } - - getAliasInfo(aliasId): Observable { - return of(null); - } - - resolveAlarmSource(alarmSource: Datasource): Observable { - return of(deepClone(alarmSource)); - } - - resolveDatasources(datasources: Array): Observable> { - return of(deepClone(datasources)); - } - - getEntityAliases(): EntityAliases { - return undefined; - } - - getInstantAliasInfo(aliasId: string): AliasInfo { - return undefined; - } - - updateCurrentAliasEntity(aliasId: string, currentEntity: EntityInfo) { - } - - updateEntityAliases(entityAliases: EntityAliases) { - } - - dashboardStateChanged() { - } - - getEntityAliasId(aliasName: string): string { - return null; - } - -} +import { map } from 'rxjs/operators'; export class AliasController implements IAliasController { 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 6d5a05e4cc..de0d82f592 100644 --- a/ui-ngx/src/app/core/api/widget-api.models.ts +++ b/ui-ngx/src/app/core/api/widget-api.models.ts @@ -259,5 +259,4 @@ export interface IWidgetSubscription { destroy(): void; [key: string]: any; - // TODO: } diff --git a/ui-ngx/src/app/core/api/widget-subscription.ts b/ui-ngx/src/app/core/api/widget-subscription.ts index bb9ecc560c..693ecc22c0 100644 --- a/ui-ngx/src/app/core/api/widget-subscription.ts +++ b/ui-ngx/src/app/core/api/widget-subscription.ts @@ -41,7 +41,7 @@ import { toHistoryTimewindow, WidgetTimewindow } from '@app/shared/models/time/time.models'; -import { Observable, of, ReplaySubject, Subject } from 'rxjs'; +import { Observable, 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'; @@ -78,7 +78,20 @@ export class WidgetSubscription implements IWidgetSubscription { alarms: Array; alarmSource: Datasource; - alarmSearchStatus: AlarmSearchStatus; + + private alarmSearchStatusValue: AlarmSearchStatus; + + set alarmSearchStatus(value: AlarmSearchStatus) { + if (this.alarmSearchStatusValue !== value) { + this.alarmSearchStatusValue = value; + this.onAlarmSearchStatusChanged(); + } + } + + get alarmSearchStatus(): AlarmSearchStatus { + return this.alarmSearchStatusValue; + } + alarmsPollingInterval: number; alarmSourceListener: AlarmSourceListener; @@ -136,7 +149,7 @@ export class WidgetSubscription implements IWidgetSubscription { this.callbacks.dataLoading = this.callbacks.dataLoading || (() => {}); this.callbacks.timeWindowUpdated = this.callbacks.timeWindowUpdated || (() => {}); this.alarmSource = options.alarmSource; - this.alarmSearchStatus = isDefined(options.alarmSearchStatus) ? + this.alarmSearchStatusValue = isDefined(options.alarmSearchStatus) ? options.alarmSearchStatus : AlarmSearchStatus.ANY; this.alarmsPollingInterval = isDefined(options.alarmsPollingInterval) ? options.alarmsPollingInterval : 5000; @@ -262,8 +275,30 @@ export class WidgetSubscription implements IWidgetSubscription { } private initAlarmSubscription(): Observable { - // TODO: - return of(null); + const initAlarmSubscriptionSubject = new ReplaySubject(1); + this.loadStDiff().subscribe(() => { + if (!this.ctx.aliasController) { + this.configureAlarmsData(); + initAlarmSubscriptionSubject.next(); + initAlarmSubscriptionSubject.complete(); + } else { + this.ctx.aliasController.resolveAlarmSource(this.alarmSource).subscribe( + (alarmSource) => { + this.alarmSource = alarmSource; + this.configureAlarmsData(); + initAlarmSubscriptionSubject.next(); + initAlarmSubscriptionSubject.complete(); + }, + (err) => { + initAlarmSubscriptionSubject.error(err); + } + ); + } + }); + return initAlarmSubscriptionSubject.asObservable(); + } + + private configureAlarmsData() { } private initDataSubscription(): Observable { @@ -417,6 +452,12 @@ export class WidgetSubscription implements IWidgetSubscription { } } + private onAlarmSearchStatusChanged() { + if (this.type === widgetType.alarm) { + this.update(); + } + } + updateDataVisibility(index: number): void { if (this.displayLegend) { const hidden = this.legendData.keys[index].dataKey.hidden; @@ -465,17 +506,125 @@ export class WidgetSubscription implements IWidgetSubscription { } sendOneWayCommand(method: string, params?: any, timeout?: number): Observable { - // TODO: - return undefined; + return this.sendCommand(true, method, params, timeout); } sendTwoWayCommand(method: string, params?: any, timeout?: number): Observable { - // TODO: - return undefined; + return this.sendCommand(false, method, params, timeout); } clearRpcError(): void { - // TODO: + this.rpcRejection = null; + this.rpcErrorText = null; + this.callbacks.onRpcErrorCleared(this); + } + + sendCommand(oneWayElseTwoWay: boolean, method: string, params?: any, timeout?: number): Observable { + if (!this.rpcEnabled) { + return throwError(new Error('Rpc disabled!')); + } else { + if (this.rpcRejection && this.rpcRejection.status !== 408) { + this.rpcRejection = null; + this.rpcErrorText = null; + this.callbacks.onRpcErrorCleared(this); + } + const requestBody: any = { + method, + params + }; + if (timeout && timeout > 0) { + requestBody.timeout = timeout; + } + const rpcSubject: Subject = new ReplaySubject(); + this.executingRpcRequest = true; + this.callbacks.rpcStateChanged(this); + if (this.ctx.utils.widgetEditMode) { + setTimeout(() => { + this.executingRpcRequest = false; + this.callbacks.rpcStateChanged(this); + if (oneWayElseTwoWay) { + rpcSubject.next(); + rpcSubject.complete(); + } else { + rpcSubject.next(requestBody); + rpcSubject.complete(); + } + }, 500); + } else { + this.executingSubjects.push(rpcSubject); + const targetSendFunction = oneWayElseTwoWay ? this.ctx.deviceService.sendOneWayRpcCommand : this.ctx.deviceService.sendTwoWayRpcCommand; + targetSendFunction(this.targetDeviceId, requestBody).subscribe((responseBody) => { + this.rpcRejection = null; + this.rpcErrorText = null; + const index = this.executingSubjects.indexOf(rpcSubject); + if (index >= 0) { + this.executingSubjects.splice( index, 1 ); + } + this.executingRpcRequest = this.executingSubjects.length > 0; + this.callbacks.onRpcSuccess(this); + rpcSubject.next(responseBody); + rpcSubject.complete(); + }, + (rejection: HttpErrorResponse) => { + const index = this.executingSubjects.indexOf(rpcSubject); + if (index >= 0) { + this.executingSubjects.splice( index, 1 ); + } + this.executingRpcRequest = this.executingSubjects.length > 0; + this.callbacks.rpcStateChanged(this); + if (!this.executingRpcRequest || rejection.status === 408) { + this.rpcRejection = rejection; + if (rejection.status === 408) { + this.rpcErrorText = 'Device is offline.'; + } else { + this.rpcErrorText = 'Error : ' + rejection.status + ' - ' + rejection.statusText; + const error = this.extractRejectionErrorText(rejection); + if (error) { + this.rpcErrorText += '
'; + this.rpcErrorText += error; + } + } + this.callbacks.onRpcFailed(this); + } + rpcSubject.error(rejection); + }); + } + return rpcSubject.asObservable(); + } + } + + private extractRejectionErrorText(rejection: HttpErrorResponse) { + let error = null; + if (rejection.error) { + error = rejection.error; + try { + error = rejection.error ? JSON.parse(rejection.error) : null; + } catch (e) {} + } + if (error && !error.message) { + error = this.prepareMessageFromData(error); + } else if (error && error.message) { + error = error.message; + } + return error; + } + + private prepareMessageFromData(data) { + if (typeof data === 'object' && data.constructor === ArrayBuffer) { + const msg = String.fromCharCode.apply(null, new Uint8Array(data)); + try { + const msgObj = JSON.parse(msg); + if (msgObj.message) { + return msgObj.message; + } else { + return msg; + } + } catch (e) { + return msg; + } + } else { + return data; + } } update() { @@ -550,8 +699,34 @@ export class WidgetSubscription implements IWidgetSubscription { } private alarmsSubscribe() { - // TODO: - this.notifyDataLoaded(); + this.notifyDataLoading(); + if (this.timeWindowConfig) { + this.updateRealtimeSubscription(); + if (this.subscriptionTimewindow.fixedWindow) { + this.onDataUpdated(); + } + } + this.alarmSourceListener = { + subscriptionTimewindow: this.subscriptionTimewindow, + alarmSource: this.alarmSource, + alarmSearchStatus: this.alarmSearchStatus, + alarmsPollingInterval: this.alarmsPollingInterval, + alarmsUpdated: alarms => this.alarmsUpdated(alarms) + }; + this.alarms = null; + + this.ctx.alarmService.subscribeForAlarms(this.alarmSourceListener); + + let forceUpdate = false; + if (this.alarmSource.unresolvedStateEntity || + (this.alarmSource.type === DatasourceType.entity && !this.alarmSource.entityId) + ) { + forceUpdate = true; + } + if (forceUpdate) { + this.notifyDataLoaded(); + this.onDataUpdated(); + } } @@ -570,7 +745,10 @@ export class WidgetSubscription implements IWidgetSubscription { } private alarmsUnsubscribe() { - // TODO: + if (this.alarmSourceListener) { + this.ctx.alarmService.unsubscribeFromAlarms(this.alarmSourceListener); + this.alarmSourceListener = null; + } } private checkRpcTarget(aliasIds: Array): boolean { @@ -684,6 +862,18 @@ export class WidgetSubscription implements IWidgetSubscription { } } + private alarmsUpdated(alarms: Array) { + this.notifyDataLoaded(); + const updated = !this.alarms || !deepEqual(this.alarms, alarms); + this.alarms = alarms; + if (this.subscriptionTimewindow && this.subscriptionTimewindow.realtimeWindowMs) { + this.updateTimewindow(); + } + if (updated) { + this.onDataUpdated(); + } + } + private updateLegend(dataIndex: number, data: DataSet, detectChanges: boolean) { const dataKey = this.legendData.keys[dataIndex].dataKey; const decimals = isDefined(dataKey.decimals) ? dataKey.decimals : this.decimals; diff --git a/ui-ngx/src/app/core/http/alarm.service.ts b/ui-ngx/src/app/core/http/alarm.service.ts index a1fcdb9810..61337ab385 100644 --- a/ui-ngx/src/app/core/http/alarm.service.ts +++ b/ui-ngx/src/app/core/http/alarm.service.ts @@ -15,8 +15,8 @@ /// import { Injectable } from '@angular/core'; -import { defaultHttpOptions, defaultHttpOptionsFromConfig, RequestConfig } from './http-utils'; -import { Observable } from 'rxjs/index'; +import { defaultHttpOptionsFromConfig, RequestConfig } from './http-utils'; +import { Observable } from 'rxjs'; import { HttpClient } from '@angular/common/http'; import { PageData } from '@shared/models/page/page-data'; import { EntityId } from '@shared/models/id/entity-id'; @@ -26,24 +26,42 @@ import { AlarmQuery, AlarmSearchStatus, AlarmSeverity, - AlarmStatus + AlarmStatus, + simulatedAlarm } from '@shared/models/alarm.models'; import { EntityType } from '@shared/models/entity-type.models'; -import { Datasource } from '@shared/models/widget.models'; +import { Datasource, DatasourceType } from '@shared/models/widget.models'; +import { SubscriptionTimewindow } from '@shared/models/time/time.models'; +import { UtilsService } from '@core/services/utils.service'; +import { TimePageLink } from '@shared/models/page/page-link'; +import { Direction, SortOrder } from '@shared/models/page/sort-order'; +import { concatMap, expand, map, toArray } from 'rxjs/operators'; +import { EMPTY } from 'rxjs'; +import Timeout = NodeJS.Timeout; + +interface AlarmSourceListenerQuery { + entityType: EntityType; + entityId: string; + alarmSearchStatus: AlarmSearchStatus; + alarmStatus: AlarmStatus; + fetchOriginator?: boolean; + limit?: number; + interval?: number; + startTime?: number; + endTime?: number; + onAlarms?: (alarms: Array) => void; +} export interface AlarmSourceListener { id?: string; + subscriptionTimewindow: SubscriptionTimewindow; alarmSource: Datasource; alarmsPollingInterval: number; alarmSearchStatus: AlarmSearchStatus; - alarmsQuery: { - entityType: EntityType; - entityId: string; - alarmSearchStatus: AlarmSearchStatus; - alarmStatus: AlarmStatus; - fetchOriginator?: boolean; - onAlarms?: (alarms: Array) => void; - }; + alarmsUpdated: (alarms: Array) => void; + lastUpdateTs?: number; + alarmsQuery?: AlarmSourceListenerQuery; + pollTimer?: Timeout; } @Injectable({ @@ -51,8 +69,11 @@ export interface AlarmSourceListener { }) export class AlarmService { + private alarmSourceListeners: {[id: string]: AlarmSourceListener} = {}; + constructor( - private http: HttpClient + private http: HttpClient, + private utils: UtilsService ) { } public getAlarm(alarmId: string, config?: RequestConfig): Observable { @@ -92,4 +113,110 @@ export class AlarmService { return this.http.get(url, defaultHttpOptionsFromConfig(config)); } + + public subscribeForAlarms(alarmSourceListener: AlarmSourceListener): void { + alarmSourceListener.id = this.utils.guid(); + this.alarmSourceListeners[alarmSourceListener.id] = alarmSourceListener; + const alarmSource = alarmSourceListener.alarmSource; + if (alarmSource.type === DatasourceType.function) { + setTimeout(() => { + alarmSourceListener.alarmsUpdated([simulatedAlarm]); + }, 0); + } else if (alarmSource.entityType && alarmSource.entityId) { + const pollingInterval = alarmSourceListener.alarmsPollingInterval; + alarmSourceListener.alarmsQuery = { + entityType: alarmSource.entityType, + entityId: alarmSource.entityId, + alarmSearchStatus: alarmSourceListener.alarmSearchStatus, + alarmStatus: null + }; + const originatorKeys = alarmSource.dataKeys.filter(dataKey => dataKey.name.toLocaleLowerCase().includes('originator')); + if (originatorKeys.length) { + alarmSourceListener.alarmsQuery.fetchOriginator = true; + } + const subscriptionTimewindow = alarmSourceListener.subscriptionTimewindow; + if (subscriptionTimewindow.realtimeWindowMs) { + alarmSourceListener.alarmsQuery.startTime = subscriptionTimewindow.startTs; + } else { + alarmSourceListener.alarmsQuery.startTime = subscriptionTimewindow.fixedWindow.startTimeMs; + alarmSourceListener.alarmsQuery.endTime = subscriptionTimewindow.fixedWindow.endTimeMs; + } + alarmSourceListener.alarmsQuery.onAlarms = (alarms) => { + if (subscriptionTimewindow.realtimeWindowMs) { + const now = Date.now(); + if (alarmSourceListener.lastUpdateTs) { + const interval = now - alarmSourceListener.lastUpdateTs; + alarmSourceListener.alarmsQuery.startTime += interval; + } + alarmSourceListener.lastUpdateTs = now; + } + alarmSourceListener.alarmsUpdated(alarms); + }; + this.onPollAlarms(alarmSourceListener.alarmsQuery); + alarmSourceListener.pollTimer = setInterval(this.onPollAlarms.bind(this), pollingInterval, alarmSourceListener.alarmsQuery); + } + } + + public unsubscribeFromAlarms(alarmSourceListener: AlarmSourceListener): void { + if (alarmSourceListener && alarmSourceListener.id) { + if (alarmSourceListener.pollTimer) { + clearInterval(alarmSourceListener.pollTimer); + alarmSourceListener.pollTimer = null; + } + delete this.alarmSourceListeners[alarmSourceListener.id]; + } + } + + private onPollAlarms(alarmsQuery: AlarmSourceListenerQuery): void { + this.getAlarmsByAlarmSourceQuery(alarmsQuery).subscribe((alarms) => { + alarmsQuery.onAlarms(alarms); + }); + } + + private getAlarmsByAlarmSourceQuery(alarmsQuery: AlarmSourceListenerQuery): Observable> { + const time = Date.now(); + let pageLink: TimePageLink; + const sortOrder: SortOrder = {property: 'createdTime', direction: Direction.DESC}; + if (alarmsQuery.limit) { + pageLink = new TimePageLink(alarmsQuery.limit, 0, + null, + sortOrder); + } else if (alarmsQuery.interval) { + pageLink = new TimePageLink(100, 0, + null, + sortOrder, time - alarmsQuery.interval); + } else if (alarmsQuery.startTime) { + pageLink = new TimePageLink(100, 0, + null, + sortOrder, Math.round(alarmsQuery.startTime)); + if (alarmsQuery.endTime) { + pageLink.endTime = Math.round(alarmsQuery.endTime); + } + } + return this.fetchAlarms(alarmsQuery, pageLink); + } + + private fetchAlarms(query: AlarmSourceListenerQuery, + pageLink: TimePageLink): Observable> { + const alarmQuery = new AlarmQuery( + {id: query.entityId, entityType: query.entityType}, + pageLink, + query.alarmSearchStatus, + query.alarmStatus, + query.fetchOriginator); + return this.getAlarms(alarmQuery, {ignoreLoading: true}).pipe( + expand((data) => { + if (data.hasNext && !query.limit) { + alarmQuery.pageLink.page += 1; + return this.getAlarms(alarmQuery, {ignoreLoading: true}); + } else { + return EMPTY; + } + }), + map((data) => data.data), + concatMap((data) => data), + toArray(), + map((data) => data.sort((a, b) => alarmQuery.pageLink.sort(a, b))), + ); + } } diff --git a/ui-ngx/src/app/core/http/device.service.ts b/ui-ngx/src/app/core/http/device.service.ts index d09fcd72c1..216dafd4ec 100644 --- a/ui-ngx/src/app/core/http/device.service.ts +++ b/ui-ngx/src/app/core/http/device.service.ts @@ -113,6 +113,14 @@ export class DeviceService { return this.http.delete(`/api/customer/device/${deviceId}`, defaultHttpOptionsFromConfig(config)); } + public sendOneWayRpcCommand(deviceId: string, requestBody: any, config?: RequestConfig): Observable { + return this.http.post(`/api/plugins/rpc/oneway/${deviceId}`, requestBody, defaultHttpOptionsFromConfig(config)); + } + + public sendTwoWayRpcCommand(deviceId: string, requestBody: any, config?: RequestConfig): Observable { + return this.http.post(`/api/plugins/rpc/twoway/${deviceId}`, requestBody, defaultHttpOptionsFromConfig(config)); + } + public findByQuery(query: DeviceSearchQuery, config?: RequestConfig): Observable> { return this.http.post>('/api/devices', query, defaultHttpOptionsFromConfig(config)); diff --git a/ui-ngx/src/app/core/http/rule-chain.service.ts b/ui-ngx/src/app/core/http/rule-chain.service.ts index ce7b23c47c..1c32171bbc 100644 --- a/ui-ngx/src/app/core/http/rule-chain.service.ts +++ b/ui-ngx/src/app/core/http/rule-chain.service.ts @@ -82,14 +82,7 @@ export class RuleChainService { public getResolvedRuleChainMetadata(ruleChainId: string, config?: RequestConfig): Observable { return this.getRuleChainMetadata(ruleChainId, config).pipe( - mergeMap((ruleChainMetaData) => { - return this.resolveTargetRuleChains(ruleChainMetaData.ruleChainConnections).pipe( - map((targetRuleChainsMap) => { - const resolvedRuleChainMetadata: ResolvedRuleChainMetaData = {...ruleChainMetaData, targetRuleChainsMap}; - return resolvedRuleChainMetadata; - }) - ); - }) + mergeMap((ruleChainMetaData) => this.resolveRuleChainMetadata(ruleChainMetaData)) ); } @@ -100,13 +93,15 @@ export class RuleChainService { public saveAndGetResolvedRuleChainMetadata(ruleChainMetaData: RuleChainMetaData, config?: RequestConfig): Observable { return this.saveRuleChainMetadata(ruleChainMetaData, config).pipe( - mergeMap((savedRuleChainMetaData) => { - return this.resolveTargetRuleChains(savedRuleChainMetaData.ruleChainConnections).pipe( - map((targetRuleChainsMap) => { - const resolvedRuleChainMetadata: ResolvedRuleChainMetaData = {...savedRuleChainMetaData, targetRuleChainsMap}; - return resolvedRuleChainMetadata; - }) - ); + mergeMap((savedRuleChainMetaData) => this.resolveRuleChainMetadata(savedRuleChainMetaData)) + ); + } + + public resolveRuleChainMetadata(ruleChainMetaData: RuleChainMetaData): Observable { + return this.resolveTargetRuleChains(ruleChainMetaData.ruleChainConnections).pipe( + map((targetRuleChainsMap) => { + const resolvedRuleChainMetadata: ResolvedRuleChainMetaData = {...ruleChainMetaData, targetRuleChainsMap}; + return resolvedRuleChainMetadata; }) ); } diff --git a/ui-ngx/src/app/core/services/item-buffer.service.ts b/ui-ngx/src/app/core/services/item-buffer.service.ts index 82232fda1f..48b3e851b5 100644 --- a/ui-ngx/src/app/core/services/item-buffer.service.ts +++ b/ui-ngx/src/app/core/services/item-buffer.service.ts @@ -26,10 +26,14 @@ import { Observable, of, throwError } from 'rxjs'; import { map } from 'rxjs/operators'; import { FcRuleEdge, FcRuleNode, ruleNodeTypeDescriptors } from '@shared/models/rule-node.models'; import { RuleChainService } from '@core/http/rule-chain.service'; +import { RuleChainImport } from '@shared/models/rule-chain.models'; +import { Simulate } from 'react-dom/test-utils'; +import error = Simulate.error; const WIDGET_ITEM = 'widget_item'; const WIDGET_REFERENCE = 'widget_reference'; const RULE_NODES = 'rule_nodes'; +const RULE_CHAIN_IMPORT = 'rule_chain_import'; export interface WidgetItem { widget: Widget; @@ -314,6 +318,20 @@ export class ItemBufferService { return null; } + public hasRuleChainImport(): boolean { + return this.storeHas(RULE_CHAIN_IMPORT); + } + + public storeRuleChainImport(ruleChainImport: RuleChainImport): void { + this.storeSet(RULE_CHAIN_IMPORT, ruleChainImport); + } + + public getRuleChainImport(): RuleChainImport { + const ruleChainImport: RuleChainImport = this.storeGet(RULE_CHAIN_IMPORT); + this.storeRemove(RULE_CHAIN_IMPORT); + return ruleChainImport; + } + private getOriginalColumns(dashboard: Dashboard, sourceState: string, sourceLayout: DashboardLayoutId): number { let originalColumns = 24; let gridSettings = null; diff --git a/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.ts b/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.ts index d2fcbe6297..48dd608d5c 100644 --- a/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.ts @@ -19,7 +19,8 @@ import { ChangeDetectionStrategy, Component, ElementRef, - Input, NgZone, + Input, + NgZone, OnInit, ViewChild, ViewContainerRef @@ -65,7 +66,7 @@ import { TelemetryWebsocketService } from '@core/ws/telemetry-websocket.service' import { WidgetsBundle } from '@shared/models/widgets-bundle.model'; import { DataKey, Datasource, DatasourceType, Widget, widgetType } from '@shared/models/widget.models'; import { IAliasController, IStateController, StateParams } from '@core/api/widget-api.models'; -import { AliasController, DummyAliasController } from '@core/api/alias-controller'; +import { AliasController } from '@core/api/alias-controller'; import { EntityAlias, EntityAliases } from '@shared/models/alias.models'; import { UtilsService } from '@core/services/utils.service'; import { DashboardUtilsService } from '@core/services/dashboard-utils.service'; @@ -73,8 +74,6 @@ import { NULL_UUID } from '@shared/models/id/has-uuid'; import { WidgetService } from '@core/http/widget.service'; import { toWidgetInfo } from '../../models/widget-component.models'; import { EntityService } from '@core/http/entity.service'; -import { SelectTargetLayoutDialogComponent } from '@home/pages/dashboard/layout/select-target-layout-dialog.component'; -import { DashboardLayoutId } from '@shared/models/dashboard.models'; import { AddWidgetToDashboardDialogComponent, AddWidgetToDashboardDialogData diff --git a/ui-ngx/src/app/modules/home/components/audit-log/audit-log-details-dialog.component.ts b/ui-ngx/src/app/modules/home/components/audit-log/audit-log-details-dialog.component.ts index bdd869ca94..7ca9bec6cd 100644 --- a/ui-ngx/src/app/modules/home/components/audit-log/audit-log-details-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/audit-log/audit-log-details-dialog.component.ts @@ -14,19 +14,10 @@ /// limitations under the License. /// -import { - Component, - ElementRef, - Inject, - OnInit, - Renderer2, - ViewChild -} from '@angular/core'; +import { Component, ElementRef, Inject, OnInit, Renderer2, ViewChild } from '@angular/core'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material'; -import { PageComponent } from '@shared/components/page.component'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; -import { TbAnchorComponent } from '@shared/components/tb-anchor.component'; import { ActionStatus, AuditLog } from '@shared/models/audit-log.models'; import * as ace from 'ace-builds'; @@ -57,8 +48,6 @@ export class AuditLogDetailsDialogComponent extends DialogComponent, protected router: Router, @Inject(MAT_DIALOG_DATA) public data: AuditLogDetailsDialogData, diff --git a/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.ts b/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.ts index a69331e670..9e38e75ce6 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.ts @@ -275,6 +275,7 @@ export class DashboardComponent extends PageComponent implements IDashboardCompo private updateWidgets() { this.dashboardWidgets.setWidgets(this.widgets, this.widgetLayouts); + this.dashboardWidgets.doCheck(); this.dashboardLoading = false; } diff --git a/ui-ngx/src/app/modules/home/components/entity/entities-table.component.html b/ui-ngx/src/app/modules/home/components/entity/entities-table.component.html index 075f34411d..23eec24073 100644 --- a/ui-ngx/src/app/modules/home/components/entity/entities-table.component.html +++ b/ui-ngx/src/app/modules/home/components/entity/entities-table.component.html @@ -152,18 +152,26 @@ - - {{ column.title | translate }} + + {{ column.title | translate }} - diff --git a/ui-ngx/src/app/modules/home/components/entity/entities-table.component.ts b/ui-ngx/src/app/modules/home/components/entity/entities-table.component.ts index d3eb695f44..23cb82d889 100644 --- a/ui-ngx/src/app/modules/home/components/entity/entities-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/entity/entities-table.component.ts @@ -43,7 +43,7 @@ import { EntityTableConfig, GroupActionDescriptor, HeaderActionDescriptor, - EntityColumn + EntityColumn, EntityActionTableColumn } from '@home/models/entity/entities-table-config.models'; import { EntityTypeTranslation } from '@shared/models/entity-type.models'; import { DialogService } from '@core/services/dialog.service'; @@ -55,6 +55,8 @@ import { import { Timewindow, historyInterval } from '@shared/models/time/time.models'; import {DomSanitizer, SafeHtml} from '@angular/platform-browser'; import { TbAnchorComponent } from '@shared/components/tb-anchor.component'; +import { instanceOf } from 'prop-types'; +import { isDefined, isDefinedAndNotNull, isUndefined } from '@core/utils'; @Component({ selector: 'tb-entities-table', @@ -73,12 +75,15 @@ export class EntitiesTableComponent extends PageComponent implements AfterViewIn groupActionDescriptors: Array>>; cellActionDescriptors: Array>>; - columns: Array>>; + actionColumns: Array>>; + entityColumns: Array>>; + displayedColumns: string[]; headerCellStyleCache: Array = []; cellContentCache: Array = []; + cellTooltipCache: Array = []; cellStyleCache: Array = []; @@ -230,6 +235,7 @@ export class EntitiesTableComponent extends PageComponent implements AfterViewIn private dataLoaded() { this.headerCellStyleCache.length = 0; this.cellContentCache.length = 0; + this.cellTooltipCache.length = 0; this.cellStyleCache.length = 0; } @@ -360,14 +366,19 @@ export class EntitiesTableComponent extends PageComponent implements AfterViewIn } columnsUpdated(resetData: boolean = false) { - this.columns = [...this.entitiesTableConfig.columns]; + this.entityColumns = this.entitiesTableConfig.columns.filter( + (column) => column instanceof EntityTableColumn) + .map(column => column as EntityTableColumn>); + this.actionColumns = this.entitiesTableConfig.columns.filter( + (column) => column instanceof EntityActionTableColumn) + .map(column => column as EntityActionTableColumn>); this.displayedColumns = []; if (this.selectionEnabled) { this.displayedColumns.push('select'); } - this.columns.forEach( + this.entitiesTableConfig.columns.forEach( (column) => { this.displayedColumns.push(column.key); } @@ -375,14 +386,15 @@ export class EntitiesTableComponent extends PageComponent implements AfterViewIn this.displayedColumns.push('actions'); this.headerCellStyleCache.length = 0; this.cellContentCache.length = 0; + this.cellTooltipCache.length = 0; this.cellStyleCache.length = 0; if (resetData) { this.dataSource.reset(); } } - headerCellStyle(column: EntityColumn>, col: number) { - const index = col; + headerCellStyle(column: EntityColumn>) { + const index = this.entitiesTableConfig.columns.indexOf(column); let res = this.headerCellStyleCache[index]; if (!res) { if (column instanceof EntityTableColumn) { @@ -395,9 +407,10 @@ export class EntitiesTableComponent extends PageComponent implements AfterViewIn return res; } - cellContent(entity: BaseData, column: EntityColumn>, row: number, col: number) { + cellContent(entity: BaseData, column: EntityColumn>, row: number) { if (column instanceof EntityTableColumn) { - const index = row * this.columns.length + col; + const col = this.entitiesTableConfig.columns.indexOf(column); + const index = row * this.entitiesTableConfig.columns.length + col; let res = this.cellContentCache[index]; if (!res) { res = this.domSanitizer.bypassSecurityTrustHtml(column.cellContentFunction(entity, column.key)); @@ -409,8 +422,26 @@ export class EntitiesTableComponent extends PageComponent implements AfterViewIn } } - cellStyle(entity: BaseData, column: EntityColumn>, row: number, col: number) { - const index = row * this.columns.length + col; + cellTooltip(entity: BaseData, column: EntityColumn>, row: number) { + if (column instanceof EntityTableColumn) { + const col = this.entitiesTableConfig.columns.indexOf(column); + const index = row * this.entitiesTableConfig.columns.length + col; + let res = this.cellTooltipCache[index]; + if (isUndefined(res)) { + res = column.cellTooltipFunction(entity, column.key); + res = isDefined(res) ? res : null; + this.cellTooltipCache[index] = res; + } else { + return res !== null ? res : undefined; + } + } else { + return undefined; + } + } + + cellStyle(entity: BaseData, column: EntityColumn>, row: number) { + const col = this.entitiesTableConfig.columns.indexOf(column); + const index = row * this.entitiesTableConfig.columns.length + col; let res = this.cellStyleCache[index]; if (!res) { if (column instanceof EntityTableColumn) { diff --git a/ui-ngx/src/app/modules/home/components/event/event-content-dialog.component.html b/ui-ngx/src/app/modules/home/components/event/event-content-dialog.component.html new file mode 100644 index 0000000000..40c43923ef --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/event/event-content-dialog.component.html @@ -0,0 +1,40 @@ + + +

{{ title }}

+ + +
+
+
+
+
+
+ + +
diff --git a/ui-ngx/src/app/modules/home/components/event/event-content-dialog.component.scss b/ui-ngx/src/app/modules/home/components/event/event-content-dialog.component.scss new file mode 100644 index 0000000000..6960dd7e53 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/event/event-content-dialog.component.scss @@ -0,0 +1,23 @@ +/** + * Copyright © 2016-2019 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. + */ +:host { + .tb-event-content { + width: 100%; + min-width: 400px; + height: 100%; + min-height: 50px; + } +} diff --git a/ui-ngx/src/app/modules/home/components/event/event-content-dialog.component.ts b/ui-ngx/src/app/modules/home/components/event/event-content-dialog.component.ts new file mode 100644 index 0000000000..4a9c58826b --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/event/event-content-dialog.component.ts @@ -0,0 +1,116 @@ +/// +/// Copyright © 2016-2019 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. +/// + +import { Component, ElementRef, Inject, OnInit, Renderer2, ViewChild } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; + +import * as ace from 'ace-builds'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { Router } from '@angular/router'; +import { ContentType, contentTypesMap } from '@shared/models/constants'; + +export interface EventContentDialogData { + content: string; + title: string; + contentType: ContentType; +} + +@Component({ + selector: 'tb-event-content-dialog', + templateUrl: './event-content-dialog.component.html', + styleUrls: ['./event-content-dialog.component.scss'] +}) +export class EventContentDialogComponent extends DialogComponent implements OnInit { + + @ViewChild('eventContentEditor', {static: true}) + eventContentEditorElmRef: ElementRef; + private eventContentEditor: ace.Ace.Editor; + + content: string; + title: string; + contentType: ContentType; + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: EventContentDialogData, + public dialogRef: MatDialogRef, + private renderer: Renderer2) { + super(store, router, dialogRef); + } + + ngOnInit(): void { + this.content = this.data.content; + this.title = this.data.title; + this.contentType = this.data.contentType; + + this.eventContentEditor = this.createEditor(this.eventContentEditorElmRef, this.content); + } + + createEditor(editorElementRef: ElementRef, content: string): ace.Ace.Editor { + const editorElement = editorElementRef.nativeElement; + let mode = 'java'; + if (this.contentType) { + mode = contentTypesMap.get(this.contentType).code; + if (this.contentType === ContentType.JSON && content) { + content = js_beautify(content, {indent_size: 4}); + } + } + let editorOptions: Partial = { + mode: `ace/mode/${mode}`, + theme: 'ace/theme/github', + showGutter: false, + showPrintMargin: false, + readOnly: true + }; + + const advancedOptions = { + enableSnippets: false, + enableBasicAutocompletion: false, + enableLiveAutocompletion: false + }; + + editorOptions = {...editorOptions, ...advancedOptions}; + const editor = ace.edit(editorElement, editorOptions); + editor.session.setUseWrapMode(false); + editor.setValue(content, -1); + this.updateEditorSize(editorElement, content, editor); + return editor; + } + + updateEditorSize(editorElement: any, content: string, editor: ace.Ace.Editor) { + let newHeight = 400; + let newWidth = 600; + if (content && content.length > 0) { + const lines = content.split('\n'); + newHeight = 16 * lines.length + 16; + let maxLineLength = 0; + lines.forEach((row) => { + const line = row.replace(/\t/g, ' ').replace(/\n/g, ''); + const lineLength = line.length; + maxLineLength = Math.max(maxLineLength, lineLength); + }); + newWidth = 8 * maxLineLength + 16; + } + // newHeight = Math.min(400, newHeight); + this.renderer.setStyle(editorElement, 'minHeight', newHeight.toString() + 'px'); + this.renderer.setStyle(editorElement, 'height', newHeight.toString() + 'px'); + this.renderer.setStyle(editorElement, 'width', newWidth.toString() + 'px'); + editor.resize(); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/event/event-table-config.ts b/ui-ngx/src/app/modules/home/components/event/event-table-config.ts index 9e2973a264..687e74ecb7 100644 --- a/ui-ngx/src/app/modules/home/components/event/event-table-config.ts +++ b/ui-ngx/src/app/modules/home/components/event/event-table-config.ts @@ -32,8 +32,12 @@ import { EntityTypeResource } from '@shared/models/entity-type.models'; import { Observable } from 'rxjs'; import { PageData } from '@shared/models/page/page-data'; import { Direction } from '@shared/models/page/sort-order'; -import { MsgDataType } from '@shared/models/rule-node.models'; import { DialogService } from '@core/services/dialog.service'; +import { ContentType } from '@shared/models/constants'; +import { + EventContentDialogComponent, + EventContentDialogData +} from '@home/components/event/event-content-dialog.component'; export class EventTableConfig extends EntityTableConfig { @@ -157,34 +161,62 @@ export class EventTableConfig extends EntityTableConfig { case DebugEventType.DEBUG_RULE_NODE: case DebugEventType.DEBUG_RULE_CHAIN: this.columns.push( - new EntityTableColumn('type', 'event.type', '100%', - (entity) => entity.body.type, entity => ({}), false), - new EntityTableColumn('entity', 'event.entity', '100%', - (entity) => entity.body.entityName, entity => ({}), false), + new EntityTableColumn('type', 'event.type', '40px', + (entity) => entity.body.type, entity => ({ + padding: '0 12px 0 0', + }), false, key => ({ + padding: '0 12px 0 0' + })), + new EntityTableColumn('entity', 'event.entity', '100px', + (entity) => entity.body.entityName, entity => ({ + padding: '0 12px 0 0', + }), false, key => ({ + padding: '0 12px 0 0' + })), new EntityTableColumn('msgId', 'event.message-id', '100%', - (entity) => entity.body.msgId, entity => ({}), false), + (entity) => entity.body.msgId, entity => ({ + whiteSpace: 'nowrap', + padding: '0 12px 0 0', + textOverflow: 'ellipsis', + display: 'inline-block', + lineHeight: '48px', + }), false, key => ({ + padding: '0 12px 0 0' + }), + entity => entity.body.msgId), new EntityTableColumn('msgType', 'event.message-type', '100%', - (entity) => entity.body.msgType, entity => ({}), false), - new EntityTableColumn('relationType', 'event.relation-type', '100%', - (entity) => entity.body.relationType, entity => ({}), false), + (entity) => entity.body.msgType, entity => ({ + whiteSpace: 'nowrap', + padding: '0 12px 0 0', + textOverflow: 'ellipsis', + display: 'inline-block', + lineHeight: '48px', + }), false, key => ({ + padding: '0 12px 0 0' + }), + entity => entity.body.msgType), + new EntityTableColumn('relationType', 'event.relation-type', '100px', + (entity) => entity.body.relationType, entity => ({padding: '0 12px 0 0',}),false, key => ({ + padding: '0 12px 0 0' + })), new EntityActionTableColumn('data', 'event.data', { name: this.translate.instant('action.view'), icon: 'more_horiz', - isEnabled: (entity) => entity.body.data && entity.body.data.length > 0, + isEnabled: (entity) => entity.body.data ? entity.body.data.length > 0 : false, onAction: ($event, entity) => this.showContent($event, entity.body.data, 'event.data', entity.body.dataType) }, - '60px'), + '40px'), new EntityActionTableColumn('metadata', 'event.metadata', { name: this.translate.instant('action.view'), icon: 'more_horiz', - isEnabled: (entity) => entity.body.metadata && entity.body.metadata.length > 0, + isEnabled: (entity) => entity.body.metadata ? entity.body.metadata.length > 0 : false, onAction: ($event, entity) => this.showContent($event, entity.body.metadata, - 'event.metadata', MsgDataType.JSON) + 'event.metadata', ContentType.JSON) }, - '60px'), + '40px'), new EntityActionTableColumn('error', 'event.error', { name: this.translate.instant('action.view'), @@ -193,7 +225,7 @@ export class EventTableConfig extends EntityTableConfig { onAction: ($event, entity) => this.showContent($event, entity.body.error, 'event.error') }, - '60px') + '40px') ); break; } @@ -202,11 +234,18 @@ export class EventTableConfig extends EntityTableConfig { } } - showContent($event: MouseEvent, content: string, title: string, contentType: MsgDataType = null): void { + showContent($event: MouseEvent, content: string, title: string, contentType: ContentType = null): void { if ($event) { $event.stopPropagation(); } - // TODO: - this.dialogService.todo(); + this.dialog.open(EventContentDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + content, + title, + contentType + } + }); } } diff --git a/ui-ngx/src/app/modules/home/components/home-components.module.ts b/ui-ngx/src/app/modules/home/components/home-components.module.ts index 65c32d68bf..5fce77ab5e 100644 --- a/ui-ngx/src/app/modules/home/components/home-components.module.ts +++ b/ui-ngx/src/app/modules/home/components/home-components.module.ts @@ -63,11 +63,13 @@ import { ImportDialogComponent } from './import-export/import-dialog.component'; import { AddWidgetToDashboardDialogComponent } from './attribute/add-widget-to-dashboard-dialog.component'; import { ImportDialogCsvComponent } from './import-export/import-dialog-csv.component'; import { TableColumnsAssignmentComponent } from './import-export/table-columns-assignment.component'; +import { EventContentDialogComponent } from '@home/components/event/event-content-dialog.component'; @NgModule({ entryComponents: [ AddEntityDialogComponent, AuditLogDetailsDialogComponent, + EventContentDialogComponent, EventTableHeaderComponent, RelationDialogComponent, AlarmTableHeaderComponent, @@ -94,6 +96,7 @@ import { TableColumnsAssignmentComponent } from './import-export/table-columns-a ContactComponent, AuditLogTableComponent, AuditLogDetailsDialogComponent, + EventContentDialogComponent, EventTableHeaderComponent, EventTableComponent, RelationTableComponent, diff --git a/ui-ngx/src/app/modules/home/components/import-export/import-export.models.ts b/ui-ngx/src/app/modules/home/components/import-export/import-export.models.ts index 1d41c4da02..d0ebe90003 100644 --- a/ui-ngx/src/app/modules/home/components/import-export/import-export.models.ts +++ b/ui-ngx/src/app/modules/home/components/import-export/import-export.models.ts @@ -19,6 +19,7 @@ import { DashboardLayoutId } from '@shared/models/dashboard.models'; import { WidgetsBundle } from '@shared/models/widgets-bundle.model'; import { ActionNotificationShow } from '@core/notification/notification.actions'; import { ActionType } from '@shared/models/audit-log.models'; +import { RuleChain, RuleChainMetaData } from '@shared/models/rule-chain.models'; export interface ImportWidgetResult { widget: Widget; diff --git a/ui-ngx/src/app/modules/home/components/import-export/import-export.service.ts b/ui-ngx/src/app/modules/home/components/import-export/import-export.service.ts index b396caf6ee..af388baeb5 100644 --- a/ui-ngx/src/app/modules/home/components/import-export/import-export.service.ts +++ b/ui-ngx/src/app/modules/home/components/import-export/import-export.service.ts @@ -44,15 +44,16 @@ import { EntityAliasesDialogData } from '@home/components/alias/entity-aliases-dialog.component'; import { ItemBufferService, WidgetItem } from '@core/services/item-buffer.service'; -import { CsvToJsonConfig, ImportWidgetResult, WidgetsBundleItem, CsvToJsonResult } from './import-export.models'; +import { ImportWidgetResult, WidgetsBundleItem } from './import-export.models'; import { EntityType } from '@shared/models/entity-type.models'; import { UtilsService } from '@core/services/utils.service'; import { WidgetService } from '@core/http/widget.service'; import { NULL_UUID } from '@shared/models/id/has-uuid'; import { WidgetsBundle } from '@shared/models/widgets-bundle.model'; -import { ImportDialogCsvComponent, ImportDialogCsvData } from './import-dialog-csv.component'; -import { ImportEntityData, ImportEntitiesResultInfo } from '@shared/models/entity.models'; +import { ImportEntitiesResultInfo, ImportEntityData } from '@shared/models/entity.models'; import { RequestConfig } from '@core/http/http-utils'; +import { RuleChain, RuleChainImport, RuleChainMetaData } from '@shared/models/rule-chain.models'; +import { RuleChainService } from '@core/http/rule-chain.service'; // @dynamic @Injectable() @@ -66,6 +67,7 @@ export class ImportExportService { private dashboardUtils: DashboardUtilsService, private widgetService: WidgetService, private entityService: EntityService, + private ruleChainService: RuleChainService, private utils: UtilsService, private itembuffer: ItemBufferService, private dialog: MatDialog) { @@ -365,6 +367,81 @@ export class ImportExportService { ); } + public exportRuleChain(ruleChainId: string) { + this.ruleChainService.getRuleChain(ruleChainId).pipe( + mergeMap(ruleChain => { + return this.ruleChainService.getRuleChainMetadata(ruleChainId).pipe( + map((ruleChainMetaData) => { + const ruleChainExport: RuleChainImport = { + ruleChain: this.prepareRuleChain(ruleChain), + metadata: this.prepareRuleChainMetaData(ruleChainMetaData) + }; + return ruleChainExport; + }) + ); + }) + ).subscribe((ruleChainExport) => { + let name = ruleChainExport.ruleChain.name; + name = name.toLowerCase().replace(/\W/g, '_'); + this.exportToPc(ruleChainExport, name + '.json'); + }, + (e) => { + this.handleExportError(e, 'rulechain.export-failed-error'); + } + ); + } + + public importRuleChain(): Observable { + return this.openImportDialog('rulechain.import', 'rulechain.rulechain-file').pipe( + mergeMap((ruleChainImport: RuleChainImport) => { + if (!this.validateImportedRuleChain(ruleChainImport)) { + this.store.dispatch(new ActionNotificationShow( + {message: this.translate.instant('rulechain.invalid-rulechain-file-error'), + type: 'error'})); + throw new Error('Invalid rule chain file'); + } else { + return this.ruleChainService.resolveRuleChainMetadata(ruleChainImport.metadata).pipe( + map((resolvedMetadata) => { + ruleChainImport.resolvedMetadata = resolvedMetadata; + return ruleChainImport; + }) + ); + } + }), + catchError((err) => { + return of(null); + }) + ); + } + + private prepareRuleChain(ruleChain: RuleChain): RuleChain { + ruleChain = this.prepareExport(ruleChain); + if (ruleChain.firstRuleNodeId) { + ruleChain.firstRuleNodeId = null; + } + ruleChain.root = false; + return ruleChain; + } + + private prepareRuleChainMetaData(ruleChainMetaData: RuleChainMetaData) { + delete ruleChainMetaData.ruleChainId; + for (let i = 0; i < ruleChainMetaData.nodes.length; i++) { + var node = ruleChainMetaData.nodes[i]; + delete node.ruleChainId; + ruleChainMetaData.nodes[i] = this.prepareExport(node); + } + return ruleChainMetaData; + } + + private validateImportedRuleChain(ruleChainImport: RuleChainImport): boolean { + if (isUndefined(ruleChainImport.ruleChain) + || isUndefined(ruleChainImport.metadata) + || isUndefined(ruleChainImport.ruleChain.name)) { + return false; + } + return true; + } + private sumObject(obj1: any, obj2: any): any { Object.keys(obj2).map((key) => { if (isObject(obj2[key])) { diff --git a/ui-ngx/src/app/modules/home/components/widget/dynamic-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/dynamic-widget.component.ts index 9b56a5c806..b70f694c36 100644 --- a/ui-ngx/src/app/modules/home/components/widget/dynamic-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/dynamic-widget.component.ts @@ -15,22 +15,17 @@ /// import { PageComponent } from '@shared/components/page.component'; -import { Inject, Input, OnDestroy, OnInit } from '@angular/core'; +import { Inject, Injector, Input, OnDestroy, OnInit } from '@angular/core'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { WidgetContext, IDynamicWidgetComponent } from '@home/models/widget-component.models'; import { ExceptionData } from '@shared/models/error.models'; import { HttpErrorResponse } from '@angular/common/http'; import { RafService } from '@core/services/raf.service'; +import { DeviceService } from '@core/http/device.service'; export class DynamicWidgetComponent extends PageComponent implements IDynamicWidgetComponent, OnInit, OnDestroy { - @Input() - widgetContext: WidgetContext; - - @Input() - errorMessages: string[]; - executingRpcRequest: boolean; rpcEnabled: boolean; rpcErrorText: string; @@ -39,8 +34,19 @@ export class DynamicWidgetComponent extends PageComponent implements IDynamicWid [key: string]: any; constructor(@Inject(RafService) public raf: RafService, - @Inject(Store) protected store: Store) { + @Inject(Store) protected store: Store, + @Inject(Injector) private $injector: Injector, + @Inject('widgetContext') public readonly ctx: WidgetContext, + @Inject('errorMessages') public readonly errorMessages: string[]) { super(store); + this.ctx.$injector = $injector; + this.ctx.$scope = this; + if (this.ctx.defaultSubscription) { + this.executingRpcRequest = this.ctx.defaultSubscription.executingRpcRequest; + this.rpcEnabled = this.ctx.defaultSubscription.rpcEnabled; + this.rpcErrorText = this.ctx.defaultSubscription.rpcErrorText; + this.rpcRejection = this.ctx.defaultSubscription.rpcRejection; + } } ngOnInit() { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/display-columns-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/display-columns-panel.component.html new file mode 100644 index 0000000000..97f7e8be8b --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/display-columns-panel.component.html @@ -0,0 +1,24 @@ + +
+ + + {{ column.title }} + +
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/display-columns-panel.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/display-columns-panel.component.scss new file mode 100644 index 0000000000..0ead8c7992 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/display-columns-panel.component.scss @@ -0,0 +1,36 @@ +/** + * Copyright © 2016-2019 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. + */ +:host { + width: 100%; + height: 100%; + min-width: 300px; + overflow: hidden; + background: #fff; + border-radius: 4px; + box-shadow: + 0 7px 8px -4px rgba(0, 0, 0, .2), + 0 13px 19px 2px rgba(0, 0, 0, .14), + 0 5px 24px 4px rgba(0, 0, 0, .12); + + .mat-content { + overflow: hidden; + background-color: #fff; + } + + .mat-padding { + padding: 16px; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/display-columns-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/display-columns-panel.component.ts new file mode 100644 index 0000000000..4d6a9c37ea --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/display-columns-panel.component.ts @@ -0,0 +1,43 @@ +/// +/// Copyright © 2016-2019 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. +/// + +import { Component, Inject, InjectionToken } from '@angular/core'; +import { DisplayColumn } from '@home/components/widget/lib/table-widget.models'; + +export const DISPLAY_COLUMNS_PANEL_DATA = new InjectionToken('DisplayColumnsPanelData'); + +export interface DisplayColumnsPanelData { + columns: DisplayColumn[]; + columnsUpdated: (columns: DisplayColumn[]) => void; +} + +@Component({ + selector: 'tb-display-columns-panel', + templateUrl: './display-columns-panel.component.html', + styleUrls: ['./display-columns-panel.component.scss'] +}) +export class DisplayColumnsPanelComponent { + + columns: DisplayColumn[]; + + constructor(@Inject(DISPLAY_COLUMNS_PANEL_DATA) public data: DisplayColumnsPanelData) { + this.columns = this.data.columns; + } + + public update() { + this.data.columnsUpdated(this.columns); + } +} 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 new file mode 100644 index 0000000000..ae779f55be --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/entities-table-widget.component.html @@ -0,0 +1,96 @@ + +
+
+ +
+ + +   + + + +
+
+
+ + + {{ column.title }} + + + + + + + +
+ +
+
+ + + + +
+
+
+ + +
+ entity.no-entities-prompt +
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/entities-table-widget.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/entities-table-widget.component.scss new file mode 100644 index 0000000000..1333a90161 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/entities-table-widget.component.scss @@ -0,0 +1,39 @@ +/** + * Copyright © 2016-2019 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. + */ +:host { + width: 100%; + height: 100%; + .tb-entity-table { + .mat-table, .mat-paginator, mat-toolbar.mat-table-toolbar { + background: transparent; + } + mat-toolbar { + height: 39px; + max-height: 39px; + .mat-toolbar-tools { + height: 39px; + max-height: 39px; + } + } + .table-container { + overflow: auto; + } + } +} + +:host ::ng-deep .mat-sort-header-sorted .mat-sort-header-arrow { + opacity: 1 !important; +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/entities-table-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/entities-table-widget.component.ts new file mode 100644 index 0000000000..666e14de3d --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/entities-table-widget.component.ts @@ -0,0 +1,710 @@ +/// +/// Copyright © 2016-2019 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. +/// + +import { AfterViewInit, Component, ElementRef, Input, OnInit, ViewChild, ViewContainerRef } from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { WidgetAction, WidgetContext } from '@home/models/widget-component.models'; +import { + DataKey, + Datasource, + DatasourceData, + DatasourceType, + WidgetActionDescriptor, + WidgetConfig +} from '@shared/models/widget.models'; +import { IWidgetSubscription } from '@core/api/widget-api.models'; +import { UtilsService } from '@core/services/utils.service'; +import { TranslateService } from '@ngx-translate/core'; +import { deepClone, isDefined, isNumber } from '@core/utils'; +import * as tinycolor_ from 'tinycolor2'; +import cssjs from '@core/css/css'; +import { PageLink } from '@shared/models/page/page-link'; +import { Direction, SortOrder, sortOrderFromString } from '@shared/models/page/sort-order'; +import { DataSource } from '@angular/cdk/typings/collections'; +import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; +import { CollectionViewer } from '@angular/cdk/collections'; +import { BehaviorSubject, fromEvent, merge, Observable, of } from 'rxjs'; +import { emptyPageData, PageData } from '@shared/models/page/page-data'; +import { EntityId } from '@shared/models/id/entity-id'; +import { entityTypeTranslations } from '@shared/models/entity-type.models'; +import { catchError, debounceTime, distinctUntilChanged, map, tap } from 'rxjs/operators'; +import { MatPaginator } from '@angular/material/paginator'; +import { MatSort } from '@angular/material/sort'; +import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; +import { + CellContentInfo, + CellStyleInfo, + DisplayColumn, + EntityColumn, + EntityData, + getEntityValue +} from '@home/components/widget/lib/table-widget.models'; +import { ConnectedPosition, Overlay, OverlayConfig, OverlayRef } from '@angular/cdk/overlay'; +import { ComponentPortal, PortalInjector } from '@angular/cdk/portal'; +import { + DISPLAY_COLUMNS_PANEL_DATA, + DisplayColumnsPanelComponent, + DisplayColumnsPanelData +} from '@home/components/widget/lib/display-columns-panel.component'; + +const tinycolor = tinycolor_; + +interface EntitiesTableWidgetSettings { + entitiesTitle: string; + enableSearch: boolean; + enableSelectColumnDisplay: boolean; + displayEntityName: boolean; + entityNameColumnTitle: string; + displayEntityType: boolean; + displayPagination: boolean; + defaultPageSize: number; + defaultSortOrder: string; +} + +interface EntitiesTableDataKeySettings { + columnWidth: string; + useCellStyleFunction: boolean; + cellStyleFunction: string; + useCellContentFunction: boolean; + cellContentFunction: string; +} + +@Component({ + selector: 'tb-entities-table-widget', + templateUrl: './entities-table-widget.component.html', + styleUrls: ['./entities-table-widget.component.scss'] +}) +export class EntitiesTableWidgetComponent extends PageComponent implements OnInit, AfterViewInit { + + @Input() + ctx: WidgetContext; + + @ViewChild('searchInput', {static: false}) searchInputField: ElementRef; + @ViewChild(MatPaginator, {static: false}) paginator: MatPaginator; + @ViewChild(MatSort, {static: false}) sort: MatSort; + + public displayPagination = true; + public pageSizeOptions; + public pageLink: PageLink; + public textSearchMode = false; + public columns: Array = []; + public displayedColumns: string[] = []; + public actionCellDescriptors: WidgetActionDescriptor[]; + public entityDatasource: EntityDatasource; + + private settings: EntitiesTableWidgetSettings; + private widgetConfig: WidgetConfig; + private subscription: IWidgetSubscription; + + private defaultPageSize = 10; + private defaultSortOrder = 'entityName'; + + private contentsInfo: {[key: string]: CellContentInfo} = {}; + private stylesInfo: {[key: string]: CellStyleInfo} = {}; + private columnWidth: {[key: string]: string} = {}; + + private searchAction: WidgetAction = { + name: 'action.search', + show: true, + icon: 'search', + onAction: () => { + this.enterFilterMode(); + } + }; + + private columnDisplayAction: WidgetAction = { + name: 'entity.columns-to-display', + show: true, + icon: 'view_column', + onAction: ($event) => { + this.editColumnsToDisplay($event); + } + }; + + constructor(protected store: Store, + private elementRef: ElementRef, + private overlay: Overlay, + private viewContainerRef: ViewContainerRef, + private utils: UtilsService, + private translate: TranslateService, + private domSanitizer: DomSanitizer) { + super(store); + + const sortOrder: SortOrder = sortOrderFromString(this.defaultSortOrder); + this.pageLink = new PageLink(this.defaultPageSize, 0, null, sortOrder); + } + + ngOnInit(): void { + this.ctx.$scope.entitiesTableWidget = this; + this.settings = this.ctx.settings; + this.widgetConfig = this.ctx.widgetConfig; + this.subscription = this.ctx.defaultSubscription; + this.initializeConfig(); + this.updateDatasources(); + this.ctx.updateWidgetParams(); + } + + ngAfterViewInit(): void { + fromEvent(this.searchInputField.nativeElement, 'keyup') + .pipe( + debounceTime(150), + distinctUntilChanged(), + tap(() => { + if (this.displayPagination) { + this.paginator.pageIndex = 0; + } + this.updateData(); + }) + ) + .subscribe(); + + if (this.displayPagination) { + this.sort.sortChange.subscribe(() => this.paginator.pageIndex = 0); + } + (this.displayPagination ? merge(this.sort.sortChange, this.paginator.page) : this.sort.sortChange) + .pipe( + tap(() => this.updateData()) + ) + .subscribe(); + this.updateData(); + } + + public onDataUpdated() { + this.entityDatasource.updateEntitiesData(this.subscription.data); + this.ctx.detectChanges(); + } + + private initializeConfig() { + this.ctx.widgetActions = [this.searchAction, this.columnDisplayAction]; + + let entitiesTitle: string; + + if (this.settings.entitiesTitle && this.settings.entitiesTitle.length) { + entitiesTitle = this.utils.customTranslation(this.settings.entitiesTitle, this.settings.entitiesTitle); + } else { + entitiesTitle = this.translate.instant('entity.entities'); + } + + const datasource = this.subscription.datasources[0]; + this.ctx.widgetTitle = this.utils.createLabelFromDatasource(datasource, entitiesTitle); + + this.searchAction.show = isDefined(this.settings.enableSearch) ? this.settings.enableSearch : true; + this.displayPagination = isDefined(this.settings.displayPagination) ? this.settings.displayPagination : true; + this.columnDisplayAction.show = isDefined(this.settings.enableSelectColumnDisplay) ? this.settings.enableSelectColumnDisplay : true; + + const pageSize = this.settings.defaultPageSize; + if (isDefined(pageSize) && isNumber(pageSize) && pageSize > 0) { + this.defaultPageSize = pageSize; + } + this.pageSizeOptions = [this.defaultPageSize, this.defaultPageSize*2, this.defaultPageSize*3]; + + if (this.settings.defaultSortOrder && this.settings.defaultSortOrder.length) { + this.defaultSortOrder = this.settings.defaultSortOrder; + } + + this.pageLink.pageSize = this.displayPagination ? this.defaultPageSize : Number.POSITIVE_INFINITY; + this.pageLink.sortOrder = sortOrderFromString(this.defaultSortOrder); + + const origColor = this.widgetConfig.color || 'rgba(0, 0, 0, 0.87)'; + const origBackgroundColor = this.widgetConfig.backgroundColor || 'rgb(255, 255, 255)'; + const defaultColor = tinycolor(origColor); + const mdDark = defaultColor.setAlpha(0.87).toRgbString(); + const mdDarkSecondary = defaultColor.setAlpha(0.54).toRgbString(); + const mdDarkDisabled = defaultColor.setAlpha(0.26).toRgbString(); + const mdDarkDivider = defaultColor.setAlpha(0.12).toRgbString(); + + const cssString = + '.mat-input-element::placeholder {\n' + + ' color: ' + mdDarkSecondary + ';\n'+ + '}\n' + + '.mat-input-element::-moz-placeholder {\n' + + ' color: ' + mdDarkSecondary + ';\n'+ + '}\n' + + '.mat-input-element::-webkit-input-placeholder {\n' + + ' color: ' + mdDarkSecondary + ';\n'+ + '}\n' + + '.mat-input-element:-ms-input-placeholder {\n' + + ' color: ' + mdDarkSecondary + ';\n'+ + '}\n' + + 'mat-toolbar.mat-table-toolbar {\n'+ + 'color: ' + mdDark + ';\n'+ + '}\n'+ + 'mat-toolbar.mat-table-toolbar button.mat-icon-button mat-icon {\n'+ + 'color: ' + mdDarkSecondary + ';\n'+ + '}\n'+ + '.mat-table .mat-header-row {\n'+ + 'background-color: ' + origBackgroundColor + ';\n'+ + '}\n'+ + '.mat-table .mat-header-cell {\n'+ + 'color: ' + mdDarkSecondary + ';\n'+ + '}\n'+ + '.mat-table .mat-header-cell .mat-sort-header-arrow {\n'+ + 'color: ' + mdDarkDisabled + ';\n'+ + '}\n'+ + '.mat-table .mat-row, .mat-table .mat-header-row {\n'+ + 'border-bottom-color: '+mdDarkDivider+';\n'+ + '}\n'+ + '.mat-table .mat-row:not(.tb-current-entity):not(:hover) .mat-cell.mat-table-sticky, .mat-table .mat-header-cell.mat-table-sticky {\n'+ + 'background-color: ' + origBackgroundColor + ';\n'+ + '}\n'+ + '.mat-table .mat-cell {\n'+ + 'color: ' + mdDark + ';\n'+ + '}\n'+ + '.mat-table .mat-cell button.mat-icon-button mat-icon {\n'+ + 'color: ' + mdDarkSecondary + ';\n'+ + '}\n'+ + '.mat-divider {\n'+ + 'border-top-color: ' + mdDarkDivider + ';\n'+ + '}\n'+ + '.mat-paginator {\n'+ + 'color: ' + mdDarkSecondary + ';\n'+ + '}\n'+ + '.mat-paginator button.mat-icon-button {\n'+ + 'color: ' + mdDarkSecondary + ';\n'+ + '}\n'+ + '.mat-paginator button.mat-icon-button[disabled][disabled] {\n'+ + 'color: ' + mdDarkDisabled + ';\n'+ + '}\n'+ + '.mat-paginator .mat-select-value {\n'+ + 'color: ' + mdDarkSecondary + ';\n'+ + '}'; + + const cssParser = new cssjs(); + cssParser.testMode = false; + const namespace = 'entities-table-' + this.utils.hashCode(cssString); + cssParser.cssPreviewNamespace = namespace; + cssParser.createStyleElement(namespace, cssString); + $(this.elementRef.nativeElement).addClass(namespace); + } + + private updateDatasources() { + + this.actionCellDescriptors = this.ctx.actionsApi.getActionDescriptors('actionCellButton'); + + const displayEntityName = isDefined(this.settings.displayEntityName) ? this.settings.displayEntityName : true; + let entityNameColumnTitle: string; + if (this.settings.entityNameColumnTitle && this.settings.entityNameColumnTitle.length) { + entityNameColumnTitle = this.utils.customTranslation(this.settings.entityNameColumnTitle, this.settings.entityNameColumnTitle); + } else { + entityNameColumnTitle = this.translate.instant('entity.entity-name'); + } + const displayEntityType = isDefined(this.settings.displayEntityType) ? this.settings.displayEntityType : true; + + if (displayEntityName) { + this.displayedColumns.push('entityName'); + this.columns.push( + { + name: 'entityName', + label: 'entityName', + title: entityNameColumnTitle + } as EntityColumn + ); + this.contentsInfo['entityName'] = { + useCellContentFunction: false + }; + this.stylesInfo['entityName'] = { + useCellStyleFunction: false + }; + this.columnWidth['entityName'] = '100px'; + } + if (displayEntityType) { + this.displayedColumns.push('entityType'); + this.columns.push( + { + name: 'entityType', + label: 'entityType', + title: this.translate.instant('entity.entity-type'), + } as EntityColumn + ); + this.contentsInfo['entityType'] = { + useCellContentFunction: false + }; + this.stylesInfo['entityType'] = { + useCellStyleFunction: false + }; + this.columnWidth['entityType'] = '100px'; + } + + const dataKeys: Array = []; + + const datasource = this.subscription.datasources[0]; + + if (datasource) { + datasource.dataKeys.forEach((_dataKey) => { + const dataKey: EntityColumn = deepClone(_dataKey) as EntityColumn; + if (dataKey.type === DataKeyType.function) { + dataKey.name = dataKey.label; + } + dataKeys.push(dataKey); + + dataKey.title = this.utils.customTranslation(dataKey.label, dataKey.label); + const keySettings: EntitiesTableDataKeySettings = dataKey.settings; + + let cellStyleFunction: Function = null; + let useCellStyleFunction = false; + + if (keySettings.useCellStyleFunction === true) { + if (isDefined(keySettings.cellStyleFunction) && keySettings.cellStyleFunction.length > 0) { + try { + cellStyleFunction = new Function('value', keySettings.cellStyleFunction); + useCellStyleFunction = true; + } catch (e) { + cellStyleFunction = null; + useCellStyleFunction = false; + } + } + } + this.stylesInfo[dataKey.label] = { + useCellStyleFunction, + cellStyleFunction + }; + + let cellContentFunction: Function = null; + let useCellContentFunction = false; + + if (keySettings.useCellContentFunction === true) { + if (isDefined(keySettings.cellContentFunction) && keySettings.cellContentFunction.length > 0) { + try { + cellContentFunction = new Function('value, entity, ctx', keySettings.cellContentFunction); + useCellContentFunction = true; + } catch (e) { + cellContentFunction = null; + useCellContentFunction = false; + } + } + } + + this.contentsInfo[dataKey.label] = { + useCellContentFunction, + cellContentFunction, + units: dataKey.units, + decimals: dataKey.decimals + }; + + const columnWidth = isDefined(keySettings.columnWidth) ? keySettings.columnWidth : '0px'; + this.columnWidth[dataKey.label] = columnWidth; + this.displayedColumns.push(dataKey.label); + this.columns.push(dataKey); + }); + } + + if (this.actionCellDescriptors.length) { + this.displayedColumns.push('actions'); + } + this.entityDatasource = new EntityDatasource( + this.translate, dataKeys, this.subscription.datasources); + } + + private editColumnsToDisplay($event: Event) { + if ($event) { + $event.stopPropagation(); + } + const target = $event.target || $event.srcElement || $event.currentTarget; + const config = new OverlayConfig(); + config.backdropClass = 'cdk-overlay-transparent-backdrop'; + config.hasBackdrop = true; + const connectedPosition: ConnectedPosition = { + originX: 'end', + originY: 'bottom', + overlayX: 'end', + overlayY: 'top' + }; + config.positionStrategy = this.overlay.position().flexibleConnectedTo(target as HTMLElement) + .withPositions([connectedPosition]); + + const overlayRef = this.overlay.create(config); + overlayRef.backdropClick().subscribe(() => { + overlayRef.dispose(); + }); + + const columns: DisplayColumn[] = this.columns.map(column => { + return { + title: column.title, + label: column.label, + display: this.displayedColumns.indexOf(column.label) > -1 + } + }); + + const injectionTokens = new WeakMap([ + [DISPLAY_COLUMNS_PANEL_DATA, { + columns, + columnsUpdated: (newColumns) => { + this.displayedColumns = newColumns.filter(column => column.display).map(column => column.label); + this.displayedColumns.push('actions'); + } + } as DisplayColumnsPanelData], + [OverlayRef, overlayRef] + ]); + const injector = new PortalInjector(this.viewContainerRef.injector, injectionTokens); + overlayRef.attach(new ComponentPortal(DisplayColumnsPanelComponent, + this.viewContainerRef, injector)); + this.ctx.detectChanges(); + } + + private enterFilterMode() { + this.textSearchMode = true; + this.pageLink.textSearch = ''; + this.ctx.hideTitlePanel = true; + this.ctx.detectChanges(true); + setTimeout(() => { + this.searchInputField.nativeElement.focus(); + this.searchInputField.nativeElement.setSelectionRange(0, 0); + }, 10); + } + + exitFilterMode() { + this.textSearchMode = false; + this.pageLink.textSearch = null; + if (this.displayPagination) { + this.paginator.pageIndex = 0; + } + this.updateData(); + this.ctx.hideTitlePanel = false; + this.ctx.detectChanges(true); + } + + private updateData() { + if (this.displayPagination) { + this.pageLink.page = this.paginator.pageIndex; + this.pageLink.pageSize = this.paginator.pageSize; + } else { + this.pageLink.page = 0; + } + this.pageLink.sortOrder.property = this.sort.active; + this.pageLink.sortOrder.direction = Direction[this.sort.direction.toUpperCase()]; + this.entityDatasource.loadEntities(this.pageLink); + this.ctx.detectChanges(); + } + + public trackByColumnLabel(index, column: EntityColumn) { + return column.label; + } + + public headerStyle(key: EntityColumn): any { + return this.widthStyle(key); + } + + public cellStyle(entity: EntityData, key: EntityColumn): any { + let style: any = {}; + if (entity && key) { + const styleInfo = this.stylesInfo[key.label]; + const value = getEntityValue(entity, key); + if (styleInfo.useCellStyleFunction && styleInfo.cellStyleFunction) { + try { + style = styleInfo.cellStyleFunction(value); + } catch (e) { + style = {}; + } + } else { + style = this.defaultStyle(key, value); + } + } + const widthStyle = this.widthStyle(key); + style = {...style, ...widthStyle}; + return style; + } + + private widthStyle(key: EntityColumn): any { + let style: any = {}; + const columnWidth = this.columnWidth[key.label]; + if (columnWidth !== "0px") { + style.minWidth = columnWidth; + style.width = columnWidth; + } else { + style.minWidth = "auto"; + style.width = "auto"; + } + return style; + } + + public cellContent(entity: EntityData, key: EntityColumn): SafeHtml { + let strContent = ''; + if (entity && key) { + const contentInfo = this.contentsInfo[key.label]; + const value = getEntityValue(entity, key); + if (contentInfo.useCellContentFunction && contentInfo.cellContentFunction) { + if (isDefined(value)) { + strContent = '' + value; + } + var content = strContent; + try { + content = contentInfo.cellContentFunction(value, entity, this.ctx); + } catch (e) { + content = strContent; + } + } else { + const decimals = (contentInfo.decimals || contentInfo.decimals === 0) ? contentInfo.decimals : this.ctx.widgetConfig.decimals; + const units = contentInfo.units || this.ctx.widgetConfig.units; + content = this.ctx.utils.formatValue(value, decimals, units, true); + } + return this.domSanitizer.bypassSecurityTrustHtml(content); + } else { + return strContent; + } + } + + public onRowClick($event: Event, entity: EntityData) { + if ($event) { + $event.stopPropagation(); + } + this.entityDatasource.toggleCurrentEntity(entity); + const descriptors = this.ctx.actionsApi.getActionDescriptors('rowClick'); + if (descriptors.length) { + let entityId; + let entityName; + if (entity) { + entityId = entity.id; + entityName = entity.entityName; + } + this.ctx.actionsApi.handleWidgetAction($event, descriptors[0], entityId, entityName); + } + } + + public onActionButtonClick($event: Event, entity: EntityData, actionDescriptor: WidgetActionDescriptor) { + if ($event) { + $event.stopPropagation(); + } + let entityId; + let entityName; + if (entity) { + entityId = entity.id; + entityName = entity.entityName; + } + this.ctx.actionsApi.handleWidgetAction($event, actionDescriptor, entityId, entityName); + } + + private defaultStyle(key: EntityColumn, value: any): any { + return {}; + } + +} + + + +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 currentEntity: EntityData = null; + + constructor( + private translate: TranslateService, + private dataKeys: Array, + datasources: Array + ) { + + for (const datasource of datasources) { + if (datasource.type === DatasourceType.entity && !datasource.entityId) { + continue; + } + const entity: EntityData = { + id: {} as EntityId, + entityName: 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) => { + entity[dataKey.label] = ''; + }); + this.allEntities.push(entity); + } + this.allEntitiesSubject.next(this.allEntities); + } + + connect(collectionViewer: CollectionViewer): Observable> { + return this.entitiesSubject.asObservable(); + } + + disconnect(collectionViewer: CollectionViewer): void { + this.entitiesSubject.complete(); + this.pageDataSubject.complete(); + } + + loadEntities(pageLink: PageLink) { + this.fetchEntities(pageLink).pipe( + catchError(() => of(emptyPageData())), + ).subscribe( + (pageData) => { + this.entitiesSubject.next(pageData.data); + this.pageDataSubject.next(pageData); + } + ); + } + + 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++) { + const dataKey = this.dataKeys[a]; + const index = i * this.dataKeys.length + a; + 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] = ''; + } + } + } + this.allEntitiesSubject.next(this.allEntities); + } + + isEmpty(): Observable { + return this.entitiesSubject.pipe( + map((entities) => !entities.length) + ); + } + + total(): Observable { + return this.pageDataSubject.pipe( + map((pageData) => pageData.totalElements) + ); + } + + public toggleCurrentEntity(entity: EntityData): boolean { + if (this.currentEntity !== entity) { + this.currentEntity = entity; + return true; + } else { + return false; + } + } + + public isCurrentEntity(entity: EntityData): boolean { + return (this.currentEntity && entity && this.currentEntity.id && entity.id) && + (this.currentEntity.id.id === entity.id.id); + } + + 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 new file mode 100644 index 0000000000..07ec5671d0 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts @@ -0,0 +1,55 @@ +/// +/// Copyright © 2016-2019 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. +/// + +import { EntityId } from '@shared/models/id/entity-id'; +import { DataKey } from '@shared/models/widget.models'; + +export interface EntityData { + id: EntityId; + entityName: string; + entityType?: string; + [key: string]: any; +} + +export interface EntityColumn extends DataKey { + title: string; +} + +export interface DisplayColumn { + title: string; + label: string; + display: boolean; +} + +export interface CellContentInfo { + useCellContentFunction: boolean; + cellContentFunction?: Function; + units?: string; + decimals?: number; +} + +export interface CellStyleInfo { + useCellStyleFunction: boolean; + cellStyleFunction?: Function; +} + +export function getEntityValue(entity: any, key: DataKey): any { + return getDescendantProp(entity, key.label); +} + +export function getDescendantProp(obj: any, path: string): any { + return path.split('.').reduce((acc, part) => acc && acc[part], obj) +} diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts b/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts index d81dcca350..cce9d797cb 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts @@ -19,18 +19,24 @@ import { CommonModule } from '@angular/common'; import { SharedModule } from '@app/shared/shared.module'; import { AlarmDetailsDialogComponent } from '@home/components/alarm/alarm-details-dialog.component'; import { LegendComponent } from '@home/components/widget/legend.component'; +import { EntitiesTableWidgetComponent } from '@home/components/widget/lib/entities-table-widget.component'; +import { DisplayColumnsPanelComponent } from '@home/components/widget/lib/display-columns-panel.component'; @NgModule({ entryComponents: [ + DisplayColumnsPanelComponent ], declarations: [ + DisplayColumnsPanelComponent, + EntitiesTableWidgetComponent ], imports: [ CommonModule, SharedModule ], exports: [ + EntitiesTableWidgetComponent ] }) export class WidgetComponentsModule { } 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 a803741bf1..5fc43e8d03 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 @@ -27,7 +27,7 @@ import { NgZone, OnChanges, OnDestroy, - OnInit, + OnInit, ReflectiveInjector, SimpleChanges, Type, ViewChild, ViewContainerRef, @@ -93,12 +93,14 @@ import { EntityService } from '@core/http/entity.service'; import { AssetService } from '@core/http/asset.service'; import { DialogService } from '@core/services/dialog.service'; import { CustomDialogService } from '@home/components/widget/dialog/custom-dialog.service'; +import { DatePipe } from '@angular/common'; const ServicesMap = new Map>(); ServicesMap.set('deviceService', DeviceService); ServicesMap.set('assetService', AssetService); ServicesMap.set('dialogs', DialogService); ServicesMap.set('customDialog', CustomDialogService); +ServicesMap.set('date', DatePipe); @Component({ selector: 'tb-widget', @@ -249,6 +251,7 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI } this.widgetContext = this.dashboardWidget.widgetContext; + this.widgetContext.changeDetector = this.cd; this.widgetContext.servicesMap = ServicesMap; this.widgetContext.isEdit = this.isEdit; this.widgetContext.isMobile = this.isMobile; @@ -420,6 +423,7 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI this.initialize().subscribe( () => { + this.cd.detectChanges(); this.onInit(); }, (err) => { @@ -579,13 +583,11 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI } } )); - - this.configureDynamicWidgetComponent(); if (!this.typeParameters.useCustomDatasources) { - // this.cre this.createDefaultSubscription().subscribe( () => { this.subscriptionInited = true; + this.configureDynamicWidgetComponent(); initSubject.next(); initSubject.complete(); }, @@ -597,6 +599,7 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI } else { this.loadingData = false; this.subscriptionInited = true; + this.configureDynamicWidgetComponent(); initSubject.next(); initSubject.complete(); } @@ -621,15 +624,24 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI private configureDynamicWidgetComponent() { this.widgetContentContainer.clear(); - this.dynamicWidgetComponentRef = this.widgetContentContainer.createComponent(this.widgetInfo.componentFactory); + const injector: Injector = Injector.create( + { + providers: [ + { + provide: 'widgetContext', + useValue: this.widgetContext + }, + { + provide: 'errorMessages', + useValue: this.errorMessages + } + ], + parent: this.injector + } + ); + this.dynamicWidgetComponentRef = this.widgetContentContainer.createComponent(this.widgetInfo.componentFactory, 0, injector); this.dynamicWidgetComponent = this.dynamicWidgetComponentRef.instance; - this.dynamicWidgetComponent.widgetContext = this.widgetContext; - this.dynamicWidgetComponent.errorMessages = this.errorMessages; - - this.widgetContext.$scope = this.dynamicWidgetComponent; - this.widgetContext.$scope.$injector = this.injector; - const containerElement = $(this.elementRef.nativeElement.querySelector('#widget-container')); // this.widgetContext.$container = $('> ng-component:not([id="container"])', containerElement); @@ -810,22 +822,30 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI }; options.callbacks = { rpcStateChanged: (subscription) => { - this.dynamicWidgetComponent.rpcEnabled = subscription.rpcEnabled; - this.dynamicWidgetComponent.executingRpcRequest = subscription.executingRpcRequest; + if (this.dynamicWidgetComponent) { + this.dynamicWidgetComponent.rpcEnabled = subscription.rpcEnabled; + this.dynamicWidgetComponent.executingRpcRequest = subscription.executingRpcRequest; + } }, onRpcSuccess: (subscription) => { - this.dynamicWidgetComponent.executingRpcRequest = subscription.executingRpcRequest; - this.dynamicWidgetComponent.rpcErrorText = subscription.rpcErrorText; - this.dynamicWidgetComponent.rpcRejection = subscription.rpcRejection; + if (this.dynamicWidgetComponent) { + this.dynamicWidgetComponent.executingRpcRequest = subscription.executingRpcRequest; + this.dynamicWidgetComponent.rpcErrorText = subscription.rpcErrorText; + this.dynamicWidgetComponent.rpcRejection = subscription.rpcRejection; + } }, onRpcFailed: (subscription) => { - this.dynamicWidgetComponent.executingRpcRequest = subscription.executingRpcRequest; - this.dynamicWidgetComponent.rpcErrorText = subscription.rpcErrorText; - this.dynamicWidgetComponent.rpcRejection = subscription.rpcRejection; + if (this.dynamicWidgetComponent) { + this.dynamicWidgetComponent.executingRpcRequest = subscription.executingRpcRequest; + this.dynamicWidgetComponent.rpcErrorText = subscription.rpcErrorText; + this.dynamicWidgetComponent.rpcRejection = subscription.rpcRejection; + } }, onRpcErrorCleared: (subscription) => { - this.dynamicWidgetComponent.rpcErrorText = null; - this.dynamicWidgetComponent.rpcRejection = null; + if (this.dynamicWidgetComponent) { + this.dynamicWidgetComponent.rpcErrorText = null; + this.dynamicWidgetComponent.rpcRejection = null; + } } }; this.createSubscription(options).subscribe( @@ -897,12 +917,11 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI } else { url = `/dashboards/${targetDashboardId}?state=${state}`; } - const urlTree = this.router.parseUrl(url); this.router.navigateByUrl(url); break; case WidgetActionType.custom: const customFunction = descriptor.customFunction; - if (isDefined(customFunction) && customFunction.length > 0) { + if (customFunction && customFunction.length > 0) { try { if (!additionalParams) { additionalParams = {}; diff --git a/ui-ngx/src/app/modules/home/models/dashboard-component.models.ts b/ui-ngx/src/app/modules/home/models/dashboard-component.models.ts index a3c237b115..6a44e5bafb 100644 --- a/ui-ngx/src/app/modules/home/models/dashboard-component.models.ts +++ b/ui-ngx/src/app/modules/home/models/dashboard-component.models.ts @@ -17,11 +17,11 @@ import { GridsterComponent, GridsterConfig, GridsterItem, GridsterItemComponentInterface } from 'angular-gridster2'; import { Widget, widgetType, WidgetPosition } from '@app/shared/models/widget.models'; import { WidgetLayout, WidgetLayouts } from '@app/shared/models/dashboard.models'; -import { WidgetAction, WidgetContext, WidgetHeaderAction } from './widget-component.models'; +import { IDashboardWidget, WidgetAction, WidgetContext, WidgetHeaderAction } from './widget-component.models'; import { Timewindow } from '@shared/models/time/time.models'; import { Observable, of, Subject } from 'rxjs'; import { guid, isDefined, isUndefined } from '@app/core/utils'; -import { IterableDiffer, KeyValueDiffer } from '@angular/core'; +import { IterableDiffer, KeyValueDiffer, NgZone } from '@angular/core'; import { IAliasController, IStateController } from '@app/core/api/widget-api.models'; import * as deepEqual from 'deep-equal'; @@ -264,7 +264,7 @@ export class DashboardWidgets implements Iterable { } -export class DashboardWidget implements GridsterItem { +export class DashboardWidget implements GridsterItem, IDashboardWidget { highlighted = false; selected = false; @@ -302,7 +302,7 @@ export class DashboardWidget implements GridsterItem { customHeaderActions: Array; widgetActions: Array; - widgetContext = new WidgetContext(this.dashboard, this.widget); + widgetContext = new WidgetContext(this.dashboard, this, this.widget); widgetId: string; diff --git a/ui-ngx/src/app/modules/home/models/entity/entities-table-config.models.ts b/ui-ngx/src/app/modules/home/models/entity/entities-table-config.models.ts index 18236ca35b..4c4982d357 100644 --- a/ui-ngx/src/app/modules/home/models/entity/entities-table-config.models.ts +++ b/ui-ngx/src/app/modules/home/models/entity/entities-table-config.models.ts @@ -41,6 +41,7 @@ export type EntityActionFunction> = (action: EntityAct export type CreateEntityOperation> = () => Observable; export type CellContentFunction> = (entity: T, key: string) => string; +export type CellTooltipFunction> = (entity: T, key: string) => string | undefined; export type HeaderCellStyleFunction> = (key: string) => object; export type CellStyleFunction> = (entity: T, key: string) => object; @@ -86,7 +87,8 @@ export class EntityTableColumn> extends BaseEntityTabl public cellContentFunction: CellContentFunction = (entity, property) => entity[property], public cellStyleFunction: CellStyleFunction = () => ({}), public sortable: boolean = true, - public headerCellStyleFunction: HeaderCellStyleFunction = () => ({})) { + public headerCellStyleFunction: HeaderCellStyleFunction = () => ({}), + public cellTooltipFunction: CellTooltipFunction = () => undefined) { super('content', key, title, maxWidth, sortable); } } diff --git a/ui-ngx/src/app/modules/home/models/widget-component.models.ts b/ui-ngx/src/app/modules/home/models/widget-component.models.ts index 4a9701f839..020242e54c 100644 --- a/ui-ngx/src/app/modules/home/models/widget-component.models.ts +++ b/ui-ngx/src/app/modules/home/models/widget-component.models.ts @@ -40,7 +40,7 @@ import { WidgetActionsApi, WidgetSubscriptionApi, WidgetSubscriptionContext, WidgetSubscriptionOptions } from '@core/api/widget-api.models'; -import { ComponentFactory, Type } from '@angular/core'; +import { ChangeDetectorRef, ComponentFactory, Injector, NgZone, Type } from '@angular/core'; import { HttpErrorResponse } from '@angular/common/http'; import { RafService } from '@core/services/raf.service'; import { WidgetTypeId } from '@shared/models/id/widget-type-id'; @@ -69,9 +69,14 @@ export interface WidgetAction extends IWidgetAction { show: boolean; } +export interface IDashboardWidget { + updateWidgetParams(); +} + export class WidgetContext { constructor(public dashboard: IDashboardComponent, + private dashboardWidget: IDashboardWidget, private widget: Widget) {} get stateController(): IStateController { @@ -102,6 +107,25 @@ export class WidgetContext { return isDefined(this.widget.config.decimals) ? this.widget.config.decimals : 2; } + set changeDetector(cd: ChangeDetectorRef) { + this._changeDetector = cd; + } + + private _changeDetector: ChangeDetectorRef; + + detectChanges(updateWidgetParams: boolean = false) { + if (updateWidgetParams) { + this.dashboardWidget.updateWidgetParams(); + } + this._changeDetector.detectChanges(); + } + + updateWidgetParams() { + setTimeout(() => { + this.dashboardWidget.updateWidgetParams(); + }, 0); + } + inited = false; subscriptions: {[id: string]: IWidgetSubscription} = {}; @@ -167,11 +191,13 @@ export class WidgetContext { widgetActions?: Array; servicesMap?: Map>; + + $injector?: Injector; } export interface IDynamicWidgetComponent { - widgetContext: WidgetContext; - errorMessages: string[]; + readonly ctx: WidgetContext; + readonly errorMessages: string[]; executingRpcRequest: boolean; rpcEnabled: boolean; rpcErrorText: string; diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-config.component.ts b/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-config.component.ts index 26c41289ae..d3d4fba033 100644 --- a/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-config.component.ts +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-config.component.ts @@ -36,6 +36,7 @@ import { RuleChainService } from '@core/http/rule-chain.service'; import { coerceBooleanProperty } from '@angular/cdk/coercion'; import { TranslateService } from '@ngx-translate/core'; import { JsonObjectEditComponent } from '@shared/components/json-object-edit.component'; +import { deepClone } from '@core/utils'; @Component({ selector: 'tb-rule-node-config', @@ -134,7 +135,7 @@ export class RuleNodeConfigComponent implements ControlValueAccessor, OnInit, On } writeValue(value: RuleNodeConfiguration): void { - this.configuration = value; + this.configuration = deepClone(value); if (this.changeSubscription) { this.changeSubscription.unsubscribe(); this.changeSubscription = null; @@ -145,7 +146,7 @@ export class RuleNodeConfigComponent implements ControlValueAccessor, OnInit, On this.updateModel(configuration); }); } else { - this.ruleNodeConfigFormGroup.get('configuration').patchValue(value, {emitEvent: false}); + this.ruleNodeConfigFormGroup.get('configuration').patchValue(this.configuration, {emitEvent: false}); this.changeSubscription = this.ruleNodeConfigFormGroup.get('configuration').valueChanges.subscribe( (configuration: RuleNodeConfiguration) => { this.updateModel(configuration); diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.html b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.html index 99e40cd539..dbab9a2d6b 100644 --- a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.html +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.html @@ -111,16 +111,21 @@
- + - + +
diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.scss b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.scss index 462554287c..399105d3a9 100644 --- a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.scss +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.scss @@ -19,6 +19,22 @@ .tb-rulechain { width: 100%; height: 100%; + + .mat-tab-group.tb-rulenode-details { + > .mat-tab-body-wrapper { + position: absolute; + top: 49px; + left: 0; + right: 0; + bottom: 0; + } + > .mat-tab-header { + .mat-tab-label { + min-width: 40px; + } + } + } + button.mat-button.mat-icon-button.tb-fullscreen-button { position: absolute; top: 10px; diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.ts b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.ts index 516057a1d9..1eac5e3b67 100644 --- a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.ts +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.ts @@ -38,9 +38,11 @@ import { DialogService } from '@core/services/dialog.service'; import { AuthService } from '@core/auth/auth.service'; import { ActivatedRoute, Router } from '@angular/router'; import { - inputNodeComponent, + inputNodeComponent, NodeConnectionInfo, ResolvedRuleChainMetaData, RuleChain, + RuleChainConnectionInfo, RuleChainImport, + RuleChainMetaData, ruleChainNodeComponent } from '@shared/models/rule-chain.models'; import { FcItemInfo, FlowchartConstants, NgxFlowchartComponent, UserCallbacks } from 'ngx-flowchart/dist/ngx-flowchart'; @@ -50,6 +52,7 @@ import { FcRuleNodeType, getRuleNodeHelpLink, LinkLabel, + RuleNode, RuleNodeComponentDescriptor, RuleNodeType, ruleNodeTypeDescriptors, @@ -66,8 +69,10 @@ import { RuleNodeLinkComponent } from './rule-node-link.component'; import { DialogComponent } from '@shared/components/dialog.component'; import { MatMenuTrigger } from '@angular/material/menu'; import { ItemBufferService, RuleNodeConnection } from '@core/services/item-buffer.service'; +import { Hotkey } from 'angular2-hotkeys'; +import { EntityType } from '@shared/models/entity-type.models'; import Timeout = NodeJS.Timeout; -import { Hotkey, HotkeysService } from 'angular2-hotkeys'; +import { DebugEventType, EventType } from '@shared/models/event.models'; @Component({ selector: 'tb-rulechain-page', @@ -94,6 +99,10 @@ export class RuleChainPageComponent extends PageComponent @ViewChild('ruleChainMenuTrigger', {static: true}) ruleChainMenuTrigger: MatMenuTrigger; + eventTypes = EventType; + + debugEventTypes = DebugEventType; + ruleChainMenuPosition = { x: '0px', y: '0px' }; contextMenuEvent: MouseEvent; @@ -260,13 +269,15 @@ export class RuleChainPageComponent extends PageComponent private init() { this.initHotKeys(); - this.ruleChain = this.route.snapshot.data.ruleChain; - if (this.route.snapshot.data.import && !this.ruleChain) { - this.router.navigateByUrl('ruleChains'); - return; - } this.isImport = this.route.snapshot.data.import; - this.ruleChainMetaData = this.route.snapshot.data.ruleChainMetaData; + if (this.isImport) { + const ruleChainImport: RuleChainImport = this.itembuffer.getRuleChainImport(); + this.ruleChain = ruleChainImport.ruleChain; + this.ruleChainMetaData = ruleChainImport.resolvedMetadata; + } else { + this.ruleChain = this.route.snapshot.data.ruleChain; + this.ruleChainMetaData = this.route.snapshot.data.ruleChainMetaData; + } this.ruleNodeComponents = this.route.snapshot.data.ruleNodeComponents; for (const type of ruleNodeTypesLibrary) { const desc = ruleNodeTypeDescriptors.get(type); @@ -564,7 +575,7 @@ export class RuleChainPageComponent extends PageComponent if (!ruleChainNode) { ruleChainNode = { id: 'rule-chain-node-' + this.nextNodeID++, - name: ruleChain.name ? name : 'Unresolved', + name: ruleChain.name ? ruleChain.name : 'Unresolved', targetRuleChainId: ruleChain.name ? ruleChainConnection.targetRuleChainId.id : null, error: ruleChain.name ? undefined : this.translate.instant('rulenode.invalid-target-rulechain'), additionalInfo: ruleChainConnection.additionalInfo, @@ -833,7 +844,6 @@ export class RuleChainPageComponent extends PageComponent } onModelChanged() { - console.log('Model changed!'); this.isDirtyValue = true; this.validate(); } @@ -1162,7 +1172,86 @@ export class RuleChainPageComponent extends PageComponent } saveRuleChain() { - // TODO: + let saveRuleChainObservable: Observable; + if (this.isImport) { + saveRuleChainObservable = this.ruleChainService.saveRuleChain(this.ruleChain); + } else { + saveRuleChainObservable = of(this.ruleChain); + } + saveRuleChainObservable.subscribe((ruleChain) => { + this.ruleChain = ruleChain; + const ruleChainMetaData: RuleChainMetaData = { + ruleChainId: this.ruleChain.id, + nodes: [], + connections: [], + ruleChainConnections: [] + }; + const nodes: FcRuleNode[] = []; + this.ruleChainModel.nodes.forEach((node) => { + if (node.component.type !== RuleNodeType.INPUT && node.component.type !== RuleNodeType.RULE_CHAIN) { + const ruleNode: RuleNode = { + id: node.ruleNodeId, + type: node.component.clazz, + name: node.name, + configuration: node.configuration, + additionalInfo: node.additionalInfo ? node.additionalInfo : {}, + debugMode: node.debugMode + }; + ruleNode.additionalInfo.layoutX = Math.round(node.x); + ruleNode.additionalInfo.layoutY = Math.round(node.y); + ruleChainMetaData.nodes.push(ruleNode); + nodes.push(node); + } + }); + const firstNodeEdge = this.ruleChainModel.edges.find((edge) => edge.source === this.inputConnectorId+''); + if (firstNodeEdge) { + const firstNode = this.ruleChainCanvas.modelService.nodes.getNodeByConnectorId(firstNodeEdge.destination); + ruleChainMetaData.firstNodeIndex = nodes.indexOf(firstNode); + } + this.ruleChainModel.edges.forEach((edge) => { + const sourceNode = this.ruleChainCanvas.modelService.nodes.getNodeByConnectorId(edge.source); + const destNode = this.ruleChainCanvas.modelService.nodes.getNodeByConnectorId(edge.destination); + if (sourceNode.component.type !== RuleNodeType.INPUT) { + const fromIndex = nodes.indexOf(sourceNode); + if (destNode.component.type === RuleNodeType.RULE_CHAIN) { + const ruleChainConnection = { + fromIndex, + targetRuleChainId: {entityType: EntityType.RULE_CHAIN, id: destNode.targetRuleChainId}, + additionalInfo: destNode.additionalInfo ? destNode.additionalInfo : {} + } as RuleChainConnectionInfo; + ruleChainConnection.additionalInfo.layoutX = Math.round(destNode.x); + ruleChainConnection.additionalInfo.layoutY = Math.round(destNode.y); + ruleChainConnection.additionalInfo.ruleChainNodeId = destNode.id; + edge.labels.forEach((label) => { + const newRuleChainConnection = deepClone(ruleChainConnection); + newRuleChainConnection.type = label; + ruleChainMetaData.ruleChainConnections.push(newRuleChainConnection); + }); + } else { + const toIndex = nodes.indexOf(destNode); + const nodeConnection = { + fromIndex, + toIndex + } as NodeConnectionInfo; + edge.labels.forEach((label) => { + const newNodeConnection = deepClone(nodeConnection); + newNodeConnection.type = label; + ruleChainMetaData.connections.push(newNodeConnection); + }); + } + } + }); + this.ruleChainService.saveAndGetResolvedRuleChainMetadata(ruleChainMetaData).subscribe((ruleChainMetaData) => { + this.ruleChainMetaData = ruleChainMetaData; + if (this.isImport) { + this.isDirtyValue = false; + this.isImport = false; + this.router.navigateByUrl(`ruleChains/${this.ruleChain.id.id}`); + } else { + this.createRuleChainModel(); + } + }); + }); } revertRuleChain() { diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-routing.module.ts b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-routing.module.ts index 16dc106694..9fb8b17daa 100644 --- a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-routing.module.ts +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-routing.module.ts @@ -16,7 +16,14 @@ import * as AngularCore from '@angular/core'; import { Injectable, NgModule } from '@angular/core'; -import { ActivatedRouteSnapshot, Resolve, RouterModule, Routes } from '@angular/router'; +import { + ActivatedRouteSnapshot, + CanActivate, + Resolve, Router, + RouterModule, + RouterStateSnapshot, + Routes, UrlTree +} from '@angular/router'; import { EntitiesTableComponent } from '../../components/entity/entities-table.component'; import { Authority } from '@shared/models/authority.enum'; @@ -41,6 +48,7 @@ import * as TranslateCore from '@ngx-translate/core'; import * as TbCore from '@core/public-api'; import * as TbShared from '@shared/public-api'; import * as TbHomeComponents from '@home/components/public-api'; +import { ItemBufferService } from '@core/public-api'; declare const SystemJS; @@ -98,6 +106,23 @@ export class RuleNodeComponentsResolver implements Resolve | Promise | boolean | UrlTree { + if (this.itembuffer.hasRuleChainImport()) { + return true; + } else { + return this.router.parseUrl('ruleChains'); + } + } + +} + export const ruleChainBreadcumbLabelFunction: BreadCrumbLabelFunction = ((route, translate, component) => { let label: string = component.ruleChain.name; if (component.ruleChain.root) { @@ -153,6 +178,7 @@ const routes: Routes = [ { path: 'ruleChain/import', component: RuleChainPageComponent, + canActivate: [RuleChainImportGuard], canDeactivate: [ConfirmOnExitGuard], data: { breadcrumb: { @@ -164,8 +190,6 @@ const routes: Routes = [ import: true }, resolve: { - ruleChain: 'importRuleChain', - ruleChainMetaData: 'importRuleChainMetadata', ruleNodeComponents: RuleNodeComponentsResolver } } @@ -182,20 +206,7 @@ const routes: Routes = [ RuleChainResolver, ResolvedRuleChainMetaDataResolver, RuleNodeComponentsResolver, - { - provide: 'importRuleChain', - useValue: (route: ActivatedRouteSnapshot) => { - const ruleChainImport: RuleChainImport = route.params.ruleChainImport; - return ruleChainImport.ruleChain; - } - }, - { - provide: 'importRuleChainMetadata', - useValue: (route: ActivatedRouteSnapshot) => { - const ruleChainImport: RuleChainImport = route.params.ruleChainImport; - return ruleChainImport.metadata; - } - } + RuleChainImportGuard ] }) export class RuleChainRoutingModule { } diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rulechains-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/rulechain/rulechains-table-config.resolver.ts index e7c7c4d76a..e725ed8f9a 100644 --- a/ui-ngx/src/app/modules/home/pages/rulechain/rulechains-table-config.resolver.ts +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rulechains-table-config.resolver.ts @@ -32,6 +32,8 @@ import {RuleChainService} from '@core/http/rule-chain.service'; import {RuleChainComponent} from '@modules/home/pages/rulechain/rulechain.component'; import {DialogService} from '@core/services/dialog.service'; import { RuleChainTabsComponent } from '@home/pages/rulechain/rulechain-tabs.component'; +import { ImportExportService } from '@home/components/import-export/import-export.service'; +import { ItemBufferService } from '@core/services/item-buffer.service'; @Injectable() export class RuleChainsTableConfigResolver implements Resolve> { @@ -40,6 +42,8 @@ export class RuleChainsTableConfigResolver implements Resolve { + if (ruleChainImport) { + this.itembuffer.storeRuleChainImport(ruleChainImport); + this.router.navigateByUrl(`ruleChains/ruleChain/import`); + } + }); } openRuleChain($event: Event, ruleChain: RuleChain) { @@ -135,8 +143,7 @@ export class RuleChainsTableConfigResolver implements Resolve { return { + getStateParams(): StateParams { + return {}; + } + } as IStateController}, + {}); @ViewChild('dashboard', {static: true}) dashboard: IDashboardComponent; @@ -97,7 +100,9 @@ export class WidgetLibraryComponent extends PageComponent implements OnInit { private dialogService: DialogService, private importExport: ImportExportService, private dialog: MatDialog, - private translate: TranslateService) { + private translate: TranslateService, + private utils: UtilsService, + private entityService: EntityService) { super(store); this.authUser = getCurrentAuthUser(this.store); diff --git a/ui-ngx/src/app/shared/components/entity/entity-type-list.component.ts b/ui-ngx/src/app/shared/components/entity/entity-type-list.component.ts index 97cec76f72..829f066c02 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-type-list.component.ts +++ b/ui-ngx/src/app/shared/components/entity/entity-type-list.component.ts @@ -190,7 +190,7 @@ export class EntityTypeListComponent implements ControlValueAccessor, OnInit, Af this.entityTypeList = []; value.forEach((entityType) => { this.entityTypeList.push({ - name: this.translate.instant(entityTypeTranslations.get(entityType).type), + name: entityTypeTranslations.has(entityType) ? this.translate.instant(entityTypeTranslations.get(entityType).type) : 'Unknown', value: entityType }); }); diff --git a/ui-ngx/src/app/shared/models/alarm.models.ts b/ui-ngx/src/app/shared/models/alarm.models.ts index 6d408e1d22..f014f865a7 100644 --- a/ui-ngx/src/app/shared/models/alarm.models.ts +++ b/ui-ngx/src/app/shared/models/alarm.models.ts @@ -22,6 +22,8 @@ import {AlarmId} from '@shared/models/id/alarm-id'; import {EntityId} from '@shared/models/id/entity-id'; import { ActionStatus } from '@shared/models/audit-log.models'; import { TimePageLink } from '@shared/models/page/page-link'; +import { NULL_UUID } from '@shared/models/id/has-uuid'; +import { EntityType } from '@shared/models/entity-type.models'; export enum AlarmSeverity { CRITICAL = 'CRITICAL', @@ -103,6 +105,27 @@ export interface AlarmInfo extends Alarm { originatorName: string; } +export const simulatedAlarm: AlarmInfo = { + tenantId: new TenantId(NULL_UUID), + createdTime: new Date().getTime(), + startTs: new Date().getTime(), + endTs: 0, + ackTs: 0, + clearTs: 0, + originatorName: 'Simulated', + originator: { + entityType: EntityType.DEVICE, + id: "1" + }, + type: 'TEMPERATURE', + severity: AlarmSeverity.MAJOR, + status: AlarmStatus.ACTIVE_UNACK, + details: { + message: "Temperature is high!" + }, + propagate: false +}; + export interface AlarmField { keyName: string; value: string; diff --git a/ui-ngx/src/app/shared/models/event.models.ts b/ui-ngx/src/app/shared/models/event.models.ts index a8f8927a99..b19a376109 100644 --- a/ui-ngx/src/app/shared/models/event.models.ts +++ b/ui-ngx/src/app/shared/models/event.models.ts @@ -14,15 +14,11 @@ /// limitations under the License. /// -import { ActionStatus, ActionType } from '@shared/models/audit-log.models'; import { BaseData } from '@shared/models/base-data'; -import { AuditLogId } from '@shared/models/id/audit-log-id'; import { TenantId } from '@shared/models/id/tenant-id'; -import { CustomerId } from '@shared/models/id/customer-id'; import { EntityId } from '@shared/models/id/entity-id'; -import { UserId } from '@shared/models/id/user-id'; import { EventId } from './id/event-id'; -import { MsgDataType } from './rule-node.models'; +import { ContentType } from '@shared/models/constants'; export enum EventType { ERROR = 'ERROR', @@ -72,7 +68,7 @@ export interface DebugRuleNodeEventBody extends BaseEventBody { msgId: string; msgType: string; relationType: string; - dataType: MsgDataType; + dataType: ContentType; data: string; metadata: string; error: string; diff --git a/ui-ngx/src/app/shared/models/page/page-link.ts b/ui-ngx/src/app/shared/models/page/page-link.ts index e4e35a481e..307d1e4e3f 100644 --- a/ui-ngx/src/app/shared/models/page/page-link.ts +++ b/ui-ngx/src/app/shared/models/page/page-link.ts @@ -94,14 +94,15 @@ export class PageLink { pageData.data = pageData.data.filter((entity) => searchFunction(entity, this.textSearch)); } pageData.totalElements = pageData.data.length; - pageData.totalPages = Math.ceil(pageData.totalElements / this.pageSize); + pageData.totalPages = this.pageSize === Number.POSITIVE_INFINITY ? 1 : Math.ceil(pageData.totalElements / this.pageSize); if (this.sortOrder) { pageData.data = pageData.data.sort((a, b) => this.sort(a, b)); } - const startIndex = this.pageSize * this.page; - const endIndex = startIndex + this.pageSize; - pageData.data = pageData.data.slice(startIndex, startIndex + this.pageSize); - pageData.hasNext = pageData.totalElements > startIndex + pageData.data.length; + if (this.pageSize !== Number.POSITIVE_INFINITY) { + const startIndex = this.pageSize * this.page; + pageData.data = pageData.data.slice(startIndex, startIndex + this.pageSize); + pageData.hasNext = pageData.totalElements > startIndex + pageData.data.length; + } return pageData; } diff --git a/ui-ngx/src/app/shared/models/page/sort-order.ts b/ui-ngx/src/app/shared/models/page/sort-order.ts index b51791ca7d..c1511e07f8 100644 --- a/ui-ngx/src/app/shared/models/page/sort-order.ts +++ b/ui-ngx/src/app/shared/models/page/sort-order.ts @@ -24,3 +24,19 @@ export enum Direction { ASC = 'ASC', DESC = 'DESC' } + +export function sortOrderFromString(strSortOrder: string): SortOrder { + 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; + } + } + return {property, direction}; +} diff --git a/ui-ngx/src/app/shared/models/rule-chain.models.ts b/ui-ngx/src/app/shared/models/rule-chain.models.ts index 3f6c205e08..8596bf4ba7 100644 --- a/ui-ngx/src/app/shared/models/rule-chain.models.ts +++ b/ui-ngx/src/app/shared/models/rule-chain.models.ts @@ -33,7 +33,7 @@ export interface RuleChain extends BaseData { export interface RuleChainMetaData { ruleChainId: RuleChainId; - firstNodeIndex: number; + firstNodeIndex?: number; nodes: Array; connections: Array; ruleChainConnections: Array; @@ -45,7 +45,8 @@ export interface ResolvedRuleChainMetaData extends RuleChainMetaData { export interface RuleChainImport { ruleChain: RuleChain; - metadata: ResolvedRuleChainMetaData; + metadata: RuleChainMetaData; + resolvedMetadata?: ResolvedRuleChainMetaData; } export interface NodeConnectionInfo { diff --git a/ui-ngx/src/app/shared/models/rule-node.models.ts b/ui-ngx/src/app/shared/models/rule-node.models.ts index e80e43a03b..082d82f40b 100644 --- a/ui-ngx/src/app/shared/models/rule-node.models.ts +++ b/ui-ngx/src/app/shared/models/rule-node.models.ts @@ -26,18 +26,12 @@ import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { AbstractControl, FormGroup } from '@angular/forms'; -export enum MsgDataType { - JSON = 'JSON', - TEXT = 'TEXT', - BINARY = 'BINARY' -} - export interface RuleNodeConfiguration { [key: string]: any; } export interface RuleNode extends BaseData { - ruleChainId: RuleChainId; + ruleChainId?: RuleChainId; type: string; name: string; debugMode: boolean; 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 d0c837332a..cf2f9d93db 100644 --- a/ui-ngx/src/app/shared/models/telemetry/telemetry.models.ts +++ b/ui-ngx/src/app/shared/models/telemetry/telemetry.models.ts @@ -229,7 +229,7 @@ export interface TelemetryService { export class TelemetrySubscriber { - private dataSubject = new ReplaySubject(); + private dataSubject = new ReplaySubject(1); private reconnectSubject = new Subject(); private zone: NgZone; diff --git a/ui-ngx/src/app/shared/models/widget.models.ts b/ui-ngx/src/app/shared/models/widget.models.ts index ab42427e58..32ec5316af 100644 --- a/ui-ngx/src/app/shared/models/widget.models.ts +++ b/ui-ngx/src/app/shared/models/widget.models.ts @@ -363,8 +363,6 @@ export interface WidgetConfig { datasources?: Array; targetDeviceAliasIds?: Array; [key: string]: any; - - // TODO: } export interface Widget { diff --git a/ui-ngx/src/styles.scss b/ui-ngx/src/styles.scss index 73180b4eae..e5d7b04f67 100644 --- a/ui-ngx/src/styles.scss +++ b/ui-ngx/src/styles.scss @@ -509,10 +509,10 @@ mat-label { .mat-row { transition: background-color .2s; &:hover:not(.tb-current-entity) { - background-color: #f4f4f4; + background-color: rgba(221, 221, 221, 0.3); } &.tb-current-entity { - background-color: #e9e9e9; + background-color: rgba(221, 221, 221, 0.65); } } @@ -561,6 +561,9 @@ mat-label { text-overflow: ellipsis; white-space: nowrap; } + &.mat-table-sticky { + background: transparent; + } } .mat-cell, .mat-footer-cell {