Merge branch 'master' of github.com:coreos-inc/quay

This commit is contained in:
Joseph Schorr 2015-06-29 21:28:16 +03:00
commit 67208046bc
16 changed files with 416 additions and 134 deletions

View file

@ -2,6 +2,7 @@
The following are features that have been merged, but not yet deployed:
- Add list/table view to the repositories page (#116)
- Add ability to disable users via the superuser panel (#26)
- Add a Changelog view to the superuser panel (#186)

View file

@ -330,29 +330,39 @@ def _list_entity_robots(entity_name):
.where(User.robot == True, User.username ** (entity_name + '+%')))
class _TupleWrapper(object):
def __init__(self, data, fields):
self._data = data
self._fields = fields
def get(self, field):
return self._data[self._fields.index(field.name + ':' + field.model_class.__name__)]
class TupleSelector(object):
""" Helper class for selecting tuples from a peewee query and easily accessing
them as if they were objects.
"""
class _TupleWrapper(object):
def __init__(self, data, fields):
self._data = data
self._fields = fields
def get(self, field):
return self._data[self._fields.index(TupleSelector.tuple_reference_key(field))]
@classmethod
def tuple_reference_key(cls, field):
""" Returns a string key for referencing a field in a TupleSelector. """
if field._node_type == 'func':
return field.name + ','.join([cls.tuple_reference_key(arg) for arg in field.arguments])
if field._node_type == 'field':
return field.name + ':' + field.model_class.__name__
raise Exception('Unknown field type %s in TupleSelector' % field._node_type)
def __init__(self, query, fields):
self._query = query.select(*fields).tuples()
self._fields = [field.name + ':' + field.model_class.__name__ for field in fields]
self._fields = [TupleSelector.tuple_reference_key(field) for field in fields]
def __iter__(self):
return self._build_iterator()
def _build_iterator(self):
for tuple_data in self._query:
yield _TupleWrapper(tuple_data, self._fields)
yield TupleSelector._TupleWrapper(tuple_data, self._fields)
@ -934,22 +944,22 @@ def get_user_teams_within_org(username, organization):
User.username == username)
def get_visible_repository_count(username=None, include_public=True,
namespace=None):
query = _visible_repository_query(username=username,
include_public=include_public,
namespace=namespace)
return query.count()
def get_visible_repositories(username=None, include_public=True, page=None,
limit=None, sort=False, namespace=None, namespace_only=False):
limit=None, namespace=None, namespace_only=False,
include_actions=False, include_latest_tag=False):
fields = [Repository.name, Repository.id, Repository.description, Visibility.name,
Namespace.username]
if include_actions:
fields.append(fn.Max(RepositoryActionCount.count))
if include_latest_tag:
fields.append(fn.Max(RepositoryTag.lifetime_start_ts))
query = _visible_repository_query(username=username, include_public=include_public, page=page,
limit=limit, namespace=namespace,
select_models=[Repository, Namespace, Visibility])
if sort:
query = query.order_by(Repository.id.desc())
select_models=fields)
if limit:
query = query.limit(limit)
@ -957,7 +967,24 @@ def get_visible_repositories(username=None, include_public=True, page=None,
if namespace and namespace_only:
query = query.where(Namespace.username == namespace)
return query
if include_actions:
# Filter the join to recent entries only.
last_week = datetime.now() - timedelta(weeks=1)
join_query = ((RepositoryActionCount.repository == Repository.id) &
(RepositoryActionCount.date >= last_week))
query = (query.switch(Repository)
.join(RepositoryActionCount, JOIN_LEFT_OUTER, on=join_query)
.group_by(RepositoryActionCount.repository, Repository.name, Repository.id,
Repository.description, Visibility.name, Namespace.username))
if include_latest_tag:
query = (query.switch(Repository)
.join(RepositoryTag, JOIN_LEFT_OUTER)
.group_by(RepositoryTag.repository, Repository.name, Repository.id,
Repository.description, Visibility.name, Namespace.username))
return TupleSelector(query, fields)
def _visible_repository_query(username=None, include_public=True, limit=None,
@ -1101,7 +1128,7 @@ def get_matching_repositories(repo_term, username=None, limit=10, include_public
namespace_term = repo_term
name_term = repo_term
visible = get_visible_repositories(username, include_public=include_public)
visible = _visible_repository_query(username, include_public=include_public)
search_clauses = (Repository.name ** ('%' + name_term + '%') |
Namespace.username ** ('%' + namespace_term + '%'))

View file

@ -3,11 +3,16 @@
import logging
import json
import datetime
from datetime import timedelta
from flask import request
from data import model
from data.model import Namespace
from data.database import (Repository as RepositoryTable, Visibility, RepositoryTag,
RepositoryActionCount, fn)
from endpoints.api import (truthy_bool, format_date, nickname, log_action, validate_json_request,
require_repo_read, require_repo_write, require_repo_admin,
RepositoryParamResource, resource, query_param, parse_args, ApiResource,
@ -109,11 +114,12 @@ class RepositoryList(ApiResource):
type=truthy_bool, default=True)
@query_param('private', 'Whether to include private repositories.', type=truthy_bool,
default=True)
@query_param('sort', 'Whether to sort the results.', type=truthy_bool, default=False)
@query_param('count', 'Whether to include a count of the total number of results available.',
type=truthy_bool, default=False)
@query_param('namespace_only', 'Whether to limit only to the given namespace.',
type=truthy_bool, default=False)
@query_param('last_modified', 'Whether to include when the repository was last modified.',
type=truthy_bool, default=False)
@query_param('popularity', 'Whether to include the repository\'s popularity metric.',
type=truthy_bool, default=False)
def get(self, args):
"""Fetch the list of repositories under a variety of situations."""
username = None
@ -126,25 +132,31 @@ class RepositoryList(ApiResource):
response = {}
repo_count = None
if args['count']:
repo_count = model.get_visible_repository_count(username, include_public=args['public'],
namespace=args['namespace'])
response['count'] = repo_count
repo_query = model.get_visible_repositories(username, limit=args['limit'], page=args['page'],
include_public=args['public'], sort=args['sort'],
repo_query = model.get_visible_repositories(username,
limit=args['limit'],
page=args['page'],
include_public=args['public'],
namespace=args['namespace'],
namespace_only=args['namespace_only'])
namespace_only=args['namespace_only'],
include_latest_tag=args['last_modified'],
include_actions=args['popularity'])
def repo_view(repo_obj):
repo = {
'namespace': repo_obj.namespace_user.username,
'name': repo_obj.name,
'description': repo_obj.description,
'is_public': repo_obj.visibility.name == 'public',
'namespace': repo_obj.get(Namespace.username),
'name': repo_obj.get(RepositoryTable.name),
'description': repo_obj.get(RepositoryTable.description),
'is_public': repo_obj.get(Visibility.name) == 'public'
}
if args['last_modified']:
repo['last_modified'] = repo_obj.get(fn.Max(RepositoryTag.lifetime_start_ts))
if args['popularity']:
repo['popularity'] = repo_obj.get(fn.Max(RepositoryActionCount.count)) or 0
if get_authenticated_user():
repo['is_starred'] = repo_obj.id in star_lookup
repo['is_starred'] = repo_obj.get(RepositoryTable.id) in star_lookup
return repo
response['repositories'] = [repo_view(repo) for repo in repo_query]

View file

@ -0,0 +1,32 @@
.repo-list-table {
margin-top: 40px;
}
.repo-list-table .repo-name-icon .avatar {
margin-right: 10px;
}
.repo-list-table .repo-name-icon .namespace {
color: #444;
font-size: 85%;
}
.repo-list-table .repo-name-icon .namespace:after {
content: " / ";
}
.repo-list-table .empty {
color: #ccc;
}
.repo-list-table .last-modified {
font-size: 13px;
}
.repo-list-table .strength-indicator {
display: inline-block;
}
.repo-list-table .popularity {
line-height: 10px;
}

View file

@ -0,0 +1,42 @@
.strength-indicator .indicator-sliver {
margin: 1px;
width: 14px;
height: 3px;
border: 1px solid #D5D5D5;
transition: 0.5s ease;
}
.strength-indicator .strength-indicator-element.good .indicator-sliver {
background: green;
border: 1px solid green;
}
.strength-indicator .strength-indicator-element.fair .indicator-sliver {
background: orange;
border: 1px solid orange;
}
.strength-indicator .strength-indicator-element.fair .indicator-sliver:last-child {
border: 1px solid #D5D5D5;
background: transparent;
}
.strength-indicator .strength-indicator-element.barely .indicator-sliver {
background: rgb(255, 61, 0);
border: 1px solid rgb(255, 61, 0);
}
.strength-indicator .strength-indicator-element.barely .indicator-sliver:last-child {
border: 1px solid #D5D5D5;
background: transparent;
}
.strength-indicator .strength-indicator-element.barely .indicator-sliver:nth-child(3) {
border: 1px solid #D5D5D5;
background: transparent;
}
.strength-indicator .strength-indicator-element.poor .indicator-sliver:first-child {
border: 1px solid red;
background: red;
}

View file

@ -1,6 +1,11 @@
.repo-list .repo-list-panel {
padding: 20px;
padding-top: 0px;
padding-top: 20px;
}
.repo-list .repo-list-panel .btn-group {
float: right;
margin-bottom: 10px;
}
.repo-list .repo-list-namespaces h4 {

View file

@ -3943,54 +3943,11 @@ pre.command:before {
transition: opacity 0.5s ease;
}
.location-view .ping-tower {
.location-view .strength-indicator {
display: inline-block;
vertical-align: middle;
}
.location-view .ping-sliver {
margin: 1px;
width: 14px;
height: 3px;
border: 1px solid #D5D5D5;
transition: 0.5s ease;
}
.location-view .ping-tower.good .ping-sliver {
background: green;
border: 1px solid green;
}
.location-view .ping-tower.fair .ping-sliver {
background: orange;
border: 1px solid orange;
}
.location-view .ping-tower.fair .ping-sliver:first-child {
border: 1px solid #D5D5D5;
background: transparent;
}
.location-view .ping-tower.barely .ping-sliver {
background: rgb(255, 61, 0);
border: 1px solid rgb(255, 61, 0);
}
.location-view .ping-tower.barely .ping-sliver:first-child {
border: 1px solid #D5D5D5;
background: transparent;
}
.location-view .ping-tower.barely .ping-sliver:nth-child(2) {
border: 1px solid #D5D5D5;
background: transparent;
}
.location-view .ping-tower.poor .ping-sliver:last-child {
border: 1px solid red;
background: red;
}
/*
This is the visible area of you carousel.
Set a width here to define how much items are visible.

View file

@ -3,10 +3,5 @@
<img ng-src="{{ '/static/img/flags/' + getLocationImage(location) }}">
</span>
<span class="ping-tower" ng-class="locationPingClass">
<div class="ping-sliver"></div>
<div class="ping-sliver"></div>
<div class="ping-sliver"></div>
<div class="ping-sliver"></div>
</span>
<span class="strength-indicator" value="1000 - (locationPing || 0)" maximum="1000"></span>
</span>

View file

@ -0,0 +1,49 @@
<div class="repo-list-table-element">
<table class="co-table">
<thead>
<td class="hidden-xs"
ng-class="tablePredicateClass('full_name', options.predicate, options.reverse)">
<a href="javascript:void(0)" ng-click="orderBy('full_name')">Repository Name</a>
</td>
<td class="hidden-xs"
ng-class="tablePredicateClass('last_modified_datetime', options.predicate, options.reverse)"
style="min-width: 120px;">
<a href="javascript:void(0)" ng-click="orderBy('last_modified_datetime')">Last Modified</a>
</td>
<td class="hidden-xs"
ng-class="tablePredicateClass('popularity', options.predicate, options.reverse)"
style="min-width: 20px;">
<a href="javascript:void(0)" ng-click="orderBy('popularity')">Activity</a>
</td>
<td class="hidden-xs"
ng-class="tablePredicateClass('is_starred', options.predicate, options.reverse)"
style="width: 70px">
<a href="javascript:void(0)" ng-click="orderBy('is_starred')">Star</a>
</td>
</thead>
<tbody>
<tr ng-repeat="repository in orderedRepositories">
<td class="repo-name-icon">
<span class="avatar" size="24" data="getAvatarData(repository.namespace)"></span>
<a href="/repository/{{ repository.namespace }}/{{ repository.name }}">
<span class="namespace">{{ repository.namespace }}</span>
<span class="name">{{ repository.name }}</span>
</a>
</td>
<td class="last-modified">
<span ng-if="repository.last_modified">
{{ repository.last_modified * 1000 | amCalendar }}
</span>
<span class="empty" ng-if="!repository.last_modified">(Empty Repository)</span>
</td>
<td class="popularity hidden-xs">
<span class="strength-indicator" value="repository.popularity" maximum="maxPopularity"></span>
</td>
<td>
<span class="repo-star" repository="repository"
star-toggled="starToggled({'repository': repository})"></span></td>
</tr>
</tbody>
</table>
</div>

View file

@ -0,0 +1,6 @@
<span class="strength-indicator-element" ng-class="strengthClass">
<span class="indicator-sliver"></span>
<span class="indicator-sliver"></span>
<span class="indicator-sliver"></span>
<span class="indicator-sliver"></span>
</span>

View file

@ -30,7 +30,6 @@ angular.module('quay').directive('locationView', function () {
};
$scope.locationPing = null;
$scope.locationPingClass = null;
$scope.getLocationTooltip = function(location, ping) {
var tip = $scope.getLocationTitle(location) + '<br>';
@ -71,35 +70,6 @@ angular.module('quay').directive('locationView', function () {
if (!location) { return; }
$scope.getLocationPing(location);
});
$scope.$watch('locationPing', function(locationPing) {
if (locationPing == null) {
$scope.locationPingClass = null;
return;
}
if (locationPing < 0) {
$scope.locationPingClass = 'error';
return;
}
if (locationPing < 100) {
$scope.locationPingClass = 'good';
return;
}
if (locationPing < 250) {
$scope.locationPingClass = 'fair';
return;
}
if (locationPing < 500) {
$scope.locationPingClass = 'barely';
return;
}
$scope.locationPingClass = 'poor';
});
}
};
return directiveDefinitionObject;

View file

@ -0,0 +1,102 @@
/**
* An element which displays a table of repositories.
*/
angular.module('quay').directive('repoListTable', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/repo-list-table.html',
replace: false,
transclude: true,
restrict: 'C',
scope: {
'repositoriesResources': '=repositoriesResources',
'namespaces': '=namespaces'
},
controller: function($scope, $element, $filter) {
var orderBy = $filter('orderBy');
$scope.repositories = null;
$scope.orderedRepositories = [];
$scope.maxPopularity = 0;
$scope.options = {
'predicate': 'is_starred',
'reverse': true
};
var buildOrderedRepositories = function() {
if (!$scope.repositories) { return; }
var modifier = $scope.options.reverse ? '-' : '';
var fields = [modifier + $scope.options.predicate];
// Secondary ordering by full name.
if ($scope.options.predicate != 'full_name') {
fields.push('full_name');
}
var ordered = orderBy($scope.repositories, fields, false);
$scope.orderedRepositories = ordered;
};
$scope.tablePredicateClass = function(name, predicate, reverse) {
if (name != predicate) {
return '';
}
return 'current ' + (reverse ? 'reversed' : '');
};
$scope.orderBy = function(predicate) {
if (predicate == $scope.options.predicate) {
$scope.options.reverse = !$scope.options.reverse;
return;
}
$scope.options.reverse = false;
$scope.options.predicate = predicate;
};
$scope.getAvatarData = function(namespace) {
var found = {};
$scope.namespaces.forEach(function(current) {
if (current.name == namespace) {
found = current.avatar;
}
});
return found;
};
$scope.getStrengthClass = function(value, max, id) {
var adjusted = Math.round((value / max) * 5);
if (adjusted >= id) {
return 'active-' + adjusted;
}
return '';
};
$scope.$watch('options.predicate', buildOrderedRepositories);
$scope.$watch('options.reverse', buildOrderedRepositories);
$scope.$watch('repositoriesResources', function(resources) {
$scope.repositories = [];
$scope.maxPopularity = 0;
resources.forEach(function(resource) {
(resource.value || []).forEach(function(repository) {
var repositoryInfo = $.extend(repository, {
'full_name': repository.namespace + '/' + repository.name,
'last_modified_datetime': (new Date(repository.last_modified || 0)).valueOf() * (-1)
});
$scope.repositories.push(repositoryInfo);
$scope.maxPopularity = Math.max($scope.maxPopularity, repository.popularity);
});
});
buildOrderedRepositories();
}, /* deep */ true);
}
};
return directiveDefinitionObject;
});

View file

@ -0,0 +1,54 @@
/**
* An element which displays the strength of a value (like a signal indicator on a cell phone).
*/
angular.module('quay').directive('strengthIndicator', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/strength-indicator.html',
replace: false,
transclude: true,
restrict: 'C',
scope: {
'value': '=value',
'maximum': '=maximum'
},
controller: function($scope, $element) {
$scope.strengthClass = '';
var calculateClass = function() {
if ($scope.value == null || $scope.maximum == null) {
$scope.strengthClass = '';
return;
}
var value = Math.round(($scope.value / $scope.maximum) * 4);
if (value <= 0) {
$scope.strengthClass = 'none';
return;
}
if (value <= 1) {
$scope.strengthClass = 'poor';
return;
}
if (value <= 2) {
$scope.strengthClass = 'barely';
return;
}
if (value <= 3) {
$scope.strengthClass = 'fair';
return;
}
$scope.strengthClass = 'good';
};
$scope.$watch('maximum', calculateClass);
$scope.$watch('value', calculateClass);
}
};
return directiveDefinitionObject;
});

View file

@ -16,12 +16,14 @@
}]);
function RepoListCtrl($scope, $sanitize, $q, Restangular, UserService, ApiService) {
function RepoListCtrl($scope, $sanitize, $q, Restangular, UserService, ApiService, CookieService) {
$scope.namespace = null;
$scope.page = 1;
$scope.publicPageCount = null;
$scope.allRepositories = {};
$scope.loading = true;
$scope.resources = [];
$scope.showAsList = CookieService.get('quay.repoview') == 'list';
// When loading the UserService, if the user is logged in, create a list of
// relevant namespaces and collect the relevant repositories.
@ -48,6 +50,11 @@
}
});
$scope.setShowAsList = function(value) {
$scope.showAsList = value;
CookieService.putPermanent('quay.repoview', value ? 'list' : 'grid');
};
$scope.isOrganization = function(namespace) {
return !!UserService.getOrganization(namespace);
};
@ -97,11 +104,15 @@
'public': false,
'sort': true,
'namespace': namespace.name,
'last_modified': true,
'popularity': true
};
namespace.repositories = ApiService.listReposAsResource().withOptions(options).get(function(resp) {
return resp.repositories.map(findDuplicateRepo);
});
$scope.resources.push(namespace.repositories);
});
};
}

