diff --git a/app/app.js b/app/app.js
index 7e5c61290..69db12057 100644
--- a/app/app.js
+++ b/app/app.js
@@ -36,6 +36,7 @@ angular.module('portainer', [
'swarm',
'network',
'networks',
+ 'node',
'createNetwork',
'task',
'templates',
@@ -398,6 +399,22 @@ angular.module('portainer', [
requiresLogin: true
}
})
+ .state('node', {
+ url: '^/nodes/:id/',
+ views: {
+ "content": {
+ templateUrl: 'app/components/node/node.html',
+ controller: 'NodeController'
+ },
+ "sidebar": {
+ templateUrl: 'app/components/sidebar/sidebar.html',
+ controller: 'SidebarController'
+ }
+ },
+ data: {
+ requiresLogin: true
+ }
+ })
.state('services', {
url: '/services/',
views: {
diff --git a/app/components/node/node.html b/app/components/node/node.html
new file mode 100644
index 000000000..714660fe3
--- /dev/null
+++ b/app/components/node/node.html
@@ -0,0 +1,262 @@
+
+
+
+
+
+
+
+ Swarm nodes > {{ node.Hostname }}
+
+
+
+
+
+
+ Loading..
+
+
+
+
+
+ It looks like the node you wish to inspect does not exist.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ View the Docker Swarm mode Node documentation here.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Leader |
+
+ Yes
+ No
+ |
+
+
+ Reachability |
+ {{ node.Reachability }} |
+
+
+ Manager address |
+ {{ node.ManagerAddr }} |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ CPU |
+ {{ node.CPUs / 1000000000 }} |
+
+
+ Memory |
+ {{ node.Memory|humansize: 2 }} |
+
+
+ Platform |
+ {{ node.PlatformOS }} {{ node.PlatformArchitecture }} |
+
+
+ Docker Engine version |
+ {{ node.EngineVersion }} |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ There are no labels for this node.
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/components/node/nodeController.js b/app/components/node/nodeController.js
new file mode 100644
index 000000000..66af4210c
--- /dev/null
+++ b/app/components/node/nodeController.js
@@ -0,0 +1,107 @@
+angular.module('node', [])
+.controller('NodeController', ['$scope', '$state', '$stateParams', 'LabelHelper', 'Node', 'NodeHelper', 'Task', 'Settings', 'Messages',
+function ($scope, $state, $stateParams, LabelHelper, Node, NodeHelper, Task, Settings, Messages) {
+
+ $scope.loading = true;
+ $scope.tasks = [];
+ $scope.displayNode = false;
+ $scope.sortType = 'Status';
+ $scope.sortReverse = false;
+ $scope.pagination_count = Settings.pagination_count;
+
+ var originalNode = {};
+ var editedKeys = [];
+
+ $scope.order = function(sortType) {
+ $scope.sortReverse = ($scope.sortType === sortType) ? !$scope.sortReverse : false;
+ $scope.sortType = sortType;
+ };
+
+ $scope.updateNodeAttribute = function updateNodeAttribute(node, key) {
+ editedKeys.push(key);
+ };
+ $scope.addLabel = function addLabel(node) {
+ node.Labels.push({ key: '', value: '', originalValue: '', originalKey: '' });
+ $scope.updateNodeAttribute(node, 'Labels');
+ };
+ $scope.removeLabel = function removeLabel(node, index) {
+ var removedElement = node.Labels.splice(index, 1);
+ if (removedElement !== null) {
+ $scope.updateNodeAttribute(node, 'Labels');
+ }
+ };
+ $scope.updateLabel = function updateLabel(node, label) {
+ if (label.value !== label.originalValue || label.key !== label.originalKey) {
+ $scope.updateNodeAttribute(node, 'Labels');
+ }
+ };
+
+ $scope.hasChanges = function(node, elements) {
+ if (!elements) {
+ elements = Object.keys(originalNode);
+ }
+ var hasChanges = false;
+ elements.forEach(function(key) {
+ hasChanges = hasChanges || ((editedKeys.indexOf(key) >= 0) && node[key] !== originalNode[key]);
+ });
+ return hasChanges;
+ };
+
+ $scope.cancelChanges = function(node) {
+ editedKeys.forEach(function(key) {
+ node[key] = originalNode[key];
+ });
+ editedKeys = [];
+ };
+
+ $scope.updateNode = function updateNode(node) {
+ var config = NodeHelper.nodeToConfig(node.Model);
+ config.Name = node.Name;
+ config.Availability = node.Availability;
+ config.Role = node.Role;
+ config.Labels = LabelHelper.fromKeyValueToLabelHash(node.Labels);
+
+ Node.update({ id: node.Id, version: node.Version }, config, function (data) {
+ $('#loadServicesSpinner').hide();
+ Messages.send("Node successfully updated", "Node updated");
+ $state.go('node', {id: node.Id}, {reload: true});
+ }, function (e) {
+ $('#loadServicesSpinner').hide();
+ Messages.error("Failure", e, "Failed to update node");
+ });
+ };
+
+ function loadNodeAndTasks() {
+ $scope.loading = true;
+ if ($scope.applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE') {
+ Node.get({ id: $stateParams.id}, function(d) {
+ if (d.message) {
+ Messages.error("Failure", e, "Unable to inspect the node");
+ } else {
+ var node = new NodeViewModel(d);
+ originalNode = angular.copy(node);
+ $scope.node = node;
+ getTasks(d);
+ }
+ $scope.loading = false;
+ });
+ } else {
+ $scope.loading = false;
+ }
+ }
+
+ function getTasks(node) {
+ if (node) {
+ Task.query({filters: {node: [node.ID]}}, function (tasks) {
+ $scope.tasks = tasks.map(function (task) {
+ return new TaskViewModel(task, [node]);
+ });
+ }, function (e) {
+ Messages.error("Failure", e, "Unable to retrieve tasks associated to the node");
+ });
+ }
+ }
+
+ loadNodeAndTasks();
+
+}]);
diff --git a/app/components/swarm/swarm.html b/app/components/swarm/swarm.html
index 7b2751c43..661dbd710 100644
--- a/app/components/swarm/swarm.html
+++ b/app/components/swarm/swarm.html
@@ -208,7 +208,7 @@
- {{ node.Description.Hostname }} |
+ {{ node.Description.Hostname }} |
{{ node.Spec.Role }} |
{{ node.Description.Resources.NanoCPUs / 1000000000 }} |
{{ node.Description.Resources.MemoryBytes|humansize }} |
diff --git a/app/shared/helpers.js b/app/shared/helpers.js
index 545c32b2d..3cddb5c65 100644
--- a/app/shared/helpers.js
+++ b/app/shared/helpers.js
@@ -29,6 +29,28 @@ angular.module('portainer.helpers', [])
return mode;
}
};
+}])
+.factory('LabelHelper', [function LabelHelperFactory() {
+ 'use strict';
+ return {
+ fromLabelHashToKeyValue: function(labels) {
+ if (labels) {
+ return Object.keys(labels).map(function(key) {
+ return {key: key, value: labels[key], originalKey: key, originalValue: labels[key], added: true};
+ });
+ }
+ return [];
+ },
+ fromKeyValueToLabelHash: function(labelKV) {
+ var labels = {};
+ if (labelKV) {
+ labelKV.forEach(function(label) {
+ labels[label.key] = label.value;
+ });
+ }
+ return labels;
+ }
+ };
}])
.factory('ImageHelper', [function ImageHelperFactory() {
'use strict';
@@ -94,6 +116,19 @@ angular.module('portainer.helpers', [])
}
};
}])
+.factory('NodeHelper', [function NodeHelperFactory() {
+ 'use strict';
+ return {
+ nodeToConfig: function(node) {
+ return {
+ Name: node.Spec.Name,
+ Role: node.Spec.Role,
+ Labels: node.Spec.Labels,
+ Availability: node.Spec.Availability
+ };
+ }
+ };
+}])
.factory('TemplateHelper', [function TemplateHelperFactory() {
'use strict';
return {
diff --git a/app/shared/services.js b/app/shared/services.js
index 3a5e1a089..dbd982663 100644
--- a/app/shared/services.js
+++ b/app/shared/services.js
@@ -153,10 +153,11 @@ angular.module('portainer.services', ['ngResource', 'ngSanitize'])
.factory('Node', ['$resource', 'Settings', function NodeFactory($resource, Settings) {
'use strict';
// https://docs.docker.com/engine/reference/api/docker_remote_api_<%= remoteApiVersion %>/#/3-7-nodes
- return $resource(Settings.url + '/nodes', {}, {
- query: {
- method: 'GET', isArray: true
- }
+ return $resource(Settings.url + '/nodes/:id/:action', {}, {
+ query: {method: 'GET', isArray: true},
+ get: {method: 'GET', params: {id: '@id'}},
+ update: { method: 'POST', params: {id: '@id', action: 'update', version: '@version'} },
+ remove: { method: 'DELETE', params: {id: '@id'} }
});
}])
.factory('Swarm', ['$resource', 'Settings', function SwarmFactory($resource, Settings) {
diff --git a/app/shared/viewmodel.js b/app/shared/viewmodel.js
index c7296a6e7..6d9e4e0b1 100644
--- a/app/shared/viewmodel.js
+++ b/app/shared/viewmodel.js
@@ -14,6 +14,7 @@ function TaskViewModel(data, node_data) {
this.Updated = data.UpdatedAt;
this.Slot = data.Slot;
this.Status = data.Status.State;
+ this.Image = data.Spec.ContainerSpec ? data.Spec.ContainerSpec.Image : '';
if (node_data) {
for (var i = 0; i < node_data.length; ++i) {
if (data.NodeID === node_data[i].ID) {
@@ -60,6 +61,42 @@ function ServiceViewModel(data) {
this.EditName = false;
}
+function NodeViewModel(data) {
+ this.Model = data;
+ this.Id = data.ID;
+ this.Version = data.Version.Index;
+ this.Name = data.Spec.Name;
+ this.Role = data.Spec.Role;
+ this.CreatedAt = data.CreatedAt;
+ this.UpdatedAt = data.UpdatedAt;
+ this.Availability = data.Spec.Availability;
+
+ var labels = data.Spec.Labels;
+ if (labels) {
+ this.Labels = Object.keys(labels).map(function(key) {
+ return { key: key, value: labels[key], originalKey: key, originalValue: labels[key], added: true };
+ });
+ } else {
+ this.Labels = [];
+ }
+
+ this.Hostname = data.Description.Hostname;
+ this.PlatformArchitecture = data.Description.Platform.Architecture;
+ this.PlatformOS = data.Description.Platform.OS;
+ this.CPUs = data.Description.Resources.NanoCPUs;
+ this.Memory = data.Description.Resources.MemoryBytes;
+ this.EngineVersion = data.Description.Engine.EngineVersion;
+ this.EngineLabels = data.Description.Engine.Labels;
+ this.Plugins = data.Description.Engine.Plugins;
+ this.Status = data.Status.State;
+
+ if (data.ManagerStatus) {
+ this.Leader = data.ManagerStatus.Leader;
+ this.Reachability = data.ManagerStatus.Reachability;
+ this.ManagerAddr = data.ManagerStatus.Addr;
+ }
+}
+
function ContainerViewModel(data) {
this.Id = data.Id;
this.Status = data.Status;