Add the image view page with the changes view, filterable by typing into a field. Still needs pagination or some other mechanism for getting an overview
This commit is contained in:
parent
8274d9af97
commit
0afea3a779
7 changed files with 239 additions and 7 deletions
|
@ -254,6 +254,18 @@ def get_repository(namespace_name, repository_name):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_repo_image(namespace_name, repository_name, image_id):
|
||||||
|
joined = Image.select().join(Repository)
|
||||||
|
query = joined.where(Repository.name == repository_name,
|
||||||
|
Repository.namespace == namespace_name,
|
||||||
|
Image.docker_image_id == image_id).limit(1)
|
||||||
|
result = list(query)
|
||||||
|
if not result:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return result[0]
|
||||||
|
|
||||||
|
|
||||||
def repository_is_public(namespace_name, repository_name):
|
def repository_is_public(namespace_name, repository_name):
|
||||||
joined = Repository.select().join(Visibility)
|
joined = Repository.select().join(Visibility)
|
||||||
query = joined.where(Repository.namespace == namespace_name,
|
query = joined.where(Repository.namespace == namespace_name,
|
||||||
|
|
|
@ -364,10 +364,24 @@ def list_repository_images(namespace, repository):
|
||||||
abort(403)
|
abort(403)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/repository/<path:repository>/image/<image_id>',
|
||||||
|
methods=['GET'])
|
||||||
|
@parse_repository_name
|
||||||
|
def get_image(namespace, repository, image_id):
|
||||||
|
permission = ReadRepositoryPermission(namespace, repository)
|
||||||
|
if permission.can() or model.repository_is_public(namespace, repository):
|
||||||
|
image = model.get_repo_image(namespace, repository, image_id)
|
||||||
|
if not image:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
return jsonify(image_view(image))
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/repository/<path:repository>/image/<image_id>/changes',
|
@app.route('/api/repository/<path:repository>/image/<image_id>/changes',
|
||||||
methods=['GET'])
|
methods=['GET'])
|
||||||
@parse_repository_name
|
@parse_repository_name
|
||||||
def get_repository_changes(namespace, repository, image_id):
|
def get_image_changes(namespace, repository, image_id):
|
||||||
permission = ReadRepositoryPermission(namespace, repository)
|
permission = ReadRepositoryPermission(namespace, repository)
|
||||||
if permission.can() or model.repository_is_public(namespace, repository):
|
if permission.can() or model.repository_is_public(namespace, repository):
|
||||||
diffs_path = store.image_file_diffs_path(namespace, repository, image_id)
|
diffs_path = store.image_file_diffs_path(namespace, repository, image_id)
|
||||||
|
|
|
@ -489,6 +489,8 @@ p.editable:hover i {
|
||||||
.repo dl.dl-normal dd {
|
.repo dl.dl-normal dd {
|
||||||
padding-left: 14px;
|
padding-left: 14px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.repo .header h3 {
|
.repo .header h3 {
|
||||||
|
@ -534,6 +536,46 @@ p.editable:hover i {
|
||||||
top: 40px;
|
top: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.repo-image-view .id-container {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-image-view .id-container input {
|
||||||
|
background: #fefefe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-image-view .id-container .input-group {
|
||||||
|
width: 542px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-image-view #clipboardCopied {
|
||||||
|
position: relative;
|
||||||
|
top: -14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-image-view .changes-container .change-side-controls {
|
||||||
|
float: right;
|
||||||
|
clear: both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-image-view .changes-container .filter-input {
|
||||||
|
display: inline-block;
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-image-view .changes-container .result-count {
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-image-view .changes-container .changes-list {
|
||||||
|
padding: 10px;
|
||||||
|
margin-top: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
.repo #clipboardCopied {
|
.repo #clipboardCopied {
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
@ -665,7 +707,7 @@ p.editable:hover i {
|
||||||
margin: 0px;
|
margin: 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.repo .changes-container:before {
|
.repo .small-changes-container:before {
|
||||||
content: "File Changes: ";
|
content: "File Changes: ";
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
|
@ -684,15 +726,15 @@ p.editable:hover i {
|
||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.repo .changes-container .added i {
|
.repo .changes-container i.icon-plus-sign-alt {
|
||||||
color: rgb(73, 209, 73);
|
color: rgb(73, 209, 73);
|
||||||
}
|
}
|
||||||
|
|
||||||
.repo .change-container .removed i {
|
.repo .changes-container i.icon-minus-sign-alt {
|
||||||
color: rgb(209, 73, 73);
|
color: rgb(209, 73, 73);
|
||||||
}
|
}
|
||||||
|
|
||||||
.repo .changes-container .changed i {
|
.repo .changes-container i.icon-edit-sign {
|
||||||
color: rgb(73, 100, 209);
|
color: rgb(73, 100, 209);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -148,6 +148,7 @@ quayApp = angular.module('quay', ['restangular', 'angularMoment', 'angulartics',
|
||||||
$routeProvider.
|
$routeProvider.
|
||||||
when('/repository/:namespace/:name', {templateUrl: '/static/partials/view-repo.html', controller: RepoCtrl, reloadOnSearch: false}).
|
when('/repository/:namespace/:name', {templateUrl: '/static/partials/view-repo.html', controller: RepoCtrl, reloadOnSearch: false}).
|
||||||
when('/repository/:namespace/:name/tag/:tag', {templateUrl: '/static/partials/view-repo.html', controller: RepoCtrl}).
|
when('/repository/:namespace/:name/tag/:tag', {templateUrl: '/static/partials/view-repo.html', controller: RepoCtrl}).
|
||||||
|
when('/repository/:namespace/:name/image/:image', {templateUrl: '/static/partials/image-view.html', controller: ImageViewCtrl}).
|
||||||
when('/repository/:namespace/:name/admin', {templateUrl: '/static/partials/repo-admin.html', controller:RepoAdminCtrl}).
|
when('/repository/:namespace/:name/admin', {templateUrl: '/static/partials/repo-admin.html', controller:RepoAdminCtrl}).
|
||||||
when('/repository/', {title: 'Repositories', templateUrl: '/static/partials/repo-list.html', controller: RepoListCtrl}).
|
when('/repository/', {title: 'Repositories', templateUrl: '/static/partials/repo-list.html', controller: RepoListCtrl}).
|
||||||
when('/user/', {title: 'User Admin', templateUrl: '/static/partials/user-admin.html', controller: UserAdminCtrl}).
|
when('/user/', {title: 'User Admin', templateUrl: '/static/partials/user-admin.html', controller: UserAdminCtrl}).
|
||||||
|
|
|
@ -782,6 +782,96 @@ function UserAdminCtrl($scope, $timeout, Restangular, PlanService, UserService,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ImageViewCtrl($scope, $routeParams, $rootScope, Restangular) {
|
||||||
|
var namespace = $routeParams.namespace;
|
||||||
|
var name = $routeParams.name;
|
||||||
|
var imageid = $routeParams.image;
|
||||||
|
|
||||||
|
$('#copyClipboard').clipboardCopy();
|
||||||
|
|
||||||
|
$scope.getMarkedDown = function(string) {
|
||||||
|
if (!string) { return ''; }
|
||||||
|
return getMarkedDown(string);
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.parseDate = function(dateString) {
|
||||||
|
return Date.parse(dateString);
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.getFolder = function(filepath) {
|
||||||
|
var index = filepath.lastIndexOf('/');
|
||||||
|
if (index < 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return filepath.substr(0, index + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.getFolders = function(filepath) {
|
||||||
|
var index = filepath.lastIndexOf('/');
|
||||||
|
if (index < 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return filepath.substr(0, index).split('/');
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.getFilename = function(filepath) {
|
||||||
|
var index = filepath.lastIndexOf('/');
|
||||||
|
if (index < 0) {
|
||||||
|
return filepath;
|
||||||
|
}
|
||||||
|
return filepath.substr(index + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.setFolderFilter = function(folderPath, index) {
|
||||||
|
var parts = folderPath.split('/');
|
||||||
|
parts = parts.slice(0, index + 1);
|
||||||
|
$scope.setFilter(parts.join('/'));
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.setFilter = function(filter) {
|
||||||
|
$scope.search = {};
|
||||||
|
$scope.search['$'] = filter;
|
||||||
|
document.getElementById('change-filter').value = filter;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch the image.
|
||||||
|
var imageFetch = Restangular.one('repository/' + namespace + '/' + name + '/image/' + imageid);
|
||||||
|
imageFetch.get().then(function(image) {
|
||||||
|
$scope.loading = false;
|
||||||
|
$scope.repo = {
|
||||||
|
'name': name,
|
||||||
|
'namespace': namespace
|
||||||
|
};
|
||||||
|
$scope.image = image;
|
||||||
|
$rootScope.title = 'View Image - ' + image.id;
|
||||||
|
}, function() {
|
||||||
|
$rootScope.title = 'Unknown Image';
|
||||||
|
$scope.loading = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch the image changes.
|
||||||
|
var changesFetch = Restangular.one('repository/' + namespace + '/' + name + '/image/' + imageid + '/changes');
|
||||||
|
changesFetch.get().then(function(changes) {
|
||||||
|
var combinedChanges = [];
|
||||||
|
var addCombinedChanges = function(c, kind) {
|
||||||
|
for (var i = 0; i < c.length; ++i) {
|
||||||
|
combinedChanges.push({
|
||||||
|
'kind': kind,
|
||||||
|
'file': c[i]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
addCombinedChanges(changes.added, 'added');
|
||||||
|
addCombinedChanges(changes.removed, 'removed');
|
||||||
|
addCombinedChanges(changes.changed, 'changed');
|
||||||
|
|
||||||
|
$scope.combinedChanges = combinedChanges;
|
||||||
|
$scope.imageChanges = changes;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function V1Ctrl($scope, UserService) {
|
function V1Ctrl($scope, UserService) {
|
||||||
$scope.$watch( function () { return UserService.currentUser(); }, function (currentUser) {
|
$scope.$watch( function () { return UserService.currentUser(); }, function (currentUser) {
|
||||||
$scope.user = currentUser;
|
$scope.user = currentUser;
|
||||||
|
|
73
static/partials/image-view.html
Normal file
73
static/partials/image-view.html
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
<div class="container" ng-show="!loading && !image">
|
||||||
|
No image found
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="loading" ng-show="loading">
|
||||||
|
<i class="icon-spinner icon-spin icon-3x"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container repo repo-image-view" ng-show="!loading && image">
|
||||||
|
<div class="header">
|
||||||
|
<a href="{{ '/repository/' + repo.namespace + '/' + repo.name }}" class="back"><i class="icon-chevron-left"></i></a>
|
||||||
|
<h3>
|
||||||
|
<i class="icon-archive icon-large" style="color: #aaa; margin-right: 10px;"></i>
|
||||||
|
<span style="color: #aaa;"> {{repo.namespace}}</span>
|
||||||
|
<span style="color: #ccc">/</span>
|
||||||
|
<span style="color: #666;">{{repo.name}}</span>
|
||||||
|
<span style="color: #ccc">/</span>
|
||||||
|
<span>{{image.id.substr(0, 12)}}</span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Information -->
|
||||||
|
<dl class="dl-normal">
|
||||||
|
<dt>Full Image ID</dt>
|
||||||
|
<dd>
|
||||||
|
<div>
|
||||||
|
<div class="id-container">
|
||||||
|
<div class="input-group">
|
||||||
|
<input id="full-id" type="text" class="form-control" value="{{ image.id }}" readonly>
|
||||||
|
<span id="copyClipboard" class="input-group-addon" title="Copy to Clipboard" data-clipboard-target="full-id">
|
||||||
|
<i class="icon-copy"></i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="clipboardCopied" style="display: none">
|
||||||
|
Copied to clipboard
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dd>
|
||||||
|
<dt>Created</dt>
|
||||||
|
<dd am-time-ago="parseDate(image.created)"></dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<!-- Comment -->
|
||||||
|
<blockquote ng-show="image.comment" ng-bind-html-unsafe="getMarkedDown(image.comment)"></blockquote>
|
||||||
|
|
||||||
|
<!-- Changes -->
|
||||||
|
<div class="changes-container full-changes-container" ng-show="combinedChanges.length > 0">
|
||||||
|
<b>File Changes:</b>
|
||||||
|
<div class="change-side-controls">
|
||||||
|
<div class="result-count">
|
||||||
|
Showing {{(combinedChanges | filter:search | limitTo:50).length}} of {{(combinedChanges | filter:search).length}} results
|
||||||
|
</div>
|
||||||
|
<div class="filter-input">
|
||||||
|
<input id="change-filter" class="form-control" placeholder="Filter Changes" type="text" ng-model="search.$">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="changes-list well well-sm">
|
||||||
|
<div ng-show="(combinedChanges | filter:search | limitTo:1).length == 0">
|
||||||
|
No matching changes
|
||||||
|
</div>
|
||||||
|
<div class="change" ng-repeat="change in combinedChanges | filter:search | limitTo:50">
|
||||||
|
<i ng-class="{'added': 'icon-plus-sign-alt', 'removed': 'icon-minus-sign-alt', 'changed': 'icon-edit-sign'}[change.kind]"></i>
|
||||||
|
<span title="{{change.file}}">
|
||||||
|
<span style="color: #888;">
|
||||||
|
<span ng-repeat="folder in getFolders(change.file)"><a href="javascript:void(0)" ng-click="setFolderFilter(getFolder(change.file), $index)">{{folder}}</a>/</span></span><span>{{getFilename(change.file)}}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
|
@ -124,7 +124,7 @@
|
||||||
<i class="icon-spinner icon-spin icon-3x"></i>
|
<i class="icon-spinner icon-spin icon-3x"></i>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="changes-container" ng-show="currentImageChanges.changed">
|
<div class="changes-container small-changes-container" ng-show="currentImageChanges.changed">
|
||||||
<div class="changes-count-container accordion-toggle" data-toggle="collapse" data-parent="#accordion" data-target="#collapseChanges">
|
<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">
|
<span class="change-count added" ng-show="currentImageChanges.added.length > 0" title="Files Added">
|
||||||
<i class="icon-plus-sign-alt"></i>
|
<i class="icon-plus-sign-alt"></i>
|
||||||
|
@ -156,7 +156,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="more-changes" ng-show="getMoreCount(currentImageChanges) > 0">
|
<div class="more-changes" ng-show="getMoreCount(currentImageChanges) > 0">
|
||||||
<a href="">And {{getMoreCount(currentImageChanges)}} more...</a>
|
<a href="{{'/repository/' + repo.namespace + '/' + repo.name + '/image/' + currentImage.id}}">And {{getMoreCount(currentImageChanges)}} more...</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
Reference in a new issue