Browse Source

Rule Node Debug UI

pull/725/head
Igor Kulikov 8 years ago
parent
commit
4bbcffdf63
  1. 71
      application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java
  2. 2
      application/src/test/java/org/thingsboard/server/controller/AbstractRuleEngineControllerTest.java
  3. 2
      common/data/src/main/java/org/thingsboard/server/common/data/DataConstants.java
  4. 30
      ui/src/app/common/types.constant.js
  5. 19
      ui/src/app/components/details-sidenav.directive.js
  6. 1
      ui/src/app/components/details-sidenav.tpl.html
  7. 17
      ui/src/app/event/event-content-dialog.controller.js
  8. 27
      ui/src/app/event/event-header-debug-rulenode.tpl.html
  9. 7
      ui/src/app/event/event-header.directive.js
  10. 63
      ui/src/app/event/event-row-debug-rulenode.tpl.html
  11. 16
      ui/src/app/event/event-row.directive.js
  12. 18
      ui/src/app/event/event-table.directive.js
  13. 15
      ui/src/app/locale/locale.constant.js
  14. 15
      ui/src/app/rulechain/rulechain.controller.js
  15. 18
      ui/src/app/rulechain/rulechain.tpl.html
  16. 3
      ui/src/app/rulechain/rulechains.tpl.html

71
application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java

@ -25,6 +25,7 @@ import com.typesafe.config.Config;
import com.typesafe.config.ConfigFactory;
import lombok.Getter;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@ -38,6 +39,7 @@ import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent;
import org.thingsboard.server.common.msg.TbMsg;
import org.thingsboard.server.common.msg.TbMsgDataType;
import org.thingsboard.server.common.msg.cluster.ServerAddress;
import org.thingsboard.server.common.transport.auth.DeviceAuthService;
import org.thingsboard.server.controller.plugin.PluginWebSocketMsgEndpoint;
@ -60,11 +62,13 @@ import org.thingsboard.server.service.cluster.routing.ClusterRoutingService;
import org.thingsboard.server.service.cluster.rpc.ClusterRpcService;
import org.thingsboard.server.service.component.ComponentDiscoveryService;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.nio.charset.StandardCharsets;
import java.util.Optional;
@Slf4j
@Component
public class ActorSystemContext {
private static final String AKKA_CONF_FILE_NAME = "actor-system.conf";
@ -292,38 +296,49 @@ public class ActorSystemContext {
}
private void persistDebug(TenantId tenantId, EntityId entityId, String type, TbMsg tbMsg, Throwable error) {
Event event = new Event();
event.setTenantId(tenantId);
event.setEntityId(entityId);
event.setType(DataConstants.DEBUG);
ObjectNode node = mapper.createObjectNode()
.put("type", type)
.put("server", getServerAddress())
.put("entityId", tbMsg.getOriginator().getId().toString())
.put("entityName", tbMsg.getOriginator().getEntityType().name())
.put("msgId", tbMsg.getId().toString())
.put("msgType", tbMsg.getType())
.put("dataType", tbMsg.getDataType().name());
ObjectNode mdNode = node.putObject("metadata");
tbMsg.getMetaData().getData().forEach(mdNode::put);
try {
Event event = new Event();
event.setTenantId(tenantId);
event.setEntityId(entityId);
event.setType(DataConstants.DEBUG_RULE_NODE);
String metadata = mapper.writeValueAsString(tbMsg.getMetaData().getData());
ObjectNode node = mapper.createObjectNode()
.put("type", type)
.put("server", getServerAddress())
.put("entityId", tbMsg.getOriginator().getId().toString())
.put("entityName", tbMsg.getOriginator().getEntityType().name())
.put("msgId", tbMsg.getId().toString())
.put("msgType", tbMsg.getType())
.put("dataType", tbMsg.getDataType().name())
.put("data", convertToString(tbMsg.getDataType(), tbMsg.getData()))
.put("metadata", metadata);
if (error != null) {
node = node.put("error", toString(error));
}
event.setBody(node);
eventService.save(event);
} catch (IOException ex) {
log.warn("Failed to persist rule node debug message", ex);
}
}
switch (tbMsg.getDataType()) {
private String convertToString(TbMsgDataType messageType, byte[] data) {
if (data == null) {
return null;
}
switch (messageType) {
case JSON:
case TEXT:
return new String(data, StandardCharsets.UTF_8);
case BINARY:
node.put("data", Base64Utils.encodeUrlSafe(tbMsg.getData()));
break;
return Base64Utils.encodeToString(data);
default:
node.put("data", new String(tbMsg.getData(), StandardCharsets.UTF_8));
break;
}
if (error != null) {
node = node.put("error", toString(error));
throw new RuntimeException("Message type: " + messageType + " is not supported!");
}
event.setBody(node);
eventService.save(event);
}
public static Exception toException(Throwable error) {

2
application/src/test/java/org/thingsboard/server/controller/AbstractRuleEngineControllerTest.java

@ -51,6 +51,6 @@ public class AbstractRuleEngineControllerTest extends AbstractControllerTest {
TimePageLink pageLink = new TimePageLink(limit);
return doGetTypedWithTimePageLink("/api/events/{entityType}/{entityId}/{eventType}?tenantId={tenantId}&",
new TypeReference<TimePageData<Event>>() {
}, pageLink, entityId.getEntityType(), entityId.getId(), DataConstants.DEBUG, tenantId.getId());
}, pageLink, entityId.getEntityType(), entityId.getId(), DataConstants.DEBUG_RULE_NODE, tenantId.getId());
}
}