View file

@ -52,23 +52,37 @@
<div class="col-lg-9 col-lg-pull-3 col-md-9 col-md-pull-3 col-sm-12">
<div class="repo-list-panel co-main-content-panel">
<!-- Starred Repository Listing -->
<div class="repo-list-grid" repositories-resource="starred_repositories"
starred="true"
star-toggled="starToggled(repository)">
<div class="repo-list-toggleb btn-group">
<i class="btn btn-default fa fa-th-large" ng-class="!showAsList ? 'active' : ''"
ng-click="setShowAsList(false)" title="Grid View" data-container="body" bs-tooltip></i>
<i class="btn btn-default fa fa-th-list" ng-class="showAsList ? 'active' : ''"
ng-click="setShowAsList(true)" title="List View" data-container="body" bs-tooltip></i>
</div>
<!-- User and Org Repository Listings -->
<div ng-repeat="namespace in namespaces">
<div class="repo-list-grid" repositories-resource="namespace.repositories"
starred="false" user="user" namespace="namespace"
<!-- Table View -->
<div ng-if="showAsList">
<div class="repo-list-table" repositories-resources="resources" namespaces="namespaces"></div>
</div>
<!-- Grid View -->
<div ng-if="!showAsList">
<!-- Starred Repository Listing -->
<div class="repo-list-grid" repositories-resource="starred_repositories"
starred="true"
star-toggled="starToggled(repository)">
</div>
<!-- User and Org Repository Listings -->
<div ng-repeat="namespace in namespaces">
<div class="repo-list-grid" repositories-resource="namespace.repositories"
starred="false" user="user" namespace="namespace"
star-toggled="starToggled(repository)">
</div>
</div>
</div>
</div>
</div>
</div>
</div>

View file

@ -1244,6 +1244,11 @@ class TestListRepos(ApiTestCase):
self.assertEquals(len(json['repositories']), 2)
def test_action_last_modified(self):
self.login(READ_ACCESS_USER)
json = self.getJsonResponse(RepositoryList, params=dict(last_modified=True, popularity=True))
self.assertTrue(len(json['repositories']) > 2)
class TestViewPublicRepository(ApiTestCase):
def test_normalview(self):