THIS IS A TEST INSTANCE ONLY! REPOSITORIES CAN BE DELETED AT ANY TIME!

Browse Source

fix(registry): Performance issues with Registry Manager (#2648)

* fix(registry): fetch datatable details on page/filter/order state change instead of fetching all data on first load

* fix(registry): fetch tags datatable details on state change instead of fetching all data on first load

* fix(registry): add pagination support for tags + loading display on data load

* fix(registry): debounce on text filter to avoid querying transient matching values

* refactor(registry): rebase on latest develop

* feat(registries): background tags and optimisation -- need code cleanup and for-await-of to cancel on page leave

* refactor(registry-management): code cleanup

* feat(registry): most optimized version -- need fix for add/retag

* fix(registry): addTag working without page reload

* fix(registry): retag working without reload

* fix(registry): remove tag working without reload

* fix(registry): remove repository working with latest changes

* fix(registry): disable cache on firefox

* feat(registry): use jquery for all 'most used' manifests requests

* feat(registry): retag with progression + rewrite manifest REST service to jquery

* fix(registry): remove forgotten DI

* fix(registry): pagination on repository details

* refactor(registry): info message + hidding images count until fetch has been done

* fix(registry): fix selection reset deleting selectAll function and not resetting status

* fix(registry): resetSelection was trying to set value on a getter

* fix(registry): tags were dropped when too much tags were impacted by a tag removal

* fix(registry): firefox add tag + progression

* refactor(registry): rewording of elements

* style(registry): add space between buttons and texts in status elements

* fix(registry): cancelling a retag/delete action was not removing the status panel

* fix(registry): tags count of empty repositories

* feat(registry): reload page on action cancel to avoid desync

* feat(registry): uncancellable modal on long operations

* feat(registry): modal now closes on error + modal message improvement

* feat(registries): remove empty repositories from the list

* fix(registry): various bugfixes

* feat(registry): independant timer on async actions + modal fix
tags/1.23.0^2
xAt0mZ GitHub 5 months ago
parent
commit
2445a5aed5
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 1372 additions and 421 deletions
  1. +1
    -1
      .babelrc
  2. +1
    -1
      .eslintrc.yml
  3. +12
    -2
      app/app.js
  4. +3
    -0
      app/docker/filters/filters.js
  5. +6
    -0
      app/docker/helpers/imageHelper.js
  6. +3
    -8
      app/extensions/registry-management/components/registries-repositories-datatable/registryRepositoriesDatatable.html
  7. +3
    -2
      app/extensions/registry-management/components/registries-repositories-datatable/registryRepositoriesDatatable.js
  8. +27
    -0
      app/extensions/registry-management/components/registries-repositories-datatable/registryRepositoriesDatatableController.js
  9. +14
    -27
      app/extensions/registry-management/components/registries-repository-tags-datatable/registriesRepositoryTagsDatatable.html
  10. +5
    -2
      app/extensions/registry-management/components/registries-repository-tags-datatable/registriesRepositoryTagsDatatable.js
  11. +31
    -0
      app/extensions/registry-management/components/registries-repository-tags-datatable/registryRepositoriesTagsDatatableController.js
  12. +5
    -12
      app/extensions/registry-management/helpers/localRegistryHelper.js
  13. +9
    -3
      app/extensions/registry-management/models/registryRepository.js
  14. +13
    -9
      app/extensions/registry-management/models/repositoryTag.js
  15. +0
    -61
      app/extensions/registry-management/rest/manifest.js
  16. +89
    -0
      app/extensions/registry-management/rest/manifestJquery.js
  17. +4
    -1
      app/extensions/registry-management/rest/tags.js
  18. +34
    -0
      app/extensions/registry-management/services/genericAsyncGenerator.js
  19. +0
    -138
      app/extensions/registry-management/services/registryAPIService.js
  20. +214
    -0
      app/extensions/registry-management/services/registryV2Service.js
  21. +13
    -0
      app/extensions/registry-management/views/repositories/edit/progression-modal/progressionModal.html
  22. +6
    -0
      app/extensions/registry-management/views/repositories/edit/progression-modal/progressionModal.js
  23. +40
    -9
      app/extensions/registry-management/views/repositories/edit/registryRepository.html
  24. +379
    -121
      app/extensions/registry-management/views/repositories/edit/registryRepositoryController.js
  25. +2
    -2
      app/extensions/registry-management/views/repositories/registryRepositories.html
  26. +28
    -5
      app/extensions/registry-management/views/repositories/registryRepositoriesController.js
  27. +5
    -0
      app/portainer/components/datatables/genericDatatableController.js
  28. +14
    -0
      app/portainer/services/modalService.js
  29. +20
    -0
      assets/css/app.css
  30. +2
    -2
      package.json
  31. +389
    -15
      yarn.lock

+ 1
- 1
.babelrc View File

@@ -5,7 +5,7 @@
"@babel/preset-env",
{
"modules": false,
"useBuiltIns": "usage"
"useBuiltIns": "entry"
}
]
]


+ 1
- 1
.eslintrc.yml View File

@@ -24,7 +24,7 @@ rules:
# no-cond-assign: error
# no-console: off
# no-constant-condition: error
# no-control-regex: error
no-control-regex: off
# no-debugger: error
# no-dupe-args: error
# no-dupe-keys: error


+ 12
- 2
app/app.js View File

@@ -1,8 +1,10 @@
import _ from 'lodash-es';
import $ from 'jquery';
import '@babel/polyfill'

