diff --git a/ui/src/app/edge/downlinks/edge-downlinks-content-dialog.controller.js b/ui/src/app/edge/downlinks/edge-downlinks-content-dialog.controller.js
new file mode 100644
index 0000000000..7be69ea17f
--- /dev/null
+++ b/ui/src/app/edge/downlinks/edge-downlinks-content-dialog.controller.js
@@ -0,0 +1,92 @@
+/*
+ * Copyright © 2016-2020 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import $ from 'jquery';
+import 'brace/ext/language_tools';
+import 'brace/ext/searchbox';
+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 EdgeDownlinksContentDialogController($mdDialog, types, content, contentType, title, showingCallback) {
+
+ var vm = this;
+
+ showingCallback.onShowing = function(scope, element) {
+ updateEditorSize(element);
+ }
+
+ 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: mode,
+ showGutter: false,
+ showPrintMargin: false,
+ theme: 'github',
+ advanced: {
+ enableSnippets: false,
+ enableBasicAutocompletion: false,
+ enableLiveAutocompletion: false
+ },
+ onLoad: function (_ace) {
+ vm.editor = _ace;
+ }
+ };
+
+ function updateEditorSize(element) {
+ var newHeight = 400;
+ var newWidth = 600;
+ if (vm.content && vm.content.length > 0) {
+ var lines = vm.content.split('\n');
+ newHeight = 16 * lines.length + 16;
+ var maxLineLength = 0;
+ for (var i = 0; i < lines.length; i++) {
+ var line = lines[i].replace(/\t/g, ' ').replace(/\n/g, '');
+ var lineLength = line.length;
+ maxLineLength = Math.max(maxLineLength, lineLength);
+ }
+ newWidth = 8 * maxLineLength + 16;
+ }
+ $('#tb-event-content', element).height(newHeight.toString() + "px")
+ .width(newWidth.toString() + "px");
+ vm.editor.resize();
+ }
+
+ vm.close = close;
+
+ function close () {
+ $mdDialog.hide();
+ }
+
+}
+
+/* eslint-enable angular/angularelement */
diff --git a/ui/src/app/edge/downlinks/edge-downlinks-header.directive.js b/ui/src/app/edge/downlinks/edge-downlinks-header.directive.js
new file mode 100644
index 0000000000..fd14df88d5
--- /dev/null
+++ b/ui/src/app/edge/downlinks/edge-downlinks-header.directive.js
@@ -0,0 +1,54 @@
+/*
+ * Copyright © 2016-2020 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+/* eslint-disable import/no-unresolved, import/default */
+
+import edgeDownlinksHeaderTemplate from './event-header-edge-event.tpl.html'
+
+/* eslint-enable import/no-unresolved, import/default */
+
+/*@ngInject*/
+export default function EventHeaderDirective2($compile, $templateCache, types) {
+
+ var linker = function (scope, element, attrs) {
+
+ var getTemplate = function(eventType) {
+ var template = '';
+ switch(eventType) {
+ case types.edgeDownlinks.value:
+ template = edgeDownlinksHeaderTemplate;
+ break;
+ }
+ return $templateCache.get(template);
+ }
+
+ scope.loadTemplate = function() {
+ element.html(getTemplate(attrs.eventType));
+ $compile(element.contents())(scope);
+ }
+
+ attrs.$observe('eventType', function() {
+ scope.loadTemplate();
+ });
+
+ }
+
+ return {
+ restrict: "A",
+ replace: false,
+ link: linker,
+ scope: false
+ };
+}
diff --git a/ui/src/app/edge/downlinks/edge-downlinks-row.directive.js b/ui/src/app/edge/downlinks/edge-downlinks-row.directive.js
new file mode 100644
index 0000000000..20c2eab89c
--- /dev/null
+++ b/ui/src/app/edge/downlinks/edge-downlinks-row.directive.js
@@ -0,0 +1,140 @@
+/*
+ * Copyright © 2016-2020 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+/* eslint-disable import/no-unresolved, import/default */
+
+import eventErrorDialogTemplate from './event-content-dialog.tpl.html';
+
+import edgeDownlinlsRowTemplate from './event-row-edge-event.tpl.html';
+
+/* eslint-enable import/no-unresolved, import/default */
+
+/*@ngInject*/
+export default function EventRowDirective2($compile, $templateCache, $mdDialog, $document, $translate,
+ types, utils, toast, entityService, ruleChainService) {
+
+ var linker = function (scope, element, attrs) {
+
+ var getTemplate = function(eventType) {
+ var template = '';
+ switch(eventType) {
+ case types.edgeDownlinks.value:
+ template = edgeDownlinlsRowTemplate;
+ break;
+ }
+ return $templateCache.get(template);
+ }
+
+ scope.loadTemplate = function() {
+ element.html(getTemplate(attrs.eventType));
+ $compile(element.contents())(scope);
+ }
+
+ attrs.$observe('eventType', function() {
+ scope.loadTemplate();
+ });
+
+ scope.types = types;
+
+ scope.event = attrs.event;
+
+ scope.showEdgeEntityContent = function($event, title, contentType) {
+ var onShowingCallback = {
+ onShowing: function(){}
+ }
+ if (!contentType) {
+ contentType = null;
+ }
+ var content = '';
+ switch(scope.event.type) {
+ case types.edgeEventType.relation:
+ content = angular.toJson(scope.event.body);
+ showDialog();
+ break;
+ case types.edgeEventType.ruleChainMetaData:
+ content = ruleChainService.getRuleChainMetaData(scope.event.entityId, {ignoreErrors: true}).then(
+ function success(info) {
+ showDialog();
+ return angular.toJson(info);
+ }, function fail() {
+ showError();
+ });
+ break;
+ default:
+ content = entityService.getEntity(scope.event.type, scope.event.entityId, {ignoreErrors: true}).then(
+ function success(info) {
+ showDialog();
+ return angular.toJson(info);
+ }, function fail() {
+ showError();
+ });
+ break;
+ }
+ function showDialog() {
+ $mdDialog.show({
+ controller: 'EventContentDialogController2',
+ controllerAs: 'vm',
+ templateUrl: eventErrorDialogTemplate,
+ locals: {content: content, title: title, contentType: contentType, showingCallback: onShowingCallback},
+ parent: angular.element($document[0].body),
+ fullscreen: true,
+ targetEvent: $event,
+ multiple: true,
+ onShowing: function(scope, element) {
+ onShowingCallback.onShowing(scope, element);
+ }
+ });
+ }
+ function showError() {
+ toast.showError($translate.instant('edge.load-entity-error'));
+ }
+ }
+
+ scope.checkEdgeEventType = function (type) {
+ return !(type === types.edgeEventType.widgetType ||
+ type === types.edgeEventType.adminSettings ||
+ type === types.edgeEventType.widgetsBundle );
+ }
+
+ scope.checkTooltip = function($event) {
+ var el = $event.target;
+ var $el = angular.element(el);
+ if(el.offsetWidth < el.scrollWidth && !$el.attr('title')){
+ $el.attr('title', $el.text());
+ }
+ }
+
+ $compile(element.contents())(scope);
+
+ scope.updateStatus = function(eventCreatedTime) {
+ var status;
+ if (eventCreatedTime < scope.queueStartTs) {
+ status = $translate.instant(types.edgeEventStatus.DEPLOYED.name);
+ scope.statusColor = types.edgeEventStatus.DEPLOYED.color;
+ } else {
+ status = $translate.instant(types.edgeEventStatus.PENDING.name);
+ scope.statusColor = types.edgeEventStatus.PENDING.color;
+ }
+ return status;
+ }
+ }
+
+ return {
+ restrict: "A",
+ replace: false,
+ link: linker,
+ scope: false
+ };
+}
diff --git a/ui/src/app/edge/downlinks/edge-downlinks-table.directive.js b/ui/src/app/edge/downlinks/edge-downlinks-table.directive.js
new file mode 100644
index 0000000000..46f194b68b
--- /dev/null
+++ b/ui/src/app/edge/downlinks/edge-downlinks-table.directive.js
@@ -0,0 +1,234 @@
+/*
+ * Copyright © 2016-2020 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import './event.scss';
+
+/* eslint-disable import/no-unresolved, import/default */
+
+import edgeDownlinksTableTemplate from './edge-downlinks-table.tpl.html';
+
+/* eslint-enable import/no-unresolved, import/default */
+
+/*@ngInject*/
+export default function EdgeDownlinksDirective2($compile, $templateCache, $rootScope, $translate, types,
+ eventService, edgeService, attributeService) {
+
+ var linker = function (scope, element) {
+
+ var template = $templateCache.get(edgeDownlinksTableTemplate);
+
+ element.html(template);
+
+ scope.eventType = types.edgeDownlinks.value;
+
+ var pageSize = 20;
+ var startTime = 0;
+ var endTime = 0;
+
+ scope.timewindow = {
+ history: {
+ timewindowMs: 24 * 60 * 60 * 1000 // 1 day
+ }
+ }
+
+ scope.topIndex = 0;
+
+ scope.theEvents = {
+ getItemAtIndex: function (index) {
+ if (index > scope.events.data.length) {
+ scope.theEvents.fetchMoreItems_(index);
+ return null;
+ }
+ var item = scope.events.data[index];
+ if (item) {
+ item.indexNumber = index + 1;
+ }
+ return item;
+ },
+
+ getLength: function () {
+ if (scope.events.hasNext) {
+ return scope.events.data.length + scope.events.nextPageLink.limit;
+ } else {
+ return scope.events.data.length;
+ }
+ },
+
+ fetchMoreItems_: function () {
+ if (scope.events.hasNext && !scope.events.pending) {
+ if (scope.entityType && scope.entityId && scope.eventType && scope.tenantId) {
+ scope.loadEdgeInfo();
+ scope.events.pending = true;
+ edgeService.getEdgeDownlinks(scope.entityId, scope.events.nextPageLink).then(
+ function success(events) {
+ scope.events.data = scope.events.data.concat(prepareEdgeEventData(events.data));
+ scope.events.nextPageLink = events.nextPageLink;
+ scope.events.hasNext = events.hasNext;
+ if (scope.events.hasNext) {
+ scope.events.nextPageLink.limit = pageSize;
+ }
+ scope.events.pending = false;
+ },
+ function fail() {
+ scope.events.hasNext = false;
+ scope.events.pending = false;
+ });
+ } else {
+ scope.events.hasNext = false;
+ }
+ }
+ }
+ };
+
+ scope.$watch("entityId", function(newVal, prevVal) {
+ if (newVal && !angular.equals(newVal, prevVal)) {
+ scope.resetFilter();
+ scope.reload();
+ }
+ });
+
+ scope.$watch("timewindow", function(newVal, prevVal) {
+ if (newVal && !angular.equals(newVal, prevVal)) {
+ scope.reload();
+ }
+ }, true);
+
+ scope.resetFilter = function() {
+ scope.timewindow = {
+ history: {
+ timewindowMs: 24 * 60 * 60 * 1000 // 1 day
+ }
+ };
+ }
+
+ scope.updateTimeWindowRange = function() {
+ if (scope.timewindow.history.timewindowMs) {
+ var currentTime = (new Date).getTime();
+ startTime = currentTime - scope.timewindow.history.timewindowMs;
+ endTime = currentTime;
+ } else {
+ startTime = scope.timewindow.history.fixedTimewindow.startTimeMs;
+ endTime = scope.timewindow.history.fixedTimewindow.endTimeMs;
+ }
+ }
+
+ scope.reload = function() {
+ scope.topIndex = 0;
+ scope.selected = [];
+ scope.updateTimeWindowRange();
+ scope.events = {
+ data: [],
+ nextPageLink: {
+ limit: pageSize,
+ startTime: startTime,
+ endTime: endTime
+ },
+ hasNext: true,
+ pending: false
+ };
+ scope.theEvents.getItemAtIndex(pageSize);
+ }
+
+ scope.noData = function() {
+ return scope.events.data.length == 0 && !scope.events.hasNext;
+ }
+
+ scope.hasData = function() {
+ return scope.events.data.length > 0;
+ }
+
+ scope.loading = function() {
+ return $rootScope.loading;
+ }
+
+ scope.hasScroll = function() {
+ var repeatContainer = scope.repeatContainer[0];
+ if (repeatContainer) {
+ var scrollElement = repeatContainer.children[0];
+ if (scrollElement) {
+ return scrollElement.scrollHeight > scrollElement.clientHeight;
+ }
+ }
+ return false;
+ }
+
+ scope.subscriptionId = null;
+
+ scope.loadEdgeInfo = function() {
+ attributeService.getEntityAttributesValues(
+ scope.entityType,
+ scope.entityId,
+ types.attributesScope.server.value,
+ types.edgeAttributeKeys.queueStartTs,
+ null).then(
+ function success(attributes) {
+ attributes.length > 0 ? scope.onEdgeAttributesUpdate(attributes) : scope.queueStartTs = 0;
+ });
+ scope.checkSubscription();
+ }
+
+ scope.onEdgeAttributesUpdate = function(attributes) {
+ let edgeAttributes = attributes.reduce(function (map, attribute) {
+ map[attribute.key] = attribute;
+ return map;
+ }, {});
+ if (edgeAttributes.queueStartTs) {
+ scope.queueStartTs = edgeAttributes.queueStartTs.lastUpdateTs;
+ }
+ }
+
+ scope.checkSubscription = function() {
+ var newSubscriptionId = null;
+ if (scope.entityId && scope.entityType && types.attributesScope.server.value) {
+ newSubscriptionId =
+ attributeService.subscribeForEntityAttributes(scope.entityType, scope.entityId, types.attributesScope.server.value);
+ }
+ if (scope.subscriptionId && scope.subscriptionId != newSubscriptionId) {
+ attributeService.unsubscribeForEntityAttributes(scope.subscriptionId);
+ }
+ scope.subscriptionId = newSubscriptionId;
+ }
+
+ scope.$on('$destroy', function () {
+ if (scope.subscriptionId) {
+ attributeService.unsubscribeForEntityAttributes(scope.subscriptionId);
+ }
+ });
+
+ scope.reload();
+
+ $compile(element.contents())(scope);
+ }
+ function prepareEdgeEventData(data) {
+
+ data.forEach(
+ edgeEvent => {
+ edgeEvent.edgeEventActionText = $translate.instant(types.edgeEventActionType[edgeEvent.action].name);
+ edgeEvent.edgeEventTypeText = $translate.instant(types.edgeEventTypeTranslations[edgeEvent.edgeId.entityType].name);
+ }
+ );
+ return data;
+ }
+
+ return {
+ restrict: "E",
+ link: linker,
+ scope: {
+ entityType: '=',
+ entityId: '=',
+ tenantId: '='
+ }
+ };
+}
diff --git a/ui/src/app/edge/downlinks/edge-downlinks-table.tpl.html b/ui/src/app/edge/downlinks/edge-downlinks-table.tpl.html
new file mode 100644
index 0000000000..4bff7afa4b
--- /dev/null
+++ b/ui/src/app/edge/downlinks/edge-downlinks-table.tpl.html
@@ -0,0 +1,46 @@
+
+