diff --git a/app/app.js b/app/app.js index c7e9a3acb..78268fe37 100644 --- a/app/app.js +++ b/app/app.js @@ -18,11 +18,15 @@ angular.module('portainer', [ 'events', 'images', 'image', + 'service', + 'services', + 'createService', 'stats', 'swarm', 'network', 'networks', 'createNetwork', + 'task', 'templates', 'volumes', 'createVolume']) @@ -80,16 +84,21 @@ angular.module('portainer', [ templateUrl: 'app/components/createContainer/createcontainer.html', controller: 'CreateContainerController' }) - .state('actions.create.volume', { - url: "/volume", - templateUrl: 'app/components/createVolume/createvolume.html', - controller: 'CreateVolumeController' - }) .state('actions.create.network', { url: "/network", templateUrl: 'app/components/createNetwork/createnetwork.html', controller: 'CreateNetworkController' }) + .state('actions.create.service', { + url: "/service", + templateUrl: 'app/components/createService/createservice.html', + controller: 'CreateServiceController' + }) + .state('actions.create.volume', { + url: "/volume", + templateUrl: 'app/components/createVolume/createvolume.html', + controller: 'CreateVolumeController' + }) .state('docker', { url: '/docker/', templateUrl: 'app/components/docker/docker.html', @@ -120,6 +129,21 @@ angular.module('portainer', [ templateUrl: 'app/components/network/network.html', controller: 'NetworkController' }) + .state('services', { + url: '/services/', + templateUrl: 'app/components/services/services.html', + controller: 'ServicesController' + }) + .state('service', { + url: '^/service/:id/', + templateUrl: 'app/components/service/service.html', + controller: 'ServiceController' + }) + .state('task', { + url: '^/task/:id', + templateUrl: 'app/components/task/task.html', + controller: 'TaskController' + }) .state('templates', { url: '/templates/', templateUrl: 'app/components/templates/templates.html', diff --git a/app/components/containers/containers.html b/app/components/containers/containers.html index c5636e5ea..63f424201 100644 --- a/app/components/containers/containers.html +++ b/app/components/containers/containers.html @@ -66,7 +66,7 @@ - + Host IP @@ -86,11 +86,11 @@ {{ container.Status|containerstatus }} - {{ container|swarmcontainername}} - {{ container|containername}} + {{ container|swarmcontainername}} + {{ container|containername}} {{ container.Image }} {{ container.IP ? container.IP : '-' }} - {{ container.hostIP }} + {{ container.hostIP }} {{ p.private }} diff --git a/app/components/containers/containersController.js b/app/components/containers/containersController.js index a0000c421..71a3b9c19 100644 --- a/app/components/containers/containersController.js +++ b/app/components/containers/containersController.js @@ -8,6 +8,7 @@ function ($scope, Container, ContainerHelper, Info, Settings, Messages, Config) $scope.sortType = 'State'; $scope.sortReverse = false; $scope.state.selectedItemCount = 0; + $scope.swarm_mode = false; $scope.order = function (sortType) { $scope.sortReverse = ($scope.sortType === sortType) ? !$scope.sortReverse : false; @@ -27,7 +28,7 @@ function ($scope, Container, ContainerHelper, Info, Settings, Messages, Config) if (model.IP) { $scope.state.displayIP = true; } - if ($scope.swarm) { + if ($scope.swarm && !$scope.swarm_mode) { model.hostIP = $scope.swarm_hosts[_.split(container.Names[0], '/')[1]]; } return model; @@ -151,7 +152,11 @@ function ($scope, Container, ContainerHelper, Info, Settings, Messages, Config) $scope.swarm = c.swarm; if (c.swarm) { Info.get({}, function (d) { - $scope.swarm_hosts = retrieveSwarmHostsInfo(d); + if (d.Swarm) { + $scope.swarm_mode = true; + } else { + $scope.swarm_hosts = retrieveSwarmHostsInfo(d); + } update({all: Settings.displayAll ? 1 : 0}); }); } else { diff --git a/app/components/createContainer/createContainerController.js b/app/components/createContainer/createContainerController.js index 4724c4111..8449a34d6 100644 --- a/app/components/createContainer/createContainerController.js +++ b/app/components/createContainer/createContainerController.js @@ -1,6 +1,6 @@ angular.module('createContainer', []) -.controller('CreateContainerController', ['$scope', '$state', 'Config', 'Container', 'Image', 'Volume', 'Network', 'Messages', -function ($scope, $state, Config, Container, Image, Volume, Network, Messages) { +.controller('CreateContainerController', ['$scope', '$state', 'Config', 'Info', 'Container', 'Image', 'Volume', 'Network', 'Messages', +function ($scope, $state, Config, Info, Container, Image, Volume, Network, Messages) { $scope.state = { alwaysPull: true @@ -55,6 +55,11 @@ function ($scope, $state, Config, Container, Image, Volume, Network, Messages) { Config.$promise.then(function (c) { var swarm = c.swarm; + Info.get({}, function(info) { + if (swarm && info.Swarm) { + $scope.swarm_mode = true; + } + }); $scope.formValues.AvailableRegistries = c.registries; diff --git a/app/components/createContainer/createcontainer.html b/app/components/createContainer/createcontainer.html index 3eb25b6f0..508e2a03f 100644 --- a/app/components/createContainer/createcontainer.html +++ b/app/components/createContainer/createcontainer.html @@ -254,7 +254,7 @@
-
+
diff --git a/app/components/createNetwork/createNetworkController.js b/app/components/createNetwork/createNetworkController.js index cb99db101..3d03b56bd 100644 --- a/app/components/createNetwork/createNetworkController.js +++ b/app/components/createNetwork/createNetworkController.js @@ -11,7 +11,10 @@ function ($scope, $state, Messages, Network) { Driver: 'bridge', CheckDuplicate: true, Internal: false, + // Force IPAM Driver to 'default', should not be required. + // See: https://github.com/docker/docker/issues/25735 IPAM: { + Driver: 'default', Config: [] } }; diff --git a/app/components/createService/createServiceController.js b/app/components/createService/createServiceController.js new file mode 100644 index 000000000..675aeb338 --- /dev/null +++ b/app/components/createService/createServiceController.js @@ -0,0 +1,178 @@ +angular.module('createService', []) +.controller('CreateServiceController', ['$scope', '$state', 'Service', 'Volume', 'Network', 'ImageHelper', 'Messages', +function ($scope, $state, Service, Volume, Network, ImageHelper, Messages) { + + $scope.formValues = { + Name: '', + Image: '', + Registry: '', + Mode: 'replicated', + Replicas: 1, + Command: '', + WorkingDir: '', + User: '', + Env: [], + Volumes: [], + Network: '', + ExtraNetworks: [], + Ports: [] + }; + + $scope.addPortBinding = function() { + $scope.formValues.Ports.push({ PublishedPort: '', TargetPort: '', Protocol: 'tcp' }); + }; + + $scope.removePortBinding = function(index) { + $scope.formValues.Ports.splice(index, 1); + }; + + $scope.addExtraNetwork = function() { + $scope.formValues.ExtraNetworks.push({ Name: '' }); + }; + + $scope.removeExtraNetwork = function(index) { + $scope.formValues.ExtraNetworks.splice(index, 1); + }; + + $scope.addVolume = function() { + $scope.formValues.Volumes.push({ name: '', containerPath: '' }); + }; + + $scope.removeVolume = function(index) { + $scope.formValues.Volumes.splice(index, 1); + }; + + $scope.addEnvironmentVariable = function() { + $scope.formValues.Env.push({ name: '', value: ''}); + }; + + $scope.removeEnvironmentVariable = function(index) { + $scope.formValues.Env.splice(index, 1); + }; + + function prepareImageConfig(config, input) { + var imageConfig = ImageHelper.createImageConfig(input.Image, input.Registry); + config.TaskTemplate.ContainerSpec.Image = imageConfig.repo + ':' + imageConfig.tag; + } + + function preparePortsConfig(config, input) { + var ports = []; + input.Ports.forEach(function (binding) { + if (binding.PublishedPort && binding.TargetPort) { + ports.push({ PublishedPort: +binding.PublishedPort, TargetPort: +binding.TargetPort, Protocol: binding.Protocol }); + } + }); + config.EndpointSpec.Ports = ports; + } + + function prepareSchedulingConfig(config, input) { + if (input.Mode === 'replicated') { + config.Mode.Replicated = { + Replicas: input.Replicas + }; + } else { + config.Mode.Global = {}; + } + } + + function prepareCommandConfig(config, input) { + if (input.Command) { + config.TaskTemplate.ContainerSpec.Command = _.split(input.Command, ' '); + } + if (input.User) { + config.TaskTemplate.ContainerSpec.User = input.User; + } + if (input.WorkingDir) { + config.TaskTemplate.ContainerSpec.Dir = input.WorkingDir; + } + } + + function prepareEnvConfig(config, input) { + var env = []; + input.Env.forEach(function (v) { + if (v.name && v.value) { + env.push(v.name + "=" + v.value); + } + }); + config.TaskTemplate.ContainerSpec.Env = env; + } + + function prepareVolumes(config, input) { + input.Volumes.forEach(function (volume) { + if (volume.Source && volume.Target) { + var mount = {}; + mount.Type = volume.Bind ? 'bind' : 'volume'; + mount.ReadOnly = volume.ReadOnly ? true : false; + mount.Source = volume.Source; + mount.Target = volume.Target; + config.TaskTemplate.ContainerSpec.Mounts.push(mount); + } + }); + } + + function prepareNetworks(config, input) { + var networks = []; + if (input.Network) { + networks.push({ Target: input.Network }); + } + input.ExtraNetworks.forEach(function (network) { + networks.push({ Target: network.Name }); + }); + config.Networks = _.uniqWith(networks, _.isEqual); + } + + function prepareConfiguration() { + var input = $scope.formValues; + var config = { + Name: input.Name, + TaskTemplate: { + ContainerSpec: { + Mounts: [] + } + }, + Mode: {}, + EndpointSpec: {} + }; + prepareSchedulingConfig(config, input); + prepareImageConfig(config, input); + preparePortsConfig(config, input); + prepareCommandConfig(config, input); + prepareEnvConfig(config, input); + prepareVolumes(config, input); + prepareNetworks(config, input); + return config; + } + + function createNewService(config) { + Service.create(config, function (d) { + $('#createServiceSpinner').hide(); + Messages.send('Service created', d.ID); + $state.go('services', {}, {reload: true}); + }, function (e) { + $('#createServiceSpinner').hide(); + Messages.error("Failure", e, 'Unable to create service'); + }); + } + + $scope.create = function createService() { + $('#createServiceSpinner').show(); + var config = prepareConfiguration(); + createNewService(config); + }; + + Volume.query({}, function (d) { + $scope.availableVolumes = d.Volumes; + }, function (e) { + Messages.error("Failure", e, "Unable to retrieve volumes"); + }); + + Network.query({}, function (d) { + $scope.availableNetworks = d.filter(function (network) { + if (network.Scope === 'swarm') { + return network; + } + }); + }, function (e) { + Messages.error("Failure", e, "Unable to retrieve networks"); + }); +}]); diff --git a/app/components/createService/createservice.html b/app/components/createService/createservice.html new file mode 100644 index 000000000..6357d667a --- /dev/null +++ b/app/components/createService/createservice.html @@ -0,0 +1,272 @@ + + + + Services > Add service + + + +
+
+ + + + +
+ +
+ +
+
+ + +
+ +
+ +
+ +
+ +
+
+ + +
+ +
+ + +
+
+
+ +
+ +
+
+
+ + +
+ +
+ + map port + +
+ +
+
+
+ host + +
+
+ container + +
+
+ + + + +
+
+
+ +
+ + +
+
+
+
+ +
+
+ + + + +
+ +
+
+ +
+ +
+ +
+
+ + +
+ +
+ +
+ +
+ +
+
+ + +
+ +
+ + environment variable + +
+ +
+
+
+ name + +
+
+ value + + + + +
+
+
+ +
+ +
+
+ + +
+
+ +
+ +
+ + volume + +
+ +
+
+
+
+ +
+
+
+ bind + + +
+
+ container + + + + +
+
+
+ +
+ +
+
+ + +
+
+ +
+ +
+ +
+
+
+ + +
+ +
+ + network + +
+ +
+
+
+ + + + +
+
+
+
+ +
+ +
+
+ + +
+
+ +
+
+
+
+
+ +
+
+
+ +
+ + Cancel +
+
diff --git a/app/components/dashboard/dashboard.html b/app/components/dashboard/dashboard.html index 4980a5c8b..d25c5bda1 100644 --- a/app/components/dashboard/dashboard.html +++ b/app/components/dashboard/dashboard.html @@ -6,7 +6,7 @@
-
+
@@ -33,7 +33,7 @@
-
+
@@ -60,6 +60,28 @@
+
+ + + + + + + + + + + + + + + + + +
This node is part of a Swarm cluster
Node role{{ infoData.Swarm.ControlAvailable ? 'Manager' : 'Worker' }}
Nodes in the cluster{{ infoData.Swarm.Nodes }}
+
+
+
diff --git a/app/components/dashboard/dashboardController.js b/app/components/dashboard/dashboardController.js index 13b2c8617..1eba490a2 100644 --- a/app/components/dashboard/dashboardController.js +++ b/app/components/dashboard/dashboardController.js @@ -14,6 +14,7 @@ function ($scope, $q, Config, Container, ContainerHelper, Image, Network, Volume $scope.volumeData = { total: 0 }; + $scope.swarm_mode = false; function prepareContainerData(d, containersToHideLabels) { var running = 0; @@ -63,6 +64,9 @@ function ($scope, $q, Config, Container, ContainerHelper, Image, Network, Volume function prepareInfoData(d) { var info = d; $scope.infoData = info; + if ($scope.swarm && info.Swarm) { + $scope.swarm_mode = true; + } } function fetchDashboardData(containersToHideLabels) { diff --git a/app/components/dashboard/master-ctrl.js b/app/components/dashboard/master-ctrl.js index 9d5080005..498bbee13 100644 --- a/app/components/dashboard/master-ctrl.js +++ b/app/components/dashboard/master-ctrl.js @@ -1,5 +1,6 @@ angular.module('dashboard') -.controller('MasterCtrl', ['$scope', '$cookieStore', 'Settings', 'Config', function ($scope, $cookieStore, Settings, Config) { +.controller('MasterCtrl', ['$scope', '$cookieStore', 'Settings', 'Config', 'Info', +function ($scope, $cookieStore, Settings, Config, Info) { /** * Sidebar Toggle & Cookie Control */ @@ -9,7 +10,20 @@ angular.module('dashboard') return window.innerWidth; }; - $scope.config = Config; + $scope.swarm_mode = false; + + Config.$promise.then(function (c) { + $scope.swarm = c.swarm; + Info.get({}, function(d) { + if ($scope.swarm && d.Swarm) { + $scope.swarm_mode = true; + $scope.swarm_manager = false; + if (d.Swarm.ControlAvailable) { + $scope.swarm_manager = true; + } + } + }); + }); $scope.$watch($scope.getWidth, function(newValue, oldValue) { if (newValue >= mobileView) { diff --git a/app/components/images/imagesController.js b/app/components/images/imagesController.js index 2d692c707..1f7e0394c 100644 --- a/app/components/images/imagesController.js +++ b/app/components/images/imagesController.js @@ -72,7 +72,7 @@ function ($scope, $state, Config, Image, Messages) { counter = counter + 1; Image.remove({id: i.Id}, function (d) { if (d[0].message) { - $('#loadingViewSpinner').hide(); + $('#loadImagesSpinner').hide(); Messages.error("Unable to remove image", {}, d[0].message); } else { Messages.send("Image deleted", i.Id); diff --git a/app/components/networks/networksController.js b/app/components/networks/networksController.js index 57c4e73d2..84e755c07 100644 --- a/app/components/networks/networksController.js +++ b/app/components/networks/networksController.js @@ -15,6 +15,11 @@ function ($scope, $state, Network, Config, Messages) { var config = angular.copy($scope.config); if ($scope.swarm) { config.Driver = 'overlay'; + // Force IPAM Driver to 'default', should not be required. + // See: https://github.com/docker/docker/issues/25735 + config.IPAM = { + Driver: 'default' + }; } return config; } diff --git a/app/components/service/service.html b/app/components/service/service.html new file mode 100644 index 000000000..ad674e4e5 --- /dev/null +++ b/app/components/service/service.html @@ -0,0 +1,150 @@ + + + + + + + + + Services > {{ service.Name }} + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Name + {{ service.Name }} + + + + + +
ID + {{ service.Id }} + +
Scheduling mode{{ service.Mode }}
Replicas + + {{ service.Replicas }} + Scale + + + + + + +
Image{{ service.Image }}
Published ports +
+ {{ mapping.TargetPort }} {{ mapping.PublishedPort }} +
+
Env + + + + + +
{{ var|key: '=' }}{{ var|value: '=' }}
+
Labels + + + + + +
{{ k }}{{ v }}
+
+
+
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + +
Id + + Status + + + + + + Slot + + + + + + Node + + + + + + Last update + + + +
{{ task.Id }}{{ task.Status }}{{ task.Slot }}{{ task.Node }}{{ task.Updated|getisodate }}
+
+
+
+
diff --git a/app/components/service/serviceController.js b/app/components/service/serviceController.js new file mode 100644 index 000000000..07fecf38a --- /dev/null +++ b/app/components/service/serviceController.js @@ -0,0 +1,97 @@ +angular.module('service', []) +.controller('ServiceController', ['$scope', '$stateParams', '$state', 'Service', 'ServiceHelper', 'Task', 'Node', 'Messages', +function ($scope, $stateParams, $state, Service, ServiceHelper, Task, Node, Messages) { + + $scope.service = {}; + $scope.tasks = []; + $scope.displayNode = false; + $scope.sortType = 'Status'; + $scope.sortReverse = false; + + $scope.order = function (sortType) { + $scope.sortReverse = ($scope.sortType === sortType) ? !$scope.sortReverse : false; + $scope.sortType = sortType; + }; + + $scope.renameService = function renameService(service) { + $('#loadServicesSpinner').show(); + var serviceName = service.Name; + var config = ServiceHelper.serviceToConfig(service.Model); + config.Name = service.newServiceName; + Service.update({ id: service.Id, version: service.Version }, config, function (data) { + $('#loadServicesSpinner').hide(); + Messages.send("Service successfully renamed", "New name: " + service.newServiceName); + $state.go('service', {id: service.Id}, {reload: true}); + }, function (e) { + $('#loadServicesSpinner').hide(); + service.EditName = false; + service.Name = serviceName; + Messages.error("Failure", e, "Unable to rename service"); + }); + }; + + $scope.scaleService = function scaleService(service) { + $('#loadServicesSpinner').show(); + var config = ServiceHelper.serviceToConfig(service.Model); + config.Mode.Replicated.Replicas = service.Replicas; + Service.update({ id: service.Id, version: service.Version }, config, function (data) { + $('#loadServicesSpinner').hide(); + Messages.send("Service successfully scaled", "New replica count: " + service.Replicas); + $state.go('service', {id: service.Id}, {reload: true}); + }, function (e) { + $('#loadServicesSpinner').hide(); + service.Scale = false; + service.Replicas = service.ReplicaCount; + Messages.error("Failure", e, "Unable to scale service"); + }); + }; + + $scope.removeService = function removeService() { + $('#loadingViewSpinner').show(); + Service.remove({id: $stateParams.id}, function (d) { + if (d.message) { + $('#loadingViewSpinner').hide(); + Messages.send("Error", {}, d.message); + } else { + $('#loadingViewSpinner').hide(); + Messages.send("Service removed", $stateParams.id); + $state.go('services', {}); + } + }, function (e) { + $('#loadingViewSpinner').hide(); + Messages.error("Failure", e, "Unable to remove service"); + }); + }; + + function fetchServiceDetails() { + $('#loadingViewSpinner').show(); + Service.get({id: $stateParams.id}, function (d) { + var service = new ServiceViewModel(d); + service.newServiceName = service.Name; + $scope.service = service; + Task.query({filters: {service: [service.Name]}}, function (tasks) { + Node.query({}, function (nodes) { + $scope.displayNode = true; + $scope.tasks = tasks.map(function (task) { + return new TaskViewModel(task, nodes); + }); + $('#loadingViewSpinner').hide(); + }, function (e) { + $('#loadingViewSpinner').hide(); + $scope.tasks = tasks.map(function (task) { + return new TaskViewModel(task, null); + }); + Messages.error("Failure", e, "Unable to retrieve node information"); + }); + }, function (e) { + $('#loadingViewSpinner').hide(); + Messages.error("Failure", e, "Unable to retrieve tasks associated to the service"); + }); + }, function (e) { + $('#loadingViewSpinner').hide(); + Messages.error("Failure", e, "Unable to retrieve service details"); + }); + } + + fetchServiceDetails(); +}]); diff --git a/app/components/services/services.html b/app/components/services/services.html new file mode 100644 index 000000000..8cc618a28 --- /dev/null +++ b/app/components/services/services.html @@ -0,0 +1,78 @@ + + + + + + + Services + + +
+
+ + +
+ +
+
+ +
+ + Add service +
+
+ +
+
+ +
+ + + + + + + + + + + + + + + +
+ + Name + + + + + + Image + + + + + + Scheduling mode + + + +
{{ service.Name }}{{ service.Image }} + {{ service.Mode }} + + {{ service.Replicas }} + Scale + + + + + + +
+
+
+ +
+
diff --git a/app/components/services/servicesController.js b/app/components/services/servicesController.js new file mode 100644 index 000000000..ac7054e01 --- /dev/null +++ b/app/components/services/servicesController.js @@ -0,0 +1,84 @@ +angular.module('services', []) +.controller('ServicesController', ['$scope', '$stateParams', '$state', 'Service', 'ServiceHelper', 'Messages', +function ($scope, $stateParams, $state, Service, ServiceHelper, Messages) { + + $scope.services = []; + $scope.state = {}; + $scope.state.selectedItemCount = 0; + $scope.sortType = 'Name'; + $scope.sortReverse = false; + + $scope.scaleService = function scaleService(service) { + $('#loadServicesSpinner').show(); + var config = ServiceHelper.serviceToConfig(service.Model); + config.Mode.Replicated.Replicas = service.Replicas; + Service.update({ id: service.Id, version: service.Version }, config, function (data) { + $('#loadServicesSpinner').hide(); + Messages.send("Service successfully scaled", "New replica count: " + service.Replicas); + $state.go('services', {}, {reload: true}); + }, function (e) { + $('#loadServicesSpinner').hide(); + service.Scale = false; + service.Replicas = service.ReplicaCount; + Messages.error("Failure", e, "Unable to scale service"); + }); + }; + + $scope.order = function (sortType) { + $scope.sortReverse = ($scope.sortType === sortType) ? !$scope.sortReverse : false; + $scope.sortType = sortType; + }; + + $scope.selectItem = function (item) { + if (item.Checked) { + $scope.state.selectedItemCount++; + } else { + $scope.state.selectedItemCount--; + } + }; + + $scope.removeAction = function () { + $('#loadServicesSpinner').show(); + var counter = 0; + var complete = function () { + counter = counter - 1; + if (counter === 0) { + $('#loadServicesSpinner').hide(); + } + }; + angular.forEach($scope.services, function (service) { + if (service.Checked) { + counter = counter + 1; + Service.remove({id: service.Id}, function (d) { + if (d.message) { + $('#loadServicesSpinner').hide(); + Messages.error("Unable to remove service", {}, d[0].message); + } else { + Messages.send("Service deleted", service.Id); + var index = $scope.services.indexOf(service); + $scope.services.splice(index, 1); + } + complete(); + }, function (e) { + Messages.error("Failure", e, 'Unable to remove service'); + complete(); + }); + } + }); + }; + + function fetchServices() { + $('#loadServicesSpinner').show(); + Service.query({}, function (d) { + $scope.services = d.map(function (service) { + return new ServiceViewModel(service); + }); + $('#loadServicesSpinner').hide(); + }, function(e) { + $('#loadServicesSpinner').hide(); + Messages.error("Failure", e, "Unable to retrieve services"); + }); + } + + fetchServices(); +}]); diff --git a/app/components/swarm/swarm.html b/app/components/swarm/swarm.html index 2e09be30a..a9b5316e8 100644 --- a/app/components/swarm/swarm.html +++ b/app/components/swarm/swarm.html @@ -16,13 +16,14 @@ Nodes - {{ swarm.Nodes }} + {{ swarm.Nodes }} + {{ info.Swarm.Nodes }} - + Images {{ info.Images }} - + Swarm version {{ docker.Version|swarmversion }} @@ -30,27 +31,29 @@ Docker API version {{ docker.ApiVersion }} - + Strategy {{ swarm.Strategy }} Total CPU - {{ info.NCPU }} + {{ info.NCPU }} + {{ totalCPU }} Total memory - {{ info.MemTotal|humansize }} + {{ info.MemTotal|humansize }} + {{ totalMemory|humansize }} - + Operating system {{ info.OperatingSystem }} - + Kernel version {{ info.KernelVersion }} - + Go version {{ docker.GoVersion }} @@ -60,8 +63,9 @@
+
-
+
@@ -126,4 +130,69 @@
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ + Name + + + + + + Role + + + + + + CPU + + + + + + Memory + + + + + + Engine + + + + + + Status + + + +
{{ node.Description.Hostname }}{{ node.Spec.Role }}{{ node.Description.Resources.NanoCPUs / 1000000000 }}{{ node.Description.Resources.MemoryBytes|humansize }}{{ node.Description.Engine.EngineVersion }}{{ node.Status.State }}
+
+
+
diff --git a/app/components/swarm/swarmController.js b/app/components/swarm/swarmController.js index eca575753..148155011 100644 --- a/app/components/swarm/swarmController.js +++ b/app/components/swarm/swarmController.js @@ -1,62 +1,80 @@ angular.module('swarm', []) - .controller('SwarmController', ['$scope', 'Info', 'Version', 'Settings', - function ($scope, Info, Version, Settings) { +.controller('SwarmController', ['$scope', 'Info', 'Version', 'Node', +function ($scope, Info, Version, Node) { - $scope.sortType = 'Name'; - $scope.sortReverse = true; - $scope.info = {}; - $scope.docker = {}; - $scope.swarm = {}; + $scope.sortType = 'Name'; + $scope.sortReverse = true; + $scope.info = {}; + $scope.docker = {}; + $scope.swarm = {}; + $scope.swarm_mode = false; + $scope.totalCPU = 0; + $scope.totalMemory = 0; - $scope.order = function(sortType) { - $scope.sortReverse = ($scope.sortType === sortType) ? !$scope.sortReverse : false; - $scope.sortType = sortType; - }; + $scope.order = function(sortType) { + $scope.sortReverse = ($scope.sortType === sortType) ? !$scope.sortReverse : false; + $scope.sortType = sortType; + }; - Version.get({}, function (d) { - $scope.docker = d; - }); - Info.get({}, function (d) { - $scope.info = d; - extractSwarmInfo(d); + Version.get({}, function (d) { + $scope.docker = d; + }); + + Info.get({}, function (d) { + $scope.info = d; + if (d.Swarm) { + $scope.swarm_mode = true; + Node.query({}, function(d) { + $scope.nodes = d; + var CPU = 0, memory = 0; + angular.forEach(d, function(node) { + CPU += node.Description.Resources.NanoCPUs; + memory += node.Description.Resources.MemoryBytes; + }); + $scope.totalCPU = CPU / 1000000000; + $scope.totalMemory = memory; }); + } else { + extractSwarmInfo(d); + } + }); - function extractSwarmInfo(info) { - // Swarm info is available in SystemStatus object - var systemStatus = info.SystemStatus; - // Swarm strategy - $scope.swarm[systemStatus[1][0]] = systemStatus[1][1]; - // Swarm filters - $scope.swarm[systemStatus[2][0]] = systemStatus[2][1]; - // Swarm node count - var node_count = parseInt(systemStatus[3][1], 10); - $scope.swarm[systemStatus[3][0]] = node_count; + function extractSwarmInfo(info) { + // Swarm info is available in SystemStatus object + var systemStatus = info.SystemStatus; + // Swarm strategy + $scope.swarm[systemStatus[1][0]] = systemStatus[1][1]; + // Swarm filters + $scope.swarm[systemStatus[2][0]] = systemStatus[2][1]; + // Swarm node count + var node_count = parseInt(systemStatus[3][1], 10); + $scope.swarm[systemStatus[3][0]] = node_count; - $scope.swarm.Status = []; - extractNodesInfo(systemStatus, node_count); - } + $scope.swarm.Status = []; + extractNodesInfo(systemStatus, node_count); + } - function extractNodesInfo(info, node_count) { - // First information for node1 available at element #4 of SystemStatus - // The next 10 elements are information related to the node - var node_offset = 4; - for (i = 0; i < node_count; i++) { - extractNodeInfo(info, node_offset); - node_offset += 9; - } - } + function extractNodesInfo(info, node_count) { + // First information for node1 available at element #4 of SystemStatus + // The next 10 elements are information related to the node + var node_offset = 4; + for (i = 0; i < node_count; i++) { + extractNodeInfo(info, node_offset); + node_offset += 9; + } + } - function extractNodeInfo(info, offset) { - var node = {}; - node.name = info[offset][0]; - node.ip = info[offset][1]; - node.id = info[offset + 1][1]; - node.status = info[offset + 2][1]; - node.containers = info[offset + 3][1]; - node.cpu = info[offset + 4][1].split('/')[1]; - node.memory = info[offset + 5][1].split('/')[1]; - node.labels = info[offset + 6][1]; - node.version = info[offset + 8][1]; - $scope.swarm.Status.push(node); - } - }]); + function extractNodeInfo(info, offset) { + var node = {}; + node.name = info[offset][0]; + node.ip = info[offset][1]; + node.id = info[offset + 1][1]; + node.status = info[offset + 2][1]; + node.containers = info[offset + 3][1]; + node.cpu = info[offset + 4][1].split('/')[1]; + node.memory = info[offset + 5][1].split('/')[1]; + node.labels = info[offset + 6][1]; + node.version = info[offset + 8][1]; + $scope.swarm.Status.push(node); + } +}]); diff --git a/app/components/task/task.html b/app/components/task/task.html new file mode 100644 index 000000000..1769c8c1b --- /dev/null +++ b/app/components/task/task.html @@ -0,0 +1,50 @@ + + + + + + Services > {{ serviceName }} > {{ task.ID }} + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ID{{ task.ID }}
State{{ task.Status.State }}
Error message{{ task.Status.Err }}
Image{{ task.Spec.ContainerSpec.Image }}
Slot{{ task.Slot }}
Created{{ task.CreatedAt|getisodate }}
Container ID{{ task.Status.ContainerStatus.ContainerID }}
+
+
+
+
diff --git a/app/components/task/taskController.js b/app/components/task/taskController.js new file mode 100644 index 000000000..e6a1dc33e --- /dev/null +++ b/app/components/task/taskController.js @@ -0,0 +1,29 @@ +angular.module('task', []) +.controller('TaskController', ['$scope', '$stateParams', '$state', 'Task', 'Service', 'Messages', +function ($scope, $stateParams, $state, Task, Service, Messages) { + + $scope.task = {}; + $scope.serviceName = 'service'; + $scope.isTaskRunning = false; + + function fetchTaskDetails() { + $('#loadingViewSpinner').show(); + Task.get({id: $stateParams.id}, function (d) { + $scope.task = d; + fetchAssociatedServiceDetails(d.ServiceID); + $('#loadingViewSpinner').hide(); + }, function (e) { + Messages.error("Failure", e, "Unable to retrieve task details"); + }); + } + + function fetchAssociatedServiceDetails(serviceId) { + Service.get({id: serviceId}, function (d) { + $scope.serviceName = d.Spec.Name; + }, function (e) { + Messages.error("Failure", e, "Unable to retrieve associated service details"); + }); + } + + fetchTaskDetails(); +}]); diff --git a/app/components/templates/templates.html b/app/components/templates/templates.html index fe4b06be9..40a42bedc 100644 --- a/app/components/templates/templates.html +++ b/app/components/templates/templates.html @@ -34,11 +34,17 @@
-
+
When using Swarm, we recommend deploying containers in a shared network. Looks like you don't have any shared network, head over the networks view to create one.
+
+
+ + App templates cannot be used with swarm-mode at the moment. You can still use them to quickly deploy containers to the Docker host. +
+
diff --git a/app/components/templates/templatesController.js b/app/components/templates/templatesController.js index 030d62050..99b8b1629 100644 --- a/app/components/templates/templatesController.js +++ b/app/components/templates/templatesController.js @@ -1,6 +1,6 @@ angular.module('templates', []) -.controller('TemplatesController', ['$scope', '$q', '$state', '$filter', 'Config', 'Container', 'ContainerHelper', 'Image', 'Volume', 'Network', 'Templates', 'Messages', -function ($scope, $q, $state, $filter, Config, Container, ContainerHelper, Image, Volume, Network, Templates, Messages) { +.controller('TemplatesController', ['$scope', '$q', '$state', '$filter', 'Config', 'Info', 'Container', 'ContainerHelper', 'Image', 'Volume', 'Network', 'Templates', 'Messages', +function ($scope, $q, $state, $filter, Config, Info, Container, ContainerHelper, Image, Volume, Network, Templates, Messages) { $scope.templates = []; $scope.selectedTemplate = null; $scope.formValues = { @@ -165,6 +165,11 @@ function ($scope, $q, $state, $filter, Config, Container, ContainerHelper, Image Config.$promise.then(function (c) { $scope.swarm = c.swarm; + Info.get({}, function(info) { + if ($scope.swarm && info.Swarm) { + $scope.swarm_mode = true; + } + }); var containersToHideLabels = c.hiddenLabels; Network.query({}, function (d) { var networks = d; diff --git a/app/shared/filters.js b/app/shared/filters.js index f680ccffa..339b7f85a 100644 --- a/app/shared/filters.js +++ b/app/shared/filters.js @@ -18,6 +18,24 @@ angular.module('portainer.filters', []) } }; }) +.filter('taskstatusbadge', function () { + 'use strict'; + return function (text) { + var status = _.toLower(text); + if (status.indexOf('new') !== -1 || status.indexOf('allocated') !== -1 || + status.indexOf('assigned') !== -1 || status.indexOf('accepted') !== -1) { + return 'info'; + } else if (status.indexOf('pending') !== -1) { + return 'warning'; + } else if (status.indexOf('shutdown') !== -1 || status.indexOf('failed') !== -1 || + status.indexOf('rejected') !== -1) { + return 'danger'; + } else if (status.indexOf('complete') !== -1) { + return 'primary'; + } + return 'success'; + }; +}) .filter('containerstatusbadge', function () { 'use strict'; return function (text) { diff --git a/app/shared/helpers.js b/app/shared/helpers.js index 7c51e4e3d..ad200560d 100644 --- a/app/shared/helpers.js +++ b/app/shared/helpers.js @@ -34,4 +34,18 @@ angular.module('portainer.helpers', []) }); } }; +}]) +.factory('ServiceHelper', [function ServiceHelperFactory() { + 'use strict'; + return { + serviceToConfig: function(service) { + return { + Name: service.Spec.Name, + TaskTemplate: service.Spec.TaskTemplate, + Mode: service.Spec.Mode, + Networks: service.Spec.Networks, + EndpointSpec: service.Spec.EndpointSpec + }; + } + }; }]); diff --git a/app/shared/services.js b/app/shared/services.js index c6cc56501..62ba0f8c3 100644 --- a/app/shared/services.js +++ b/app/shared/services.js @@ -37,6 +37,25 @@ angular.module('portainer.services', ['ngResource', 'ngSanitize']) } }); }]) + .factory('Service', ['$resource', 'Settings', function ServiceFactory($resource, Settings) { + 'use strict'; + // https://docs.docker.com/engine/reference/api/docker_remote_api_<%= remoteApiVersion %>/#/3-9-services + return $resource(Settings.url + '/services/:id/:action', {}, { + get: { method: 'GET', params: {id: '@id'} }, + query: { method: 'GET', isArray: true }, + create: { method: 'POST', params: {action: 'create'} }, + update: { method: 'POST', params: {id: '@id', action: 'update', version: '@version'} }, + remove: { method: 'DELETE', params: {id: '@id'} } + }); + }]) + .factory('Task', ['$resource', 'Settings', function TaskFactory($resource, Settings) { + 'use strict'; + // https://docs.docker.com/engine/reference/api/docker_remote_api_<%= remoteApiVersion %>/#/3-9-services + return $resource(Settings.url + '/tasks/:id', {}, { + get: { method: 'GET', params: {id: '@id'} }, + query: { method: 'GET', isArray: true, params: {filters: '@filters'} } + }); + }]) .factory('Exec', ['$resource', 'Settings', function ExecFactory($resource, Settings) { 'use strict'; // https://docs.docker.com/engine/reference/api/docker_remote_api_<%= remoteApiVersion %>/#/exec-resize @@ -131,6 +150,22 @@ angular.module('portainer.services', ['ngResource', 'ngSanitize']) get: {method: 'GET'} }); }]) + .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 + } + }); + }]) + .factory('Swarm', ['$resource', 'Settings', function SwarmFactory($resource, Settings) { + 'use strict'; + // https://docs.docker.com/engine/reference/api/docker_remote_api_<%= remoteApiVersion %>/#/3-8-swarm + return $resource(Settings.url + '/swarm', {}, { + get: {method: 'GET'} + }); + }]) .factory('Auth', ['$resource', 'Settings', function AuthFactory($resource, Settings) { 'use strict'; // http://docs.docker.com/reference/api/docker_remote_api_<%= remoteApiVersion %>/#check-auth-configuration diff --git a/app/shared/viewmodel.js b/app/shared/viewmodel.js index ec02f1755..b505ac162 100644 --- a/app/shared/viewmodel.js +++ b/app/shared/viewmodel.js @@ -8,6 +8,47 @@ function ImageViewModel(data) { this.VirtualSize = data.VirtualSize; } +function TaskViewModel(data, node_data) { + this.Id = data.ID; + this.Created = data.CreatedAt; + this.Updated = data.UpdatedAt; + this.Slot = data.Slot; + this.Status = data.Status.State; + if (node_data) { + for (var i = 0; i < node_data.length; ++i) { + if (data.NodeID === node_data[i].ID) { + this.Node = node_data[i].Description.Hostname; + } + } + } +} + +function ServiceViewModel(data) { + this.Model = data; + this.Id = data.ID; + this.Name = data.Spec.Name; + this.Image = data.Spec.TaskTemplate.ContainerSpec.Image; + this.Version = data.Version.Index; + if (data.Spec.Mode.Replicated) { + this.Mode = 'replicated' ; + this.Replicas = data.Spec.Mode.Replicated.Replicas; + } else { + this.Mode = 'global'; + } + if (data.Spec.Labels) { + this.Labels = data.Spec.Labels; + } + if (data.Spec.TaskTemplate.ContainerSpec.Env) { + this.Env = data.Spec.TaskTemplate.ContainerSpec.Env; + } + if (data.Endpoint.Ports) { + this.Ports = data.Endpoint.Ports; + } + this.Checked = false; + this.Scale = false; + this.EditName = false; +} + function ContainerViewModel(data) { this.Id = data.Id; this.Status = data.Status; diff --git a/gruntFile.js b/gruntFile.js index 4b9ae9fb8..abf497528 100644 --- a/gruntFile.js +++ b/gruntFile.js @@ -274,7 +274,7 @@ module.exports = function (grunt) { command: [ 'docker stop portainer', 'docker rm portainer', - 'docker run -d -p 9000:9000 -v /tmp/portainer:/data --name portainer portainer -H tcp://10.0.7.10:4000 --swarm -d /data' + 'docker run -d -p 9000:9000 -v /tmp/portainer:/data --name portainer portainer -H tcp://10.0.7.10:2375 --swarm -d /data' ].join(';') }, runSsl: { diff --git a/index.html b/index.html index 3b60136b7..1886d21ee 100644 --- a/index.html +++ b/index.html @@ -44,6 +44,9 @@ + @@ -56,13 +59,13 @@ - - -