From fa9ba303aa0e13349380f324f55d6244872ec652 Mon Sep 17 00:00:00 2001 From: Glowbal Date: Wed, 25 Jan 2017 22:12:04 +0100 Subject: [PATCH] #414 feat(node-details): add ability to view and edit Swarm mode nodes (#417) --- app/app.js | 17 ++ app/components/node/node.html | 262 ++++++++++++++++++++++++++ app/components/node/nodeController.js | 107 +++++++++++ app/components/swarm/swarm.html | 2 +- app/shared/helpers.js | 35 ++++ app/shared/services.js | 9 +- app/shared/viewmodel.js | 37 ++++ 7 files changed, 464 insertions(+), 5 deletions(-) create mode 100644 app/components/node/node.html create mode 100644 app/components/node/nodeController.js 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.

+
+
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Name + +
Host name{{ node.Hostname }}
Role{{ node.Role }}
Availability +
+ +
+
Status{{ node.Status }}
+
+ +

+ 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.

+
+ + + + + + + + + + + + + + +
LabelValue
+
+ name + +
+
+
+ value + + + + +
+
+
+ + + +
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + +
Id + + Status + + + + + + Slot + + + + + + Image + + + + + + Last update + + + +
{{ task.Id }}{{ task.Status }}{{ task.Slot }}{{ task.Image }}{{ task.Updated|getisodate }}
+
+ +
+
+
+
+
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;