angular.module('portainer')
.run(['$rootScope', '$state', '$interval', 'Authentication', 'authManager', 'StateManager', 'EndpointProvider', 'Notifications', 'Analytics', 'SystemService', 'cfpLoadingBar', '$transitions', 'HttpRequestHelper',
function ($rootScope, $state, $interval, Authentication, authManager, StateManager, EndpointProvider, Notifications, Analytics, SystemService, cfpLoadingBar, $transitions, HttpRequestHelper) {
.run(['$rootScope', '$state', '$interval', 'LocalStorage', 'Authentication', 'authManager', 'StateManager', 'EndpointProvider', 'Notifications', 'Analytics', 'SystemService', 'cfpLoadingBar', '$transitions', 'HttpRequestHelper',
function ($rootScope, $state, $interval, LocalStorage, Authentication, authManager, StateManager, EndpointProvider, Notifications, Analytics, SystemService, cfpLoadingBar, $transitions, HttpRequestHelper) {
'use strict';

EndpointProvider.initialize();
@@ -40,6 +42,14 @@ function ($rootScope, $state, $interval, Authentication, authManager, StateManag
ping(EndpointProvider, SystemService);
}, 60 * 1000)

$(document).ajaxSend(function (event, jqXhr, jqOpts) {
const type = jqOpts.type === 'POST' || jqOpts.type === 'PUT' || jqOpts.type === 'PATCH';
const hasNoContentType = jqOpts.contentType !== 'application/json' && jqOpts.headers && !jqOpts.headers['Content-Type'];
if (type && hasNoContentType) {
jqXhr.setRequestHeader('Content-Type', 'application/json');
}
jqXhr.setRequestHeader('Authorization', 'Bearer ' + LocalStorage.getJWT());
});
}]);

function ping(EndpointProvider, SystemService) {


+ 3
- 0
app/docker/filters/filters.js View File

@@ -278,6 +278,9 @@ angular.module('portainer.docker')
.filter('trimshasum', function () {
'use strict';
return function (imageName) {
if (!imageName) {
return;
}
if (imageName.indexOf('sha256:') === 0) {
return imageName.substring(7, 19);
}


+ 6
- 0
app/docker/helpers/imageHelper.js View File

@@ -6,6 +6,12 @@ angular.module('portainer.docker')

var helper = {};

helper.isValidTag = isValidTag;

function isValidTag(tag) {
return tag.match(/^(?![\.\-])([a-zA-Z0-9\_\.\-])+$/g);
}

helper.extractImageAndRegistryFromRepository = function(repository) {
var slashCount = _.countBy(repository)['/'];
var registry = null;


+ 3
- 8
app/extensions/registry-management/components/registries-repositories-datatable/registryRepositoriesDatatable.html View File

@@ -8,7 +8,7 @@
</div>
<div class="searchBar">
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" placeholder="Search..." auto-focus ng-model-options="{ debounce: 300 }">
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" ng-model-options="{ debounce: 300 }" ng-change="$ctrl.onTextFilterChange()" placeholder="Search..." auto-focus>
</div>
<div class="table-responsive">
<table class="table table-hover table-filters nowrap-cells">
@@ -22,16 +22,12 @@
</a>
</th>
<th>
<a ng-click="$ctrl.changeOrderBy('TagsCount')">
Tags count
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'TagsCount' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'TagsCount' && $ctrl.state.reverseOrder"></i>
</a>
</th>
</tr>
</thead>
<tbody>
<tr dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit))"
<tr ng-hide="$ctrl.loading" dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit))"
ng-class="{active: item.Checked}">
<td>
<a ui-sref="portainer.registries.registry.repository({repository: item.Name})" class="monospaced"
@@ -39,7 +35,7 @@
</td>
<td>{{ item.TagsCount }}</td>
</tr>
<tr ng-if="!$ctrl.dataset">
<tr ng-if="!$ctrl.dataset || $ctrl.loading">
<td colspan="5" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="$ctrl.state.filteredDataSet.length === 0">
@@ -59,7 +55,6 @@
Items per page
</span>
<select class="form-control" ng-model="$ctrl.state.paginatedItemLimit" ng-change="$ctrl.changePaginationLimit()">
<option value="0">All</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>


+ 3
- 2
app/extensions/registry-management/components/registries-repositories-datatable/registryRepositoriesDatatable.js View File

@@ -1,6 +1,6 @@
angular.module('portainer.extensions.registrymanagement').component('registryRepositoriesDatatable', {
templateUrl: './registryRepositoriesDatatable.html',
controller: 'GenericDatatableController',
controller: 'RegistryRepositoriesDatatableController',
bindings: {
titleText: '@',
titleIcon: '@',
@@ -8,6 +8,7 @@ angular.module('portainer.extensions.registrymanagement').component('registryRep
tableKey: '@',
orderBy: '@',
reverseOrder: '<',
removeAction: '<'
paginationAction: '<',
loading: '<'
}
});

+ 27
- 0
app/extensions/registry-management/components/registries-repositories-datatable/registryRepositoriesDatatableController.js View File

@@ -0,0 +1,27 @@
import _ from 'lodash-es';

angular.module('portainer.app')
.controller('RegistryRepositoriesDatatableController', ['$scope', '$controller',
function($scope, $controller) {
var ctrl = this;

angular.extend(this, $controller('GenericDatatableController', { $scope: $scope }));
this.state.orderBy = this.orderBy;

function areDifferent(a, b) {
if (!a || !b) {
return true;
}
var namesA = a.map( function(x){ return x.Name; } ).sort();
var namesB = b.map( function(x){ return x.Name; } ).sort();
return namesA.join(',') !== namesB.join(',');
}

$scope.$watch(function() { return ctrl.state.filteredDataSet;},
function(newValue, oldValue) {
if (newValue && areDifferent(oldValue, newValue)) {
ctrl.paginationAction(_.filter(newValue, {'TagsCount':0}));
}
}, true);
}
]);

+ 14
- 27
app/extensions/registry-management/components/registries-repository-tags-datatable/registriesRepositoryTagsDatatable.html View File

@@ -3,18 +3,17 @@
<rd-widget-body classes="no-padding">
<div class="toolBar">
<div class="toolBarTitle">
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{
$ctrl.titleText }}
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }}
</div>
</div>
<div class="actionBar">
<div class="actionBar" ng-if="$ctrl.advancedFeaturesAvailable">
<button type="button" class="btn btn-sm btn-danger" ng-disabled="$ctrl.state.selectedItemCount === 0" ng-click="$ctrl.removeAction($ctrl.state.selectedItems)">
<i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Remove
</button>
</div>
<div class="searchBar">
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" placeholder="Search..." auto-focus ng-model-options="{ debounce: 300 }">
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" ng-model-options="{ debounce: 300 }" ng-change="$ctrl.onTextFilterChange()" placeholder="Search..." auto-focus>
</div>
<div class="table-responsive">
<table class="table table-hover nowrap-cells">
@@ -32,25 +31,13 @@
</a>
</th>
<th>Os/Architecture</th>
<th>
<a ng-click="$ctrl.changeOrderBy('ImageId')">
Image ID
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'ImageId' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'ImageId' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th>
<a ng-click="$ctrl.changeOrderBy('Size')">
Size
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Size' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Size' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th>Actions</th>
<th>Image ID</th>
<th>Compressed size</th>
<th ng-if="$ctrl.advancedFeaturesAvailable">Actions</th>
</tr>
</thead>
<tbody>
<tr dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit))"
<tr ng-hide="$ctrl.loading" dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit))"
ng-class="{active: item.Checked}">
<td>
<span class="md-checkbox">
@@ -60,15 +47,16 @@
{{ item.Name }}
</td>
<td>{{ item.Os }}/{{ item.Architecture }}</td>
<td>{{ item.ImageId | truncate:40 }}</td>
<td>{{ item.ImageId | trimshasum }}</td>
<td>{{ item.Size | humansize }}</td>
<td>
<td ng-if="$ctrl.advancedFeaturesAvailable">
<span ng-if="!item.Modified">
<a class="interactive" ng-click="item.Modified = true; item.NewName = item.Name; $event.stopPropagation();">
<i class="fa fa-tag" aria-hidden="true"></i> Retag
</a>
</span>
<span ng-if="item.Modified">
<portainer-tooltip position="bottom" message="Tag can only contain alphanumeric (a-zA-Z0-9) and special _ . - characters. Tag must not start with . - characters."></portainer-tooltip>
<input class="input-sm" type="text" ng-model="item.NewName" on-enter-key="$ctrl.retagAction(item)"
auto-focus ng-click="$event.stopPropagation();" />
<a class="interactive" ng-click="item.Modified = false; $event.stopPropagation();"><i class="fa fa-times"></i></a>
@@ -76,11 +64,11 @@
</span>
</td>
</tr>
<tr ng-if="!$ctrl.dataset">
<td colspan="3" class="text-center text-muted">Loading...</td>
<tr ng-if="$ctrl.loading">
<td colspan="5" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="$ctrl.state.filteredDataSet.length === 0">
<td colspan="3" class="text-center text-muted">No tag available.</td>
<tr ng-if="!$ctrl.loading && $ctrl.state.filteredDataSet.length === 0">
<td colspan="5" class="text-center text-muted">No tag available.</td>
</tr>
</tbody>
</table>
@@ -96,7 +84,6 @@
Items per page
</span>
<select class="form-control" ng-model="$ctrl.state.paginatedItemLimit" ng-change="$ctrl.changePaginationLimit()">
<option value="0">All</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>


+ 5
- 2
app/extensions/registry-management/components/registries-repository-tags-datatable/registriesRepositoryTagsDatatable.js View File

