1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-02 20:35:25 +02:00

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
This commit is contained in:
xAt0mZ 2019-10-14 15:45:09 +02:00 committed by GitHub
parent 8a8cef9b20
commit 2445a5aed5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 1372 additions and 421 deletions

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>

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: '<'
}
});

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);
}
]);

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>

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: '<'
}
});

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);
}
]);

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;

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;
}
}

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;
}

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'
}
}
});
}]);

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
}
}]);

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
}
});
}]);

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;
}

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;
}
]);

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;
}
]);

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>

View file

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

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>

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.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');
});
};
/**
* 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();
}
});
}
};
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 () {
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');
});
return $async(addTagAsync);
};
/**
* !END ADD TAG SECTION
*/
$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;
/**
* 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));
$scope.removeTags = function (selectedItems) {
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');
}
}
initView();
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;
}
}
$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
*/
}
]);
]);

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>

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;