diff --git a/endpoints/api/tag.py b/endpoints/api/tag.py index a5783e59b..10d466e81 100644 --- a/endpoints/api/tag.py +++ b/endpoints/api/tag.py @@ -1,5 +1,7 @@ -from endpoints.api import (resource, nickname, require_repo_read, require_repo_admin, - RepositoryParamResource, log_action, NotFound) +from flask import request + +from endpoints.api import (resource, nickname, require_repo_read, require_repo_write, + RepositoryParamResource, log_action, NotFound, validate_json_request) from endpoints.api.image import image_view from data import model from auth.auth_context import get_authenticated_user @@ -8,8 +10,54 @@ from auth.auth_context import get_authenticated_user @resource('/v1/repository//tag/') class RepositoryTag(RepositoryParamResource): """ Resource for managing repository tags. """ + schemas = { + 'MoveTag': { + 'id': 'MoveTag', + 'type': 'object', + 'description': 'Description of to which image a new or existing tag should point', + 'required': [ + 'image', + ], + 'properties': { + 'image': { + 'type': 'string', + 'description': 'Image identifier to which the tag should point', + }, + }, + }, + } - @require_repo_admin + @require_repo_write + @nickname('changeTagImage') + @validate_json_request('MoveTag') + def put(self, namespace, repository, tag): + """ Change which image a tag points to or create a new tag.""" + image_id = request.get_json()['image'] + image = model.get_repo_image(namespace, repository, image_id) + if not image: + raise NotFound() + + 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 = get_authenticated_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 'Updated', 201 + + @require_repo_write @nickname('deleteFullTag') def delete(self, namespace, repository, tag): """ Delete the specified repository tag. """ diff --git a/initdb.py b/initdb.py index 2f8a77429..cf330aabe 100644 --- a/initdb.py +++ b/initdb.py @@ -196,6 +196,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 8eb25f740..6f25a8233 100644 --- a/static/css/quay.css +++ b/static/css/quay.css @@ -995,6 +995,24 @@ i.toggle-icon:hover { } } +.visible-xl { + display: none; +} + +.visible-xl-inline { + display: none; +} + +@media (min-width: 1200px) { + .visible-xl { + display: block; + } + + .visible-xl-inline { + display: inline-block; + } +} + .plans-list .plan-box .description { color: white; margin-top: 6px; @@ -1528,22 +1546,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; @@ -1554,15 +1572,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; @@ -1575,14 +1593,55 @@ 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.container-fluid { + padding-left: 10px; + padding-right: 10px; +} + +@media (min-width: 768px) { + .repo.container-fluid { + padding-left: 20px; + padding-right: 20px; + } +} + +@media (min-width: 1200px) { + .repo.container-fluid { + padding-left: 40px; + padding-right: 40px; + } + + .repo.container-fluid .col-md-4 { + width: 30%; + } + + .repo.container-fluid .col-md-8 { + width: 70%; + } +} + + +.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; @@ -1798,6 +1857,77 @@ 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; } @@ -2106,19 +2236,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 { @@ -2127,16 +2249,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); } @@ -2154,7 +2282,7 @@ p.editable:hover i { } .repo .change-count i { - font-size: 20px; + font-size: 16px; vertical-align: middle; } @@ -2166,6 +2294,7 @@ p.editable:hover i { .repo .more-changes { padding: 6px; + text-align: right; } .repo #collapseChanges .well { @@ -2406,10 +2535,13 @@ p.editable:hover i { text-align: center; } -.tags .tag, #confirmdeleteTagModal .tag { +.tags .tag, .tag-specific-images-view .tag { + display: inline-block; border-radius: 10px; margin-right: 4px; cursor: pointer; + overflow: hidden; + text-overflow: ellipsis; } .tooltip-tags { diff --git a/static/directives/tag-specific-images-view.html b/static/directives/tag-specific-images-view.html new file mode 100644 index 000000000..c4e9424d2 --- /dev/null +++ b/static/directives/tag-specific-images-view.html @@ -0,0 +1,17 @@ +
+
+
+
+ + + + {{ image.id.substr(0, 12) }} + +
+
+
+ And {{ tagSpecificImages.length - 5 }} more... +
+
diff --git a/static/js/app.js b/static/js/app.js index 6a74554c9..8d10d20a3 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -425,6 +425,8 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading 'role': 'th-large', 'original_role': 'th-large', 'application_name': 'cloud', + 'image': 'archive', + 'original_image': 'archive', 'client_id': 'chain' }; @@ -436,6 +438,9 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading for (var key in metadata) { if (metadata.hasOwnProperty(key)) { var value = metadata[key] != null ? 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); @@ -2133,6 +2138,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}', @@ -2212,6 +2219,8 @@ quayApp.directive('logsView', function () { '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', @@ -4522,6 +4531,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 74e6b2a24..ff7d4e354 100644 --- a/static/js/controllers.js +++ b/static/js/controllers.js @@ -398,9 +398,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; @@ -429,57 +429,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) { @@ -489,6 +453,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'); @@ -569,20 +566,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 d549f47c3..f74175015 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' + }); }); }; @@ -664,7 +672,19 @@ ImageHistoryTree.prototype.update_ = function(source) { if (d.collapsed) { that.expandCollapsed_(d); } }) .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") @@ -732,15 +752,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 51f34204c..d8a4c3112 100644 --- a/static/partials/view-repo.html +++ b/static/partials/view-repo.html @@ -5,7 +5,7 @@
-
+

@@ -127,9 +127,13 @@