@@ -1,6 +1,6 @@
angular.module('portainer.extensions.registrymanagement').component('registriesRepositoryTagsDatatable', {
templateUrl: './registriesRepositoryTagsDatatable.html',
controller: 'GenericDatatableController',
controller: 'RegistryRepositoriesTagsDatatableController',
bindings: {
titleText: '@',
titleIcon: '@',
@@ -9,6 +9,9 @@ angular.module('portainer.extensions.registrymanagement').component('registriesR
orderBy: '@',
reverseOrder: '<',
removeAction: '<',
retagAction: '<'
retagAction: '<',
advancedFeaturesAvailable: '<',
paginationAction: '<',
loading: '<'
}
});

+ 31
- 0
app/extensions/registry-management/components/registries-repository-tags-datatable/registryRepositoriesTagsDatatableController.js View File

@@ -0,0 +1,31 @@
import _ from 'lodash-es';

angular.module('portainer.app')
.controller('RegistryRepositoriesTagsDatatableController', ['$scope', '$controller',
function($scope, $controller) {
angular.extend(this, $controller('GenericDatatableController', { $scope: $scope }));
var ctrl = this;
this.state.orderBy = this.orderBy;

function diff(item) {
return item.Name + item.ImageDigest;
}

function areDifferent(a, b) {
if (!a || !b) {
return true;
}
var namesA = _.sortBy(_.map(a, diff));
var namesB = _.sortBy(_.map(b, diff));
return namesA.join(',') !== namesB.join(',');
}

$scope.$watch(function() { return ctrl.state.filteredDataSet;},
function(newValue, oldValue) {
if (newValue && newValue.length && areDifferent(oldValue, newValue)) {
ctrl.paginationAction(_.filter(newValue, {'ImageId': ''}));
ctrl.resetSelectionState();
}
}, true);
}
]);

+ 5
- 12
app/extensions/registry-management/helpers/localRegistryHelper.js View File

@@ -7,12 +7,7 @@ angular.module('portainer.extensions.registrymanagement')
var helper = {};

function historyRawToParsed(rawHistory) {
var history = [];
for (var i = 0; i < rawHistory.length; i++) {
var item = rawHistory[i];
history.push(angular.fromJson(item.v1Compatibility));
}
return history;
return angular.fromJson(rawHistory[0].v1Compatibility);
}

helper.manifestsToTag = function (manifests) {
@@ -20,20 +15,18 @@ angular.module('portainer.extensions.registrymanagement')
var v2 = manifests.v2;

var history = historyRawToParsed(v1.history);
var imageId = history[0].id;
var name = v1.tag;
var os = history[0].os;
var os = history.os;
var arch = v1.architecture;
var size = v2.layers.reduce(function (a, b) {
return {
size: a.size + b.size
};
}).size;
var digest = v2.digest;
var repositoryName = v1.name;
var fsLayers = v1.fsLayers;
var imageId = v2.config.digest;
var imageDigest = v2.digest;

return new RepositoryTagViewModel(name, imageId, os, arch, size, digest, repositoryName, fsLayers, history, v2);
return new RepositoryTagViewModel(name, os, arch, size, imageDigest, imageId, v2);
};

return helper;


+ 9
- 3
app/extensions/registry-management/models/registryRepository.js View File

@@ -1,4 +1,10 @@
export function RegistryRepositoryViewModel(data) {
this.Name = data.name;
this.TagsCount = data.tags.length;
import _ from 'lodash-es';
export default function RegistryRepositoryViewModel(item) {
if (item.name && item.tags) {
this.Name = item.name;
this.TagsCount = _.without(item.tags, null).length;
} else {
this.Name = item;
this.TagsCount = 0;
}
}

+ 13
- 9
app/extensions/registry-management/models/repositoryTag.js View File

@@ -1,12 +1,16 @@
export function RepositoryTagViewModel(name, imageId, os, arch, size, digest, repositoryName, fsLayers, history, manifestv2) {
export function RepositoryTagViewModel(name, os, arch, size, imageDigest, imageId, v2) {
this.Name = name;
this.Os = os || '';
this.Architecture = arch || '';
this.Size = size || 0;
this.ImageDigest = imageDigest || '';
this.ImageId = imageId || '';
this.ManifestV2 = v2 || {};
}

export function RepositoryShortTag(name, imageId, imageDigest, manifest) {
this.Name = name;
this.ImageId = imageId;
this.Os = os;
this.Architecture = arch;
this.Size = size;
this.Digest = digest;
this.RepositoryName = repositoryName;
this.FsLayers = fsLayers;
this.History = history;
this.ManifestV2 = manifestv2;
this.ImageDigest = imageDigest;
this.ManifestV2 = manifest;
}

+ 0
- 61
app/extensions/registry-management/rest/manifest.js View File

@@ -1,61 +0,0 @@
angular.module('portainer.extensions.registrymanagement')
.factory('RegistryManifests', ['$resource', 'API_ENDPOINT_REGISTRIES', function RegistryManifestsFactory($resource, API_ENDPOINT_REGISTRIES) {
'use strict';
return $resource(API_ENDPOINT_REGISTRIES + '/:id/v2/:repository/manifests/:tag', {}, {
get: {
method: 'GET',
params: {
id: '@id',
repository: '@repository',
tag: '@tag'
},
headers: {
'Cache-Control': 'no-cache'
},
transformResponse: function (data, headers) {
var response = angular.fromJson(data);
response.digest = headers('docker-content-digest');
return response;
}
},
getV2: {
method: 'GET',
params: {
id: '@id',
repository: '@repository',
tag: '@tag'
},
headers: {
'Accept': 'application/vnd.docker.distribution.manifest.v2+json',
'Cache-Control': 'no-cache'
},
transformResponse: function (data, headers) {
var response = angular.fromJson(data);
response.digest = headers('docker-content-digest');
return response;
}
},
put: {
method: 'PUT',
params: {
id: '@id',
repository: '@repository',
tag: '@tag'
},
headers: {
'Content-Type': 'application/vnd.docker.distribution.manifest.v2+json'
},
transformRequest: function (data) {
return angular.toJson(data, 3);
}
},
delete: {
method: 'DELETE',
params: {
id: '@id',
repository: '@repository',
tag: '@tag'
}
}
});
}]);

+ 89
- 0
app/extensions/registry-management/rest/manifestJquery.js View File

@@ -0,0 +1,89 @@
/**
* This service has been created to request the docker registry API
* without triggering AngularJS digest cycles
* For more information, see https://github.com/portainer/portainer/pull/2648#issuecomment-505644913
*/

import $ from 'jquery';

angular.module('portainer.extensions.registrymanagement')
.factory('RegistryManifestsJquery', ['API_ENDPOINT_REGISTRIES',
function RegistryManifestsJqueryFactory(API_ENDPOINT_REGISTRIES) {
'use strict';

function buildUrl(params) {
return API_ENDPOINT_REGISTRIES + '/' + params.id + '/v2/' + params.repository + '/manifests/'+ params.tag;
}

function _get(params) {
return new Promise((resolve, reject) => {
$.ajax({
type: 'GET',
dataType: 'JSON',
url: buildUrl(params),
headers: {
'Cache-Control': 'no-cache',
'If-Modified-Since':'Mon, 26 Jul 1997 05:00:00 GMT'
},
success: (result) => resolve(result),
error: (error) => reject(error)
})
});
}

function _getV2(params) {
return new Promise((resolve, reject) => {
$.ajax({
type: 'GET',
dataType: 'JSON',
url: buildUrl(params),
headers: {
'Accept': 'application/vnd.docker.distribution.manifest.v2+json',
'Cache-Control': 'no-cache',
'If-Modified-Since':'Mon, 26 Jul 1997 05:00:00 GMT'
},
success: (result, status, request) => {
result.digest = request.getResponseHeader('Docker-Content-Digest');
resolve(result);
},
error: (error) => reject(error)
})
});
}

function _put(params, data) {
const transformRequest = (d) => {
return angular.toJson(d, 3);
}
return new Promise((resolve, reject) => {
$.ajax({
type: 'PUT',
url: buildUrl(params),
headers: {
'Content-Type': 'application/vnd.docker.distribution.manifest.v2+json'
},
data: transformRequest(data),
success: (result) => resolve(result),
error: (error) => reject(error)
});
})
}

function _delete(params) {
return new Promise((resolve, reject) => {
$.ajax({
type: 'DELETE',
url: buildUrl(params),
success: (result) => resolve(result),
error: (error) => reject(error)
});
})
}

return {
get: _get,
getV2: _getV2,
put: _put,
delete: _delete
}
}]);

+ 4
- 1
app/extensions/registry-management/rest/tags.js View File

@@ -1,10 +1,13 @@
import linkGetResponse from './transform/linkGetResponse';

angular.module('portainer.extensions.registrymanagement')
.factory('RegistryTags', ['$resource', 'API_ENDPOINT_REGISTRIES', function RegistryTagsFactory($resource, API_ENDPOINT_REGISTRIES) {
'use strict';
return $resource(API_ENDPOINT_REGISTRIES + '/:id/v2/:repository/tags/list', {}, {
get: {
method: 'GET',
params: { id: '@id', repository: '@repository' }
params: { id: '@id', repository: '@repository' },
transformResponse: linkGetResponse
}
});
}]);

+ 34
- 0
app/extensions/registry-management/services/genericAsyncGenerator.js View File

@@ -0,0 +1,34 @@
import _ from 'lodash-es';

function findBestStep(length) {
let step = Math.trunc(length / 10);
if (step < 10) {
step = 10;
} else if (step > 100) {
step = 100;
}
return step;
}

export default async function* genericAsyncGenerator($q, list, func, params) {
const step = findBestStep(list.length);
let start = 0;
let end = start + step;
let results = [];
while (start < list.length) {
const batch = _.slice(list, start, end);
const promises = [];
for (let i = 0; i < batch.length; i++) {
promises.push(func(...params, batch[i]));
}
yield start;
const res = await $q.all(promises);
for (let i = 0; i < res.length; i++) {
results.push(res[i]);
}
start = end;
end += step;
}
yield list.length;
yield results;
}

+ 0
- 138
app/extensions/registry-management/services/registryAPIService.js View File

@@ -1,138 +0,0 @@
import _ from 'lodash-es';
import { RegistryRepositoryViewModel } from '../models/registryRepository';

angular.module('portainer.extensions.registrymanagement')
.factory('RegistryV2Service', ['$q', 'RegistryCatalog', 'RegistryTags', 'RegistryManifests', 'RegistryV2Helper',
function RegistryV2ServiceFactory($q, RegistryCatalog, RegistryTags, RegistryManifests, RegistryV2Helper) {
'use strict';
var service = {};

service.ping = function(id, forceNewConfig) {
if (forceNewConfig) {
return RegistryCatalog.pingWithForceNew({ id: id }).$promise;
}
return RegistryCatalog.ping({ id: id }).$promise;
};

function getCatalog(id) {
var deferred = $q.defer();
var repositories = [];

_getCatalogPage({id: id}, deferred, repositories);

return deferred.promise;
}

function _getCatalogPage(params, deferred, repositories) {
RegistryCatalog.get(params).$promise.then(function(data) {
repositories = _.concat(repositories, data.repositories);
if (data.last && data.n) {
_getCatalogPage({id: params.id, n: data.n, last: data.last}, deferred, repositories);
} else {
deferred.resolve(repositories);
}
});
}

service.repositories = function (id) {
var deferred = $q.defer();

getCatalog(id).then(function success(data) {
var promises = [];
for (var i = 0; i < data.length; i++) {
var repository = data[i];
promises.push(RegistryTags.get({
id: id,
repository: repository
}).$promise);
}
return $q.all(promises);
})
.then(function success(data) {
var repositories = data.map(function (item) {
if (!item.tags) {
return;
}
return new RegistryRepositoryViewModel(item);
});
repositories = _.without(repositories, undefined);
deferred.resolve(repositories);
})
.catch(function error(err) {
deferred.reject({
msg: 'Unable to retrieve repositories',
err: err
});
});

return deferred.promise;
};

service.tags = function (id, repository) {
var deferred = $q.defer();

RegistryTags.get({
id: id,
repository: repository
}).$promise
.then(function succes(data) {
deferred.resolve(data.tags);
}).catch(function error(err) {
deferred.reject({
msg: 'Unable to retrieve tags',
err: err
});
});

return deferred.promise;
};

service.tag = function (id, repository, tag) {
var deferred = $q.defer();

var promises = {
v1: RegistryManifests.get({
id: id,
repository: repository,
tag: tag
}).$promise,
v2: RegistryManifests.getV2({
id: id,
repository: repository,
tag: tag
}).$promise
};
$q.all(promises)
.then(function success(data) {
var tag = RegistryV2Helper.manifestsToTag(data);
deferred.resolve(tag);
}).catch(function error(err) {
deferred.reject({
msg: 'Unable to retrieve tag ' + tag,
err: err
});
});

return deferred.promise;
};

service.addTag = function (id, repository, tag, manifest) {
delete manifest.digest;
return RegistryManifests.put({
id: id,
repository: repository,
tag: tag
}, manifest).$promise;
};

service.deleteManifest = function (id, repository, digest) {
return RegistryManifests.delete({
id: id,
repository: repository,
tag: digest
}).$promise;
};

return service;
}
]);

+ 214
- 0
app/extensions/registry-management/services/registryV2Service.js View File

@@ -0,0 +1,214 @@
import _ from 'lodash-es';
import { RepositoryShortTag } from '../models/repositoryTag';
import RegistryRepositoryViewModel from '../models/registryRepository';
import genericAsyncGenerator from './genericAsyncGenerator';

angular.module('portainer.extensions.registrymanagement')
.factory('RegistryV2Service', ['$q', '$async', 'RegistryCatalog', 'RegistryTags', 'RegistryManifestsJquery', 'RegistryV2Helper',
function RegistryV2ServiceFactory($q, $async, RegistryCatalog, RegistryTags, RegistryManifestsJquery, RegistryV2Helper) {
'use strict';
var service = {};

service.ping = function(id, forceNewConfig) {
if (forceNewConfig) {
return RegistryCatalog.pingWithForceNew({ id: id }).$promise;
}
return RegistryCatalog.ping({ id: id }).$promise;
};

function _getCatalogPage(params, deferred, repositories) {
RegistryCatalog.get(params).$promise.then(function(data) {
repositories = _.concat(repositories, data.repositories);
if (data.last && data.n) {
_getCatalogPage({id: params.id, n: data.n, last: data.last}, deferred, repositories);
} else {
deferred.resolve(repositories);
}
});
}

function getCatalog(id) {
var deferred = $q.defer();
var repositories = [];

_getCatalogPage({id: id}, deferred, repositories);
return deferred.promise;
}

service.catalog = function (id) {
var deferred = $q.defer();

getCatalog(id).then(function success(data) {
var repositories = data.map(function (repositoryName) {
return new RegistryRepositoryViewModel(repositoryName);
});
deferred.resolve(repositories);
})
.catch(function error(err) {
deferred.reject({
msg: 'Unable to retrieve repositories',
err: err
});
});

return deferred.promise;
};

service.tags = function (id, repository) {
var deferred = $q.defer();

_getTagsPage({id: id, repository: repository}, deferred, {tags:[]});
return deferred.promise;
};

function _getTagsPage(params, deferred, previousTags) {
RegistryTags.get(params).$promise.then(function(data) {
previousTags.name = data.name;
previousTags.tags = _.concat(previousTags.tags, data.tags);
if (data.last && data.n) {
_getTagsPage({id: params.id, repository: params.repository, n: data.n, last: data.last}, deferred, previousTags);
} else {
deferred.resolve(previousTags);
}
}).catch(function error(err) {
deferred.reject({
msg: 'Unable to retrieve tags',
err: err
});
});
}

service.getRepositoriesDetails = function (id, repositories) {
var deferred = $q.defer();
var promises = [];
for (var i = 0; i < repositories.length; i++) {
var repository = repositories[i].Name;
promises.push(service.tags(id, repository));
}

$q.all(promises)
.then(function success(data) {
var repositories = data.map(function (item) {
return new RegistryRepositoryViewModel(item);
});
repositories = _.without(repositories, undefined);
deferred.resolve(repositories);
})
.catch(function error(err) {
deferred.reject({
msg: 'Unable to retrieve repositories',
err: err
});
});

return deferred.promise;
};

service.getTagsDetails = function (id, repository, tags) {
var promises = [];

for (var i = 0; i < tags.length; i++) {
var tag = tags[i].Name;
promises.push(service.tag(id, repository, tag));
}

return $q.all(promises);
};

service.tag = function (id, repository, tag) {
var deferred = $q.defer();

var promises = {
v1: RegistryManifestsJquery.get({
id: id,
repository: repository,
tag: tag
}),
v2: RegistryManifestsJquery.getV2({
id: id,
repository: repository,
tag: tag
})
};
$q.all(promises)
.then(function success(data) {
var tag = RegistryV2Helper.manifestsToTag(data);
deferred.resolve(tag);
}).catch(function error(err) {
deferred.reject({
msg: 'Unable to retrieve tag ' + tag,
err: err
});
});

return deferred.promise;
};

service.addTag = function (id, repository, {tag, manifest}) {
delete manifest.digest;
return RegistryManifestsJquery.put({
id: id,
repository: repository,
tag: tag
}, manifest);
};

service.deleteManifest = function (id, repository, imageDigest) {
return RegistryManifestsJquery.delete({
id: id,
repository: repository,
tag: imageDigest
});
};

service.shortTag = function(id, repository, tag) {
return new Promise ((resolve, reject) => {
RegistryManifestsJquery.getV2({id:id, repository: repository, tag: tag})
.then((data) => resolve(new RepositoryShortTag(tag, data.config.digest, data.digest, data)))
.catch((err) => reject(err))
});
};

async function* addTagsWithProgress(id, repository, tagsList, progression = 0) {
for await (const partialResult of genericAsyncGenerator($q, tagsList, service.addTag, [id, repository])) {
if (typeof partialResult === 'number') {
yield progression + partialResult;
} else {
yield partialResult;
}
}
}

service.shortTagsWithProgress = async function* (id, repository, tagsList) {
yield* genericAsyncGenerator($q, tagsList, service.shortTag, [id, repository]);
}

async function* deleteManifestsWithProgress(id, repository, manifests) {
for await (const partialResult of genericAsyncGenerator($q, manifests, service.deleteManifest, [id, repository])) {
yield partialResult;
}
}

service.retagWithProgress = async function* (id, repository, modifiedTags, modifiedDigests, impactedTags){
yield* deleteManifestsWithProgress(id, repository, modifiedDigests);

const newTags = _.map(impactedTags, (item) => {
const tagFromTable = _.find(modifiedTags, { 'Name': item.Name });
const name = tagFromTable && tagFromTable.Name !== tagFromTable.NewName ? tagFromTable.NewName : item.Name;
return { tag: name, manifest: item.ManifestV2 };
});

yield* addTagsWithProgress(id, repository, newTags, modifiedDigests.length);
}

service.deleteTagsWithProgress = async function* (id, repository, modifiedDigests, impactedTags) {
yield* deleteManifestsWithProgress(id, repository, modifiedDigests);

const newTags = _.map(impactedTags, (item) => {return {tag: item.Name, manifest: item.ManifestV2}})

yield* addTagsWithProgress(id, repository, newTags, modifiedDigests.length);
}

return service;
}
]);

+ 13
- 0
app/extensions/registry-management/views/repositories/edit/progression-modal/progressionModal.html View File

@@ -0,0 +1,13 @@
<rd-widget>
<rd-widget-body>
<span class="small text-muted">
<p>
<i class="fa fa-exclamation-circle orange-icon" aria-hidden="true" style="margin-right: 2px;"></i>
{{ $ctrl.resolve.message }}
</p>
</span>
<span>
&nbsp; {{ $ctrl.resolve.progressLabel }} : {{ $ctrl.resolve.context.progression }}% - {{ $ctrl.resolve.context.elapsedTime |number:0 }}s
</span>
</rd-widget-body>
</rd-widget>

+ 6
- 0
app/extensions/registry-management/views/repositories/edit/progression-modal/progressionModal.js View File

@@ -0,0 +1,6 @@
angular.module('portainer.extensions.registrymanagement').component('progressionModal', {
templateUrl: './progressionModal.html',
bindings: {
resolve: '<'
}
});

+ 40
- 9
app/extensions/registry-management/views/repositories/edit/registryRepository.html View File

@@ -11,6 +11,31 @@
</rd-header-content>
</rd-header>

<div class="row">
<information-panel ng-if="!state.tagsRetrieval.auto" title-text="Information regarding repository size">
<span class="small text-muted">
<p>
<i class="fa fa-exclamation-circle orange-icon" aria-hidden="true" style="margin-right: 2px;"></i>
Portainer needs to retrieve additional information to enable <code>tags modifications (addition, removal, rename)</code> and <code>repository removal</code> features.<br>
As this repository contains more than <code>{{ state.tagsRetrieval.limit }}</code> tags, the additional retrieval wasn't started automatically.<br>
Once started you can still navigate this page, leaving the page will cancel the retrieval process.<br>
<br>
<span style="font-weight: 700">Note:</span> on very large repositories or high latency environments the retrieval process can take a few minutes.
</p>
<button class="btn btn-sm btn-primary" ng-if="!state.tagsRetrieval.running && short.Tags.length === 0"
ng-click="startStopRetrieval()">Start</button>
<button class="btn btn-sm btn-danger" ng-if="state.tagsRetrieval.running"
ng-click="startStopRetrieval()">Cancel</button>
</span>
<span ng-if="state.tagsRetrieval.running && state.tagsRetrieval.progression !== '100'">
&nbsp; Retrieval progress : {{ state.tagsRetrieval.progression }}% - {{ state.tagsRetrieval.elapsedTime | number:0 }}s
</span>
<span ng-if="!state.tagsRetrieval.running && state.tagsRetrieval.progression === '100'">
<i class="fa fa-check-circle green-icon"></i> Retrieval completed in {{ state.tagsRetrieval.elapsedTime | number:0}}s
</span>
</information-panel>
</div>

<div class="row">
<div class="col-sm-8">
<rd-widget>
@@ -23,7 +48,7 @@
<td>Repository</td>
<td>
{{ repository.Name }}
<button class="btn btn-xs btn-danger" ng-click="removeRepository()">
<button class="btn btn-xs btn-danger" ng-if="!state.tagsRetrieval.running && state.tagsRetrieval.progression !== 0" ng-click="removeRepository()">
<i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Delete this repository
</button>
</td>
@@ -32,9 +57,9 @@
<td>Tags count</td>
<td>{{ repository.Tags.length }}</td>
</tr>
<tr>
<tr ng-if="short.Images.length">
<td>Images count</td>
<td>{{ repository.Images.length }}</td>
<td>{{ short.Images.length }}</td>
</tr>
</tbody>
</table>
@@ -42,14 +67,16 @@
</rd-widget>
</div>

<div class="col-sm-4">
<div class="col-sm-4" ng-if="short.Images.length > 0">
<rd-widget>
<rd-widget-header icon="fa-plus" title-text="Add tag">
</rd-widget-header>
<rd-widget-body>
<form class="form-horizontal">
<div class="form-group">
<label for="tag" class="col-sm-3 col-lg-2 control-label text-left">Tag</label>
<label for="tag" class="col-sm-3 col-lg-2 control-label text-left">Tag
<portainer-tooltip position="bottom" message="Tag can only contain alphanumeric (a-zA-Z0-9) and special _ . - characters. Tag must not start with . - characters."></portainer-tooltip>
</label>
<div class="col-sm-9 col-lg-10">
<input type="text" class="form-control" id="tag" ng-model="formValues.Tag">
</div>
@@ -58,10 +85,10 @@
<label for="image" class="col-sm-3 col-lg-2 control-label text-left">Image</label>
<ui-select class="col-sm-9 col-lg-10" ng-model="formValues.SelectedImage" id="image">
<ui-select-match placeholder="Select an image" allow-clear="true">
<span>{{ $select.selected }}</span>
<span>{{ $select.selected | trimshasum }}</span>
</ui-select-match>
<ui-select-choices repeat="image in (repository.Images | filter: $select.search)">
<span>{{ image }}</span>
<ui-select-choices repeat="image in (short.Images | filter: $select.search)">
<span>{{ image | trimshasum }}</span>
</ui-select-choices>
</ui-select>
</div>
@@ -83,6 +110,10 @@
<div class="row">
<div class="col-sm-12">
<registries-repository-tags-datatable title-text="Tags" title-icon="fa-tags" dataset="tags" table-key="registryRepositoryTags"
order-by="Name" remove-action="removeTags" retag-action="retagAction"></registries-repository-tags-datatable>
order-by="Name" remove-action="removeTags" retag-action="retagAction"
advanced-features-available="short.Images.length > 0"
pagination-action="paginationAction"
loading="state.loading">
</registries-repository-tags-datatable>
</div>
</div>

+ 379
- 121
app/extensions/registry-management/views/repositories/edit/registryRepositoryController.js View File

@@ -1,105 +1,335 @@
import _ from 'lodash-es';
import { RepositoryTagViewModel, RepositoryShortTag } from '../../../models/repositoryTag';

angular.module('portainer.app')
.controller('RegistryRepositoryController', ['$q', '$scope', '$transition$', '$state', 'RegistryV2Service', 'RegistryService', 'ModalService', 'Notifications',
function ($q, $scope, $transition$, $state, RegistryV2Service, RegistryService, ModalService, Notifications) {
.controller('RegistryRepositoryController', ['$q', '$async', '$scope', '$uibModal', '$interval', '$transition$', '$state', 'RegistryV2Service', 'RegistryService', 'ModalService', 'Notifications', 'ImageHelper',
function ($q, $async, $scope, $uibModal, $interval, $transition$, $state, RegistryV2Service, RegistryService, ModalService, Notifications, ImageHelper) {

$scope.state = {
actionInProgress: false
actionInProgress: false,
loading: false,
tagsRetrieval: {
auto: true,
running: false,
limit: 100,
progression: 0,
elapsedTime: 0,
asyncGenerator: null,
clock: null
},
tagsRetag: {
running: false,
progression: 0,
elapsedTime: 0,
asyncGenerator: null,
clock: null
},
tagsDelete: {
running: false,
progression: 0,
elapsedTime: 0,
asyncGenerator: null,
clock: null
},
};
$scope.formValues = {
Tag: ''
Tag: '' // new tag name on add feature
};
$scope.tags = []; // RepositoryTagViewModel (for datatable)
$scope.short = {
Tags: [], // RepositoryShortTag
Images: [] // strings extracted from short.Tags
};
$scope.tags = [];
$scope.repository = {
Name: [],
Tags: [],
Images: []
Name: '',
Tags: [], // string list
};

$scope.$watch('tags.length', function () {
var images = $scope.tags.map(function (item) {
return item.ImageId;
function toSeconds(time) {
return time / 1000;
}
function toPercent(progress, total) {
return (progress / total * 100).toFixed();
}

function openModal(resolve) {
return $uibModal.open({
component: 'progressionModal',
backdrop: 'static',
keyboard: false,
resolve: resolve
});
$scope.repository.Images = _.uniq(images);
});
}

$scope.addTag = function () {
var manifest = $scope.tags.find(function (item) {
return item.ImageId === $scope.formValues.SelectedImage;
}).ManifestV2;
RegistryV2Service.addTag($scope.registryId, $scope.repository.Name, $scope.formValues.Tag, manifest)
.then(function success() {
Notifications.success('Success', 'Tag successfully added');
$state.reload();
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to add tag');
});
$scope.paginationAction = function (tags) {
$scope.state.loading = true;
RegistryV2Service.getTagsDetails($scope.registryId, $scope.repository.Name, tags)
.then(function success(data) {
for (var i = 0; i < data.length; i++) {
var idx = _.findIndex($scope.tags, {'Name': data[i].Name});
if (idx !== -1) {
$scope.tags[idx] = data[i];
}
}
$scope.state.loading = false;
}).catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve tags details');
});
};

$scope.retagAction = function (tag) {
RegistryV2Service.deleteManifest($scope.registryId, $scope.repository.Name, tag.Digest)
.then(function success() {
var promises = [];
var tagsToAdd = $scope.tags.filter(function (item) {
return item.Digest === tag.Digest;
});
tagsToAdd.map(function (item) {
var tagValue = item.Modified && item.Name !== item.NewName ? item.NewName : item.Name;
promises.push(RegistryV2Service.addTag($scope.registryId, $scope.repository.Name, tagValue, item.ManifestV2));
});
return $q.all(promises);
})
.then(function success() {
Notifications.success('Success', 'Tag successfully modified');
$state.reload();
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to modify tag');
tag.Modified = false;
tag.NewValue = tag.Value;
/**
* RETRIEVAL SECTION
*/
function updateRetrievalClock(startTime) {
$scope.state.tagsRetrieval.elapsedTime = toSeconds(Date.now() - startTime);
}

function createRetrieveAsyncGenerator() {
$scope.state.tagsRetrieval.asyncGenerator =
RegistryV2Service.shortTagsWithProgress($scope.registryId, $scope.repository.Name, $scope.repository.Tags);
}

function resetTagsRetrievalState() {
$scope.state.tagsRetrieval.running = false;
$scope.state.tagsRetrieval.progression = 0;
$scope.state.tagsRetrieval.elapsedTime = 0;
$scope.state.tagsRetrieval.clock = null;
}

function computeImages() {
const images = _.map($scope.short.Tags, 'ImageId');
$scope.short.Images = _.without(_.uniq(images), '');
}

$scope.startStopRetrieval = function () {
if ($scope.state.tagsRetrieval.running) {
$scope.state.tagsRetrieval.asyncGenerator.return();
$interval.cancel($scope.state.tagsRetrieval.clock);
} else {
retrieveTags().then(() => {
createRetrieveAsyncGenerator();
if ($scope.short.Tags.length === 0) {
resetTagsRetrievalState();
} else {
computeImages();
}
});
}
};

$scope.removeTags = function (selectedItems) {
function retrieveTags() {
return $async(retrieveTagsAsync);
}

async function retrieveTagsAsync() {
$scope.state.tagsRetrieval.running = true;
const startTime = Date.now();
$scope.state.tagsRetrieval.clock = $interval(updateRetrievalClock, 1000, 0, true, startTime);
for await (const partialResult of $scope.state.tagsRetrieval.asyncGenerator) {
if (typeof partialResult === 'number') {
$scope.state.tagsRetrieval.progression = toPercent(partialResult, $scope.repository.Tags.length);
} else {
$scope.short.Tags = _.sortBy(partialResult, 'Name');
}
}
$scope.state.tagsRetrieval.running = false;
$interval.cancel($scope.state.tagsRetrieval.clock);
}
/**
* !END RETRIEVAL SECTION
*/

/**
* ADD TAG SECTION
*/

async function addTagAsync() {
try {
$scope.state.actionInProgress = true;
if (!ImageHelper.isValidTag($scope.formValues.Tag)) {
throw {msg: 'Invalid tag pattern, see info for more details on format.'}
}
const tag = $scope.short.Tags.find((item) => item.ImageId === $scope.formValues.SelectedImage);
const manifest = tag.ManifestV2;
await RegistryV2Service.addTag($scope.registryId, $scope.repository.Name, {tag: $scope.formValues.Tag, manifest: manifest})

Notifications.success('Success', 'Tag successfully added');
$scope.short.Tags.push(new RepositoryShortTag($scope.formValues.Tag, tag.ImageId, tag.ImageDigest, tag.ManifestV2));

await loadRepositoryDetails();
$scope.formValues.Tag = '';
delete $scope.formValues.SelectedImage;
} catch (err) {
Notifications.error('Failure', err, 'Unable to add tag');
} finally {
$scope.state.actionInProgress = false;
}
}

$scope.addTag = function () {
return $async(addTagAsync);
};
/**
* !END ADD TAG SECTION
*/

/**
* RETAG SECTION
*/
function updateRetagClock(startTime) {
$scope.state.tagsRetag.elapsedTime = toSeconds(Date.now() - startTime);
}

function createRetagAsyncGenerator(modifiedTags, modifiedDigests, impactedTags) {
$scope.state.tagsRetag.asyncGenerator =
RegistryV2Service.retagWithProgress($scope.registryId, $scope.repository.Name, modifiedTags, modifiedDigests, impactedTags);
}

async function retagActionAsync() {
let modal = null;
try {
$scope.state.tagsRetag.running = true;

const modifiedTags = _.filter($scope.tags, (item) => item.Modified === true);
for (const tag of modifiedTags) {
if (!ImageHelper.isValidTag(tag.NewName)) {
throw {msg: 'Invalid tag pattern, see info for more details on format.'}
}
}
modal = await openModal({
message: () => 'Retag is in progress! Closing your browser or refreshing the page while this operation is in progress will result in loss of tags.',
progressLabel: () => 'Retag progress',
context: () => $scope.state.tagsRetag
});
const modifiedDigests = _.uniq(_.map(modifiedTags, 'ImageDigest'));
const impactedTags = _.filter($scope.short.Tags, (item) => _.includes(modifiedDigests, item.ImageDigest));

const totalOps = modifiedDigests.length + impactedTags.length;

createRetagAsyncGenerator(modifiedTags, modifiedDigests, impactedTags);

const startTime = Date.now();
$scope.state.tagsRetag.clock = $interval(updateRetagClock, 1000, 0, true, startTime);
for await (const partialResult of $scope.state.tagsRetag.asyncGenerator) {
if (typeof partialResult === 'number') {
$scope.state.tagsRetag.progression = toPercent(partialResult, totalOps);
}
}

_.map(modifiedTags, (item) => {
const idx = _.findIndex($scope.short.Tags, (i) => i.Name === item.Name);
$scope.short.Tags[idx].Name = item.NewName;
});

Notifications.success('Success', 'Tags successfully renamed');

await loadRepositoryDetails();
} catch (err) {
Notifications.error('Failure', err, 'Unable to rename tags');
} finally {
$interval.cancel($scope.state.tagsRetag.clock);
$scope.state.tagsRetag.running = false;
if (modal) {
modal.close();
}
}
}

$scope.retagAction = function() {
return $async(retagActionAsync);
}
/**
* !END RETAG SECTION
*/

/**
* REMOVE TAGS SECTION
*/

function updateDeleteClock(startTime) {
$scope.state.tagsDelete.elapsedTime = toSeconds(Date.now() - startTime);
}

function createDeleteAsyncGenerator(modifiedDigests, impactedTags) {
$scope.state.tagsDelete.asyncGenerator =
RegistryV2Service.deleteTagsWithProgress($scope.registryId, $scope.repository.Name, modifiedDigests, impactedTags);
}

async function removeTagsAsync(selectedTags) {
let modal = null;
try {
$scope.state.tagsDelete.running = true;
modal = await openModal({
message: () => 'Tag delete is in progress! Closing your browser or refreshing the page while this operation is in progress will result in loss of tags.',
progressLabel: () => 'Deletion progress',
context: () => $scope.state.tagsDelete
});

const deletedTagNames = _.map(selectedTags, 'Name');
const deletedShortTags = _.filter($scope.short.Tags, (item) => _.includes(deletedTagNames, item.Name));
const modifiedDigests = _.uniq(_.map(deletedShortTags, 'ImageDigest'));
const impactedTags = _.filter($scope.short.Tags, (item) => _.includes(modifiedDigests, item.ImageDigest));
const tagsToKeep = _.without(impactedTags, ...deletedShortTags);

const totalOps = modifiedDigests.length + tagsToKeep.length;

createDeleteAsyncGenerator(modifiedDigests, tagsToKeep);

const startTime = Date.now();
$scope.state.tagsDelete.clock = $interval(updateDeleteClock, 1000, 0, true, startTime);
for await (const partialResult of $scope.state.tagsDelete.asyncGenerator) {
if (typeof partialResult === 'number') {
$scope.state.tagsDelete.progression = toPercent(partialResult, totalOps);
}
}

_.pull($scope.short.Tags, ...deletedShortTags);
$scope.short.Images = _.map(_.uniqBy($scope.short.Tags, 'ImageId'), 'ImageId');

Notifications.success('Success', 'Tags successfully deleted');

if ($scope.short.Tags.length === 0) {
$state.go('portainer.registries.registry.repositories', {id: $scope.registryId}, {reload: true});
}
await loadRepositoryDetails();
} catch (err) {
Notifications.error('Failure', err, 'Unable to delete tags');
} finally {
$interval.cancel($scope.state.tagsDelete.clock);
$scope.state.tagsDelete.running = false;
modal.close();
}
}

$scope.removeTags = function(selectedItems) {
ModalService.confirmDeletion(
'Are you sure you want to remove the selected tags ?',
function onConfirm(confirmed) {
(confirmed) => {
if (!confirmed) {
return;
}
var promises = [];
var uniqItems = _.uniqBy(selectedItems, 'Digest');
uniqItems.map(function (item) {
promises.push(RegistryV2Service.deleteManifest($scope.registryId, $scope.repository.Name, item.Digest));
});
$q.all(promises)
.then(function success() {
var promises = [];
var tagsToReupload = _.differenceBy($scope.tags, selectedItems, 'Name');
tagsToReupload.map(function (item) {
promises.push(RegistryV2Service.addTag($scope.registryId, $scope.repository.Name, item.Name, item.ManifestV2));
});
return $q.all(promises);
})
.then(function success(data) {
Notifications.success('Success', 'Tags successfully deleted');
if (data.length === 0) {
$state.go('portainer.registries.registry.repositories', {
id: $scope.registryId
}, {
reload: true
});
} else {
$state.reload();
}
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to delete tags');
});
return $async(removeTagsAsync, selectedItems);
});
};
}
/**
* !END REMOVE TAGS SECTION
*/

/**
* REMOVE REPOSITORY SECTION
*/
async function removeRepositoryAsync() {
try {
const digests = _.uniqBy($scope.short.Tags, 'ImageDigest');
const promises = [];
_.map(digests, (item) => promises.push(RegistryV2Service.deleteManifest($scope.registryId, $scope.repository.Name, item.ImageDigest)));
await Promise.all(promises);
Notifications.success('Success', 'Repository sucessfully removed');
$state.go('portainer.registries.registry.repositories', {id: $scope.registryId}, {reload: true});
} catch (err) {
Notifications.error('Failure', err, 'Unable to delete repository');
}
}

$scope.removeRepository = function () {
ModalService.confirmDeletion(
@@ -108,53 +338,81 @@ angular.module('portainer.app')
if (!confirmed) {
return;
}
var promises = [];
var uniqItems = _.uniqBy($scope.tags, 'Digest');
uniqItems.map(function (item) {
promises.push(RegistryV2Service.deleteManifest($scope.registryId, $scope.repository.Name, item.Digest));
});
$q.all(promises)
.then(function success() {
Notifications.success('Success', 'Repository sucessfully removed');
$state.go('portainer.registries.registry.repositories', {
id: $scope.registryId
}, {
reload: true
});
}).catch(function error(err) {
Notifications.error('Failure', err, 'Unable to delete repository');
});
return $async(removeRepositoryAsync);
}
);
};
/**
* !END REMOVE REPOSITORY SECTION
*/

function initView() {
var registryId = $scope.registryId = $transition$.params().id;
var repository = $scope.repository.Name = $transition$.params().repository;
$q.all({
registry: RegistryService.registry(registryId),
tags: RegistryV2Service.tags(registryId, repository)
})
.then(function success(data) {
$scope.registry = data.registry;
$scope.repository.Tags = [].concat(data.tags || []);
$scope.tags = [];
for (var i = 0; i < $scope.repository.Tags.length; i++) {
var tag = data.tags[i];
RegistryV2Service.tag(registryId, repository, tag)
.then(function success(data) {
$scope.tags.push(data);
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve tag information');
});
}
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve repository information');
});
/**
* INIT SECTION
*/
async function loadRepositoryDetails() {
try {
const registryId = $scope.registryId;
const repository = $scope.repository.Name;
const tags = await RegistryV2Service.tags(registryId, repository);
$scope.tags = [];
$scope.repository.Tags = [];
$scope.repository.Tags = _.sortBy(_.concat($scope.repository.Tags, _.without(tags.tags, null)));
_.map($scope.repository.Tags, (item) => $scope.tags.push(new RepositoryTagViewModel(item)));
} catch (err) {
Notifications.error('Failure', err, 'Unable to retrieve tags details');
}
}

async function initView() {
try {
const registryId = $scope.registryId = $transition$.params().id;
$scope.repository.Name = $transition$.params().repository;
$scope.state.loading = true;

$scope.registry = await RegistryService.registry(registryId);
await loadRepositoryDetails();
if ($scope.repository.Tags.length > $scope.state.tagsRetrieval.limit) {
$scope.state.tagsRetrieval.auto = false;
}
createRetrieveAsyncGenerator();
} catch (err) {
Notifications.error('Failure', err, 'Unable to retrieve repository information');
} finally {
$scope.state.loading = false;
}
}

initView();
$scope.$on('$destroy', () => {
if ($scope.state.tagsRetrieval.asyncGenerator) {
$scope.state.tagsRetrieval.asyncGenerator.return();
}
if ($scope.state.tagsRetrieval.clock) {
$interval.cancel($scope.state.tagsRetrieval.clock);
}
if ($scope.state.tagsRetag.asyncGenerator) {
$scope.state.tagsRetag.asyncGenerator.return();
}
if ($scope.state.tagsRetag.clock) {
$interval.cancel($scope.state.tagsRetag.clock);
}
if ($scope.state.tagsDelete.asyncGenerator) {
$scope.state.tagsDelete.asyncGenerator.return();
}
if ($scope.state.tagsDelete.clock) {
$interval.cancel($scope.state.tagsDelete.clock);
}
});

this.$onInit = function() {
return $async(initView)
.then(() => {
if ($scope.state.tagsRetrieval.auto) {
$scope.startStopRetrieval();
}
});
};
/**
* !END INIT SECTION
*/
}
]);
]);

+ 2
- 2
app/extensions/registry-management/views/repositories/registryRepositories.html View File

@@ -5,7 +5,7 @@
</a>
</rd-header-title>
<rd-header-content>
<a ui-sref="portainer.registries">Registries</a> &gt; <a ng-if="isAdmin" ui-sref="portainer.registries.registry({id: registry.Id})">{{ registry.Name }}</a><span ng-if="!isAdmin">{{ registry.Name}}</span> &gt; Repositories
<a ui-sref="portainer.registries">Registries</a> &gt; <a ng-if="isAdmin" ui-sref="portainer.registries.registry({id: registry.Id})" ui-sref-opts="{reload:true}">{{ registry.Name }}</a><span ng-if="!isAdmin">{{ registry.Name}}</span> &gt; Repositories
</rd-header-content>
</rd-header>

@@ -31,7 +31,7 @@
<registry-repositories-datatable
title-text="Repositories" title-icon="fa-book"
dataset="repositories" table-key="registryRepositories"
order-by="Name">
order-by="Name" pagination-action="paginationAction" loading="state.loading">
</registry-repositories-datatable>
</div>
</div>

+ 28
- 5
app/extensions/registry-management/views/repositories/registryRepositoriesController.js View File

@@ -1,26 +1,49 @@
import _ from 'lodash-es';

angular.module('portainer.extensions.registrymanagement')
.controller('RegistryRepositoriesController', ['$transition$', '$scope', 'RegistryService', 'RegistryV2Service', 'Notifications', 'Authentication',
function ($transition$, $scope, RegistryService, RegistryV2Service, Notifications, Authentication) {

$scope.state = {
displayInvalidConfigurationMessage: false
displayInvalidConfigurationMessage: false,
loading: false
};

$scope.paginationAction = function (repositories) {
$scope.state.loading = true;
RegistryV2Service.getRepositoriesDetails($scope.state.registryId, repositories)
.then(function success(data) {
for (var i = 0; i < data.length; i++) {
var idx = _.findIndex($scope.repositories, {'Name': data[i].Name});
if (idx !== -1) {
if (data[i].TagsCount === 0) {
$scope.repositories.splice(idx, 1);
} else {
$scope.repositories[idx].TagsCount = data[i].TagsCount;
}
}
}
$scope.state.loading = false;
}).catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve repositories details');
});
};

function initView() {
var registryId = $transition$.params().id;
$scope.state.registryId = $transition$.params().id;

var authenticationEnabled = $scope.applicationState.application.authentication;
if (authenticationEnabled) {
$scope.isAdmin = Authentication.isAdmin();
}

RegistryService.registry(registryId)
RegistryService.registry($scope.state.registryId)
.then(function success(data) {
$scope.registry = data;

RegistryV2Service.ping(registryId, false)
RegistryV2Service.ping($scope.state.registryId, false)
.then(function success() {
return RegistryV2Service.repositories(registryId);
return RegistryV2Service.catalog($scope.state.registryId);
})
.then(function success(data) {
$scope.repositories = data;


+ 5
- 0
app/portainer/components/datatables/genericDatatableController.js View File

@@ -27,6 +27,11 @@ function ($interval, PaginationService, DatatableService, PAGINATION_MAX_ITEMS)
refreshRate: '30'
}
}
this.resetSelectionState = function() {
this.state.selectAll = false;
this.state.selectedItems = [];
_.map(this.state.filteredDataSet, (item) => item.Checked = false);
};

this.onTextFilterChange = function() {
DatatableService.setDataTableTextFilters(this.tableKey, this.state.textFilter);


+ 14
- 0
app/portainer/services/modalService.js View File

@@ -98,6 +98,20 @@ angular.module('portainer.app')
});
};

service.cancelRegistryRepositoryAction = function(callback) {
service.confirm({
title: 'Are you sure?',
message: 'WARNING: interrupting this operation before it has finished will result in the loss of all tags. Are you sure you want to do this?',
buttons: {
confirm: {
label: 'Stop',
className: 'btn-danger'
}
},
callback: callback
});
};

service.confirmDeletion = function(message, callback) {
message = $sanitize(message);
service.confirm({


+ 20
- 0
assets/css/app.css View File

@@ -834,6 +834,26 @@ ul.sidebar .sidebar-list .sidebar-sublist a.active {
margin: 20px auto 10px auto;
}

.modal {
text-align: center;
padding: 0!important;
}

.modal::before {
content: '';
display: inline-block;
height: 100%;
vertical-align: middle;
margin-right: -4px;
}

.modal-dialog {
display: inline-block;
text-align: left;
vertical-align: middle;
}


/*bootbox override*/
.modal-open {
padding-right: 0 !important;


+ 2
- 2
package.json View File

@@ -95,8 +95,8 @@
"clean-webpack-plugin": "^0.1.19",
"css-loader": "^1.0.0",
"cssnano": "^3.10.0",
"eslint": "^3.19.0",
"eslint-loader": "^2.1.1",
"eslint": "5.16.0",
"eslint-loader": "^2.1.2",
"file-loader": "^1.1.11",
"grunt": "~0.4.0",
"grunt-cli": "^1.2.0",


+ 389
- 15
yarn.lock View File

@@ -830,6 +830,11 @@ acorn-jsx@^3.0.0:
dependencies:
acorn "^3.0.4"

acorn-jsx@^5.0.0:
version "5.0.1"
resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.0.1.tgz#32a064fd925429216a09b141102bfdd185fae40e"
integrity sha512-HJ7CfNHrfJLlNTzIEUTj43LNWGkqpRLxm3YjAlcD0ACydk9XynzYsCBHxut+iqt+1aBXkx9UP/w/ZqMr13XIzg==

acorn@^3.0.4:
version "3.3.0"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-3.3.0.tgz#45e37fb39e8da3f25baee3ff5369e2bb5f22017a"
@@ -845,6 +850,11 @@ acorn@^5.2.1:
resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.2.1.tgz#317ac7821826c22c702d66189ab8359675f135d7"
integrity sha512-jG0u7c4Ly+3QkkW18V+NRDN+4bWHdln30NL1ZL2AvFZZmQe/BfopYCtghCKKVBUSetZ4QKcyA0pY6/4Gw8Pv8w==

acorn@^6.0.7:
version "6.1.1"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.1.1.tgz#7d25ae05bb8ad1f9b699108e1094ecd7884adc1f"
integrity sha512-jPTiwtOxaHNaAPg/dmrJ/beuzLRnXtB0kQPQ8JpotKJgTB6rX6c8mlf315941pyjBSaPg8NHXS9fhP4u17DpGA==

active-x-obfuscator@0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/active-x-obfuscator/-/active-x-obfuscator-0.0.1.tgz#089b89b37145ff1d9ec74af6530be5526cae1f1a"
@@ -885,6 +895,16 @@ ajv@^6.1.0:
json-schema-traverse "^0.4.1"
uri-js "^4.2.2"

ajv@^6.9.1:
version "6.10.0"
resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.10.0.tgz#90d0d54439da587cd7e843bfb7045f50bd22bdf1"
integrity sha512-nffhOpkymDECQyR0mnsUtoCE8RlX38G0rYP+wgLWFyZuUyuuojSSvi/+euOiQBIn63whYwYVIIH1TvE3tu4OEg==
dependencies:
fast-deep-equal "^2.0.1"
fast-json-stable-stringify "^2.0.0"
json-schema-traverse "^0.4.1"
uri-js "^4.2.2"

align-text@^0.1.1, align-text@^0.1.3:
version "0.1.4"
resolved "https://registry.yarnpkg.com/align-text/-/align-text-0.1.4.tgz#0cd90a561093f35d0a99256c22b7069433fad117"
@@ -1031,6 +1051,11 @@ ansi-escapes@^1.1.0:
resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-1.4.0.tgz#d3a8a83b319aa67793662b13e761c7911422306e"
integrity sha1-06ioOzGapneTZisT52HHkRQiMG4=

ansi-escapes@^3.2.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-3.2.0.tgz#8780b98ff9dbf5638152d1f1fe5c1d7b4442976b"
integrity sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ==

ansi-html@0.0.7:
version "0.0.7"
resolved "https://registry.yarnpkg.com/ansi-html/-/ansi-html-0.0.7.tgz#813584021962a9e9e6fd039f940d12f56ca7859e"
@@ -1046,6 +1071,11 @@ ansi-regex@^3.0.0:
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998"
integrity sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=

ansi-regex@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-4.1.0.tgz#8b9f8f08cf1acb843756a839ca8c7e3168c51997"
integrity sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==

ansi-styles@^2.2.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe"
@@ -1058,7 +1088,7 @@ ansi-styles@^3.1.0:
dependencies:
color-convert "^1.9.0"

ansi-styles@^3.2.1:
ansi-styles@^3.2.0, ansi-styles@^3.2.1:
version "3.2.1"
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d"
integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==
@@ -1254,6 +1284,11 @@ ast-types@0.9.6:
resolved "https://registry.yarnpkg.com/ast-types/-/ast-types-0.9.6.tgz#102c9e9e9005d3e7e3829bf0c4fa24ee862ee9b9"
integrity sha1-ECyenpAF0+fjgpvwxPok7oYu6bk=

astral-regex@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-1.0.0.tgz#6c8c3fb827dd43ee3918f27b82782ab7658a6fd9"
integrity sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg==

async-done@^0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/async-done/-/async-done-0.4.0.tgz#ab8053f5f62290f8bfc58f37cd9b73070b3307b9"
@@ -1954,6 +1989,11 @@ callsites@^0.2.0:
resolved "https://registry.yarnpkg.com/callsites/-/callsites-0.2.0.tgz#afab96262910a7f33c19a5775825c69f34e350ca"
integrity sha1-r6uWJikQp/M8GaV3WCXGnzTjUMo=

callsites@^3.0.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73"
integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==

camel-case@3.0.x:
version "3.0.0"
resolved "https://registry.yarnpkg.com/camel-case/-/camel-case-3.0.0.tgz#ca3c3688a4e9cf3a4cda777dc4dcbc713249cf73"
@@ -2063,6 +2103,15 @@ chalk@^2.0.0, chalk@^2.0.1, chalk@^2.4.1:
escape-string-regexp "^1.0.5"
supports-color "^5.3.0"

chalk@^2.1.0, chalk@^2.4.2:
version "2.4.2"
resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424"
integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==
dependencies:
ansi-styles "^3.2.1"
escape-string-regexp "^1.0.5"
supports-color "^5.3.0"

chalk@^2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.3.0.tgz#b5ea48efc9c1793dccc9b4767c93914d3f2d52ba"
@@ -2072,6 +2121,11 @@ chalk@^2.3.0:
escape-string-regexp "^1.0.5"
supports-color "^4.0.0"

chardet@^0.7.0:
version "0.7.0"
resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e"
integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==

chart.js@~2.6.0:
version "2.6.0"
resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-2.6.0.tgz#308f9a4b0bfed5a154c14f5deb1d9470d22abe71"
@@ -2210,6 +2264,13 @@ cli-cursor@^1.0.1:
dependencies:
restore-cursor "^1.0.1"

cli-cursor@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-2.1.0.tgz#b35dac376479facc3e94747d41d0d0f5238ffcb5"
integrity sha1-s12sN2R5+sw+lHR9QdDQ9SOP/LU=
dependencies:
restore-cursor "^2.0.0"

cli-width@^2.0.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-2.2.0.tgz#ff19ede8a9a5e579324147b0c11f0fbcbabed639"
@@ -3022,6 +3083,13 @@ debug@^3.1.0, debug@^3.2.5:
dependencies:
ms "^2.1.1"

debug@^4.0.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791"
integrity sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==
dependencies:
ms "^2.1.1"

debug@~2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/debug/-/debug-2.2.0.tgz#f87057e995b1a1f6ae6a4960664137bc56f039da"
@@ -3368,6 +3436,13 @@ doctrine@^2.0.0:
dependencies:
esutils "^2.0.2"

doctrine@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-3.0.0.tgz#addebead72a6574db783639dc87a121773973961"
integrity sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==
dependencies:
esutils "^2.0.2"

dom-converter@~0.2:
version "0.2.0"
resolved "https://registry.yarnpkg.com/dom-converter/-/dom-converter-0.2.0.tgz#6721a9daee2e293682955b6afe416771627bb768"
@@ -3576,6 +3651,11 @@ elliptic@^6.0.0:
minimalistic-assert "^1.0.0"
minimalistic-crypto-utils "^1.0.0"

emoji-regex@^7.0.1:
version "7.0.3"
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156"
integrity sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==