From 20ad66630873aaef5cd28decd245f6f6ad25568e Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Fri, 28 Feb 2014 00:12:09 -0500 Subject: [PATCH 1/2] Add ability to tag images from the UI, including moving existing tags to different images --- endpoints/api.py | 36 ++- initdb.py | 2 + static/css/quay.css | 135 +++++++++-- .../directives/tag-specific-images-view.html | 16 ++ static/js/app.js | 185 ++++++++++++--- static/js/controllers.js | 113 ++++----- static/js/graphing.js | 33 ++- static/partials/view-repo.html | 216 ++++++++++++------ test/data/test.db | Bin 157696 -> 413696 bytes test/specs.py | 9 + test/test_api_usage.py | 36 ++- 11 files changed, 581 insertions(+), 200 deletions(-) create mode 100644 static/directives/tag-specific-images-view.html diff --git a/endpoints/api.py b/endpoints/api.py index b337f7faa..d480923b5 100644 --- a/endpoints/api.py +++ b/endpoints/api.py @@ -1450,11 +1450,45 @@ def get_image_changes(namespace, repository, image_id): abort(403) +@api.route('/repository//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//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) diff --git a/initdb.py b/initdb.py index 06ca0053c..eba642754 100644 --- a/initdb.py +++ b/initdb.py @@ -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') diff --git a/static/css/quay.css b/static/css/quay.css index ba72550ed..014aee4b9 100644 --- a/static/css/quay.css +++ b/static/css/quay.css @@ -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 { diff --git a/static/directives/tag-specific-images-view.html b/static/directives/tag-specific-images-view.html new file mode 100644 index 000000000..1a5f3a19b --- /dev/null +++ b/static/directives/tag-specific-images-view.html @@ -0,0 +1,16 @@ +
+
+
+
+ + + + {{ image.id.substr(0, 12) }} + +
+
+
+ And {{ tagSpecificImages.length - 5 }} more... +
+
diff --git a/static/js/app.js b/static/js/app.js index 771ae6098..550230624 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -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('

'.length, markedDown.length - '

'.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 ) { diff --git a/static/js/controllers.js b/static/js/controllers.js index 6b7599d59..85151dd87 100644 --- a/static/js/controllers.js +++ b/static/js/controllers.js @@ -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; diff --git a/static/js/graphing.js b/static/js/graphing.js index b796fd8a4..bcb8c8129 100644 --- a/static/js/graphing.js +++ b/static/js/graphing.js @@ -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 = '
'; for (var i = 0; i < d.tags.length; ++i) { var tag = d.tags[i]; var kind = 'default'; if (tag == currentTag) { kind = 'success'; } - html += '' + tag + ''; + html += '' + tag + ''; } + html += '
'; return html; }); diff --git a/static/partials/view-repo.html b/static/partials/view-repo.html index e3b8ef3f8..26ca9fe9b 100644 --- a/static/partials/view-repo.html +++ b/static/partials/view-repo.html @@ -111,9 +111,13 @@ sudo docker push quay.io/{{repo.namespace}}/{{repo.name}}