2
common/data/src/main/java/org/thingsboard/server/common/data/DataConstants.java

@ -37,7 +37,7 @@ public class DataConstants {
public static final String ERROR = "ERROR";
public static final String LC_EVENT = "LC_EVENT";
public static final String STATS = "STATS";
public static final String DEBUG = "DEBUG";
public static final String DEBUG_RULE_NODE = "DEBUG_RULE_NODE";
public static final String ONEWAY = "ONEWAY";
public static final String TWOWAY = "TWOWAY";

30
ui/src/app/common/types.constant.js

@ -279,6 +279,23 @@ export default angular.module('thingsboard.types', [])
function: "function",
alarm: "alarm"
},
contentType: {
"JSON": {
value: "JSON",
name: "content-type.json",
code: "json"
},
"TEXT": {
value: "TEXT",
name: "content-type.text",
code: "text"
},
"BINARY": {
value: "BINARY",
name: "content-type.binary",
code: "text"
}
},
componentType: {
filter: "FILTER",
processor: "PROCESSOR",
@ -295,7 +312,8 @@ export default angular.module('thingsboard.types', [])
user: "USER",
dashboard: "DASHBOARD",
alarm: "ALARM",
rulechain: "RULE_CHAIN"
rulechain: "RULE_CHAIN",
rulenode: "RULE_NODE"
},
aliasEntityType: {
current_customer: "CURRENT_CUSTOMER"
@ -388,6 +406,16 @@ export default angular.module('thingsboard.types', [])
name: "event.type-stats"
}
},
debugEventType: {
debugRuleNode: {
value: "DEBUG_RULE_NODE",
name: "event.type-debug-rule-node"
},
debugRuleChain: {
value: "DEBUG_RULE_CHAIN",
name: "event.type-debug-rule-chain"
}
},
extensionType: {
http: "HTTP",
mqtt: "MQTT",

19
ui/src/app/components/details-sidenav.directive.js

@ -26,7 +26,7 @@ export default angular.module('thingsboard.directives.detailsSidenav', [])
.name;
/*@ngInject*/
function DetailsSidenav($timeout) {
function DetailsSidenav($timeout, $window) {
var linker = function (scope, element, attrs) {
@ -42,6 +42,23 @@ function DetailsSidenav($timeout) {
scope.isEdit = true;
}
if (angular.isDefined(attrs.closeOnClickOutside && attrs.closeOnClickOutside)) {
scope.closeOnClickOutside = true;
var clickOutsideHandler = function() {
scope.closeDetails();
};
angular.element($window).click(clickOutsideHandler);
scope.$on("$destroy", function () {
angular.element($window).unbind('click', clickOutsideHandler);
});
}
scope.onClick = function($event) {
if (scope.closeOnClickOutside) {
$event.stopPropagation();
}
};
scope.toggleDetailsEditMode = function () {
if (!scope.isAlwaysEdit) {
if (!scope.isEdit) {

1
ui/src/app/components/details-sidenav.tpl.html

@ -19,6 +19,7 @@
md-disable-backdrop="true"
md-is-open="isOpen"
md-component-id="right"
ng-click="onClick($event)"
layout="column">
<header>
<md-toolbar class="md-theme-light" ng-style="{'height':headerHeightPx+'px'}">

17
ui/src/app/event/event-content-dialog.controller.js

@ -17,11 +17,14 @@ import $ from 'jquery';
import 'brace/ext/language_tools';
import 'brace/mode/java';
import 'brace/theme/github';
import beautify from 'js-beautify';
/* eslint-disable angular/angularelement */
const js_beautify = beautify.js;
/*@ngInject*/
export default function EventContentDialogController($mdDialog, content, title, showingCallback) {
export default function EventContentDialogController($mdDialog, types, content, contentType, title, showingCallback) {
var vm = this;
@ -32,9 +35,19 @@ export default function EventContentDialogController($mdDialog, content, title,
vm.content = content;
vm.title = title;
var mode;
if (contentType) {
mode = types.contentType[contentType].code;
if (contentType == types.contentType.JSON.value && vm.content) {
vm.content = js_beautify(vm.content, {indent_size: 4});
}
} else {
mode = 'java';
}
vm.contentOptions = {
useWrapMode: false,
mode: 'java',
mode: mode,
showGutter: false,
showPrintMargin: false,
theme: 'github',

27
ui/src/app/event/event-header-debug-rulenode.tpl.html

@ -0,0 +1,27 @@
<!--
Copyright © 2016-2018 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.
-->
<div hide-xs hide-sm translate class="tb-cell" flex="30">event.event-time</div>
<div translate class="tb-cell" flex="20">event.server</div>
<div translate class="tb-cell" flex="20">event.type</div>
<div translate class="tb-cell" flex="20">event.entity</div>
<div translate class="tb-cell" flex="20">event.message-id</div>
<div translate class="tb-cell" flex="20">event.message-type</div>
<div translate class="tb-cell" flex="20">event.data-type</div>
<div translate class="tb-cell" flex="20">event.data</div>
<div translate class="tb-cell" flex="20">event.metadata</div>
<div translate class="tb-cell" flex="20">event.error</div>

7
ui/src/app/event/event-header.directive.js

@ -18,6 +18,7 @@
import eventHeaderLcEventTemplate from './event-header-lc-event.tpl.html';
import eventHeaderStatsTemplate from './event-header-stats.tpl.html';
import eventHeaderErrorTemplate from './event-header-error.tpl.html';
import eventHeaderDebugRuleNodeTemplate from './event-header-debug-rulenode.tpl.html';
/* eslint-enable import/no-unresolved, import/default */
@ -38,6 +39,12 @@ export default function EventHeaderDirective($compile, $templateCache, types) {
case types.eventType.error.value:
template = eventHeaderErrorTemplate;
break;
case types.debugEventType.debugRuleNode.value:
template = eventHeaderDebugRuleNodeTemplate;
break;
case types.debugEventType.debugRuleChain.value:
template = eventHeaderDebugRuleNodeTemplate;
break;
}
return $templateCache.get(template);
}

63
ui/src/app/event/event-row-debug-rulenode.tpl.html

@ -0,0 +1,63 @@
<!--
Copyright © 2016-2018 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.
-->
<div hide-xs hide-sm class="tb-cell" flex="30">{{event.createdTime | date : 'yyyy-MM-dd HH:mm:ss'}}</div>
<div class="tb-cell" flex="20">{{event.body.server}}</div>
<div class="tb-cell" flex="20">{{event.body.type}}</div>
<div class="tb-cell" flex="20">{{event.body.entityName}}</div>
<div class="tb-cell" flex="20">{{event.body.msgId}}</div>
<div class="tb-cell" flex="20">{{event.body.msgType}}</div>
<div class="tb-cell" flex="20">{{event.body.dataType}}</div>
<div class="tb-cell" flex="20">
<md-button ng-if="event.body.data" class="md-icon-button md-primary"
ng-click="showContent($event, event.body.data, 'event.data', event.body.msgType)"
aria-label="{{ 'action.view' | translate }}">
<md-tooltip md-direction="top">
{{ 'action.view' | translate }}
</md-tooltip>
<md-icon aria-label="{{ 'action.view' | translate }}"
class="material-icons">
more_horiz
</md-icon>
</md-button>
</div>
<div class="tb-cell" flex="20">
<md-button ng-if="event.body.metadata" class="md-icon-button md-primary"
ng-click="showContent($event, event.body.metadata, 'event.metadata', 'JSON')"
aria-label="{{ 'action.view' | translate }}">
<md-tooltip md-direction="top">
{{ 'action.view' | translate }}
</md-tooltip>
<md-icon aria-label="{{ 'action.view' | translate }}"
class="material-icons">
more_horiz
</md-icon>
</md-button>
</div>
<div class="tb-cell" flex="20">
<md-button ng-if="event.body.error" class="md-icon-button md-primary"
ng-click="showContent($event, event.body.error, 'event.error')"
aria-label="{{ 'action.view' | translate }}">
<md-tooltip md-direction="top">
{{ 'action.view' | translate }}
</md-tooltip>
<md-icon aria-label="{{ 'action.view' | translate }}"
class="material-icons">
more_horiz
</md-icon>
</md-button>
</div>

16
ui/src/app/event/event-row.directive.js

@ -20,6 +20,7 @@ import eventErrorDialogTemplate from './event-content-dialog.tpl.html';
import eventRowLcEventTemplate from './event-row-lc-event.tpl.html';
import eventRowStatsTemplate from './event-row-stats.tpl.html';
import eventRowErrorTemplate from './event-row-error.tpl.html';
import eventRowDebugRuleNodeTemplate from './event-row-debug-rulenode.tpl.html';
/* eslint-enable import/no-unresolved, import/default */
@ -40,6 +41,12 @@ export default function EventRowDirective($compile, $templateCache, $mdDialog, $
case types.eventType.error.value:
template = eventRowErrorTemplate;
break;
case types.debugEventType.debugRuleNode.value:
template = eventRowDebugRuleNodeTemplate;
break;
case types.debugEventType.debugRuleChain.value:
template = eventRowDebugRuleNodeTemplate;
break;
}
return $templateCache.get(template);
}
@ -53,17 +60,22 @@ export default function EventRowDirective($compile, $templateCache, $mdDialog, $
scope.loadTemplate();
});
scope.types = types;
scope.event = attrs.event;
scope.showContent = function($event, content, title) {
scope.showContent = function($event, content, title, contentType) {
var onShowingCallback = {
onShowing: function(){}
}
if (!contentType) {
contentType = null;
}
$mdDialog.show({
controller: 'EventContentDialogController',
controllerAs: 'vm',
templateUrl: eventErrorDialogTemplate,
locals: {content: content, title: title, showingCallback: onShowingCallback},
locals: {content: content, title: title, contentType: contentType, showingCallback: onShowingCallback},
parent: angular.element($document[0].body),
fullscreen: true,
targetEvent: $event,

18
ui/src/app/event/event-table.directive.js

@ -36,8 +36,8 @@ export default function EventTableDirective($compile, $templateCache, $rootScope
for (var type in types.eventType) {
var eventType = types.eventType[type];
var enabled = true;
for (var disabledType in disabledEventTypes) {
if (eventType.value === disabledEventTypes[disabledType]) {
for (var i=0;i<disabledEventTypes.length;i++) {
if (eventType.value === disabledEventTypes[i]) {
enabled = false;
break;
}
@ -47,7 +47,19 @@ export default function EventTableDirective($compile, $templateCache, $rootScope
}
}
} else {
scope.eventTypes = types.eventType;
scope.eventTypes = angular.copy(types.eventType);
}
if (attrs.debugEventTypes) {
var debugEventTypes = attrs.debugEventTypes.split(',');
for (i=0;i<debugEventTypes.length;i++) {
for (type in types.debugEventType) {
eventType = types.debugEventType[type];
if (eventType.value === debugEventTypes[i]) {
scope.eventTypes[type] = eventType;
}
}
}
}
scope.eventType = attrs.defaultEventType;

15
ui/src/app/locale/locale.constant.js

@ -341,6 +341,11 @@ export default angular.module('thingsboard.locale', [])
"enter-password": "Enter password",
"enter-search": "Enter search"
},
"content-type": {
"json": "Json",
"text": "Text",
"binary": "Binary (Base64)"
},
"customer": {
"customer": "Customer",
"customers": "Customers",
@ -762,6 +767,8 @@ export default angular.module('thingsboard.locale', [])
"type-error": "Error",
"type-lc-event": "Lifecycle event",
"type-stats": "Statistics",
"type-debug-rule-node": "Debug",
"type-debug-rule-chain": "Debug",
"no-events-prompt": "No events found",
"error": "Error",
"alarm": "Alarm",
@ -769,6 +776,13 @@ export default angular.module('thingsboard.locale', [])
"server": "Server",
"body": "Body",
"method": "Method",
"type": "Type",
"entity": "Entity",
"message-id": "Message Id",
"message-type": "Message Type",
"data-type": "Data Type",
"metadata": "Metadata",
"data": "Data",
"event": "Event",
"status": "Status",
"success": "Success",
@ -1172,6 +1186,7 @@ export default angular.module('thingsboard.locale', [])
},
"rulenode": {
"details": "Details",
"events": "Events",
"add": "Add rule node",
"name": "Name",
"name-required": "Name is required.",

15
ui/src/app/rulechain/rulechain.controller.js

@ -28,7 +28,7 @@ import addRuleNodeLinkTemplate from './add-link.tpl.html';
/* eslint-enable import/no-unresolved, import/default */
/*@ngInject*/
export function RuleChainController($stateParams, $scope, $compile, $q, $mdUtil, $timeout, $mdExpansionPanel, $document, $mdDialog,
export function RuleChainController($stateParams, $scope, $compile, $q, $mdUtil, $timeout, $mdExpansionPanel, $window, $document, $mdDialog,
$filter, $translate, hotkeys, types, ruleChainService, Modelfactory, flowchartConstants,
ruleChain, ruleChainMetaData, ruleNodeComponents) {
@ -77,6 +77,8 @@ export function RuleChainController($stateParams, $scope, $compile, $q, $mdUtil,
vm.objectsSelected = objectsSelected;
vm.deleteSelected = deleteSelected;
vm.triggerResize = triggerResize;
initHotKeys();
function initHotKeys() {
@ -129,18 +131,17 @@ export function RuleChainController($stateParams, $scope, $compile, $q, $mdUtil,
}
vm.onEditRuleNodeClosed = function() {
vm.editingRuleNode = null;
//vm.editingRuleNode = null;
};
vm.onEditRuleNodeLinkClosed = function() {
vm.editingRuleNodeLink = null;
//vm.editingRuleNodeLink = null;
};
vm.saveRuleNode = function(theForm) {
$scope.$broadcast('form-submit');
if (theForm.$valid) {
theForm.$setPristine();
vm.isEditingRuleNode = false;
vm.ruleChainModel.nodes[vm.editingRuleNodeIndex] = vm.editingRuleNode;
vm.editingRuleNode = angular.copy(vm.editingRuleNode);
}
@ -148,7 +149,6 @@ export function RuleChainController($stateParams, $scope, $compile, $q, $mdUtil,
vm.saveRuleNodeLink = function(theForm) {
theForm.$setPristine();
vm.isEditingRuleNodeLink = false;
vm.ruleChainModel.edges[vm.editingRuleNodeLinkIndex] = vm.editingRuleNodeLink;
vm.editingRuleNodeLink = angular.copy(vm.editingRuleNodeLink);
};
@ -663,6 +663,11 @@ export function RuleChainController($stateParams, $scope, $compile, $q, $mdUtil,
function deleteSelected() {
vm.modelservice.deleteSelected();
}
function triggerResize() {
var w = angular.element($window);
w.triggerHandler('resize');
}
}
/*@ngInject*/

18
ui/src/app/rulechain/rulechain.tpl.html

@ -67,8 +67,9 @@
header-title="{{vm.editingRuleNode.name}}"
header-subtitle="{{(vm.types.ruleNodeType[vm.editingRuleNode.component.type].name | translate)
+ ' - ' + vm.editingRuleNode.component.name}}"
is-read-only="false"
is-read-only="vm.selectedRuleNodeTabIndex > 0"
is-open="vm.isEditingRuleNode"
close-on-click-outside="true"
is-always-edit="true"
on-close-details="vm.onEditRuleNodeClosed()"
on-toggle-details-edit-mode="vm.onRevertRuleNodeEdit(vm.ruleNodeForm)"
@ -77,9 +78,10 @@
<details-buttons tb-help="vm.helpLinkIdForRuleNodeType()" help-container-id="help-container">
<div id="help-container"></div>
</details-buttons>
<md-tabs id="ruleNodeTabs" md-border-bottom flex class="tb-absolute-fill">
<md-tabs md-selected="vm.selectedRuleNodeTabIndex"
id="ruleNodeTabs" md-border-bottom flex class="tb-absolute-fill" ng-if="vm.isEditingRuleNode">
<md-tab label="{{ 'rulenode.details' | translate }}">
<form name="vm.ruleNodeForm" ng-if="vm.isEditingRuleNode">
<form name="vm.ruleNodeForm">
<tb-rule-node
rule-node="vm.editingRuleNode"
rule-chain-id="vm.ruleChain.id.id"
@ -90,6 +92,15 @@
</tb-rule-node>
</form>
</md-tab>
<md-tab ng-if="vm.isEditingRuleNode && vm.editingRuleNode.ruleNodeId"
md-on-select="vm.triggerResize()" label="{{ 'rulenode.events' | translate }}">
<tb-event-table flex entity-type="vm.types.entityType.rulenode"
entity-id="vm.editingRuleNode.ruleNodeId.id"
tenant-id="vm.ruleChain.tenantId.id"
debug-event-types="{{vm.types.debugEventType.debugRuleNode.value}}"
default-event-type="{{vm.types.debugEventType.debugRuleNode.value}}">
</tb-event-table>
</md-tab>
</md-tabs>
</tb-details-sidenav>
<tb-details-sidenav class="tb-rulenode-link-details-sidenav"
@ -97,6 +108,7 @@
header-subtitle="{{'rulenode.link-details' | translate}}"
is-read-only="false"
is-open="vm.isEditingRuleNodeLink"
close-on-click-outside="true"
is-always-edit="true"
on-close-details="vm.onEditRuleNodeLinkClosed()"
on-toggle-details-edit-mode="vm.onRevertRuleNodeLinkEdit(vm.ruleNodeLinkForm)"

3
ui/src/app/rulechain/rulechains.tpl.html

@ -55,7 +55,8 @@
<tb-event-table flex entity-type="vm.types.entityType.rulechain"
entity-id="vm.grid.operatingItem().id.id"
tenant-id="vm.grid.operatingItem().tenantId.id"
default-event-type="{{vm.types.eventType.lcEvent.value}}">
debug-event-types="{{vm.types.debugEventType.debugRuleChain.value}}"
default-event-type="{{vm.types.debugEventType.debugRuleChain.value}}">
</tb-event-table>
</md-tab>
<md-tab ng-if="!vm.grid.detailsConfig.isDetailsEditMode && vm.isRuleChainEditable(vm.grid.operatingItem())" md-on-select="vm.grid.triggerResize()" label="{{ 'relation.relations' | translate }}">

Loading…
Cancel
Save