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:
parent
8a8cef9b20
commit
2445a5aed5
31 changed files with 1372 additions and 421 deletions
|
@ -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>
|
||||
|
|
|
@ -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: '<'
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
]);
|
|
@ -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>
|
||||
|
|
|
@ -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: '<'
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
]);
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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
app/extensions/registry-management/rest/manifestJquery.js
Normal file
89
app/extensions/registry-management/rest/manifestJquery.js
Normal 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
|
||||
}
|
||||
}]);
|
|
@ -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
|
||||
}
|
||||
});
|
||||
}]);
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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
app/extensions/registry-management/services/registryV2Service.js
Normal file
214
app/extensions/registry-management/services/registryV2Service.js
Normal 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;
|
||||
}
|
||||
]);
|
|
@ -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>
|
||||
{{ $ctrl.resolve.progressLabel }} : {{ $ctrl.resolve.context.progression }}% - {{ $ctrl.resolve.context.elapsedTime |number:0 }}s
|
||||
</span>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
|
@ -0,0 +1,6 @@
|
|||
angular.module('portainer.extensions.registrymanagement').component('progressionModal', {
|
||||
templateUrl: './progressionModal.html',
|
||||
bindings: {
|
||||
resolve: '<'
|
||||
}
|
||||
});
|
|
@ -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'">
|
||||
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>
|
|
@ -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
|
||||
*/
|
||||
}
|
||||
]);
|
||||
]);
|
|
@ -5,7 +5,7 @@
|
|||
</a>
|
||||
</rd-header-title>
|
||||
<rd-header-content>
|
||||
<a ui-sref="portainer.registries">Registries</a> > <a ng-if="isAdmin" ui-sref="portainer.registries.registry({id: registry.Id})">{{ registry.Name }}</a><span ng-if="!isAdmin">{{ registry.Name}}</span> > Repositories
|
||||
<a ui-sref="portainer.registries">Registries</a> > <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> > 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>
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue