Add ability to tag images from the UI, including moving existing tags to different images

This commit is contained in:
Joseph Schorr 2014-02-28 00:12:09 -05:00
parent 9371c70941
commit 20ad666308
11 changed files with 581 additions and 200 deletions

View file

@ -1450,11 +1450,45 @@ def get_image_changes(namespace, repository, image_id):
abort(403)
@api.route('/repository/<path:repository>/tag/<tag>',
methods=['PUT'])
@parse_repository_name
def change_tag_image(namespace, repository, tag):
permission = ModifyRepositoryPermission(namespace, repository)
if permission.can():
image_id = request.get_json()['image']
image = model.get_repo_image(namespace, repository, image_id)
if not image:
abort(404)
original_image_id = None
try:
original_tag_image = model.get_tag_image(namespace, repository, tag)
if original_tag_image:
original_image_id = original_tag_image.docker_image_id
except model.DataModelException:
# This is a new tag.
pass
model.create_or_update_tag(namespace, repository, tag, image_id)
model.garbage_collect_repository(namespace, repository)
username = current_user.db_user().username
log_action('move_tag' if original_image_id else 'create_tag', namespace,
{ 'username': username, 'repo': repository, 'tag': tag,
'image': image_id, 'original_image': original_image_id },
repo=model.get_repository(namespace, repository))
return make_response('Updated', 201)
abort(403) # Permission denied
@api.route('/repository/<path:repository>/tag/<tag>',
methods=['DELETE'])
@parse_repository_name
def delete_full_tag(namespace, repository, tag):
permission = AdministerRepositoryPermission(namespace, repository)
permission = ModifyRepositoryPermission(namespace, repository)
if permission.can():
model.delete_tag(namespace, repository, tag)
model.garbage_collect_repository(namespace, repository)

View file

@ -195,6 +195,8 @@ def initialize_database():
LogEntryKind.create(name='push_repo')
LogEntryKind.create(name='pull_repo')
LogEntryKind.create(name='delete_repo')
LogEntryKind.create(name='create_tag')
LogEntryKind.create(name='move_tag')
LogEntryKind.create(name='delete_tag')
LogEntryKind.create(name='add_repo_permission')
LogEntryKind.create(name='change_repo_permission')

View file

@ -1452,22 +1452,22 @@ p.editable:hover i {
border: 0px;
}
#confirmdeleteTagModal .image-listings {
.tag-specific-images-view .image-listings {
margin: 10px;
}
#confirmdeleteTagModal .image-listings .image-listing {
.tag-specific-images-view .image-listings .image-listing {
margin: 4px;
padding: 2px;
position: relative;
}
#confirmdeleteTagModal .image-listings .image-listing .image-listing-id {
.tag-specific-images-view .image-listings .image-listing .image-listing-id {
display: inline-block;
margin-left: 20px;
}
#confirmdeleteTagModal .image-listings .image-listing .image-listing-line {
.tag-specific-images-view .image-listings .image-listing .image-listing-line {
border-left: 2px solid steelblue;
display: inline-block;
position: absolute;
@ -1478,15 +1478,15 @@ p.editable:hover i {
z-index: 1;
}
#confirmdeleteTagModal .image-listings .image-listing.tag-image .image-listing-line {
.tag-specific-images-view .image-listings .image-listing.tag-image .image-listing-line {
top: 8px;
}
#confirmdeleteTagModal .image-listings .image-listing.child .image-listing-line {
.tag-specific-images-view .image-listings .image-listing.child .image-listing-line {
bottom: -2px;
}
#confirmdeleteTagModal .image-listings .image-listing .image-listing-circle {
.tag-specific-images-view .image-listings .image-listing .image-listing-circle {
position: absolute;
top: 8px;
@ -1499,14 +1499,27 @@ p.editable:hover i {
z-index: 2;
}
#confirmdeleteTagModal .image-listings .image-listing.tag-image .image-listing-circle {
.tag-specific-images-view .image-listings .image-listing.tag-image .image-listing-circle {
background: steelblue;
}
#confirmdeleteTagModal .more-changes {
.tag-specific-images-view .more-changes {
margin-left: 16px;
}
.repo .current-context {
display: inline-block;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
vertical-align: middle;
}
.repo .current-context-icon {
vertical-align: middle;
margin-right: 4px;
}
.repo .header {
margin-bottom: 20px;
padding-bottom: 16px;
@ -1684,6 +1697,76 @@ p.editable:hover i {
text-decoration: none !important;
}
.repo .image-comment {
margin-bottom: 4px;
}
.repo .image-section {
margin-top: 12px;
padding-bottom: 2px;
position: relative;
}
.repo .image-section .tag {
margin: 2px;
border-radius: 8px;
display: inline-block;
padding: 4px;
}
.repo .image-section .section-icon {
float: left;
font-size: 16px;
margin-left: -4px;
margin-right: 14px;
color: #bbb;
width: 18px;
text-align: center;
padding-top: 6px;
}
.repo .image-section .section-info {
padding: 4px;
padding-left: 6px;
-webkit-box-shadow: inset 0 1px 1px rgba(0,0,0,0.05);
box-shadow: inset 0 1px 1px rgba(0,0,0,0.05);
background-color: #f5f5f5;
vertical-align: middle;
display: block;
overflow: hidden;
text-overflow: ellipsis;
border-radius: 6px;
}
.repo .image-section .section-info-with-dropdown {
padding-right: 30px;
}
.repo .image-section .dropdown {
display: inline-block;
position: absolute;
top: 0px;
bottom: 2px;
right: 0px;
}
.repo .image-section .dropdown-button {
position: absolute;
right: 0px;
top: 0px;
bottom: 0px;
background: white;
padding: 4px;
padding-left: 8px;
padding-right: 8px;
border: 1px solid #eee;
border-top-right-radius: 4px;
border-bottom-right-radius: 4px;
cursor: pointer;
}
.repo-list {
margin-bottom: 40px;
}
@ -1985,19 +2068,11 @@ p.editable:hover i {
margin: 0px;
}
.repo .small-changes-container:before {
content: "File Changes: ";
display: inline-block;
margin-right: 10px;
font-weight: bold;
float: left;
padding-top: 4px;
}
.repo .formatted-command {
margin-top: 4px;
padding: 4px;
font-size: 12px;
font-family: Consolas, "Lucida Console", Monaco, monospace;
}
.repo .formatted-command.trimmed {
@ -2006,16 +2081,22 @@ p.editable:hover i {
text-overflow: ellipsis;
}
.repo .changes-count-container {
text-align: center;
}
.repo .change-count {
font-size: 18px;
font-size: 16px;
display: inline-block;
margin-right: 10px;
}
.repo .change-count b {
font-weight: normal;
margin-left: 6px;
vertical-align: middle;
}
.repo .changes-container .well {
border: 0px;
}
.repo .changes-container i.fa-plus-square {
color: rgb(73, 209, 73);
}
@ -2033,7 +2114,7 @@ p.editable:hover i {
}
.repo .change-count i {
font-size: 20px;
font-size: 16px;
vertical-align: middle;
}
@ -2045,6 +2126,7 @@ p.editable:hover i {
.repo .more-changes {
padding: 6px;
text-align: right;
}
.repo #collapseChanges .well {
@ -2290,10 +2372,13 @@ p.editable:hover i {
text-align: center;
}
#image-history-container .tags .tag, #confirmdeleteTagModal .tag {
#image-history-container .tags .tag, .tag-specific-images-view .tag {
display: inline-block;
border-radius: 10px;
margin-right: 4px;
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
}
#changes-tree-container {

View file

@ -0,0 +1,16 @@
<div class="tag-specific-images-view-element" ng-show="tagSpecificImages.length">
<div ng-transclude></div>
<div class="image-listings">
<div class="image-listing" ng-repeat="image in tagSpecificImages | limitTo:5"
ng-class="getImageListingClasses(image)">
<span class="image-listing-circle"></span>
<span class="image-listing-line"></span>
<span class="context-tooltip image-listing-id" bs-tooltip="getFirstTextLine(image.comment)">
{{ image.id.substr(0, 12) }}
</span>
</div>
</div>
<div class="more-changes" ng-show="tagSpecificImages.length > 5">
And {{ tagSpecificImages.length - 5 }} more...
</div>
</div>

View file

@ -1316,6 +1316,8 @@ quayApp.directive('logsView', function () {
}
},
'delete_tag': 'Tag {tag} deleted in repository {repo} by user {username}',
'create_tag': 'Tag {tag} created in repository {repo} on image {image} by user {username}',
'move_tag': 'Tag {tag} moved from image {original_image} to image {image} in repository {repo} by user {username}',
'change_repo_visibility': 'Change visibility for repository {repo} to {visibility}',
'add_repo_accesstoken': 'Create access token {token} in repository {repo}',
'delete_repo_accesstoken': 'Delete access token {token} in repository {repo}',
@ -1353,35 +1355,37 @@ quayApp.directive('logsView', function () {
};
var logKinds = {
'account_change_plan': 'Change plan',
'account_change_cc': 'Update credit card',
'account_change_password': 'Change password',
'account_convert': 'Convert account to organization',
'create_robot': 'Create Robot Account',
'delete_robot': 'Delete Robot Account',
'create_repo': 'Create Repository',
'push_repo': 'Push to repository',
'pull_repo': 'Pull repository',
'delete_repo': 'Delete repository',
'change_repo_permission': 'Change repository permission',
'delete_repo_permission': 'Remove user permission from repository',
'change_repo_visibility': 'Change repository visibility',
'add_repo_accesstoken': 'Create access token',
'delete_repo_accesstoken': 'Delete access token',
'add_repo_webhook': 'Add webhook',
'delete_repo_webhook': 'Delete webhook',
'set_repo_description': 'Change repository description',
'build_dockerfile': 'Build image from Dockerfile',
'delete_tag': 'Delete Tag',
'org_create_team': 'Create team',
'org_delete_team': 'Delete team',
'org_add_team_member': 'Add team member',
'org_remove_team_member': 'Remove team member',
'org_set_team_description': 'Change team description',
'org_set_team_role': 'Change team permission',
'create_prototype_permission': 'Create default permission',
'modify_prototype_permission': 'Modify default permission',
'delete_prototype_permission': 'Delete default permission'
'account_change_plan': 'Change plan',
'account_change_cc': 'Update credit card',
'account_change_password': 'Change password',
'account_convert': 'Convert account to organization',
'create_robot': 'Create Robot Account',
'delete_robot': 'Delete Robot Account',
'create_repo': 'Create Repository',
'push_repo': 'Push to repository',
'pull_repo': 'Pull repository',
'delete_repo': 'Delete repository',
'change_repo_permission': 'Change repository permission',
'delete_repo_permission': 'Remove user permission from repository',
'change_repo_visibility': 'Change repository visibility',
'add_repo_accesstoken': 'Create access token',
'delete_repo_accesstoken': 'Delete access token',
'add_repo_webhook': 'Add webhook',
'delete_repo_webhook': 'Delete webhook',
'set_repo_description': 'Change repository description',
'build_dockerfile': 'Build image from Dockerfile',
'delete_tag': 'Delete Tag',
'create_tag': 'Create Tag',
'move_tag': 'Move Tag',
'org_create_team': 'Create team',
'org_delete_team': 'Delete team',
'org_add_team_member': 'Add team member',
'org_remove_team_member': 'Remove team member',
'org_set_team_description': 'Change team description',
'org_set_team_role': 'Change team permission',
'create_prototype_permission': 'Create default permission',
'modify_prototype_permission': 'Modify default permission',
'delete_prototype_permission': 'Delete default permission'
};
var getDateString = function(date) {
@ -1468,7 +1472,9 @@ quayApp.directive('logsView', function () {
'robot': 'wrench',
'tag': 'tag',
'role': 'th-large',
'original_role': 'th-large'
'original_role': 'th-large',
'image': 'archive',
'original_image': 'archive'
};
log.metadata['_ip'] = log.ip ? log.ip : null;
@ -1481,6 +1487,10 @@ quayApp.directive('logsView', function () {
for (var key in log.metadata) {
if (log.metadata.hasOwnProperty(key)) {
var value = log.metadata[key] != null ? log.metadata[key].toString() : '(Unknown)';
if (key.indexOf('image') >= 0) {
value = value.substr(0, 12);
}
var markedDown = getMarkedDown(value);
markedDown = markedDown.substr('<p>'.length, markedDown.length - '<p></p>'.length);
@ -3023,6 +3033,121 @@ quayApp.directive('dockerfileBuildForm', function () {
});
quayApp.directive('tagSpecificImagesView', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/tag-specific-images-view.html',
replace: false,
transclude: true,
restrict: 'C',
scope: {
'repository': '=repository',
'tag': '=tag',
'images': '=images'
},
controller: function($scope, $element) {
$scope.getFirstTextLine = getFirstTextLine;
$scope.hasImages = false;
$scope.tagSpecificImages = [];
$scope.getImageListingClasses = function(image) {
var classes = '';
if (image.ancestors.length > 1) {
classes += 'child ';
}
var currentTag = $scope.repository.tags[$scope.tag];
if (image.dbid == currentTag.image.dbid) {
classes += 'tag-image ';
}
return classes;
};
var forAllTagImages = function(tag, callback) {
if (!tag) { return; }
callback(tag.image);
if (!$scope.imageByDBID) {
$scope.imageByDBID = [];
for (var i = 0; i < $scope.images.length; ++i) {
var currentImage = $scope.images[i];
$scope.imageByDBID[currentImage.dbid] = currentImage;
}
}
var ancestors = tag.image.ancestors.split('/');
for (var i = 0; i < ancestors.length; ++i) {
var image = $scope.imageByDBID[ancestors[i]];
if (image) {
callback(image);
}
}
};
var refresh = function() {
if (!$scope.repository || !$scope.tag || !$scope.images) {
$scope.tagSpecificImages = [];
return;
}
var tag = $scope.repository.tags[$scope.tag];
if (!tag) {
$scope.tagSpecificImages = [];
return;
}
var getIdsForTag = function(currentTag) {
var ids = {};
forAllTagImages(currentTag, function(image) {
ids[image.dbid] = true;
});
return ids;
};
// Remove any IDs that match other tags.
var toDelete = getIdsForTag(tag);
for (var currentTagName in $scope.repository.tags) {
var currentTag = $scope.repository.tags[currentTagName];
if (currentTag != tag) {
for (var dbid in getIdsForTag(currentTag)) {
delete toDelete[dbid];
}
}
}
// Return the matching list of images.
var images = [];
for (var i = 0; i < $scope.images.length; ++i) {
var image = $scope.images[i];
if (toDelete[image.dbid]) {
images.push(image);
}
}
images.sort(function(a, b) {
var result = new Date(b.created) - new Date(a.created);
if (result != 0) {
return result;
}
return b.dbid - a.dbid;
});
$scope.tagSpecificImages = images;
};
$scope.$watch('repository', refresh);
$scope.$watch('tag', refresh);
$scope.$watch('images', refresh);
}
};
return directiveDefinitionObject;
});
// Note: ngBlur is not yet in Angular stable, so we add it manaully here.
quayApp.directive('ngBlur', function() {
return function( scope, elem, attrs ) {

View file

@ -384,9 +384,9 @@ function RepoCtrl($scope, $sanitize, Restangular, ImageMetadataService, ApiServi
$scope.getMoreCount = function(changes) {
if (!changes) { return 0; }
var addedDisplayed = Math.min(5, changes.added.length);
var removedDisplayed = Math.min(5, changes.removed.length);
var changedDisplayed = Math.min(5, changes.changed.length);
var addedDisplayed = Math.min(2, changes.added.length);
var removedDisplayed = Math.min(2, changes.removed.length);
var changedDisplayed = Math.min(2, changes.changed.length);
return (changes.added.length + changes.removed.length + changes.changed.length) -
addedDisplayed - removedDisplayed - changedDisplayed;
@ -415,57 +415,21 @@ function RepoCtrl($scope, $sanitize, Restangular, ImageMetadataService, ApiServi
$location.search('tag', null);
$location.search('image', imageId.substr(0, 12));
}
};
$scope.showAddTag = function(image) {
$scope.toTagImage = image;
$('#addTagModal').modal('show');
};
$scope.tagSpecificImages = function(tagName) {
if (!tagName) { return []; }
$scope.isOwnedTag = function(image, tagName) {
if (!image || !tagName) { return false; }
return image.tags.indexOf(tagName) >= 0;
};
var tag = $scope.repo.tags[tagName];
if (!tag) { return []; }
if ($scope.specificImages && $scope.specificImages[tagName]) {
return $scope.specificImages[tagName];
}
var getIdsForTag = function(currentTag) {
var ids = {};
forAllTagImages(currentTag, function(image) {
ids[image.dbid] = true;
});
return ids;
};
// Remove any IDs that match other tags.
var toDelete = getIdsForTag(tag);
for (var currentTagName in $scope.repo.tags) {
var currentTag = $scope.repo.tags[currentTagName];
if (currentTag != tag) {
for (var dbid in getIdsForTag(currentTag)) {
delete toDelete[dbid];
}
}
}
// Return the matching list of images.
var images = [];
for (var i = 0; i < $scope.images.length; ++i) {
var image = $scope.images[i];
if (toDelete[image.dbid]) {
images.push(image);
}
}
images.sort(function(a, b) {
var result = new Date(b.created) - new Date(a.created);
if (result != 0) {
return result;
}
return b.dbid - a.dbid;
});
$scope.specificImages[tagName] = images;
return images;
$scope.isAnotherImageTag = function(image, tagName) {
if (!image || !tagName) { return false; }
return image.tags.indexOf(tagName) < 0 && $scope.repo.tags[tagName];
};
$scope.askDeleteTag = function(tagName) {
@ -475,6 +439,39 @@ function RepoCtrl($scope, $sanitize, Restangular, ImageMetadataService, ApiServi
$('#confirmdeleteTagModal').modal('show');
};
$scope.createOrMoveTag = function(image, tagName, opt_invalid) {
if (opt_invalid) { return; }
$scope.creatingTag = true;
var params = {
'repository': $scope.repo.namespace + '/' + $scope.repo.name,
'tag': tagName
};
var data = {
'image': image.id
};
ApiService.changeTagImage(data, params).then(function(resp) {
$scope.creatingTag = false;
loadViewInfo();
$('#addTagModal').modal('hide');
}, function(resp) {
$('#addTagModal').modal('hide');
bootbox.dialog({
"message": resp.data ? resp.data : 'Could not create or move tag',
"title": "Cannot create or move tag",
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
});
};
$scope.deleteTag = function(tagName) {
if (!$scope.repo.can_admin) { return; }
$('#confirmdeleteTagModal').modal('hide');
@ -555,20 +552,6 @@ function RepoCtrl($scope, $sanitize, Restangular, ImageMetadataService, ApiServi
$scope.getFirstTextLine = getFirstTextLine;
$scope.getImageListingClasses = function(image, tagName) {
var classes = '';
if (image.ancestors.length > 1) {
classes += 'child ';
}
var currentTag = $scope.repo.tags[tagName];
if (image.dbid == currentTag.image.dbid) {
classes += 'tag-image ';
}
return classes;
};
$scope.getTagCount = function(repo) {
if (!repo) { return 0; }
var count = 0;

View file

@ -115,12 +115,20 @@ ImageHistoryTree.prototype.setupOverscroll_ = function() {
$(that).trigger({
'type': 'hideTagMenu'
});
$(that).trigger({
'type': 'hideImageMenu'
});
});
overscroll.on('scroll', function() {
$(that).trigger({
'type': 'hideTagMenu'
});
$(that).trigger({
'type': 'hideImageMenu'
});
});
};
@ -634,7 +642,19 @@ ImageHistoryTree.prototype.update_ = function(source) {
.text(function(d) { return d.name; })
.on("click", function(d) { if (d.image) { that.changeImage_(d.image.id); } })
.on('mouseover', tip.show)
.on('mouseout', tip.hide);
.on('mouseout', tip.hide)
.on("contextmenu", function(d, e) {
d3.event.preventDefault();
if (d.image) {
$(that).trigger({
'type': 'showImageMenu',
'image': d.image.id,
'clientX': d3.event.clientX,
'clientY': d3.event.clientY
});
}
});
nodeEnter.selectAll("tags")
.append("svg:text")
@ -685,9 +705,9 @@ ImageHistoryTree.prototype.update_ = function(source) {
if (d.virtual) {
return 'virtual';
}
if (!currentImage) {
return '';
}
if (!currentImage) {
return '';
}
return d.image.id == currentImage.id ? 'current' : '';
});
@ -702,15 +722,16 @@ ImageHistoryTree.prototype.update_ = function(source) {
return '';
}
var html = '';
var html = '<div style="width: ' + DEPTH_HEIGHT + 'px">';
for (var i = 0; i < d.tags.length; ++i) {
var tag = d.tags[i];
var kind = 'default';
if (tag == currentTag) {
kind = 'success';
}
html += '<span class="label label-' + kind + ' tag" data-tag="' + tag + '"">' + tag + '</span>';
html += '<span class="label label-' + kind + ' tag" data-tag="' + tag + '"" style="max-width: ' + DEPTH_HEIGHT + 'px">' + tag + '</span>';
}
html += '</div>';
return html;
});

View file

@ -111,9 +111,13 @@ sudo docker push quay.io/{{repo.namespace}}/{{repo.name}}</pre>
<div class="panel-heading">
<!-- Dropdown -->
<div id="side-panel-dropdown" class="tag-dropdown dropdown" data-placement="top">
<i class="fa fa-tag" ng-show="currentTag"></i>
<i class="fa fa-archive" ng-show="!currentTag"></i>
<a href="javascript:void(0)" class="dropdown-toggle" data-toggle="dropdown">{{currentTag ? currentTag.name : currentImage.id.substr(0, 12)}} <b class="caret"></b></a>
<i class="fa fa-tag current-context-icon" ng-show="currentTag"></i>
<i class="fa fa-archive current-context-icon" ng-show="!currentTag"></i>
<a href="javascript:void(0)" class="dropdown-toggle" data-toggle="dropdown">
<span class="current-context">
{{currentTag ? currentTag.name : currentImage.id.substr(0, 12)}}
</span>
<b class="caret"></b></a>
<ul class="dropdown-menu">
<li ng-repeat="tag in repo.tags">
<a href="javascript:void(0)" ng-click="setTag(tag.name, true)">
@ -170,72 +174,116 @@ sudo docker push quay.io/{{repo.namespace}}/{{repo.name}}</pre>
<!-- Current Image -->
<div id="current-image" ng-show="currentImage && !currentTag">
<div ng-show="currentImage.comment">
<div class="image-comment" ng-if="currentImage.comment">
<blockquote style="margin-top: 10px;">
<span class="markdown-view" content="currentImage.comment"></span>
</blockquote>
</div>
<dl class="dl-normal">
<dt>Created</dt>
<dd am-time-ago="parseDate(currentImage.created)"></dd>
<dt>Image ID</dt>
<dd><a href="{{'/repository/' + repo.namespace + '/' + repo.name + '/image/' + currentImage.id}}">{{ currentImage.id }}</a></dd>
<dt>Compressed Image Size</dt>
<dd><span class="context-tooltip"
title="The amount of data sent between Docker and Quay.io when pushing/pulling"
bs-tooltip="tooltip.title" data-container="body">{{ currentImage.size | bytes }}</span>
</dd>
<dt ng-show="currentImage.command && currentImage.command.length">Command</dt>
<dd ng-show="currentImage.command && currentImage.command.length" class="codetooltipcontainer">
<pre class="formatted-command trimmed"
bs-tooltip="getTooltipCommand(currentImage)"
data-placement="top">{{ getFormattedCommand(currentImage) }}</pre>
</dd>
</dl>
<div class="image-section">
<i class="fa fa-code section-icon" bs-tooltip="tooltip.title" title="Full Image ID"></i>
<span class="section-info">
<a href="{{'/repository/' + repo.namespace + '/' + repo.name + '/image/' + currentImage.id}}">{{ currentImage.id }}</a>
</span>
</div>
<div class="image-section">
<i class="fa fa-tag section-icon" bs-tooltip="tooltip.title" title="Current Tags"></i>
<span class="section-info section-info-with-dropdown">
<a class="label tag label-default" ng-repeat="tag in currentImage.tags"
href="/repository/{{ repo.namespace }}/{{ repo.name }}?tag={{ tag }}">
{{ tag }}
</a>
<span style="color: #ccc;" ng-if="!currentImage.tags.length">(No Tags)</span>
<div class="dropdown" data-placement="top" ng-if="repo.can_write || currentImage.tags">
<a href="javascript:void(0)" class="dropdown-button" data-toggle="dropdown" bs-tooltip="tooltip.title" title="Manage Tags"
data-container="body">
<b class="caret"></b>
</a>
<ul class="dropdown-menu pull-right">
<li ng-repeat="tag in currentImage.tags">
<a href="javascript:void(0)" ng-click="setTag(tag, true)">
<i class="fa fa-tag"></i>{{tag}}
</a>
</li>
<li class="divider" role="presentation" ng-if="repo.can_write && currentImage.tags"></li>
<li>
<a href="javascript:void(0)" ng-click="showAddTag(currentImage)" ng-if="repo.can_write">
<i class="fa fa-plus"></i>Add New Tag
</a>
</li>
</ul>
</div>
</span>
</div>
<div class="image-section" ng-if="currentImage.command && currentImage.command.length">
<i class="fa fa-terminal section-icon" bs-tooltip="tooltip.title" title="Image Command"></i>
<span class="section-info">
<span class="formatted-command trimmed"
bs-tooltip="getTooltipCommand(currentImage)"
data-placement="top">{{ getFormattedCommand(currentImage) }}</span>
</span>
</div>
<div class="image-section">
<i class="fa fa-calendar section-icon" bs-tooltip="tooltip.title" title="Created"></i>
<span class="section-info">
<dd am-time-ago="parseDate(currentImage.created)"></dd>
</span>
</div>
<div class="image-section">
<i class="fa fa-cloud-upload section-icon" bs-tooltip="tooltip.title"
title="The amount of data sent between Docker and Quay.io when pushing/pulling"></i>
<span class="section-info">{{ currentImage.size | bytes }}</span>
</div>
<!-- Image changes loading -->
<div class="resource-view" resource="currentImageChangeResource">
<div class="changes-container small-changes-container"
<div class="changes-container small-changes-container section-info"
ng-show="currentImageChanges.changed.length || currentImageChanges.added.length || currentImageChanges.removed.length">
<div class="changes-count-container accordion-toggle" data-toggle="collapse" data-parent="#accordion" data-target="#collapseChanges">
<span class="change-count added" ng-show="currentImageChanges.added.length > 0" title="Files Added"
bs-tooltip="tooltip.title" data-placement="top">
<i class="fa fa-plus-square"></i>
<b>{{currentImageChanges.added.length}}</b>
</span>
<span class="change-count removed" ng-show="currentImageChanges.removed.length > 0" title="Files Removed"
bs-tooltip="tooltip.title" data-placement="top">
<i class="fa fa-minus-square"></i>
<b>{{currentImageChanges.removed.length}}</b>
</span>
<span class="change-count changed" ng-show="currentImageChanges.changed.length > 0" title="Files Changed"
bs-tooltip="tooltip.title" data-placement="top">
<i class="fa fa-pencil-square"></i>
<b>{{currentImageChanges.changed.length}}</b>
</span>
</div>
<div id="collapseChanges" class="panel-collapse collapse in">
<div class="well well-sm">
<div class="change added" ng-repeat="file in currentImageChanges.added | limitTo:5">
<div class="changes-count-container image-section">
<i class="fa fa-code-fork section-icon" bs-tooltip="tooltip.title" title="File Changes"></i>
<div style="float: right; display: inline-block">
<span class="change-count added" ng-show="currentImageChanges.added.length > 0" title="Files Added"
bs-tooltip="tooltip.title" data-placement="top" data-container="body">
<i class="fa fa-plus-square"></i>
<span title="{{file}}">{{file}}</span>
</div>
<div class="change removed" ng-repeat="file in currentImageChanges.removed | limitTo:5">
<b>{{currentImageChanges.added.length}}</b>
</span>
<span class="change-count removed" ng-show="currentImageChanges.removed.length > 0" title="Files Removed"
bs-tooltip="tooltip.title" data-placement="top" data-container="body">
<i class="fa fa-minus-square"></i>
<span title="{{file}}">{{file}}</span>
</div>
<div class="change changed" ng-repeat="file in currentImageChanges.changed | limitTo:5">
<b>{{currentImageChanges.removed.length}}</b>
</span>
<span class="change-count changed" ng-show="currentImageChanges.changed.length > 0" title="Files Changed"
bs-tooltip="tooltip.title" data-placement="top" data-container="body">
<i class="fa fa-pencil-square"></i>
<span title="{{file}}">{{file}}</span>
<b>{{currentImageChanges.changed.length}}</b>
</span>
</div>
<div id="collapseChanges" style="padding-top: 24px;">
<div class="well well-sm">
<div class="change added" ng-repeat="file in currentImageChanges.added | limitTo:2">
<i class="fa fa-plus-square"></i>
<span title="{{file}}">{{file}}</span>
</div>
<div class="change removed" ng-repeat="file in currentImageChanges.removed | limitTo:2">
<i class="fa fa-minus-square"></i>
<span title="{{file}}">{{file}}</span>
</div>
<div class="change changed" ng-repeat="file in currentImageChanges.changed | limitTo:2">
<i class="fa fa-pencil-square"></i>
<span title="{{file}}">{{file}}</span>
</div>
</div>
<div class="more-changes" ng-show="getMoreCount(currentImageChanges) > 0">
<a href="{{'/repository/' + repo.namespace + '/' + repo.name + '/image/' + currentImage.id}}">
And {{getMoreCount(currentImageChanges)}} more...
</a>
</div>
</div>
<div class="more-changes" ng-show="getMoreCount(currentImageChanges) > 0">
<a href="{{'/repository/' + repo.namespace + '/' + repo.name + '/image/' + currentImage.id}}">
And {{getMoreCount(currentImageChanges)}} more...
</a>
</div>
</div>
</div>
</div>
@ -253,6 +301,44 @@ sudo docker push quay.io/{{repo.namespace}}/{{repo.name}}</pre>
build-started="handleBuildStarted(build)">
</div>
<!-- Modal message dialog -->
<div class="modal fade" id="addTagModal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true" ng-show="!creatingTag">&times;</button>
<h4 class="modal-title">{{ isAnotherImageTag(toTagImage, tagToCreate) ? 'Move' : 'Add' }} Tag to Image {{ toTagImage.id.substr(0, 12) }}</h4>
</div>
<form name="addTagForm" ng-submit="createOrMoveTag(toTagImage, tagToCreate, addTagForm.$invalid); addTagForm.$setPristine(); tagToCreate=''">
<div class="modal-body">
<input type="text" class="form-control" id="tagName" placeholder="Enter tag name"
ng-model="tagToCreate" ng-pattern="/^([a-z0-9_]){3,30}$/" required
ng-disabled="creatingTag">
<div style="margin: 10px; margin-top: 20px;" ng-show="isOwnedTag(toTagImage, tagToCreate)">
Note: <span class="label tag label-default">{{ tagToCreate }}</span> is already applied to this image.
</div>
<div style="margin: 10px; margin-top: 20px;" ng-show="isAnotherImageTag(toTagImage, tagToCreate)">
Note: <span class="label tag label-default">{{ tagToCreate }}</span> is already applied to another image. This will <b>move</b> the tag.
</div>
<div class="tag-specific-images-view" tag="tagToCreate" repository="repo" images="images"
style="margin: 10px; margin-top: 20px; margin-bottom: -10px;" ng-show="isAnotherImageTag(toTagImage, tagToCreate)">
This will also delete any unattach images and delete the following images:
</div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary"
ng-disabled="!tagToCreate || addTagForm.$invalid || isOwnedTag(toTagImage, tagToCreate)"
ng-class="isAnotherImageTag(toTagImage, tagToCreate) ? 'btn-warning' : 'btn-primary'" ng-show="!creatingTag">
{{ isAnotherImageTag(toTagImage, tagToCreate) ? 'Move Tag' : 'Create Tag' }}
</button>
<button class="btn btn-default" data-dismiss="modal" ng-show="!creatingTag">Cancel</button>
<div class="quay-spinner" ng-show="creatingTag"></div>
</div>
</form>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
<!-- Modal message dialog -->
<div class="modal fade" id="confirmdeleteTagModal">
<div class="modal-dialog">
@ -271,22 +357,8 @@ sudo docker push quay.io/{{repo.namespace}}/{{repo.name}}</pre>
{{ tagToDelete }}
</span>?
<div ng-show="tagSpecificImages(tagToDelete).length" style="margin-top: 20px">
The following images and any other images not referenced by a tag will be deleted:
<div class="image-listings">
<div class="image-listing" ng-repeat="image in tagSpecificImages(tagToDelete) | limitTo:5"
ng-class="getImageListingClasses(image, tagToDelete)">
<!--<i class="fa fa-archive"></i>-->
<span class="image-listing-circle"></span>
<span class="image-listing-line"></span>
<span class="context-tooltip image-listing-id" bs-tooltip="getFirstTextLine(image.comment)">
{{ image.id.substr(0, 12) }}
</span>
</div>
</div>
<div class="more-changes" ng-show="tagSpecificImages(tagToDelete).length > 5">
And {{ tagSpecificImages(tagToDelete).length - 5 }} more...
</div>
<div class="tag-specific-images-view" tag="tagToDelete" repository="repo" images="images" style="margin-top: 20px">
The following images and any other images not referenced by a tag will be deleted:
</div>
</div>
<div class="modal-footer">

Binary file not shown.

View file

@ -68,6 +68,9 @@ UPDATE_REPO_DETAILS = {
'description': 'A new description',
}
CHANGE_TAG_IMAGE_DETAILS = {
'image': FAKE_IMAGE_ID
}
class TestSpec(object):
def __init__(self, url, anon_code=401, no_access_code=403, read_code=403,
@ -425,6 +428,12 @@ def build_specs():
TestSpec(url_for('api.list_repo_logs', repository=PRIVATE_REPO)),
TestSpec(url_for('api.list_org_logs', orgname=ORG)),
TestSpec(url_for('api.delete_full_tag', repository=PRIVATE_REPO, tag='latest'), 403, 403, 403, 204)
.set_method('DELETE'),
TestSpec(url_for('api.change_tag_image', repository=PRIVATE_REPO, tag='latest'), 403, 403, 403, 404)
.set_method('PUT').set_data_from_obj(CHANGE_TAG_IMAGE_DETAILS),
]

View file

@ -55,6 +55,13 @@ class ApiTestCase(unittest.TestCase):
self.assertEquals(rv.status_code, expected_code)
return rv.data
def putResponse(self, method_name, params={}, data={}, expected_code=200):
rv = self.app.put(url_for(method_name, **params),
data=py_json.dumps(data),
headers={"Content-Type": "application/json"})
self.assertEquals(rv.status_code, expected_code)
return rv.data
def deleteResponse(self, method_name, params={}, expected_code=204):
rv = self.app.delete(url_for(method_name, **params))
self.assertEquals(rv.status_code, expected_code)
@ -961,7 +968,7 @@ class TestGetImageChanges(ApiTestCase):
class TestListAndDeleteTag(ApiTestCase):
def test_listtagimagesanddeletetag(self):
def test_listdeletecreateandmovetag(self):
self.login(ADMIN_ACCESS_USER)
# List the images for prod.
@ -994,6 +1001,33 @@ class TestListAndDeleteTag(ApiTestCase):
self.assertEquals(staging_images, json['images'])
# Add a new tag to the staging image.
self.putResponse('api.change_tag_image',
params=dict(repository=ADMIN_ACCESS_USER + '/complex', tag='sometag'),
data=dict(image=staging_images[0]['id']),
expected_code=201)
# Make sure the tag is present.
json = self.getJsonResponse('api.list_tag_images',
params=dict(repository=ADMIN_ACCESS_USER + '/complex', tag='sometag'))
sometag_images = json['images']
self.assertEquals(sometag_images, staging_images)
# Move the tag.
self.putResponse('api.change_tag_image',
params=dict(repository=ADMIN_ACCESS_USER + '/complex', tag='sometag'),
data=dict(image=staging_images[-1]['id']),
expected_code=201)
# Make sure the tag has moved.
json = self.getJsonResponse('api.list_tag_images',
params=dict(repository=ADMIN_ACCESS_USER + '/complex', tag='sometag'))
sometag_new_images = json['images']
self.assertEquals(1, len(sometag_new_images))
self.assertEquals(staging_images[-1], sometag_new_images[0])
def test_deletesubtag(self):
self.login(ADMIN_ACCESS_USER)