Merge branch 'master' into contact

Conflicts:
	static/js/controllers.js
	templates/base.html
This commit is contained in:
yackob03 2014-01-15 14:32:51 -05:00
commit 82c4c8a28b
78 changed files with 5071 additions and 1953 deletions

View file

@ -1,27 +1,26 @@
to prepare a new host:
```
sudo apt-get install software-properties-common
sudo apt-add-repository -y ppa:nginx/stable
sudo apt-get update
sudo apt-get install -y git python-virtualenv python-dev phantomjs libjpeg8 libjpeg62-dev libfreetype6 libfreetype6-dev libevent-dev
sudo apt-get install -y nginx-full
sudo apt-get install -y git python-virtualenv python-dev phantomjs libjpeg8 libjpeg62-dev libfreetype6 libfreetype6-dev libevent-dev gdebi-core
```
check out the code:
```
git clone https://bitbucket.org/yackob03/quay.git
cd quay
virtualenv --distribute venv
source venv/bin/activate
pip install -r requirements.txt
sudo gdebi --n binary_dependencies/*.deb
```
running:
```
sudo nginx -c `pwd`/nginx.conf
STACK=prod gunicorn -D --workers 4 -b unix:/tmp/gunicorn.sock --worker-class gevent -t 2000 application:application
sudo mkdir -p /mnt/nginx/ && sudo /usr/local/nginx/sbin/nginx -c `pwd`/nginx.conf
STACK=prod gunicorn -c gunicorn_config.py application:application
```
start the workers:

View file

@ -34,7 +34,7 @@ def process_basic_auth(auth):
if len(credentials) != 2:
logger.debug('Invalid basic auth credential format.')
if credentials[0] == '$token':
elif credentials[0] == '$token':
# Use as token auth
try:
token = model.load_token_data(credentials[1])
@ -77,7 +77,6 @@ def process_basic_auth(auth):
# We weren't able to authenticate via basic auth.
logger.debug('Basic auth present but could not be validated.')
abort(401)
def process_token(auth):

Binary file not shown.

View file

@ -22,5 +22,5 @@ RUN venv/bin/pip install -r requirements.txt
VOLUME /var/lib/docker
EXPOSE 5002:5002
EXPOSE 5002
CMD startserver

View file

@ -6,10 +6,9 @@ import re
import requests
import json
from flask import Flask, jsonify, url_for, abort, make_response
from flask import Flask, jsonify, abort, make_response
from zipfile import ZipFile
from tempfile import TemporaryFile, mkdtemp
from uuid import uuid4
from multiprocessing.pool import ThreadPool
from base64 import b64encode
@ -53,16 +52,24 @@ def prepare_dockerfile(request_file):
return build_dir
def total_completion(statuses, total_images):
percentage_with_sizes = float(len(statuses.values()))/total_images
sent_bytes = sum([status[u'current'] for status in statuses.values()])
total_bytes = sum([status[u'total'] for status in statuses.values()])
return float(sent_bytes)/total_bytes*percentage_with_sizes
def build_image(build_dir, tag_name, num_steps, result_object):
try:
logger.debug('Starting build.')
docker_cl = docker.Client(version='1.5')
docker_cl = docker.Client(timeout=1200)
result_object['status'] = 'building'
build_status = docker_cl.build(path=build_dir, tag=tag_name)
build_status = docker_cl.build(path=build_dir, tag=tag_name, stream=True)
current_step = 0
built_image = None
for status in build_status:
# logger.debug('Status: %s', str(status))
step_increment = re.search(r'Step ([0-9]+) :', status)
if step_increment:
current_step = int(step_increment.group(1))
@ -84,40 +91,36 @@ def build_image(build_dir, tag_name, num_steps, result_object):
result_object['message'] = 'Unable to build dockerfile.'
return
history = docker_cl.history(built_image)
history = json.loads(docker_cl.history(built_image))
num_images = len(history)
result_object['total_images'] = num_images
result_object['status'] = 'pushing'
logger.debug('Pushing to tag name: %s' % tag_name)
resp = docker_cl.push(tag_name)
resp = docker_cl.push(tag_name, stream=True)
current_image = 0
image_progress = 0
for status in resp:
for status_str in resp:
status = json.loads(status_str)
logger.debug('Status: %s', status_str)
if u'status' in status:
status_msg = status[u'status']
next_image = r'(Pushing|Image) [a-z0-9]+( already pushed, skipping)?$'
match = re.match(next_image, status_msg)
if match:
current_image += 1
image_progress = 0
logger.debug('Now pushing image %s/%s' %
(current_image, num_images))
elif status_msg == u'Pushing' and u'progress' in status:
percent = r'\(([0-9]+)%\)'
match = re.search(percent, status[u'progress'])
if match:
image_progress = int(match.group(1))
if status_msg == 'Pushing':
if u'progressDetail' in status and u'id' in status:
image_id = status[u'id']
detail = status[u'progressDetail']
result_object['current_image'] = current_image
result_object['image_completion_percent'] = image_progress
if u'current' in detail and 'total' in detail:
images = result_object['image_completion']
images[image_id] = detail
result_object['push_completion'] = total_completion(images,
num_images)
elif u'errorDetail' in status:
result_object['status'] = 'error'
if u'message' in status[u'errorDetail']:
result_object['message'] = status[u'errorDetail'][u'message']
result_object['message'] = str(status[u'errorDetail'][u'message'])
return
result_object['status'] = 'complete'
@ -133,15 +136,14 @@ MIME_PROCESSORS = {
'application/octet-stream': prepare_dockerfile,
}
# If this format it should also be changed in the api method get_repo_builds
build = {
'total_commands': None,
'total_images': None,
'current_command': None,
'current_image': None,
'image_completion_percent': None,
'push_completion': 0.0,
'status': 'waiting',
'message': None,
'image_completion': {},
}
pool = ThreadPool(1)

View file

@ -127,6 +127,7 @@ class DigitalOceanConfig(object):
DO_SSH_KEY_ID = '46986'
DO_SSH_PRIVATE_KEY_FILENAME = 'certs/digital_ocean'
DO_ALLOWED_REGIONS = {1, 4}
DO_DOCKER_IMAGE = 1341147
class BuildNodeConfig(object):

View file

@ -167,7 +167,9 @@ class Image(BaseModel):
checksum = CharField(null=True)
created = DateTimeField(null=True)
comment = TextField(null=True)
command = TextField(null=True)
repository = ForeignKeyField(Repository)
image_size = BigIntegerField(null=True)
# '/' separated list of ancestory ids, e.g. /1/2/6/7/10/
ancestors = CharField(index=True, default='/', max_length=64535)

View file

@ -6,12 +6,14 @@ import operator
import json
from datetime import timedelta
from database import *
from util.validation import *
from util.names import format_robot_username
logger = logging.getLogger(__name__)
store = app.config['STORAGE']
class DataModelException(Exception):
@ -306,12 +308,18 @@ def create_federated_user(username, email, service_name, service_id):
return new_user
def attach_federated_login(user, service_name, service_id):
service = LoginService.get(LoginService.name == service_name)
FederatedLogin.create(user=user, service=service, service_ident=service_id)
return user
def verify_federated_login(service_name, service_id):
selected = FederatedLogin.select(FederatedLogin, User)
with_service = selected.join(LoginService)
with_user = with_service.switch(FederatedLogin).join(User)
found = with_user.where(FederatedLogin.service_ident == service_id,
LoginService.name == service_name)
LoginService.name == service_name)
found_list = list(found)
@ -321,14 +329,25 @@ def verify_federated_login(service_name, service_id):
return None
def list_federated_logins(user):
selected = FederatedLogin.select(FederatedLogin.service_ident,
LoginService.name)
joined = selected.join(LoginService)
return joined.where(LoginService.name != 'quayrobot',
FederatedLogin.user == user)
def create_confirm_email_code(user):
code = EmailConfirmation.create(user=user, email_confirm=True)
return code
def confirm_user_email(code):
code = EmailConfirmation.get(EmailConfirmation.code == code,
EmailConfirmation.email_confirm == True)
try:
code = EmailConfirmation.get(EmailConfirmation.code == code,
EmailConfirmation.email_confirm == True)
except EmailConfirmation.DoesNotExist:
raise DataModelException('Invalid email confirmation code.')
user = code.user
user.verified = True
@ -345,6 +364,9 @@ def create_reset_password_email_code(email):
except User.DoesNotExist:
raise InvalidEmailAddressException('Email address was not found.');
if user.organization:
raise InvalidEmailAddressException('Organizations can not have passwords.')
code = EmailConfirmation.create(user=user, pw_reset=True)
return code
@ -383,7 +405,6 @@ def get_matching_teams(team_prefix, organization):
def get_matching_users(username_prefix, robot_namespace=None,
organization=None):
Org = User.alias()
direct_user_query = (User.username ** (username_prefix + '%') &
(User.organization == False) & (User.robot == False))
@ -393,14 +414,16 @@ def get_matching_users(username_prefix, robot_namespace=None,
(User.username ** (robot_prefix + '%') &
(User.robot == True)))
query = User.select(User.username, Org.username, User.robot).where(direct_user_query)
query = (User
.select(User.username, fn.Sum(Team.id), User.robot)
.group_by(User.username)
.where(direct_user_query))
if organization:
with_team = query.join(TeamMember, JOIN_LEFT_OUTER).join(Team,
JOIN_LEFT_OUTER)
with_org = with_team.join(Org, JOIN_LEFT_OUTER,
on=(Org.id == Team.organization))
query = with_org.where((Org.id == organization) | (Org.id >> None))
query = (query
.join(TeamMember, JOIN_LEFT_OUTER)
.join(Team, JOIN_LEFT_OUTER, on=((Team.id == TeamMember.team) &
(Team.organization == organization))))
class MatchingUserResult(object):
@ -408,7 +431,7 @@ def get_matching_users(username_prefix, robot_namespace=None,
self.username = args[0]
self.is_robot = args[2]
if organization:
self.is_org_member = (args[1] == organization.username)
self.is_org_member = (args[1] != None)
else:
self.is_org_member = None
@ -489,13 +512,23 @@ def get_user_teams_within_org(username, organization):
User.username == username)
def get_visible_repositories(username=None, include_public=True, limit=None,
def get_visible_repository_count(username=None, include_public=True, sort=False, namespace=None):
return get_visible_repository_internal(username=username, include_public=include_public,
sort=sort, namespace=namespace, get_count=True)
def get_visible_repositories(username=None, include_public=True, page=None, limit=None,
sort=False, namespace=None):
return get_visible_repository_internal(username=username, include_public=include_public, page=page,
limit=limit, sort=sort, namespace=namespace, get_count=False)
def get_visible_repository_internal(username=None, include_public=True, limit=None, page=None,
sort=False, namespace=None, get_count=False):
if not username and not include_public:
return []
query = (Repository
.select(Repository, Visibility)
.select() # Note: We need to leave this blank for the get_count case. Otherwise, MySQL/RDS complains.
.distinct()
.join(Visibility)
.switch(Repository)
@ -543,10 +576,19 @@ def get_visible_repositories(username=None, include_public=True, limit=None,
else:
where_clause = new_clause
if limit:
query.limit(limit)
if sort:
query = query.order_by(Repository.description.desc())
return query.where(where_clause)
if page:
query = query.paginate(page, limit)
elif limit:
query = query.limit(limit)
where = query.where(where_clause)
if get_count:
return where.count()
else:
return where
def get_matching_repositories(repo_term, username=None):
@ -702,8 +744,23 @@ def set_image_checksum(docker_image_id, repository, checksum):
return fetched
def set_image_size(docker_image_id, namespace_name, repository_name, image_size):
joined = Image.select().join(Repository)
image_list = list(joined.where(Repository.name == repository_name,
Repository.namespace == namespace_name,
Image.docker_image_id == docker_image_id))
if not image_list:
raise DataModelException('No image with specified id and repository')
fetched = image_list[0]
fetched.image_size = image_size
fetched.save()
return fetched
def set_image_metadata(docker_image_id, namespace_name, repository_name,
created_date_str, comment, parent=None):
created_date_str, comment, command, parent=None):
joined = Image.select().join(Repository)
image_list = list(joined.where(Repository.name == repository_name,
Repository.namespace == namespace_name,
@ -715,6 +772,7 @@ def set_image_metadata(docker_image_id, namespace_name, repository_name,
fetched = image_list[0]
fetched.created = dateutil.parser.parse(created_date_str)
fetched.comment = comment
fetched.command = command
if parent:
fetched.ancestors = '%s%s/' % (parent.ancestors, parent.id)
@ -736,6 +794,50 @@ def list_repository_tags(namespace_name, repository_name):
return with_image.where(Repository.name == repository_name,
Repository.namespace == namespace_name)
def delete_tag_and_images(namespace_name, repository_name, tag_name):
all_images = get_repository_images(namespace_name, repository_name)
all_tags = list_repository_tags(namespace_name, repository_name)
# Find the tag's information.
found_tag = None
for tag in all_tags:
if tag.name == tag_name:
found_tag = tag
break
if not found_tag:
return
# Build the set of database IDs corresponding to the tag's ancestor images,
# as well as the tag's image itself.
tag_image_ids = set(found_tag.image.ancestors.split('/'))
tag_image_ids.add(str(found_tag.image.id))
# Filter out any images that belong to any other tags.
for tag in all_tags:
if tag.name != tag_name:
# Remove all ancestors of the tag.
tag_image_ids = tag_image_ids - set(tag.image.ancestors.split('/'))
# Remove the current image ID.
tag_image_ids.discard(str(tag.image.id))
# Find all the images that belong to the tag.
tag_images = [image for image in all_images
if str(image.id) in tag_image_ids]
# Delete the tag found.
found_tag.delete_instance()
# Delete the images found.
for image in tag_images:
image.delete_instance()
repository_path = store.image_path(namespace_name, repository_name,
image.docker_image_id)
logger.debug('Recursively deleting image path: %s' % repository_path)
store.remove(repository_path)
def get_tag_image(namespace_name, repository_name, tag_name):
joined = Image.select().join(RepositoryTag).join(Repository)
@ -933,6 +1035,11 @@ def purge_repository(namespace_name, repository_name):
Repository.namespace == namespace_name)
fetched.delete_instance(recursive=True)
repository_path = store.repository_namespace_path(namespace_name,
repository_name)
logger.debug('Recursively deleting path: %s' % repository_path)
store.remove(repository_path)
def get_private_repo_count(username):
joined = Repository.select().join(Visibility)

View file

@ -1,20 +1,13 @@
import json
import itertools
USER_PLANS = [
{
'title': 'Open Source',
'price': 0,
'privateRepos': 0,
'stripeId': 'free',
'audience': 'Share with the world',
},
PLANS = [
# Deprecated Plans
{
'title': 'Micro',
'price': 700,
'privateRepos': 5,
'stripeId': 'micro',
'audience': 'For smaller teams',
'bus_features': False,
'deprecated': True,
},
{
'title': 'Basic',
@ -22,6 +15,8 @@ USER_PLANS = [
'privateRepos': 10,
'stripeId': 'small',
'audience': 'For your basic team',
'bus_features': False,
'deprecated': True,
},
{
'title': 'Medium',
@ -29,6 +24,8 @@ USER_PLANS = [
'privateRepos': 20,
'stripeId': 'medium',
'audience': 'For medium teams',
'bus_features': False,
'deprecated': True,
},
{
'title': 'Large',
@ -36,16 +33,28 @@ USER_PLANS = [
'privateRepos': 50,
'stripeId': 'large',
'audience': 'For larger teams',
'bus_features': False,
'deprecated': True,
},
]
BUSINESS_PLANS = [
# Active plans
{
'title': 'Open Source',
'price': 0,
'privateRepos': 0,
'stripeId': 'bus-free',
'stripeId': 'free',
'audience': 'Committment to FOSS',
'bus_features': False,
'deprecated': False,
},
{
'title': 'Personal',
'price': 1200,
'privateRepos': 5,
'stripeId': 'personal',
'audience': 'Individuals',
'bus_features': False,
'deprecated': False,
},
{
'title': 'Skiff',
@ -53,6 +62,8 @@ BUSINESS_PLANS = [
'privateRepos': 10,
'stripeId': 'bus-micro',
'audience': 'For startups',
'bus_features': True,
'deprecated': False,
},
{
'title': 'Yacht',
@ -60,6 +71,8 @@ BUSINESS_PLANS = [
'privateRepos': 20,
'stripeId': 'bus-small',
'audience': 'For small businesses',
'bus_features': True,
'deprecated': False,
},
{
'title': 'Freighter',
@ -67,6 +80,8 @@ BUSINESS_PLANS = [
'privateRepos': 50,
'stripeId': 'bus-medium',
'audience': 'For normal businesses',
'bus_features': True,
'deprecated': False,
},
{
'title': 'Tanker',
@ -74,14 +89,16 @@ BUSINESS_PLANS = [
'privateRepos': 125,
'stripeId': 'bus-large',
'audience': 'For large businesses',
'bus_features': True,
'deprecated': False,
},
]
def get_plan(id):
def get_plan(plan_id):
""" Returns the plan with the given ID or None if none. """
for plan in itertools.chain(USER_PLANS, BUSINESS_PLANS):
if plan['stripeId'] == id:
for plan in PLANS:
if plan['stripeId'] == plan_id:
return plan
return None

View file

@ -12,7 +12,7 @@ from collections import defaultdict
from data import model
from data.queue import dockerfile_build_queue
from data.plans import USER_PLANS, BUSINESS_PLANS, get_plan
from data.plans import PLANS, get_plan
from app import app
from util.email import send_confirmation_email, send_recovery_email
from util.names import parse_repository_name, format_robot_username
@ -25,20 +25,53 @@ from auth.permissions import (ReadRepositoryPermission,
AdministerOrganizationPermission,
OrganizationMemberPermission,
ViewTeamPermission)
from endpoints import registry
from endpoints.web import common_login
from endpoints.common import common_login
from util.cache import cache_control
from datetime import datetime, timedelta
store = app.config['STORAGE']
user_files = app.config['USERFILES']
logger = logging.getLogger(__name__)
route_data = None
def get_route_data():
global route_data
if route_data:
return route_data
routes = []
for rule in app.url_map.iter_rules():
if rule.rule.startswith('/api/'):
endpoint_method = globals()[rule.endpoint]
is_internal = '__internal_call' in dir(endpoint_method)
is_org_api = '__user_call' in dir(endpoint_method)
methods = list(rule.methods.difference(['HEAD', 'OPTIONS']))
route = {
'name': rule.endpoint,
'methods': methods,
'path': rule.rule,
'parameters': list(rule.arguments)
}
if is_org_api:
route['user_method'] = endpoint_method.__user_call
routes.append(route)
route_data = {
'endpoints': routes
}
return route_data
def log_action(kind, user_or_orgname, metadata={}, repo=None):
performer = current_user.db_user()
model.log_action(kind, user_or_orgname, performer=performer, ip=request.remote_addr,
metadata=metadata, repository=repo)
model.log_action(kind, user_or_orgname, performer=performer,
ip=request.remote_addr, metadata=metadata, repository=repo)
def api_login_required(f):
@wraps(f)
@ -58,26 +91,51 @@ def api_login_required(f):
return decorated_view
def internal_api_call(f):
@wraps(f)
def decorated_view(*args, **kwargs):
return f(*args, **kwargs)
decorated_view.__internal_call = True
return decorated_view
def org_api_call(user_call_name):
def internal_decorator(f):
@wraps(f)
def decorated_view(*args, **kwargs):
return f(*args, **kwargs)
decorated_view.__user_call = user_call_name
return decorated_view
return internal_decorator
@app.errorhandler(model.DataModelException)
def handle_dme(ex):
return make_response(ex.message, 400)
@app.errorhandler(KeyError)
def handle_dme(ex):
def handle_dme_key_error(ex):
return make_response(ex.message, 400)
@app.route('/api/discovery')
def discovery():
return jsonify(get_route_data())
@app.route('/api/')
@internal_api_call
def welcome():
return make_response('welcome', 200)
@app.route('/api/plans/')
def plans_list():
def list_plans():
return jsonify({
'user': USER_PLANS,
'business': BUSINESS_PLANS,
'plans': PLANS,
})
@ -93,6 +151,14 @@ def user_view(user):
organizations = model.get_user_organizations(user.username)
def login_view(login):
return {
'service': login.service.name,
'service_identifier': login.service_ident,
}
logins = model.list_federated_logins(user)
return {
'verified': user.verified,
'anonymous': False,
@ -101,12 +167,14 @@ def user_view(user):
'gravatar': compute_hash(user.email),
'askForPassword': user.password_hash is None,
'organizations': [org_view(o) for o in organizations],
'logins': [login_view(login) for login in logins],
'can_create_repo': True,
'invoice_email': user.invoice_email
}
@app.route('/api/user/', methods=['GET'])
@internal_api_call
def get_logged_in_user():
if current_user.is_anonymous():
return jsonify({'anonymous': True})
@ -118,8 +186,30 @@ def get_logged_in_user():
return jsonify(user_view(user))
@app.route('/api/user/private', methods=['GET'])
@api_login_required
@internal_api_call
def get_user_private_count():
user = current_user.db_user()
private_repos = model.get_private_repo_count(user.username)
repos_allowed = 0
if user.stripe_id:
cus = stripe.Customer.retrieve(user.stripe_id)
if cus.subscription:
plan = get_plan(cus.subscription.plan.id)
if plan:
repos_allowed = plan['privateRepos']
return jsonify({
'privateCount': private_repos,
'reposAllowed': repos_allowed
})
@app.route('/api/user/convert', methods=['POST'])
@api_login_required
@internal_api_call
def convert_user_to_organization():
user = current_user.db_user()
convert_data = request.get_json()
@ -144,7 +234,7 @@ def convert_user_to_organization():
# Subscribe the organization to the new plan.
plan = convert_data['plan']
subscribe(user, plan, None, BUSINESS_PLANS)
subscribe(user, plan, None, True) # Require business plans
# Convert the user to an organization.
model.convert_user_to_organization(user, model.get_user(admin_username))
@ -152,11 +242,11 @@ def convert_user_to_organization():
# And finally login with the admin credentials.
return conduct_signin(admin_username, admin_password)
@app.route('/api/user/', methods=['PUT'])
@api_login_required
@internal_api_call
def change_user_details():
user = current_user.db_user()
@ -183,7 +273,8 @@ def change_user_details():
@app.route('/api/user/', methods=['POST'])
def create_user_api():
@internal_api_call
def create_new_user():
user_data = request.get_json()
existing_user = model.get_user(user_data['username'])
@ -209,7 +300,8 @@ def create_user_api():
@app.route('/api/signin', methods=['POST'])
def signin_api():
@internal_api_call
def signin_user():
signin_data = request.get_json()
username = signin_data['username']
@ -243,6 +335,7 @@ def conduct_signin(username, password):
@app.route("/api/signout", methods=['POST'])
@api_login_required
@internal_api_call
def logout():
logout_user()
identity_changed.send(app, identity=AnonymousIdentity())
@ -250,7 +343,8 @@ def logout():
@app.route("/api/recovery", methods=['POST'])
def send_recovery():
@internal_api_call
def request_recovery_email():
email = request.get_json()['email']
code = model.create_reset_password_email_code(email)
send_recovery_email(email, code.code)
@ -272,9 +366,10 @@ def get_matching_users(prefix):
def get_matching_entities(prefix):
teams = []
namespace_name = request.args.get('namespace', None)
namespace_name = request.args.get('namespace', '')
robot_namespace = None
organization = None
try:
organization = model.get_organization(namespace_name)
@ -308,7 +403,7 @@ def get_matching_entities(prefix):
'is_robot': user.is_robot,
}
if user.is_org_member is not None:
if organization is not None:
user_json['is_org_member'] = user.is_robot or user.is_org_member
return user_json
@ -334,7 +429,8 @@ def team_view(orgname, team):
@app.route('/api/organization/', methods=['POST'])
@api_login_required
def create_organization_api():
@internal_api_call
def create_organization():
org_data = request.get_json()
existing = None
@ -398,6 +494,7 @@ def get_organization(orgname):
@app.route('/api/organization/<orgname>', methods=['PUT'])
@api_login_required
@org_api_call('change_user_details')
def change_organization_details(orgname):
permission = AdministerOrganizationPermission(orgname)
if permission.can():
@ -406,7 +503,7 @@ def change_organization_details(orgname):
except model.InvalidOrganizationException:
abort(404)
org_data = request.get_json();
org_data = request.get_json()
if 'invoice_email' in org_data:
logger.debug('Changing invoice_email for organization: %s', org.username)
model.change_invoice_email(org, org_data['invoice_email'])
@ -456,7 +553,7 @@ def get_organization_member(orgname, membername):
abort(404)
member_dict = None
member_teams = model.get_organization_members_with_teams(org, membername = membername)
member_teams = model.get_organization_members_with_teams(org, membername=membername)
for member in member_teams:
if not member_dict:
member_dict = {'username': member.user.username,
@ -475,6 +572,7 @@ def get_organization_member(orgname, membername):
@app.route('/api/organization/<orgname>/private', methods=['GET'])
@api_login_required
@internal_api_call
def get_organization_private_allowed(orgname):
permission = CreateRepositoryPermission(orgname)
if permission.can():
@ -484,7 +582,11 @@ def get_organization_private_allowed(orgname):
if organization.stripe_id:
cus = stripe.Customer.retrieve(organization.stripe_id)
if cus.subscription:
repos_allowed = get_plan(cus.subscription.plan.id)
repos_allowed = 0
plan = get_plan(cus.subscription.plan.id)
if plan:
repos_allowed = plan['privateRepos']
return jsonify({
'privateAllowed': (private_repos < repos_allowed)
})
@ -526,17 +628,20 @@ def update_organization_team(orgname, teamname):
log_action('org_create_team', orgname, {'team': teamname})
if is_existing:
if 'description' in details and team.description != details['description']:
if ('description' in details and
team.description != details['description']):
team.description = details['description']
team.save()
log_action('org_set_team_description', orgname, {'team': teamname, 'description': team.description})
log_action('org_set_team_description', orgname,
{'team': teamname, 'description': team.description})
if 'role' in details:
role = model.get_team_org_role(team).name
if role != details['role']:
team = model.set_team_org_permission(team, details['role'],
current_user.db_user().username)
log_action('org_set_team_role', orgname, {'team': teamname, 'role': details['role']})
log_action('org_set_team_role', orgname,
{'team': teamname, 'role': details['role']})
resp = jsonify(team_view(orgname, team))
if not is_existing:
@ -604,7 +709,8 @@ def update_organization_team_member(orgname, teamname, membername):
# Add the user to the team.
model.add_user_to_team(user, team)
log_action('org_add_team_member', orgname, {'member': membername, 'team': teamname})
log_action('org_add_team_member', orgname,
{'member': membername, 'team': teamname})
return jsonify(member_view(user))
abort(403)
@ -619,7 +725,8 @@ def delete_organization_team_member(orgname, teamname, membername):
# Remote the user from the team.
invoking_user = current_user.db_user().username
model.remove_user_from_team(orgname, teamname, membername, invoking_user)
log_action('org_remove_team_member', orgname, {'member': membername, 'team': teamname})
log_action('org_remove_team_member', orgname,
{'member': membername, 'team': teamname})
return make_response('Deleted', 204)
abort(403)
@ -627,7 +734,7 @@ def delete_organization_team_member(orgname, teamname, membername):
@app.route('/api/repository', methods=['POST'])
@api_login_required
def create_repo_api():
def create_repo():
owner = current_user.db_user()
req = request.get_json()
namespace_name = req['namespace'] if 'namespace' in req else owner.username
@ -648,7 +755,9 @@ def create_repo_api():
repo.description = req['description']
repo.save()
log_action('create_repo', namespace_name, {'repo': repository_name, 'namespace': namespace_name}, repo=repo)
log_action('create_repo', namespace_name,
{'repo': repository_name, 'namespace': namespace_name},
repo=repo)
return jsonify({
'namespace': namespace_name,
'name': repository_name
@ -658,7 +767,7 @@ def create_repo_api():
@app.route('/api/find/repository', methods=['GET'])
def match_repos_api():
def find_repos():
prefix = request.args.get('query', '')
def repo_view(repo):
@ -681,7 +790,7 @@ def match_repos_api():
@app.route('/api/repository/', methods=['GET'])
def list_repos_api():
def list_repos():
def repo_view(repo_obj):
return {
'namespace': repo_obj.namespace,
@ -690,11 +799,13 @@ def list_repos_api():
'is_public': repo_obj.visibility.name == 'public',
}
page = request.args.get('page', None)
limit = request.args.get('limit', None)
namespace_filter = request.args.get('namespace', None)
include_public = request.args.get('public', 'true')
include_private = request.args.get('private', 'true')
sort = request.args.get('sort', 'false')
include_count = request.args.get('count', 'false')
try:
limit = int(limit) if limit else None
@ -703,28 +814,45 @@ def list_repos_api():
include_public = include_public == 'true'
include_private = include_private == 'true'
include_count = include_count == 'true'
sort = sort == 'true'
if page:
try:
page = int(page)
except:
page = None
username = None
if current_user.is_authenticated() and include_private:
username = current_user.db_user().username
repo_query = model.get_visible_repositories(username, limit=limit,
repo_count = None
if include_count:
repo_count = model.get_visible_repository_count(username,
include_public=include_public,
sort=sort,
namespace=namespace_filter)
repo_query = model.get_visible_repositories(username, limit=limit, page=page,
include_public=include_public,
sort=sort,
namespace=namespace_filter)
repos = [repo_view(repo) for repo in repo_query]
response = {
'repositories': repos
}
if include_count:
response['count'] = repo_count
return jsonify(response)
@app.route('/api/repository/<path:repository>', methods=['PUT'])
@api_login_required
@parse_repository_name
def update_repo_api(namespace, repository):
def update_repo(namespace, repository):
permission = ModifyRepositoryPermission(namespace, repository)
if permission.can():
repo = model.get_repository(namespace, repository)
@ -733,7 +861,8 @@ def update_repo_api(namespace, repository):
repo.description = values['description']
repo.save()
log_action('set_repo_description', namespace, {'repo': repository, 'description': values['description']},
log_action('set_repo_description', namespace,
{'repo': repository, 'description': values['description']},
repo=repo)
return jsonify({
'success': True
@ -746,14 +875,15 @@ def update_repo_api(namespace, repository):
methods=['POST'])
@api_login_required
@parse_repository_name
def change_repo_visibility_api(namespace, repository):
def change_repo_visibility(namespace, repository):
permission = AdministerRepositoryPermission(namespace, repository)
if permission.can():
repo = model.get_repository(namespace, repository)
if repo:
values = request.get_json()
model.set_repository_visibility(repo, values['visibility'])
log_action('change_repo_visibility', namespace, {'repo': repository, 'visibility': values['visibility']},
log_action('change_repo_visibility', namespace,
{'repo': repository, 'visibility': values['visibility']},
repo=repo)
return jsonify({
'success': True
@ -769,8 +899,8 @@ def delete_repository(namespace, repository):
permission = AdministerRepositoryPermission(namespace, repository)
if permission.can():
model.purge_repository(namespace, repository)
registry.delete_repository_storage(namespace, repository)
log_action('delete_repo', namespace, {'repo': repository, 'namespace': namespace})
log_action('delete_repo', namespace,
{'repo': repository, 'namespace': namespace})
return make_response('Deleted', 204)
abort(403)
@ -781,14 +911,16 @@ def image_view(image):
'id': image.docker_image_id,
'created': image.created,
'comment': image.comment,
'command': json.loads(image.command) if image.command else None,
'ancestors': image.ancestors,
'dbid': image.id,
'size': image.image_size,
}
@app.route('/api/repository/<path:repository>', methods=['GET'])
@parse_repository_name
def get_repo_api(namespace, repository):
def get_repo(namespace, repository):
logger.debug('Get repo: %s/%s' % (namespace, repository))
def tag_view(tag):
@ -849,15 +981,15 @@ def get_repo_builds(namespace, repository):
return node_status
# If there was no status url, do the best we can
# The format of this block should mirror that of the buildserver.
return {
'id': build_obj.id,
'total_commands': None,
'total_images': None,
'current_command': None,
'current_image': None,
'image_completion_percent': None,
'push_completion': 0.0,
'status': build_obj.phase,
'message': None,
'image_completion': {},
}
builds = model.list_repository_builds(namespace, repository)
@ -887,7 +1019,8 @@ def request_repo_build(namespace, repository):
dockerfile_build_queue.put(json.dumps({'build_id': build_request.id}))
log_action('build_dockerfile', namespace,
{'repo': repository, 'namespace': namespace, 'fileid': dockerfile_id}, repo=repo)
{'repo': repository, 'namespace': namespace,
'fileid': dockerfile_id}, repo=repo)
resp = jsonify({
'started': True
@ -918,7 +1051,8 @@ def create_webhook(namespace, repository):
resp.headers['Location'] = url_for('get_webhook', repository=repo_string,
public_id=webhook.public_id)
log_action('add_repo_webhook', namespace,
{'repo': repository, 'webhook_id': webhook.public_id}, repo=repo)
{'repo': repository, 'webhook_id': webhook.public_id},
repo=repo)
return resp
abort(403) # Permissions denied
@ -969,6 +1103,7 @@ def delete_webhook(namespace, repository, public_id):
@app.route('/api/filedrop/', methods=['POST'])
@api_login_required
@internal_api_call
def get_filedrop_url():
mime_type = request.get_json()['mimeType']
(url, file_id) = user_files.prepare_for_drop(mime_type)
@ -1051,6 +1186,24 @@ def get_image_changes(namespace, repository, image_id):
abort(403)
@app.route('/api/repository/<path:repository>/tag/<tag>',
methods=['DELETE'])
@parse_repository_name
def delete_full_tag(namespace, repository, tag):
permission = AdministerRepositoryPermission(namespace, repository)
if permission.can():
model.delete_tag_and_images(namespace, repository, tag)
username = current_user.db_user().username
log_action('delete_tag', namespace,
{'username': username, 'repo': repository, 'tag': tag},
repo=model.get_repository(namespace, repository))
return make_response('Deleted', 204)
abort(403) # Permission denied
@app.route('/api/repository/<path:repository>/tag/<tag>/images',
methods=['GET'])
@parse_repository_name
@ -1118,7 +1271,8 @@ def list_repo_user_permissions(namespace, repository):
current_func = role_view_func
def wrapped_role_org_view(repo_perm):
return wrap_role_view_org(current_func(repo_perm), repo_perm.user, org_members)
return wrap_role_view_org(current_func(repo_perm), repo_perm.user,
org_members)
role_view_func = wrapped_role_org_view
@ -1203,7 +1357,8 @@ def change_user_permissions(namespace, repository, username):
return error_resp
log_action('change_repo_permission', namespace,
{'username': username, 'repo': repository, 'role': new_permission['role']},
{'username': username, 'repo': repository,
'role': new_permission['role']},
repo=model.get_repository(namespace, repository))
resp = jsonify(perm_view)
@ -1230,7 +1385,8 @@ def change_team_permissions(namespace, repository, teamname):
new_permission['role'])
log_action('change_repo_permission', namespace,
{'team': teamname, 'repo': repository, 'role': new_permission['role']},
{'team': teamname, 'repo': repository,
'role': new_permission['role']},
repo=model.get_repository(namespace, repository))
resp = jsonify(role_view(perm))
@ -1257,7 +1413,8 @@ def delete_user_permissions(namespace, repository, username):
error_resp.status_code = 400
return error_resp
log_action('delete_repo_permission', namespace, {'username': username, 'repo': repository},
log_action('delete_repo_permission', namespace,
{'username': username, 'repo': repository},
repo=model.get_repository(namespace, repository))
return make_response('Deleted', 204)
@ -1274,7 +1431,8 @@ def delete_team_permissions(namespace, repository, teamname):
if permission.can():
model.delete_team_permission(teamname, namespace, repository)
log_action('delete_repo_permission', namespace, {'team': teamname, 'repo': repository},
log_action('delete_repo_permission', namespace,
{'team': teamname, 'repo': repository},
repo=model.get_repository(namespace, repository))
return make_response('Deleted', 204)
@ -1328,7 +1486,8 @@ def create_token(namespace, repository):
token = model.create_delegate_token(namespace, repository,
token_params['friendlyName'])
log_action('add_repo_accesstoken', namespace, {'repo': repository, 'token': token_params['friendlyName']},
log_action('add_repo_accesstoken', namespace,
{'repo': repository, 'token': token_params['friendlyName']},
repo = model.get_repository(namespace, repository))
resp = jsonify(token_view(token))
@ -1353,7 +1512,8 @@ def change_token(namespace, repository, code):
new_permission['role'])
log_action('change_repo_permission', namespace,
{'repo': repository, 'token': token.friendly_name, 'code': code, 'role': new_permission['role']},
{'repo': repository, 'token': token.friendly_name, 'code': code,
'role': new_permission['role']},
repo = model.get_repository(namespace, repository))
resp = jsonify(token_view(token))
@ -1372,7 +1532,8 @@ def delete_token(namespace, repository, code):
token = model.delete_delegate_token(namespace, repository, code)
log_action('delete_repo_accesstoken', namespace,
{'repo': repository, 'token': token.friendly_name, 'code': code},
{'repo': repository, 'token': token.friendly_name,
'code': code},
repo = model.get_repository(namespace, repository))
return make_response('Deleted', 204)
@ -1391,14 +1552,17 @@ def subscription_view(stripe_subscription, used_repos):
@app.route('/api/user/card', methods=['GET'])
@api_login_required
def get_user_card_api():
@internal_api_call
def get_user_card():
user = current_user.db_user()
return get_card(user)
@app.route('/api/organization/<orgname>/card', methods=['GET'])
@api_login_required
def get_org_card_api(orgname):
@internal_api_call
@org_api_call('get_user_card')
def get_org_card(orgname):
permission = AdministerOrganizationPermission(orgname)
if permission.can():
organization = model.get_organization(orgname)
@ -1409,7 +1573,8 @@ def get_org_card_api(orgname):
@app.route('/api/user/card', methods=['POST'])
@api_login_required
def set_user_card_api():
@internal_api_call
def set_user_card():
user = current_user.db_user()
token = request.get_json()['token']
response = set_card(user, token)
@ -1419,7 +1584,8 @@ def set_user_card_api():
@app.route('/api/organization/<orgname>/card', methods=['POST'])
@api_login_required
def set_org_card_api(orgname):
@org_api_call('set_user_card')
def set_org_card(orgname):
permission = AdministerOrganizationPermission(orgname)
if permission.can():
organization = model.get_organization(orgname)
@ -1461,21 +1627,22 @@ def get_card(user):
if default_card:
card_info = {
'owner': card.name,
'type': card.type,
'last4': card.last4
'owner': default_card.name,
'type': default_card.type,
'last4': default_card.last4
}
return jsonify({'card': card_info})
@app.route('/api/user/plan', methods=['PUT'])
@api_login_required
def subscribe_api():
@internal_api_call
def update_user_subscription():
request_data = request.get_json()
plan = request_data['plan']
token = request_data['token'] if 'token' in request_data else None
user = current_user.db_user()
return subscribe(user, plan, token, USER_PLANS)
return subscribe(user, plan, token, False) # Business features not required
def carderror_response(e):
@ -1486,15 +1653,22 @@ def carderror_response(e):
return resp
def subscribe(user, plan, token, accepted_plans):
def subscribe(user, plan, token, require_business_plan):
plan_found = None
for plan_obj in accepted_plans:
for plan_obj in PLANS:
if plan_obj['stripeId'] == plan:
plan_found = plan_obj
if not plan_found:
if not plan_found or plan_found['deprecated']:
logger.warning('Plan not found or deprecated: %s', plan)
abort(404)
if (require_business_plan and not plan_found['bus_features'] and not
plan_found['price'] == 0):
logger.warning('Business attempting to subscribe to personal plan: %s',
user.username)
abort(400)
private_repos = model.get_private_repo_count(user.username)
# This is the default response
@ -1553,9 +1727,32 @@ def subscribe(user, plan, token, accepted_plans):
return resp
@app.route('/api/user/invoices', methods=['GET'])
@api_login_required
def list_user_invoices():
user = current_user.db_user()
if not user.stripe_id:
abort(404)
return get_invoices(user.stripe_id)
@app.route('/api/organization/<orgname>/invoices', methods=['GET'])
@api_login_required
def org_invoices_api(orgname):
@org_api_call('list_user_invoices')
def list_org_invoices(orgname):
permission = AdministerOrganizationPermission(orgname)
if permission.can():
organization = model.get_organization(orgname)
if not organization.stripe_id:
abort(404)
return get_invoices(organization.stripe_id)
abort(403)
def get_invoices(customer_id):
def invoice_view(i):
return {
'id': i.id,
@ -1571,37 +1768,32 @@ def org_invoices_api(orgname):
'plan': i.lines.data[0].plan.id if i.lines.data[0].plan else None
}
permission = AdministerOrganizationPermission(orgname)
if permission.can():
organization = model.get_organization(orgname)
if not organization.stripe_id:
abort(404)
invoices = stripe.Invoice.all(customer=organization.stripe_id, count=12)
return jsonify({
'invoices': [invoice_view(i) for i in invoices.data]
})
abort(403)
invoices = stripe.Invoice.all(customer=customer_id, count=12)
return jsonify({
'invoices': [invoice_view(i) for i in invoices.data]
})
@app.route('/api/organization/<orgname>/plan', methods=['PUT'])
@api_login_required
def subscribe_org_api(orgname):
@internal_api_call
@org_api_call('update_user_subscription')
def update_org_subscription(orgname):
permission = AdministerOrganizationPermission(orgname)
if permission.can():
request_data = request.get_json()
plan = request_data['plan']
token = request_data['token'] if 'token' in request_data else None
organization = model.get_organization(orgname)
return subscribe(organization, plan, token, BUSINESS_PLANS)
return subscribe(organization, plan, token, True) # Business plan required
abort(403)
@app.route('/api/user/plan', methods=['GET'])
@api_login_required
def get_subscription():
@internal_api_call
def get_user_subscription():
user = current_user.db_user()
private_repos = model.get_private_repo_count(user.username)
@ -1619,6 +1811,8 @@ def get_subscription():
@app.route('/api/organization/<orgname>/plan', methods=['GET'])
@api_login_required
@internal_api_call
@org_api_call('get_user_subscription')
def get_org_subscription(orgname):
permission = AdministerOrganizationPermission(orgname)
if permission.can():
@ -1631,7 +1825,7 @@ def get_org_subscription(orgname):
return jsonify(subscription_view(cus.subscription, private_repos))
return jsonify({
'plan': 'bus-free',
'plan': 'free',
'usedPrivateRepos': private_repos,
})
@ -1657,6 +1851,7 @@ def get_user_robots():
@app.route('/api/organization/<orgname>/robots', methods=['GET'])
@api_login_required
@org_api_call('get_user_robots')
def get_org_robots(orgname):
permission = OrganizationMemberPermission(orgname)
if permission.can():
@ -1670,7 +1865,7 @@ def get_org_robots(orgname):
@app.route('/api/user/robots/<robot_shortname>', methods=['PUT'])
@api_login_required
def create_robot(robot_shortname):
def create_user_robot(robot_shortname):
parent = current_user.db_user()
robot, password = model.create_robot(robot_shortname, parent)
resp = jsonify(robot_view(robot.username, password))
@ -1682,6 +1877,7 @@ def create_robot(robot_shortname):
@app.route('/api/organization/<orgname>/robots/<robot_shortname>',
methods=['PUT'])
@api_login_required
@org_api_call('create_user_robot')
def create_org_robot(orgname, robot_shortname):
permission = AdministerOrganizationPermission(orgname)
if permission.can():
@ -1697,7 +1893,7 @@ def create_org_robot(orgname, robot_shortname):
@app.route('/api/user/robots/<robot_shortname>', methods=['DELETE'])
@api_login_required
def delete_robot(robot_shortname):
def delete_user_robot(robot_shortname):
parent = current_user.db_user()
model.delete_robot(format_robot_username(parent.username, robot_shortname))
log_action('delete_robot', parent.username, {'robot': robot_shortname})
@ -1707,6 +1903,7 @@ def delete_robot(robot_shortname):
@app.route('/api/organization/<orgname>/robots/<robot_shortname>',
methods=['DELETE'])
@api_login_required
@org_api_call('delete_user_robot')
def delete_org_robot(orgname, robot_shortname):
permission = AdministerOrganizationPermission(orgname)
if permission.can():
@ -1718,27 +1915,27 @@ def delete_org_robot(orgname, robot_shortname):
def log_view(log):
view = {
'kind': log.kind.name,
'metadata': json.loads(log.metadata_json),
'ip': log.ip,
'datetime': log.datetime,
view = {
'kind': log.kind.name,
'metadata': json.loads(log.metadata_json),
'ip': log.ip,
'datetime': log.datetime,
}
if log.performer:
view['performer'] = {
'username': log.performer.username,
'is_robot': log.performer.robot,
}
if log.performer:
view['performer'] = {
'username': log.performer.username,
'is_robot': log.performer.robot,
}
return view
return view
@app.route('/api/repository/<path:repository>/logs', methods=['GET'])
@api_login_required
@parse_repository_name
def repo_logs_api(namespace, repository):
def list_repo_logs(namespace, repository):
permission = AdministerRepositoryPermission(namespace, repository)
if permission.can():
repo = model.get_repository(namespace, repository)
@ -1754,19 +1951,33 @@ def repo_logs_api(namespace, repository):
@app.route('/api/organization/<orgname>/logs', methods=['GET'])
@api_login_required
def org_logs_api(orgname):
@org_api_call('list_user_logs')
def list_org_logs(orgname):
permission = AdministerOrganizationPermission(orgname)
if permission.can():
performer_name = request.args.get('performer', None)
start_time = request.args.get('starttime', None)
end_time = request.args.get('endtime', None)
return get_logs(orgname, start_time, end_time, performer_name=performer_name)
return get_logs(orgname, start_time, end_time,
performer_name=performer_name)
abort(403)
def get_logs(namespace, start_time, end_time, performer_name=None, repository=None):
@app.route('/api/user/logs', methods=['GET'])
@api_login_required
def list_user_logs():
performer_name = request.args.get('performer', None)
start_time = request.args.get('starttime', None)
end_time = request.args.get('endtime', None)
return get_logs(current_user.db_user().username, start_time, end_time,
performer_name=performer_name)
def get_logs(namespace, start_time, end_time, performer_name=None,
repository=None):
performer = None
if performer_name:
performer = model.get_user(performer_name)
@ -1790,7 +2001,8 @@ def get_logs(namespace, start_time, end_time, performer_name=None, repository=No
if not end_time:
end_time = datetime.today()
logs = model.list_logs(namespace, start_time, end_time, performer = performer, repository=repository)
logs = model.list_logs(namespace, start_time, end_time, performer=performer,
repository=repository)
return jsonify({
'start_time': start_time,
'end_time': end_time,

48
endpoints/common.py Normal file
View file

@ -0,0 +1,48 @@
import logging
from flask.ext.login import login_user, UserMixin
from flask.ext.principal import identity_changed
from data import model
from app import app, login_manager
from auth.permissions import QuayDeferredPermissionUser
logger = logging.getLogger(__name__)
@login_manager.user_loader
def load_user(username):
logger.debug('Loading user: %s' % username)
return _LoginWrappedDBUser(username)
class _LoginWrappedDBUser(UserMixin):
def __init__(self, db_username, db_user=None):
self._db_username = db_username
self._db_user = db_user
def db_user(self):
if not self._db_user:
self._db_user = model.get_user(self._db_username)
return self._db_user
def is_authenticated(self):
return self.db_user() is not None
def is_active(self):
return self.db_user().verified
def get_id(self):
return unicode(self._db_username)
def common_login(db_user):
if login_user(_LoginWrappedDBUser(db_user.username, db_user)):
logger.debug('Successfully signed in as: %s' % db_user.username)
new_identity = QuayDeferredPermissionUser(db_user.username, 'username')
identity_changed.send(app, identity=new_identity)
return True
else:
logger.debug('User could not be logged in, inactive?.')
return False

View file

@ -63,14 +63,14 @@ def create_user():
model.load_token_data(password)
return make_response('Verified', 201)
except model.InvalidTokenException:
abort(401)
return make_response('Invalid access token.', 400)
elif '+' in username:
try:
model.verify_robot(username, password)
return make_response('Verified', 201)
except model.InvalidRobotException:
abort(401)
return make_response('Invalid robot account or password.', 400)
existing_user = model.get_user(username)
if existing_user:
@ -78,7 +78,7 @@ def create_user():
if verified:
return make_response('Verified', 201)
else:
abort(401)
return make_response('Invalid password.', 400)
else:
# New user case
new_user = model.create_user(username, password, user_data['email'])
@ -134,8 +134,8 @@ def create_repository(namespace, repository):
repo = model.get_repository(namespace, repository)
if not repo and get_authenticated_user() is None:
logger.debug('Attempt to create new repository with token auth.')
abort(400)
logger.debug('Attempt to create new repository without user auth.')
abort(401)
elif repo:
permission = ModifyRepositoryPermission(namespace, repository)
@ -158,10 +158,6 @@ def create_repository(namespace, repository):
for existing in model.get_repository_images(namespace, repository):
if existing.docker_image_id in new_repo_images:
added_images.pop(existing.docker_image_id)
else:
logger.debug('Deleting existing image with id: %s' %
existing.docker_image_id)
existing.delete_instance(recursive=True)
for image_description in added_images.values():
model.create_image(image_description['id'], repo)

View file

@ -43,6 +43,7 @@ def require_completion(f):
def wrapper(namespace, repository, *args, **kwargs):
if store.exists(store.image_mark_path(namespace, repository,
kwargs['image_id'])):
logger.warning('Image is already being uploaded: %s', kwargs['image_id'])
abort(400) # 'Image is being uploaded, retry later')
return f(namespace, repository, *args, **kwargs)
return wrapper
@ -87,6 +88,7 @@ def get_image_layer(namespace, repository, image_id, headers):
try:
return Response(store.stream_read(path), headers=headers)
except IOError:
logger.warning('Image not found: %s', image_id)
abort(404) # 'Image not found', 404)
abort(403)
@ -124,6 +126,11 @@ def put_image_layer(namespace, repository, image_id):
store.stream_write(layer_path, sr)
csums.append('sha256:{0}'.format(h.hexdigest()))
try:
image_size = tmp.tell()
# Save the size of the image.
model.set_image_size(image_id, namespace, repository, image_size)
tmp.seek(0)
csums.append(checksums.compute_tarsum(tmp, json_data))
tmp.close()
@ -141,7 +148,7 @@ def put_image_layer(namespace, repository, image_id):
return make_response('true', 200)
# We check if the checksums provided matches one the one we computed
if checksum not in csums:
logger.debug('put_image_layer: Wrong checksum')
logger.warning('put_image_layer: Wrong checksum')
abort(400) # 'Checksum mismatch, ignoring the layer')
# Checksum is ok, we remove the marker
store.remove(mark_path)
@ -168,8 +175,10 @@ def put_image_checksum(namespace, repository, image_id):
checksum = request.headers.get('X-Docker-Checksum')
if not checksum:
logger.warning('Missing Image\'s checksum: %s', image_id)
abort(400) # 'Missing Image\'s checksum')
if not session.get('checksum'):
logger.warning('Checksum not found in Cookie for image: %s', image_id)
abort(400) # 'Checksum not found in Cookie')
if not store.exists(store.image_json_path(namespace, repository, image_id)):
abort(404) # 'Image not found', 404)
@ -287,8 +296,11 @@ def put_image_json(namespace, repository, image_id):
except json.JSONDecodeError:
pass
if not data or not isinstance(data, dict):
logger.warning('Invalid JSON for image: %s json: %s', image_id,
request.data)
abort(400) # 'Invalid JSON')
if 'id' not in data:
logger.warning('Missing key `id\' in JSON for image: %s', image_id)
abort(400) # 'Missing key `id\' in JSON')
# Read the checksum
checksum = request.headers.get('X-Docker-Checksum')
@ -301,11 +313,14 @@ def put_image_json(namespace, repository, image_id):
# We cleanup any old checksum in case it's a retry after a fail
store.remove(store.image_checksum_path(namespace, repository, image_id))
if image_id != data['id']:
logger.warning('JSON data contains invalid id for image: %s', image_id)
abort(400) # 'JSON data contains invalid id')
parent_id = data.get('parent')
if parent_id and not store.exists(store.image_json_path(namespace,
repository,
data['parent'])):
logger.warning('Image depends on a non existing parent image: %s',
image_id)
abort(400) # 'Image depends on a non existing parent')
json_path = store.image_json_path(namespace, repository, image_id)
mark_path = store.image_mark_path(namespace, repository, image_id)
@ -319,8 +334,10 @@ def put_image_json(namespace, repository, image_id):
else:
parent_obj = None
command_list = data.get('container_config', {}).get('Cmd', None)
command = json.dumps(command_list) if command_list else None
model.set_image_metadata(image_id, namespace, repository,
data.get('created'), data.get('comment'),
data.get('created'), data.get('comment'), command,
parent_obj)
store.put_content(mark_path, 'true')
store.put_content(json_path, request.data)
@ -328,14 +345,6 @@ def put_image_json(namespace, repository, image_id):
return make_response('true', 200)
def delete_repository_storage(namespace, repository):
""" Caller should have already verified proper permissions. """
repository_path = store.repository_namespace_path(namespace, repository)
logger.debug('Recursively deleting path: %s' % repository_path)
store.remove(repository_path)
def process_image_changes(namespace, repository, image_id):
logger.debug('Generating diffs for image: %s' % image_id)

View file

@ -1,19 +1,33 @@
import math
from random import SystemRandom
from flask import jsonify, send_file
from flask import jsonify
from app import app
def generate_image_completion(rand_func):
images = {}
for image_id in range(rand_func.randint(1, 11)):
total = int(math.pow(abs(rand_func.gauss(0, 1000)), 2))
current = rand_func.randint(0, total)
image_id = 'image_id_%s' % image_id
images[image_id] = {
'total': total,
'current': current,
}
return images
@app.route('/test/build/status', methods=['GET'])
def generate_random_build_status():
response = {
'id': 1,
'total_commands': None,
'total_images': None,
'current_command': None,
'current_image': None,
'image_completion_percent': None,
'push_completion': 0.0,
'status': None,
'message': None,
'image_completion': {},
}
random = SystemRandom()
@ -35,9 +49,8 @@ def generate_random_build_status():
'pushing': {
'total_commands': 7,
'current_command': 7,
'total_images': 11,
'current_image': random.randint(1, 11),
'image_completion_percent': random.randint(0, 100),
'push_completion': random.random(),
'image_completion': generate_image_completion(random),
},
}

View file

@ -4,53 +4,33 @@ import stripe
from flask import (abort, redirect, request, url_for, render_template,
make_response, Response)
from flask.ext.login import login_user, UserMixin
from flask.ext.principal import identity_changed
from flask.ext.login import login_required, current_user
from urlparse import urlparse
from data import model
from app import app, login_manager, mixpanel
from auth.permissions import (QuayDeferredPermissionUser,
AdministerOrganizationPermission)
from app import app, mixpanel
from auth.permissions import AdministerOrganizationPermission
from util.invoice import renderInvoiceToPdf
from util.seo import render_snapshot
from util.cache import no_cache
from endpoints.api import get_route_data
from endpoints.common import common_login
logger = logging.getLogger(__name__)
class _LoginWrappedDBUser(UserMixin):
def __init__(self, db_username, db_user=None):
self._db_username = db_username
self._db_user = db_user
def db_user(self):
if not self._db_user:
self._db_user = model.get_user(self._db_username)
return self._db_user
def is_authenticated(self):
return self.db_user() is not None
def is_active(self):
return self.db_user().verified
def get_id(self):
return unicode(self._db_username)
@login_manager.user_loader
def load_user(username):
logger.debug('Loading user: %s' % username)
return _LoginWrappedDBUser(username)
def render_page_template(name, **kwargs):
return make_response(render_template(name, route_data=get_route_data(),
**kwargs))
@app.route('/', methods=['GET'], defaults={'path': ''})
@app.route('/repository/<path:path>', methods=['GET'])
@app.route('/organization/<path:path>', methods=['GET'])
@no_cache
def index(path):
return render_template('index.html')
return render_page_template('index.html')
@app.route('/snapshot', methods=['GET'])
@ -67,26 +47,32 @@ def snapshot(path = ''):
@app.route('/plans/')
@no_cache
def plans():
return index('')
@app.route('/guide/')
@no_cache
def guide():
return index('')
@app.route('/organizations/')
@app.route('/organizations/new/')
@no_cache
def organizations():
return index('')
@app.route('/user/')
@no_cache
def user():
return index('')
@app.route('/signin/')
@no_cache
def signin():
return index('')
@ -97,76 +83,85 @@ def contact():
@app.route('/new/')
@no_cache
def new():
return index('')
@app.route('/repository/')
@no_cache
def repository():
return index('')
@app.route('/security/')
@no_cache
def security():
return index('')
@app.route('/v1')
@app.route('/v1/')
@no_cache
def v1():
return index('')
@app.route('/status', methods=['GET'])
@no_cache
def status():
return make_response('Healthy')
@app.route('/tos', methods=['GET'])
@no_cache
def tos():
return render_template('tos.html')
return render_page_template('tos.html')
@app.route('/disclaimer', methods=['GET'])
@no_cache
def disclaimer():
return render_template('disclaimer.html')
return render_page_template('disclaimer.html')
@app.route('/privacy', methods=['GET'])
@no_cache
def privacy():
return render_template('privacy.html')
return render_page_template('privacy.html')
@app.route('/receipt', methods=['GET'])
def receipt():
if not current_user.is_authenticated():
abort(401)
return
id = request.args.get('id')
if id:
invoice = stripe.Invoice.retrieve(id)
if invoice:
org = model.get_user_or_org_by_customer_id(invoice.customer)
if org and org.organization:
admin_org = AdministerOrganizationPermission(org.username)
if admin_org.can():
file_data = renderInvoiceToPdf(invoice, org)
return Response(file_data,
mimetype="application/pdf",
headers={"Content-Disposition":
"attachment;filename=receipt.pdf"})
user_or_org = model.get_user_or_org_by_customer_id(invoice.customer)
if user_or_org:
if user_or_org.organization:
admin_org = AdministerOrganizationPermission(user_or_org.username)
if not admin_org.can():
abort(404)
return
else:
if not user_or_org.username == current_user.db_user().username:
abort(404)
return
file_data = renderInvoiceToPdf(invoice, user_or_org)
return Response(file_data,
mimetype="application/pdf",
headers={"Content-Disposition": "attachment;filename=receipt.pdf"})
abort(404)
def common_login(db_user):
if login_user(_LoginWrappedDBUser(db_user.username, db_user)):
logger.debug('Successfully signed in as: %s' % db_user.username)
new_identity = QuayDeferredPermissionUser(db_user.username, 'username')
identity_changed.send(app, identity=new_identity)
return True
else:
logger.debug('User could not be logged in, inactive?.')
return False
@app.route('/oauth2/github/callback', methods=['GET'])
def github_oauth_callback():
def exchange_github_code_for_token(code):
code = request.args.get('code')
payload = {
'client_id': app.config['GITHUB_CLIENT_ID'],
@ -181,19 +176,37 @@ def github_oauth_callback():
params=payload, headers=headers)
token = get_access_token.json()['access_token']
return token
def get_github_user(token):
token_param = {
'access_token': token,
}
get_user = requests.get(app.config['GITHUB_USER_URL'], params=token_param)
user_data = get_user.json()
return get_user.json()
@app.route('/oauth2/github/callback', methods=['GET'])
def github_oauth_callback():
error = request.args.get('error', None)
if error:
return render_page_template('githuberror.html', error_message=error)
token = exchange_github_code_for_token(request.args.get('code'))
user_data = get_github_user(token)
username = user_data['login']
github_id = user_data['id']
v3_media_type = {
'Accept': 'application/vnd.github.v3'
}
token_param = {
'access_token': token,
}
get_email = requests.get(app.config['GITHUB_USER_EMAILS'],
params=token_param, headers=v3_media_type)
@ -220,18 +233,33 @@ def github_oauth_callback():
mixpanel.alias(to_login.username, state)
except model.DataModelException, ex:
return render_template('githuberror.html', error_message=ex.message)
return render_page_template('githuberror.html', error_message=ex.message)
if common_login(to_login):
return redirect(url_for('index'))
return render_template('githuberror.html')
return render_page_template('githuberror.html')
@app.route('/oauth2/github/callback/attach', methods=['GET'])
@login_required
def github_oauth_attach():
token = exchange_github_code_for_token(request.args.get('code'))
user_data = get_github_user(token)
github_id = user_data['id']
user_obj = current_user.db_user()
model.attach_federated_login(user_obj, 'github', github_id)
return redirect(url_for('user'))
@app.route('/confirm', methods=['GET'])
def confirm_email():
code = request.values['code']
user = model.confirm_user_email(code)
try:
user = model.confirm_user_email(code)
except model.DataModelException as ex:
return redirect(url_for('signin'))
common_login(user)
@ -248,8 +276,3 @@ def confirm_recovery():
return redirect(url_for('user'))
else:
abort(403)
@app.route('/reset', methods=['GET'])
def password_reset():
pass

View file

@ -1,19 +1,14 @@
import logging
import requests
import stripe
from flask import (abort, redirect, request, url_for, render_template,
make_response)
from flask.ext.login import login_user, UserMixin, login_required
from flask.ext.principal import identity_changed, Identity, AnonymousIdentity
from flask import request, make_response
from data import model
from app import app, login_manager, mixpanel
from auth.permissions import QuayDeferredPermissionUser
from data.plans import USER_PLANS, BUSINESS_PLANS, get_plan
from app import app
from util.invoice import renderInvoiceToHtml
from util.email import send_invoice_email
logger = logging.getLogger(__name__)
@ -33,10 +28,10 @@ def stripe_webhook():
# Find the user associated with the customer ID.
user = model.get_user_or_org_by_customer_id(customer_id)
if user and user.invoice_email:
# Lookup the invoice.
invoice = stripe.Invoice.retrieve(invoice_id)
if invoice:
invoice_html = renderInvoiceToHtml(invoice, user)
send_invoice_email(user.email, invoice_html)
# Lookup the invoice.
invoice = stripe.Invoice.retrieve(invoice_id)
if invoice:
invoice_html = renderInvoiceToHtml(invoice, user)
send_invoice_email(user.email, invoice_html)
return make_response('Okay')

5
gunicorn_config.py Normal file
View file

@ -0,0 +1,5 @@
bind = 'unix:/tmp/gunicorn.sock'
workers = 8
worker_class = 'gevent'
timeout = 2000
daemon = True

View file

@ -1,11 +1,9 @@
import logging
import string
import shutil
import os
import json
import hashlib
import random
from datetime import datetime, timedelta
from flask import url_for
from peewee import SqliteDatabase, create_model_tables, drop_model_tables
from data.database import *
@ -18,6 +16,15 @@ store = app.config['STORAGE']
SAMPLE_DIFFS = ['test/data/sample/diffs/diffs%s.json' % i
for i in range(1, 10)]
SAMPLE_CMDS = [["/bin/bash"],
["/bin/sh", "-c",
"echo \"PasswordAuthentication no\" >> /etc/ssh/sshd_config"],
["/bin/sh", "-c",
"sed -i 's/#\\(force_color_prompt\\)/\\1/' /etc/skel/.bashrc"],
["/bin/sh", "-c", "#(nop) EXPOSE [8080]"],
["/bin/sh", "-c",
"#(nop) MAINTAINER Jake Moshenko <jake@devtable.com>"],
None]
REFERENCE_DATE = datetime(2013, 6, 23)
TEST_STRIPE_ID = 'cus_2tmnh3PkXQS8NG'
@ -51,9 +58,14 @@ def __create_subtree(repo, structure, parent):
model.set_image_checksum(docker_image_id, repo, checksum)
creation_time = REFERENCE_DATE + timedelta(days=image_num)
command_list = SAMPLE_CMDS[image_num % len(SAMPLE_CMDS)]
command = json.dumps(command_list) if command_list else None
new_image = model.set_image_metadata(docker_image_id, repo.namespace,
repo.name, str(creation_time),
'no comment', parent)
'no comment', command, parent)
model.set_image_size(docker_image_id, repo.namespace, repo.name,
random.randrange(1, 1024 * 1024 * 1024))
# Populate the diff file
diff_path = store.image_file_diffs_path(repo.namespace, repo.name,
@ -123,6 +135,7 @@ def initialize_database():
LogEntryKind.create(name='push_repo')
LogEntryKind.create(name='pull_repo')
LogEntryKind.create(name='delete_repo')
LogEntryKind.create(name='delete_tag')
LogEntryKind.create(name='add_repo_permission')
LogEntryKind.create(name='change_repo_permission')
LogEntryKind.create(name='delete_repo_permission')
@ -160,6 +173,7 @@ def populate_database():
new_user_1 = model.create_user('devtable', 'password',
'jschorr@devtable.com')
new_user_1.verified = True
new_user_1.stripe_id = TEST_STRIPE_ID
new_user_1.save()
model.create_robot('dtrobot', new_user_1)
@ -279,6 +293,12 @@ def populate_database():
model.log_action('pull_repo', org.username, repository=org_repo, timestamp=today,
metadata={'token': 'sometoken', 'token_code': 'somecode', 'repo': 'orgrepo'})
model.log_action('delete_tag', org.username, performer=new_user_2, repository=org_repo, timestamp=today,
metadata={'username': new_user_2.username, 'repo': 'orgrepo', 'tag': 'sometag'})
model.log_action('pull_repo', org.username, repository=org_repo, timestamp=today,
metadata={'token_code': 'somecode', 'repo': 'orgrepo'})
if __name__ == '__main__':
logging.basicConfig(**app.config['LOGGING_CONFIG'])
initialize_database()

View file

@ -1,8 +1,8 @@
worker_processes 1;
worker_processes 2;
user root nogroup;
pid /tmp/nginx.pid;
error_log /tmp/nginx.error.log;
pid /mnt/nginx/nginx.pid;
error_log /mnt/nginx/nginx.error.log;
events {
worker_connections 1024;
@ -11,10 +11,10 @@ events {
http {
types_hash_max_size 2048;
include /etc/nginx/mime.types;
include /usr/local/nginx/conf/mime.types.default;
default_type application/octet-stream;
access_log /tmp/nginx.access.log combined;
access_log /mnt/nginx/nginx.access.log combined;
sendfile on;
root /root/quay/;
@ -43,6 +43,7 @@ http {
server {
listen 443 default;
client_max_body_size 8G;
client_body_temp_path /mnt/nginx/client_body 1 2;
server_name _;
keepalive_timeout 5;
@ -71,8 +72,12 @@ http {
proxy_redirect off;
proxy_buffering off;
proxy_request_buffering off;
proxy_set_header Transfer-Encoding $http_transfer_encoding;
proxy_pass http://app_server;
proxy_read_timeout 2000;
proxy_temp_path /mnt/nginx/proxy_temp 1 2;
}
}
}

View file

@ -1,8 +1,8 @@
worker_processes 1;
worker_processes 8;
user nobody nogroup;
pid /tmp/nginx.pid;
error_log /tmp/nginx.error.log;
pid /mnt/nginx/nginx.pid;
error_log /mnt/nginx/nginx.error.log;
events {
worker_connections 1024;
@ -11,10 +11,10 @@ events {
http {
types_hash_max_size 2048;
include /etc/nginx/mime.types;
include /usr/local/nginx/conf/mime.types.default;
default_type application/octet-stream;
access_log /tmp/nginx.access.log combined;
access_log /mnt/nginx/nginx.access.log combined;
sendfile on;
gzip on;
@ -41,6 +41,7 @@ http {
server {
listen 443 default;
client_max_body_size 8G;
client_body_temp_path /mnt/nginx/client_body 1 2;
server_name _;
keepalive_timeout 5;
@ -69,8 +70,12 @@ http {
proxy_redirect off;
proxy_buffering off;
proxy_request_buffering off;
proxy_set_header Transfer-Encoding $http_transfer_encoding;
proxy_pass http://app_server;
proxy_read_timeout 2000;
proxy_temp_path /mnt/nginx/proxy_temp 1 2;
}
}
}

View file

@ -1,20 +1,20 @@
APScheduler==2.1.1
Flask==0.10.1
Flask-Login==0.2.7
Flask-Login==0.2.9
Flask-Mail==0.9.0
Flask-Principal==0.4.0
Jinja2==2.7.1
MarkupSafe==0.18
Pillow==2.2.1
Pillow==2.3.0
PyMySQL==0.6.1
Werkzeug==0.9.4
argparse==1.2.1
beautifulsoup4==4.3.2
blinker==1.3
boto==2.17.0
boto==2.21.2
distribute==0.6.34
ecdsa==0.10
gevent==0.13.8
gevent==1.0
greenlet==0.4.1
gunicorn==18.0
html5lib==1.0b3
@ -23,16 +23,16 @@ lockfile==0.9.1
marisa-trie==0.5.1
mixpanel-py==3.0.0
paramiko==1.12.0
peewee==2.1.5
peewee==2.1.7
py-bcrypt==0.4
pyPdf==1.13
pycrypto==2.6.1
python-daemon==1.6
python-dateutil==2.2
python-digitalocean==0.5.1
python-digitalocean==0.6
reportlab==2.7
requests==2.0.1
requests==2.1.0
six==1.4.1
stripe==1.9.8
stripe==1.11.0
wsgiref==0.1.2
xhtml2pdf==0.0.5

View file

@ -10,20 +10,6 @@ var casper = require('casper').create({
logLevel: "debug"
});
var disableOlark = function() {
casper.then(function() {
this.waitForText('Chat with us!', function() {
this.evaluate(function() {
console.log(olark);
window.olark.configure('box.start_hidden', true);
window.olark('api.box.hide');
});
}, function() {
// Do nothing, if olark never loaded we're ok with that
});
});
};
var options = casper.cli.options;
var isDebug = !!options['d'];
@ -56,12 +42,18 @@ casper.thenClick('.form-signin button[type=submit]', function() {
this.waitForText('Top Repositories');
});
disableOlark();
casper.then(function() {
this.log('Generating user home screenshot.');
});
casper.then(function() {
this.capture(outputDir + 'user-home.png');
});
casper.then(function() {
this.log('Generating repository view screenshot.');
});
casper.thenOpen(rootUrl + 'repository/devtable/' + repo + '?tag=v2.0', function() {
// Wait for the tree to initialize.
this.waitForSelector('.image-tree', function() {
@ -70,12 +62,14 @@ casper.thenOpen(rootUrl + 'repository/devtable/' + repo + '?tag=v2.0', function(
});
});
disableOlark();
casper.then(function() {
this.capture(outputDir + 'repo-view.png');
});
casper.then(function() {
this.log('Generating repository changes screenshot.');
});
casper.thenClick('#current-image dd a', function() {
this.waitForSelector('.result-count', function() {
this.capture(outputDir + 'repo-changes.png', {
@ -87,58 +81,79 @@ casper.thenClick('#current-image dd a', function() {
});
})
casper.then(function() {
this.log('Generating repository admin screenshot.');
});
casper.thenOpen(rootUrl + 'repository/devtable/' + repo + '/admin', function() {
this.waitForSelector('.repo-access-state');
});
disableOlark();
casper.then(function() {
this.capture(outputDir + 'repo-admin.png');
});
casper.then(function() {
this.log('Generating organization repo list screenshot.');
});
casper.thenOpen(rootUrl + 'repository/?namespace=' + org, function() {
this.waitForText('Repositories');
});
disableOlark();
casper.then(function() {
this.capture(outputDir + 'org-repo-list.png');
});
casper.then(function() {
this.log('Generating organization teams screenshot.');
});
casper.thenOpen(rootUrl + 'organization/' + org, function() {
this.waitForSelector('.organization-name');
});
disableOlark();
casper.then(function() {
this.capture(outputDir + 'org-teams.png');
});
casper.then(function() {
this.log('Generating organization admin screenshot.');
});
casper.thenOpen(rootUrl + 'organization/' + org + '/admin', function() {
this.waitForSelector('#repository-usage-chart');
});
disableOlark();
casper.then(function() {
this.capture(outputDir + 'org-admin.png');
});
casper.then(function() {
this.log('Generating organization logs screenshot.');
});
casper.thenClick('a[data-target="#logs"]', function() {
this.waitForSelector('svg > g', function() {
this.capture(outputDir + 'org-logs.png');
this.wait(1000, function() {
this.capture(outputDir + 'org-logs.png', {
top: 0,
left: 0,
width: width,
height: height + 200
});
});
});
});
casper.then(function() {
this.log('Generating oganization repository admin screenshot.');
});
casper.thenOpen(rootUrl + 'repository/' + org + '/' + orgrepo + '/admin', function() {
this.waitForText('outsideorg')
});
disableOlark();
casper.then(function() {
this.capture(outputDir + 'org-repo-admin.png');
});

View file

@ -3,6 +3,76 @@
margin: 0;
}
@media (max-width: 410px) {
.olrk-normal {
display: none;
}
}
.codetooltipcontainer .tooltip-inner {
white-space:pre;
max-width:none;
}
.codetooltip {
font-family: Consolas, "Lucida Console", Monaco, monospace;
display: block;
}
.resource-view-element {
position: relative;
}
.resource-view-element .resource-spinner {
z-index: 1;
position: absolute;
top: 10px;
left: 10px;
opacity: 0;
transition: opacity 0s ease-in-out;
text-align: center;
}
.resource-view-element .resource-content {
visibility: hidden;
}
.resource-view-element .resource-content.visible {
z-index: 2;
visibility: visible;
}
.resource-view-element .resource-error {
margin: 10px;
font-size: 16px;
color: #444;
text-align: center;
}
.resource-view-element .resource-spinner.visible {
opacity: 1;
transition: opacity 1s ease-in-out;
}
.small-spinner {
display: inline-block;
width: 14px;
height: 14px;
border: solid 2px transparent;
border-top-color: #444;
border-left-color: #444;
border-radius: 10px;
-webkit-animation: loading-bar-spinner 400ms linear infinite;
-moz-animation: loading-bar-spinner 400ms linear infinite;
-ms-animation: loading-bar-spinner 400ms linear infinite;
-o-animation: loading-bar-spinner 400ms linear infinite;
animation: loading-bar-spinner 400ms linear infinite;
}
#loading-bar-spinner {
top: 70px;
}
.entity-search-element input {
vertical-align: middle;
}
@ -48,10 +118,7 @@ html, body {
.tooltip {
word-break: normal !important;
word-wrap: normal !important;
}
.code-info {
border-bottom: 1px dashed #aaa;
pointer-events: none;
}
.toggle-icon {
@ -136,12 +203,11 @@ i.toggle-icon:hover {
min-height: 100%;
height: auto !important;
height: 100%;
margin: 0 auto -136px;
margin: 0 auto -176px;
}
.footer-container, .push {
height: 110px;
overflow: hidden;
height: 74px;
}
.footer-container.fixed {
@ -193,6 +259,10 @@ i.toggle-icon:hover {
margin-left: 10px;
}
.logs-view-element .logs-date-picker {
width: 122px;
}
.logs-view-element .header input {
font-size: 14px;
text-align: center;
@ -608,49 +678,165 @@ i.toggle-icon:hover {
.plans-list .plan {
vertical-align: top;
padding: 10px;
border: 1px solid #eee;
border-top: 4px solid #94C9F7;
font-size: 1.4em;
margin-top: 5px;
}
.plans-list .plan.small {
border: 1px solid #ddd;
border-top: 4px solid #428bca;
margin-top: 0px;
font-size: 1.6em;
}
.plans-list .plan.business-plan {
border: 1px solid #eee;
border-top: 4px solid #94F794;
}
.plans-list .plan.bus-small {
border: 1px solid #ddd;
border-top: 4px solid #47A447;
margin-top: 0px;
font-size: 1.6em;
border-top: 6px solid #46ac39;
margin-top: -10px;
}
.plans-list .plan.bus-small .plan-box {
background: black !important;
}
.plans-list .plan:last-child {
margin-right: 0px;
}
.plans-list .plan .plan-box {
background: #444;
padding: 10px;
color: white;
}
.plans-list .plan .plan-title {
text-transform: uppercase;
padding-top: 25px;
padding-bottom: 20px;
margin-bottom: 10px;
display: block;
font-weight: bold;
border-bottom: 1px solid #eee;
}
.visible-sm-inline {
display: none;
}
.hidden-sm-inline {
display: inline;
}
@media (max-width: 991px) and (min-width: 768px) {
.visible-sm-inline {
display: inline;
}
.hidden-sm-inline {
display: none;
}
}
.plans-list .plan-box .description {
color: white;
margin-top: 6px;
font-size: 12px !important;
}
.plans-list .plan button {
margin-top: 6px;
margin-bottom: 6px;
}
.plans-list .plan.bus-small button {
font-size: 1em;
}
.plans-list .features-bar {
padding-top: 248px;
}
.plans-list .features-bar .feature .count {
padding: 10px;
}
.plans-list .features-bar .feature {
height: 43px;
text-align: right;
white-space: nowrap;
}
.context-tooltip {
border-bottom: 1px dotted black;
cursor: default;
}
.plans-list .features-bar .feature i {
margin-left: 16px;
float: right;
width: 16px;
font-size: 16px;
text-align: center;
margin-top: 2px;
}
.plans-list .plan .features {
padding: 6px;
background: #eee;
padding-bottom: 0px;
}
.plans-list .plan .feature {
text-align: center;
padding: 10px;
border-bottom: 1px solid #ddd;
font-size: 14px;
}
.plans-list .plan .feature:after {
content: "";
border-radius: 50%;
display: inline-block;
width: 16px;
height: 16px;
}
.plans-list .plan .visible-xs .feature {
text-align: left;
}
.plans-list .plan .visible-xs .feature:after {
float: left;
margin-right: 10px;
}
.plans-list .plan .feature.notpresent {
color: #ccc;
}
.plans-list .plan .feature.present:after {
background: #428bca;
}
.plans-list .plan.business-plan .feature.present:after {
background: #46ac39;
}
.plans-list .plan .count, .plans-list .features-bar .count {
background: white;
border-bottom: 0px;
text-align: center !important;
font-size: 14px;
padding: 10px;
}
.plans-list .plan .count b, .plans-list .features-bar .count b {
font-size: 1.5em;
display: block;
}
.plans-list .plan .feature:last-child {
border-bottom: 0px;
}
.plans-list .plan-price {
margin-bottom: 10px;
}
.plan-price {
margin-bottom: 10px;
display: block;
font-weight: bold;
font-size: 1.8em;
position: relative;
}
@ -678,7 +864,8 @@ i.toggle-icon:hover {
.plans-list .plan .description {
font-size: 1em;
font-size: 16px;
margin-bottom: 10px;
height: 34px;
}
.plans-list .plan .smaller {
@ -829,12 +1016,20 @@ form input.ng-valid.ng-dirty,
}
.page-footer {
padding: 10px;
padding-bottom: 0px;
border-top: 1px solid #eee;
}
.page-footer-padder {
margin-top: 76px;
background-color: white;
background-image: none;
padding: 10px;
padding-bottom: 40px;
margin-top: 52px;
border-top: 1px solid #eee;
overflow: hidden;
width: 100%;
height: 80px;
padding-top: 24px;
}
.page-footer .row {
@ -909,12 +1104,20 @@ form input.ng-valid.ng-dirty,
.entity-mini-listing {
margin: 2px;
white-space: nowrap !important;
position: relative;
}
.entity-mini-listing i {
margin-right: 8px;
}
.entity-mini-listing i.fa-exclamation-triangle {
position: absolute;
right: -16px;
top: 4px;
color: #c09853;
}
.entity-mini-listing .warning {
margin-top: 6px;
font-size: 10px;
@ -967,20 +1170,84 @@ p.editable:hover i {
font-size: 1.15em;
}
#tagContextMenu {
display: none;
position: absolute;
z-index: 100000;
outline: none;
}
#tagContextMenu ul {
display: block;
position: static;
margin-bottom: 5px;
}
.tag-controls {
display: inline-block;
margin-right: 20px;
margin-top: 2px;
opacity: 0;
float: right;
-webkit-transition: opacity 200ms ease-in-out;
-moz-transition: opacity 200ms ease-in-out;
transition: opacity 200ms ease-in-out;
}
.right-title {
display: inline-block;
float: right;
padding: 4px;
font-size: 12px;
color: #aaa;
}
.tag-dropdown .tag-count {
.right-tag-controls {
display: inline-block;
float: right;
padding: 4px;
padding-left: 10px;
border-left: 1px solid #ccc;
vertical-align: middle;
margin-top: -2px;
margin-right: -10px;
color: #666;
}
.right-tag-controls .tag-count {
display: inline-block;
margin-left: 4px;
margin-right: 6px;
padding-right: 10px;
border-right: 1px solid #ccc;
}
.tag-image-sizes .tag-image-size {
height: 22px;
position: relative;
}
.tag-image-sizes .tag-image-size .size-title {
position: absolute;
right: 0px;
top: 0px;
font-size: 12px;
}
.tag-image-sizes .tag-image-size .size-limiter {
display: inline-block;
padding-right: 90px;
width: 100%;
}
.tag-image-sizes .tag-image-size .size-bar {
display: inline-block;
background: steelblue;
height: 12px;
}
#current-tag .control-bar {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid #eee;
text-align: right;
}
.tag-dropdown a {
@ -993,6 +1260,61 @@ p.editable:hover i {
border: 0px;
}
#confirmdeleteTagModal .image-listings {
margin: 10px;
}
#confirmdeleteTagModal .image-listings .image-listing {
margin: 4px;
padding: 2px;
position: relative;
}
#confirmdeleteTagModal .image-listings .image-listing .image-listing-id {
display: inline-block;
margin-left: 20px;
}
#confirmdeleteTagModal .image-listings .image-listing .image-listing-line {
border-left: 2px solid steelblue;
display: inline-block;
position: absolute;
top: -2px;
bottom: 8px;
left: 6px;
width: 1px;
z-index: 1;
}
#confirmdeleteTagModal .image-listings .image-listing.tag-image .image-listing-line {
top: 8px;
}
#confirmdeleteTagModal .image-listings .image-listing.child .image-listing-line {
bottom: -2px;
}
#confirmdeleteTagModal .image-listings .image-listing .image-listing-circle {
position: absolute;
top: 8px;
border-radius: 50%;
border: 2px solid steelblue;
width: 10px;
height: 10px;
display: inline-block;
background: white;
z-index: 2;
}
#confirmdeleteTagModal .image-listings .image-listing.tag-image .image-listing-circle {
background: steelblue;
}
#confirmdeleteTagModal .more-changes {
margin-left: 16px;
}
.repo .header {
margin-bottom: 10px;
position: relative;
@ -1009,7 +1331,7 @@ p.editable:hover i {
.repo .description {
margin-top: 10px;
margin-bottom: 40px;
margin-bottom: 20px;
}
.repo .empty-message {
@ -1343,6 +1665,18 @@ p.editable:hover i {
padding-top: 4px;
}
.repo .formatted-command {
margin-top: 4px;
padding: 4px;
font-size: 12px;
}
.repo .formatted-command.trimmed {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.repo .changes-count-container {
text-align: center;
}
@ -1567,6 +1901,10 @@ p.editable:hover i {
margin-top: 10px;
}
.user-admin .check-green {
color: #46ac39;
}
#image-history-container {
overflow: hidden;
min-height: 400px;
@ -1617,7 +1955,7 @@ p.editable:hover i {
text-align: center;
}
#image-history-container .tags .tag {
#image-history-container .tags .tag, #confirmdeleteTagModal .tag {
border-radius: 10px;
margin-right: 4px;
cursor: pointer;
@ -1864,28 +2202,28 @@ p.editable:hover i {
display: inline-block;
}
.org-admin .invoice-title {
.billing-invoices-element .invoice-title {
padding: 6px;
cursor: pointer;
}
.org-admin .invoice-status .success {
.billing-invoices-element .invoice-status .success {
color: green;
}
.org-admin .invoice-status .pending {
.billing-invoices-element .invoice-status .pending {
color: steelblue;
}
.org-admin .invoice-status .danger {
.billing-invoices-element .invoice-status .danger {
color: red;
}
.org-admin .invoice-amount:before {
.billing-invoices-element .invoice-amount:before {
content: '$';
}
.org-admin .invoice-details {
.billing-invoices-element .invoice-details {
margin-left: 10px;
margin-bottom: 10px;
@ -1894,21 +2232,21 @@ p.editable:hover i {
border-left: 2px solid #eee !important;
}
.org-admin .invoice-details td {
.billing-invoices-element .invoice-details td {
border: 0px solid transparent !important;
}
.org-admin .invoice-details dl {
.billing-invoices-element .invoice-details dl {
margin: 0px;
}
.org-admin .invoice-details dd {
.billing-invoices-element .invoice-details dd {
margin-left: 10px;
padding: 6px;
margin-bottom: 10px;
}
.org-admin .invoice-title:hover {
.billing-invoices-element .invoice-title:hover {
color: steelblue;
}
@ -2034,6 +2372,14 @@ p.editable:hover i {
margin-bottom: 0px;
}
.plan-manager-element .plans-list-table .deprecated-plan {
color: #aaa;
}
.plan-manager-element .plans-list-table .deprecated-plan-label {
font-size: 0.7em;
}
.plans-table-element table {
margin: 20px;
border: 1px solid #eee;
@ -2179,16 +2525,37 @@ p.editable:hover i {
display: block;
}
.d3-tip .created {
.d3-tip .command {
font-size: 12px;
color: white;
display: block;
margin-bottom: 6px;
font-family: Consolas, "Lucida Console", Monaco, monospace;
}
.d3-tip .info-line {
display: block;
margin-top: 6px;
padding-top: 6px;
}
.d3-tip .info-line i {
margin-right: 10px;
font-size: 14px;
color: #888;
}
.d3-tip .comment {
display: block;
font-size: 14px;
padding-bottom: 4px;
margin-bottom: 10px;
border-bottom: 1px dotted #ccc;
}
.d3-tip .created {
font-size: 12px;
color: white;
display: block;
margin-bottom: 6px;
}

View file

@ -0,0 +1,53 @@
<div class="billing-invoices-element">
<div ng-show="loading">
<div class="quay-spinner"></div>
</div>
<div ng-show="!loading && !invoices">
No invoices have been created
</div>
<div ng-show="!loading && invoices">
<table class="table">
<thead>
<th>Billing Date/Time</th>
<th>Amount Due</th>
<th>Status</th>
<th></th>
</thead>
<tbody class="invoice" ng-repeat="invoice in invoices">
<tr class="invoice-title">
<td ng-click="toggleInvoice(invoice.id)"><span class="invoice-datetime">{{ invoice.date * 1000 | date:'medium' }}</span></td>
<td ng-click="toggleInvoice(invoice.id)"><span class="invoice-amount">{{ invoice.amount_due / 100 }}</span></td>
<td>
<span class="invoice-status">
<span class="success" ng-show="invoice.paid">Paid - Thank you!</span>
<span class="danger" ng-show="!invoice.paid && invoice.attempted && invoice.closed">Payment failed</span>
<span class="danger" ng-show="!invoice.paid && invoice.attempted && !invoice.closed">Payment failed - Will retry soon</span>
<span class="pending" ng-show="!invoice.paid && !invoice.attempted">Payment pending</span>
</span>
</td>
<td>
<a ng-show="invoice.paid" href="/receipt?id={{ invoice.id }}" download="receipt.pdf" target="_new">
<i class="fa fa-download" title="Download Receipt" bs-tooltip="tooltip.title"></i>
</a>
</td>
</tr>
<tr ng-class="invoiceExpanded[invoice.id] ? 'in' : 'out'" class="invoice-details panel-collapse collapse">
<td colspan="3">
<dl class="dl-normal">
<dt>Billing Period</dt>
<dd>
<span>{{ invoice.period_start * 1000 | date:'mediumDate' }}</span> -
<span>{{ invoice.period_end * 1000 | date:'mediumDate' }}</span>
</dd>
</dl>
</td>
</tr>
</tbody>
</table>
</div>
</div>

View file

@ -5,9 +5,9 @@
Credit Card
</div>
<div class="panel-body">
<i class="fa fa-spinner fa-spin fa-2x" ng-show="!currentCard || changingCard"></i>
<div class="quay-spinner" ng-show="!currentCard || changingCard"></div>
<div class="current-card" ng-show="currentCard && !changingCard">
<img src="{{ '/static/img/creditcards/' + getCreditImage(currentCard) }}" ng-show="currentCard.last4">
<img ng-src="{{ '/static/img/creditcards/' + getCreditImage(currentCard) }}" ng-show="currentCard.last4">
<span class="no-card-outline" ng-show="!currentCard.last4"></span>
<span class="last4" ng-show="currentCard.last4">****-****-****-<b>{{ currentCard.last4 }}</b></span>
@ -24,7 +24,7 @@
<div class="panel">
<div class="panel-title">
Billing Options
<i class="fa fa-spinner fa-spin" ng-show="working"></i>
<div class="quay-spinner" ng-show="working"></div>
</div>
<div class="panel-body">
<div class="settings-option">

View file

@ -5,7 +5,7 @@
<span class="caret"></span>
</button>
<ul class="dropdown-menu" role="menu" aria-labelledby="entityDropdownMenu">
<li ng-show="lazyLoading"><i class="fa fa-spinner"></i></li>
<li ng-show="lazyLoading" style="padding: 10px"><div class="quay-spinner"></div></li>
<li role="presentation" ng-repeat="team in teams" ng-show="!lazyLoading"
ng-click="setEntity(team.name, 'team', false)">

View file

@ -38,14 +38,21 @@
<a href="javascript:void(0)" class="dropdown-toggle user-dropdown" data-toggle="dropdown">
<img src="//www.gravatar.com/avatar/{{ user.gravatar }}?s=32&d=identicon" />
{{ user.username }}
<span class="badge user-notification notification-animated" ng-show="user.askForPassword">1</span>
<span class="badge user-notification notification-animated" ng-show="user.askForPassword || overPlan"
bs-tooltip="(user.askForPassword ? 'A password is needed for this account<br>' : '') + (overPlan ? 'You are using more private repositories than your plan allows' : '')"
data-placement="left"
data-container="body">
{{ (user.askForPassword ? 1 : 0) + (overPlan ? 1 : 0) }}
</span>
<b class="caret"></b>
</a>
<ul class="dropdown-menu">
<li>
<a href="/user/" target="{{ appLinkTarget() }}">
Account Settings
<span class="badge user-notification" ng-show="user.askForPassword">1</span>
<span class="badge user-notification" ng-show="user.askForPassword || overPlan">
{{ (user.askForPassword ? 1 : 0) + (overPlan ? 1 : 0) }}
</span>
</a>
</li>
<li><a ng-href="/organizations/" target="{{ appLinkTarget() }}">Organizations</a></li>

View file

@ -0,0 +1,8 @@
<div class="container loading-status-element">
<div ng-show="hasError && !loading">
<span ng-transclude></span>
</div>
<div ng-show="loading">
Loading...
</div>
</div>

View file

@ -6,9 +6,9 @@
<span class="entity-reference" name="performer.username" isrobot="performer.is_robot" ng-show="performer"></span>
<span id="logs-range" class="mini">
From
<input type="text" class="input-small" name="start" ng-model="logStartDate" data-date-format="mm/dd/yyyy" bs-datepicker/>
<input type="text" class="logs-date-picker input-sm" name="start" ng-model="logStartDate" data-date-format="mm/dd/yyyy" bs-datepicker/>
<span class="add-on">to</span>
<input type="text" class="input-small" name="end" ng-model="logEndDate" data-date-format="mm/dd/yyyy" bs-datepicker/>
<input type="text" class="logs-date-picker input-sm" name="end" ng-model="logEndDate" data-date-format="mm/dd/yyyy" bs-datepicker/>
</span>
</span>
<span class="right">
@ -21,7 +21,7 @@
</div>
<div ng-show="loading">
<i class="fa fa-spinner fa-spin fa-3x"></i>
<div class="quay-spinner 3x"></div>
</div>
<div ng-show="!loading">
<div id="bar-chart" style="width: 800px; height: 500px;" ng-show="chartVisible">

View file

@ -1,6 +1,6 @@
<div class="plan-manager-element">
<!-- Loading/Changing -->
<i class="fa fa-spinner fa-spin fa-3x" ng-show="planLoading"></i>
<div class="quay-spinner 3x" ng-show="planLoading"></div>
<!-- Alerts -->
<div class="alert alert-danger" ng-show="limit == 'over' && !planLoading">
@ -32,26 +32,35 @@
<td></td>
</thead>
<tr ng-repeat="plan in plans" ng-class="(subscribedPlan.stripeId === plan.stripeId) ? getActiveSubClass() : ''">
<td>{{ plan.title }}</td>
<tr ng-repeat="plan in plans" ng-show="isPlanVisible(plan, subscribedPlan)"
ng-class="{'active':(subscribedPlan.stripeId === plan.stripeId), 'deprecated-plan':plan.deprecated}">
<td>
{{ plan.title }}
<div class="deprecated-plan-label" ng-show="plan.deprecated">
<span class="context-tooltip" title="This plan has been discontinued. As a valued early adopter, you may continue to stay on this plan indefinitely." bs-tooltip="tooltip.title" data-placement="right">Discontinued Plan</span>
</div>
</td>
<td>{{ plan.privateRepos }}</td>
<td><div class="plan-price">${{ plan.price / 100 }}</div></td>
<td class="controls">
<div ng-switch='plan.stripeId'>
<div ng-switch-when='bus-free'>
<button class="btn button-hidden">Hidden!</button>
<div ng-switch='plan.deprecated'>
<div ng-switch-when='true'>
<button class="btn btn-danger" ng-click="cancelSubscription()">
<span class="quay-spinner" ng-show="planChanging"></span>
<span ng-show="!planChanging">Cancel</span>
</button>
</div>
<div ng-switch-default>
<button class="btn" ng-show="subscribedPlan.stripeId !== plan.stripeId"
ng-class="subscribedPlan.price == 0 ? 'btn-primary' : 'btn-default'"
ng-click="changeSubscription(plan.stripeId)">
<i class="fa fa-spinner fa-spin" ng-show="planChanging"></i>
<span class="quay-spinner" ng-show="planChanging"></span>
<span ng-show="!planChanging && subscribedPlan.price != 0">Change</span>
<span ng-show="!planChanging && subscribedPlan.price == 0">Subscribe</span>
</button>
<button class="btn btn-danger" ng-show="subscription.plan === plan.stripeId && plan.price > 0"
ng-click="cancelSubscription()">
<i class="fa fa-spinner fa-spin" ng-show="planChanging"></i>
<span class="quay-spinner" ng-show="planChanging"></span>
<span ng-show="!planChanging">Cancel</span>
</button>
</div>

View file

@ -1,2 +1,2 @@
<i class="fa fa-lock fa-lg" style="{{ repo.is_public ? 'visibility: hidden' : 'visibility: visible' }}" title="Private Repository"></i>
<i class="fa fa-lock fa-lg" style="{{ repo.is_public ? 'visibility: hidden' : 'visibility: inherit' }}" title="Private Repository"></i>
<i class="fa fa-hdd"></i>

View file

@ -0,0 +1,11 @@
<div class="resource-view-element">
<div class="resource-spinner" ng-class="resource.loading ? 'visible' : ''">
<div class="small-spinner"></div>
</div>
<div class="resource-error" ng-show="!resource.loading && resource.hasError">
{{ errorMessage }}
</div>
<div class="resource-content" ng-class="(!resource.loading && !resource.hasError) ? 'visible' : ''">
<span ng-transclude></span>
</div>
</div>

View file

@ -1,5 +1,5 @@
<div class="robots-manager-element">
<i class="fa fa-spinner fa-spin fa-3x" ng-show="loading"></i>
<div class="quay-spinner" ng-show="loading"></div>
<div class="alert alert-info">Robot accounts allow for delegating access in multiple repositories to role-based accounts that you manage</div>
<div class="container" ng-show="!loading">

View file

@ -19,7 +19,7 @@
</div>
</form>
<div ng-show="registering" style="text-align: center">
<i class="fa fa-spinner fa-spin fa-3x"></i>
<div class="quay-spinner 2x"></div>
</div>
<div ng-show="awaitingConfirmation">
<div class="sub-message">

View file

@ -0,0 +1 @@
<div class="small-spinner"></div>

View file

@ -43,7 +43,7 @@
<button class="btn btn-lg btn-primary btn-block" type="submit">Send Recovery Email</button>
</form>
<div class="alert alert-danger" ng-show="invalidEmail">Unable to locate account.</div>
<div class="alert alert-danger" ng-show="invalidRecovery">{{errorMessage}}</div>
<div class="alert alert-success" ng-show="sent">Account recovery email was sent.</div>
</div>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 70 KiB

BIN
static/img/org-logs.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 208 KiB

After

Width:  |  Height:  |  Size: 196 KiB

File diff suppressed because it is too large Load diff

13
static/js/bootstrap.js vendored Normal file
View file

@ -0,0 +1,13 @@
$.ajax({
type: 'GET',
async: false,
url: '/api/discovery',
success: function(data) {
window.__endpoints = data.endpoints;
},
error: function() {
setTimeout(function() {
$('#couldnotloadModal').modal({});
}, 250);
}
});

File diff suppressed because it is too large Load diff

View file

@ -1,10 +1,37 @@
/**
* Bind polyfill from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/bind#Compatibility
*/
if (!Function.prototype.bind) {
Function.prototype.bind = function (oThis) {
if (typeof this !== "function") {
// closest thing possible to the ECMAScript 5 internal IsCallable function
throw new TypeError("Function.prototype.bind - what is trying to be bound is not callable");
}
var aArgs = Array.prototype.slice.call(arguments, 1),
fToBind = this,
fNOP = function () {},
fBound = function () {
return fToBind.apply(this instanceof fNOP && oThis
? this
: oThis,
aArgs.concat(Array.prototype.slice.call(arguments)));
};
fNOP.prototype = this.prototype;
fBound.prototype = new fNOP();
return fBound;
};
}
var DEPTH_HEIGHT = 100;
var DEPTH_WIDTH = 132;
/**
* Based off of http://mbostock.github.io/d3/talk/20111018/tree.html by Mike Bostock (@mbostock)
*/
function ImageHistoryTree(namespace, name, images, formatComment, formatTime) {
function ImageHistoryTree(namespace, name, images, formatComment, formatTime, formatCommand) {
/**
* The namespace of the repo.
*/
@ -30,6 +57,11 @@ function ImageHistoryTree(namespace, name, images, formatComment, formatTime) {
*/
this.formatTime_ = formatTime;
/**
* Method to invoke to format the command for an image.
*/
this.formatCommand_ = formatCommand;
/**
* The current tag (if any).
*/
@ -40,6 +72,11 @@ function ImageHistoryTree(namespace, name, images, formatComment, formatTime) {
*/
this.currentImage_ = null;
/**
* The currently highlighted node (if any).
*/
this.currentNode_ = null;
/**
* Counter for creating unique IDs.
*/
@ -54,7 +91,7 @@ ImageHistoryTree.prototype.calculateDimensions_ = function(container) {
var cw = Math.max(document.getElementById(container).clientWidth, this.maxWidth_ * DEPTH_WIDTH);
var ch = this.maxHeight_ * (DEPTH_HEIGHT + 10);
var margin = { top: 40, right: 20, bottom: 20, left: 40 };
var margin = { top: 40, right: 20, bottom: 20, left: 80 };
var m = [margin.top, margin.right, margin.bottom, margin.left];
var w = cw - m[1] - m[3];
var h = ch - m[0] - m[2];
@ -69,6 +106,25 @@ ImageHistoryTree.prototype.calculateDimensions_ = function(container) {
};
ImageHistoryTree.prototype.setupOverscroll_ = function() {
var container = this.container_;
var that = this;
var overscroll = $('#' + container).overscroll();
overscroll.on('overscroll:dragstart', function() {
$(that).trigger({
'type': 'hideTagMenu'
});
});
overscroll.on('scroll', function() {
$(that).trigger({
'type': 'hideTagMenu'
});
});
};
/**
* Updates the dimensions of the tree.
*/
@ -86,17 +142,22 @@ ImageHistoryTree.prototype.updateDimensions_ = function() {
$('#' + container).removeOverscroll();
var viewportHeight = $(window).height();
var boundingBox = document.getElementById(container).getBoundingClientRect();
document.getElementById(container).style.maxHeight = (viewportHeight - boundingBox.top - 110) + 'px';
$('#' + container).overscroll();
document.getElementById(container).style.maxHeight = (viewportHeight - boundingBox.top - 150) + 'px';
this.setupOverscroll_();
// Update the tree.
var rootSvg = this.rootSvg_;
var tree = this.tree_;
var vis = this.vis_;
var ow = w + m[1] + m[3];
var oh = h + m[0] + m[2];
rootSvg
.attr("width", w + m[1] + m[3])
.attr("height", h + m[0] + m[2]);
.attr("width", ow)
.attr("height", oh)
.attr("style", "width: " + ow + "px; height: " + oh + "px");
tree.size([w, h]);
vis.attr("transform", "translate(" + m[3] + "," + m[0] + ")");
@ -131,6 +192,8 @@ ImageHistoryTree.prototype.draw = function(container) {
var formatComment = this.formatComment_;
var formatTime = this.formatTime_;
var formatCommand = this.formatCommand_;
var tip = d3.tip()
.attr('class', 'd3-tip')
.offset([-1, 24])
@ -156,8 +219,10 @@ ImageHistoryTree.prototype.draw = function(container) {
if (d.image.comment) {
html += '<span class="comment">' + formatComment(d.image.comment) + '</span>';
}
html += '<span class="created">' + formatTime(d.image.created) + '</span>';
html += '<span class="full-id">' + d.image.id + '</span>';
if (d.image.command && d.image.command.length) {
html += '<span class="command info-line"><i class="fa fa-terminal"></i>' + formatCommand(d.image) + '</span>';
}
html += '<span class="created info-line"><i class="fa fa-calendar"></i>' + formatTime(d.image.created) + '</span>';
return html;
})
@ -178,8 +243,7 @@ ImageHistoryTree.prototype.draw = function(container) {
this.root_.y0 = 0;
this.setTag_(this.currentTag_);
$('#' + container).overscroll();
this.setupOverscroll_();
};
@ -208,6 +272,23 @@ ImageHistoryTree.prototype.setImage = function(imageId) {
};
/**
* Updates the highlighted path in the tree.
*/
ImageHistoryTree.prototype.setHighlightedPath_ = function(image) {
if (this.currentNode_) {
this.markPath_(this.currentNode_, false);
}
var imageByDBID = this.imageByDBID_;
var currentNode = imageByDBID[image.dbid];
if (currentNode) {
this.markPath_(currentNode, true);
this.currentNode_ = currentNode;
}
};
/**
* Returns the ancestors of the given image.
*/
@ -445,26 +526,15 @@ ImageHistoryTree.prototype.setTag_ = function(tagName) {
// Save the current tag.
var previousTagName = this.currentTag_;
this.currentTag_ = tagName;
this.currentImage_ = null;
// Update the state of each existing node to no longer be highlighted.
var previousImage = this.findImage_(function(image) {
return image.tags.indexOf(previousTagName || '(no tag specified)') >= 0;
});
if (previousImage) {
var currentNode = imageByDBID[previousImage.dbid];
this.markPath_(currentNode, false);
}
// Find the new current image (if any).
this.currentImage_ = this.findImage_(function(image) {
// Update the path.
var tagImage = this.findImage_(function(image) {
return image.tags.indexOf(tagName || '(no tag specified)') >= 0;
});
// Update the state of the new node path.
if (this.currentImage_) {
var currentNode = imageByDBID[this.currentImage_.dbid];
this.markPath_(currentNode, true);
if (tagImage) {
this.setHighlightedPath_(tagImage);
}
// Ensure that the children are in the correct order.
@ -508,7 +578,9 @@ ImageHistoryTree.prototype.setImage_ = function(imageId) {
return;
}
this.setHighlightedPath_(newImage);
this.currentImage_ = newImage;
this.currentTag_ = null;
this.update_(this.root_);
};
@ -637,7 +709,7 @@ ImageHistoryTree.prototype.update_ = function(source) {
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 + '"">' + tag + '</span>';
}
return html;
});
@ -649,6 +721,19 @@ ImageHistoryTree.prototype.update_ = function(source) {
if (tag) {
that.changeTag_(tag);
}
})
.on("contextmenu", function(d, e) {
d3.event.preventDefault();
var tag = this.getAttribute('data-tag');
if (tag) {
$(that).trigger({
'type': 'showTagMenu',
'tag': tag,
'clientX': d3.event.clientX,
'clientY': d3.event.clientY
});
}
});
// Ensure the tags are visible.

View file

@ -0,0 +1,32 @@
/**
* @license Angulartics v0.8.5
* (c) 2013 Luis Farzati http://luisfarzati.github.io/angulartics
* Universal Analytics update contributed by http://github.com/willmcclellan
* License: MIT
*/
(function(angular) {
'use strict';
/**
* @ngdoc overview
* @name angulartics.google.analytics
* Enables analytics support for Google Analytics (http://google.com/analytics)
*/
angular.module('angulartics.google.analytics', ['angulartics'])
.config(['$analyticsProvider', function ($analyticsProvider) {
// GA already supports buffered invocations so we don't need
// to wrap these inside angulartics.waitForVendorApi
$analyticsProvider.registerPageTrack(function (path) {
if (window._gaq) _gaq.push(['_trackPageview', path]);
if (window.ga) ga('send', 'pageview', path);
});
$analyticsProvider.registerEventTrack(function (action, properties) {
if (window._gaq) _gaq.push(['_trackEvent', properties.category, action, properties.label, properties.value]);
if (window.ga) ga('send', 'event', properties.category, action, properties.label, properties.value);
});
}]);
})(angular);

View file

@ -0,0 +1,793 @@
/**
* Overscroll v1.7.3
* A jQuery Plugin that emulates the iPhone scrolling experience in a browser.
* http://azoffdesign.com/overscroll
*
* Intended for use with the latest jQuery
* http://code.jquery.com/jquery-latest.js
*
* Copyright 2013, Jonathan Azoff
* Licensed under the MIT license.
* https://github.com/azoff/overscroll/blob/master/mit.license
*
* For API documentation, see the README file
* http://azof.fr/pYCzuM
*
* Date: Tuesday, March 18th 2013
*/
(function(global, dom, browser, math, wait, cancel, namespace, $, none){
// We want to run this plug-in in strict-mode
// so that we may benefit from its optimizations
'use strict';
// The key used to bind-instance specific data to an object
var datakey = 'overscroll';
// create <body> node if there's not one present (e.g., for test runners)
if (dom.body === null) {
dom.documentElement.appendChild(
dom.createElement('body')
);
}
// quick fix for IE 8 and below since getComputedStyle() is not supported
// TODO: find a better solution
if (!global.getComputedStyle) {
global.getComputedStyle = function (el, pseudo) {
this.el = el;
this.getPropertyValue = function (prop) {
var re = /(\-([a-z]){1})/g;
if (prop == 'float') prop = 'styleFloat';
if (re.test(prop)) {
prop = prop.replace(re, function () {
return arguments[2].toUpperCase();
});
}
return el.currentStyle[prop] ? el.currentStyle[prop] : null;
};
return this;
};
}
// runs feature detection for overscroll
var compat = {
animate: (function(){
var fn = global.requestAnimationFrame ||
global.webkitRequestAnimationFrame ||
global.mozRequestAnimationFrame ||
global.oRequestAnimationFrame ||
global.msRequestAnimationFrame ||
function(callback) { wait(callback, 1000/60); };
return function(callback) {
fn.call(global, callback);
};
})(),
overflowScrolling: (function(){
var style = '';
var div = dom.createElement('div');
var prefixes = ['webkit', 'moz', 'o', 'ms'];
dom.body.appendChild(div);
$.each(prefixes, function(i, prefix){
div.style[prefix + 'OverflowScrolling'] = 'touch';
});
div.style.overflowScrolling = 'touch';
var computedStyle = global.getComputedStyle(div);
if (!!computedStyle.overflowScrolling) {
style = 'overflow-scrolling';
} else {
$.each(prefixes, function(i, prefix){
if (!!computedStyle[prefix + 'OverflowScrolling']) {
style = '-' + prefix + '-overflow-scrolling';
}
return !style;
});
}
div.parentNode.removeChild(div);
return style;
})(),
cursor: (function() {
var div = dom.createElement('div');
var prefixes = ['webkit', 'moz'];
var gmail = 'https://mail.google.com/mail/images/2/';
var style = {
grab: 'url('+gmail+'openhand.cur), move',
grabbing: 'url('+gmail+'closedhand.cur), move'
};
dom.body.appendChild(div);
$.each(prefixes, function(i, prefix){
var found, cursor = '-' + prefix + '-grab';
div.style.cursor = cursor;
var computedStyle = global.getComputedStyle(div);
found = computedStyle.cursor === cursor;
if (found) {
style = {
grab: '-' + prefix + '-grab',
grabbing: '-' + prefix + '-grabbing'
};
}
return !found;
});
div.parentNode.removeChild(div);
return style;
})()
};
// These are all the events that could possibly
// be used by the plug-in
var events = {
drag: 'mousemove touchmove',
end: 'mouseup mouseleave click touchend touchcancel',
hover: 'mouseenter mouseleave',
ignored: 'select dragstart drag',
scroll: 'scroll',
start: 'mousedown touchstart',
wheel: 'mousewheel DOMMouseScroll'
};
// These settings are used to tweak drift settings
// for the plug-in
var settings = {
captureThreshold: 3,
driftDecay: 1.1,
driftSequences: 22,
driftTimeout: 100,
scrollDelta: 15,
thumbOpacity: 0.7,
thumbThickness: 6,
thumbTimeout: 400,
wheelDelta: 20,
wheelTicks: 120
};
// These defaults are used to complement any options
// passed into the plug-in entry point
var defaults = {
cancelOn: 'select,input,textarea',
direction: 'multi',
dragHold: false,
hoverThumbs: false,
scrollDelta: settings.scrollDelta,
showThumbs: true,
persistThumbs: false,
captureWheel: true,
wheelDelta: settings.wheelDelta,
wheelDirection: 'multi',
zIndex: 999,
ignoreSizing: false
};
// Triggers a DOM event on the overscrolled element.
// All events are namespaced under the overscroll name
function triggerEvent(event, target) {
target.trigger('overscroll:' + event);
}
// Utility function to return a timestamp
function time() {
return (new Date()).getTime();
}
// Captures the position from an event, modifies the properties
// of the second argument to persist the position, and then
// returns the modified object
function capturePosition(event, position, index) {
position.x = event.pageX;
position.y = event.pageY;
position.time = time();
position.index = index;
return position;
}
// Used to move the thumbs around an overscrolled element
function moveThumbs(thumbs, sizing, left, top) {
var ml, mt;
if (thumbs && thumbs.added) {
if (thumbs.horizontal) {
ml = left * (1 + sizing.container.width / sizing.container.scrollWidth);
mt = top + sizing.thumbs.horizontal.top;
thumbs.horizontal.css('margin', mt + 'px 0 0 ' + ml + 'px');
}
if (thumbs.vertical) {
ml = left + sizing.thumbs.vertical.left;
mt = top * (1 + sizing.container.height / sizing.container.scrollHeight);
thumbs.vertical.css('margin', mt + 'px 0 0 ' + ml + 'px');
}
}
}
// Used to toggle the thumbs on and off
// of an overscrolled element
function toggleThumbs(thumbs, options, dragging) {
if (thumbs && thumbs.added && !options.persistThumbs) {
if (dragging) {
if (thumbs.vertical) {
thumbs.vertical.stop(true, true).fadeTo('fast', settings.thumbOpacity);
}
if (thumbs.horizontal) {
thumbs.horizontal.stop(true, true).fadeTo('fast', settings.thumbOpacity);
}
} else {
if (thumbs.vertical) {
thumbs.vertical.fadeTo('fast', 0);
}
if (thumbs.horizontal) {
thumbs.horizontal.fadeTo('fast', 0);
}
}
}
}
// Defers click event listeners to after a mouseup event.
// Used to avoid unintentional clicks
function deferClick(target) {
var clicks, key = 'events';
var events = $._data ? $._data(target[0], key) : target.data(key);
if (events && events.click) {
clicks = events.click.slice();
target.off('click').one('click', function(){
$.each(clicks, function(i, click){
target.click(click);
}); return false;
});
}
}
// Toggles thumbs on hover. This event is only triggered
// if the hoverThumbs option is set
function hover(event) {
var data = event.data,
thumbs = data.thumbs,
options = data.options,
dragging = event.type === 'mouseenter';
toggleThumbs(thumbs, options, dragging);
}
// This function is only ever used when the overscrolled element
// scrolled outside of the scope of this plugin.
function scroll(event) {
var data = event.data;
if (!data.flags.dragged) {
/*jshint validthis:true */
moveThumbs(data.thumbs, data.sizing, this.scrollLeft, this.scrollTop);
}
}
// handles mouse wheel scroll events
function wheel(event) {
// prevent any default wheel behavior
event.preventDefault();
var data = event.data,
options = data.options,
sizing = data.sizing,
thumbs = data.thumbs,
dwheel = data.wheel,
flags = data.flags,
original = event.originalEvent,
delta = 0, deltaX = 0, deltaY = 0;
// stop any drifts
flags.drifting = false;
// normalize the wheel ticks
if (original.detail) {
delta = -original.detail;
if (original.detailX) {
deltaX = -original.detailX;
}
if (original.detailY) {
deltaY = -original.detailY;
}
} else if (original.wheelDelta) {
delta = original.wheelDelta / settings.wheelTicks;
if (original.wheelDeltaX) {
deltaX = original.wheelDeltaX / settings.wheelTicks;
}
if (original.wheelDeltaY) {
deltaY = original.wheelDeltaY / settings.wheelTicks;
}
}
// apply a pixel delta to each tick
delta *= options.wheelDelta;
deltaX *= options.wheelDelta;
deltaY *= options.wheelDelta;
// initialize flags if this is the first tick
if (!dwheel) {
data.target.data(datakey).dragging = flags.dragging = true;
data.wheel = dwheel = { timeout: null };
toggleThumbs(thumbs, options, true);
}
// actually modify scroll offsets
if (options.wheelDirection === 'vertical'){
/*jshint validthis:true */
this.scrollTop -= delta;
} else if ( options.wheelDirection === 'horizontal') {
this.scrollLeft -= delta;
} else {
this.scrollLeft -= deltaX;
this.scrollTop -= deltaY || delta;
}
if (dwheel.timeout) { cancel(dwheel.timeout); }
moveThumbs(thumbs, sizing, this.scrollLeft, this.scrollTop);
dwheel.timeout = wait(function() {
data.target.data(datakey).dragging = flags.dragging = false;
toggleThumbs(thumbs, options, data.wheel = null);
}, settings.thumbTimeout);
}
// updates the current scroll offset during a mouse move
function drag(event) {
event.preventDefault();
var data = event.data,
touches = event.originalEvent.touches,
options = data.options,
sizing = data.sizing,
thumbs = data.thumbs,
position = data.position,
flags = data.flags,
target = data.target.get(0);
// correct page coordinates for touch devices
if (touches && touches.length) {
event = touches[0];
}
if (!flags.dragged) {
toggleThumbs(thumbs, options, true);
}
flags.dragged = true;
if (options.direction !== 'vertical') {
target.scrollLeft -= (event.pageX - position.x);
}
if (data.options.direction !== 'horizontal') {
target.scrollTop -= (event.pageY - position.y);
}
capturePosition(event, data.position);
if (--data.capture.index <= 0) {
data.target.data(datakey).dragging = flags.dragging = true;
capturePosition(event, data.capture, settings.captureThreshold);
}
moveThumbs(thumbs, sizing, target.scrollLeft, target.scrollTop);
}
// sends the overscrolled element into a drift
function drift(target, event, callback) {
var data = event.data, dx, dy, xMod, yMod,
capture = data.capture,
options = data.options,
sizing = data.sizing,
thumbs = data.thumbs,
elapsed = time() - capture.time,
scrollLeft = target.scrollLeft,
scrollTop = target.scrollTop,
decay = settings.driftDecay;
// only drift if enough time has passed since
// the last capture event
if (elapsed > settings.driftTimeout) {
callback(data); return;
}
// determine offset between last capture and current time
dx = options.scrollDelta * (event.pageX - capture.x);
dy = options.scrollDelta * (event.pageY - capture.y);
// update target scroll offsets
if (options.direction !== 'vertical') {
scrollLeft -= dx;
} if (options.direction !== 'horizontal') {
scrollTop -= dy;
}
// split the distance to travel into a set of sequences
xMod = dx / settings.driftSequences;
yMod = dy / settings.driftSequences;
triggerEvent('driftstart', data.target);
data.drifting = true;
// animate the drift sequence
compat.animate(function render() {
if (data.drifting) {
var min = 1, max = -1;
data.drifting = false;
if (yMod > min && target.scrollTop > scrollTop || yMod < max && target.scrollTop < scrollTop) {
data.drifting = true;
target.scrollTop -= yMod;
yMod /= decay;
}
if (xMod > min && target.scrollLeft > scrollLeft || xMod < max && target.scrollLeft < scrollLeft) {
data.drifting = true;
target.scrollLeft -= xMod;
xMod /= decay;
}
moveThumbs(thumbs, sizing, target.scrollLeft, target.scrollTop);
compat.animate(render);
} else {
triggerEvent('driftend', data.target);
callback(data);
}
});
}
// starts the drag operation and binds the mouse move handler
function start(event) {
var data = event.data,
touches = event.originalEvent.touches,
target = data.target,
dstart = data.start = $(event.target),
flags = data.flags;
// stop any drifts
flags.drifting = false;
// only start drag if the user has not explictly banned it.
if (dstart.size() && !dstart.is(data.options.cancelOn)) {
// without this the simple "click" event won't be recognized on touch clients
if (!touches) { event.preventDefault(); }
if (!compat.overflowScrolling) {
target.css('cursor', compat.cursor.grabbing);
target.data(datakey).dragging = flags.dragging = flags.dragged = false;
// apply the drag listeners to the doc or target
if(data.options.dragHold) {
$(document).on(events.drag, data, drag);
} else {
target.on(events.drag, data, drag);
}
}
data.position = capturePosition(event, {});
data.capture = capturePosition(event, {}, settings.captureThreshold);
triggerEvent('dragstart', target);
}
}
// ends the drag operation and unbinds the mouse move handler
function stop(event) {
var data = event.data,
target = data.target,
options = data.options,
flags = data.flags,
thumbs = data.thumbs,
// hides the thumbs after the animation is done
done = function () {
if (thumbs && !options.hoverThumbs) {
toggleThumbs(thumbs, options, false);
}
};
// remove drag listeners from doc or target
if(options.dragHold) {
$(document).unbind(events.drag, drag);
} else {
target.unbind(events.drag, drag);
}
// only fire events and drift if we started with a
// valid position
if (data.position) {
triggerEvent('dragend', target);
// only drift if a drag passed our threshold
if (flags.dragging && !compat.overflowScrolling) {
drift(target.get(0), event, done);
} else {
done();
}
}
// only if we moved, and the mouse down is the same as
// the mouse up target do we defer the event
if (flags.dragging && !compat.overflowScrolling && data.start && data.start.is(event.target)) {
deferClick(data.start);
}
// clear all internal flags and settings
target.data(datakey).dragging =
data.start =
data.capture =
data.position =
flags.dragged =
flags.dragging = false;
// set the cursor back to normal
target.css('cursor', compat.cursor.grab);
}
// Ensures that a full set of options are provided
// for the plug-in. Also does some validation
function getOptions(options) {
// fill in missing values with defaults
options = $.extend({}, defaults, options);
// check for inconsistent directional restrictions
if (options.direction !== 'multi' && options.direction !== options.wheelDirection) {
options.wheelDirection = options.direction;
}
// ensure positive values for deltas
options.scrollDelta = math.abs(parseFloat(options.scrollDelta));
options.wheelDelta = math.abs(parseFloat(options.wheelDelta));
// fix values for scroll offset
options.scrollLeft = options.scrollLeft === none ? null : math.abs(parseFloat(options.scrollLeft));
options.scrollTop = options.scrollTop === none ? null : math.abs(parseFloat(options.scrollTop));
return options;
}
// Returns the sizing information (bounding box) for the
// target DOM element
function getSizing(target) {
var $target = $(target),
width = $target.width(),
height = $target.height(),
scrollWidth = width >= target.scrollWidth ? width : target.scrollWidth,
scrollHeight = height >= target.scrollHeight ? height : target.scrollHeight,
hasScroll = scrollWidth > width || scrollHeight > height;
return {
valid: hasScroll,
container: {
width: width,
height: height,
scrollWidth: scrollWidth,
scrollHeight: scrollHeight
},
thumbs: {
horizontal: {
width: width * width / scrollWidth,
height: settings.thumbThickness,
corner: settings.thumbThickness / 2,
left: 0,
top: height - settings.thumbThickness
},
vertical: {
width: settings.thumbThickness,
height: height * height / scrollHeight,
corner: settings.thumbThickness / 2,
left: width - settings.thumbThickness,
top: 0
}
}
};
}
// Attempts to get (or implicitly creates) the
// remover function for the target passed
// in as an argument
function getRemover(target, orCreate) {
var $target = $(target), thumbs,
data = $target.data(datakey) || {},
style = $target.attr('style'),
fallback = orCreate ? function () {
data = $target.data(datakey);
thumbs = data.thumbs;
// restore original styles (if any)
if (style) {
$target.attr('style', style);
} else {
$target.removeAttr('style');
}
// remove any created thumbs
if (thumbs) {
if (thumbs.horizontal) { thumbs.horizontal.remove(); }
if (thumbs.vertical) { thumbs.vertical.remove(); }
}
// remove any bound overscroll events and data
$target
.removeData(datakey)
.off(events.wheel, wheel)
.off(events.start, start)
.off(events.end, stop)
.off(events.ignored, ignore);
} : $.noop;
return $.isFunction(data.remover) ? data.remover : fallback;
}
// Genterates CSS specific to a particular thumb.
// It requires sizing data and options
function getThumbCss(size, options) {
return {
position: 'absolute',
opacity: options.persistThumbs ? settings.thumbOpacity : 0,
'background-color': 'black',
width: size.width + 'px',
height: size.height + 'px',
'border-radius': size.corner + 'px',
'margin': size.top + 'px 0 0 ' + size.left + 'px',
'z-index': options.zIndex
};
}
// Creates the DOM elements used as "thumbs" within
// the target container.
function createThumbs(target, sizing, options) {
var div = '<div/>',
thumbs = {},
css = false;
if (sizing.container.scrollWidth > 0 && options.direction !== 'vertical') {
css = getThumbCss(sizing.thumbs.horizontal, options);
thumbs.horizontal = $(div).css(css).prependTo(target);
}
if (sizing.container.scrollHeight > 0 && options.direction !== 'horizontal') {
css = getThumbCss(sizing.thumbs.vertical, options);
thumbs.vertical = $(div).css(css).prependTo(target);
}
thumbs.added = !!css;
return thumbs;
}
// ignores events on the overscroll element
function ignore(event) {
event.preventDefault();
}
// This function takes a jQuery element, some
// (optional) options, and sets up event metadata
// for each instance the plug-in affects
function setup(target, options) {
// create initial data properties for this instance
options = getOptions(options);
var sizing = getSizing(target),
thumbs, data = {
options: options, sizing: sizing,
flags: { dragging: false },
remover: getRemover(target, true)
};
// only apply handlers if the overscrolled element
// actually has an area to scroll
if (sizing.valid || options.ignoreSizing) {
// provide a circular-reference, enable events, and
// apply any required CSS
data.target = target = $(target).css({
position: 'relative',
cursor: compat.cursor.grab
}).on(events.start, data, start)
.on(events.end, data, stop)
.on(events.ignored, data, ignore);
// apply the stop listeners for drag end
if(options.dragHold) {
$(document).on(events.end, data, stop);
} else {
data.target.on(events.end, data, stop);
}
// apply any user-provided scroll offsets
if (options.scrollLeft !== null) {
target.scrollLeft(options.scrollLeft);
} if (options.scrollTop !== null) {
target.scrollTop(options.scrollTop);
}
// use native oversroll, if it exists
if (compat.overflowScrolling) {
target.css(compat.overflowScrolling, 'touch');
} else {
target.on(events.scroll, data, scroll);
}
// check to see if the user would like mousewheel support
if (options.captureWheel) {
target.on(events.wheel, data, wheel);
}
// add thumbs and listeners (if we're showing them)
if (options.showThumbs) {
if (compat.overflowScrolling) {
target.css('overflow', 'scroll');
} else {
target.css('overflow', 'hidden');
data.thumbs = thumbs = createThumbs(target, sizing, options);
if (thumbs.added) {
moveThumbs(thumbs, sizing, target.scrollLeft(), target.scrollTop());
if (options.hoverThumbs) {
target.on(events.hover, data, hover);
}
}
}
} else {
target.css('overflow', 'hidden');
}
target.data(datakey, data);
}
}
// Removes any event listeners and other instance-specific
// data from the target. It attempts to leave the target
// at the state it found it.
function teardown(target) {
getRemover(target)();
}
// This is the entry-point for enabling the plug-in;
// You can find it's exposure point at the end
// of this closure
function overscroll(options) {
/*jshint validthis:true */
return this.removeOverscroll().each(function() {
setup(this, options);
});
}
// This is the entry-point for disabling the plug-in;
// You can find it's exposure point at the end
// of this closure
function removeOverscroll() {
/*jshint validthis:true */
return this.each(function () {
teardown(this);
});
}
// Extend overscroll to expose settings to the user
overscroll.settings = settings;
// Extend jQuery's prototype to expose the plug-in.
// If the supports native overflowScrolling, overscroll will not
// attempt to override the browser's built in support
$.extend(namespace, {
overscroll: overscroll,
removeOverscroll: removeOverscroll
});
})(window, document, navigator, Math, setTimeout, clearTimeout, jQuery.fn, jQuery);

102
static/lib/loading-bar.css Executable file
View file

@ -0,0 +1,102 @@
/* Make clicks pass-through */
#loading-bar,
#loading-bar-spinner {
pointer-events: none;
-webkit-pointer-events: none;
-webkit-transition: 0.5s linear all;
-moz-transition: 0.5s linear all;
-o-transition: 0.5s linear all;
transition: 0.5s linear all;
}
#loading-bar.ng-enter,
#loading-bar.ng-leave.ng-leave-active,
#loading-bar-spinner.ng-enter,
#loading-bar-spinner.ng-leave.ng-leave-active {
opacity: 0;
}
#loading-bar.ng-enter.ng-enter-active,
#loading-bar.ng-leave,
#loading-bar-spinner.ng-enter.ng-enter-active,
#loading-bar-spinner.ng-leave {
opacity: 1;
}
#loading-bar .bar {
-webkit-transition: width 350ms;
-moz-transition: width 350ms;
-o-transition: width 350ms;
transition: width 350ms;
background: #29d;
position: fixed;
z-index: 2000;
top: 0;
left: 0;
width: 100%;
height: 2px;
}
/* Fancy blur effect */
#loading-bar .peg {
display: block;
position: absolute;
right: 0px;
width: 100px;
height: 100%;
box-shadow: 0 0 10px #29d, 0 0 5px #29d;
opacity: 1.0;
-webkit-transform: rotate(3deg) translate(0px, -4px);
-moz-transform: rotate(3deg) translate(0px, -4px);
-ms-transform: rotate(3deg) translate(0px, -4px);
-o-transform: rotate(3deg) translate(0px, -4px);
transform: rotate(3deg) translate(0px, -4px);
}
#loading-bar-spinner {
display: block;
position: fixed;
z-index: 100;
top: 10px;
left: 10px;
}
#loading-bar-spinner .spinner-icon {
width: 14px;
height: 14px;
border: solid 2px transparent;
border-top-color: #29d;
border-left-color: #29d;
border-radius: 10px;
-webkit-animation: loading-bar-spinner 400ms linear infinite;
-moz-animation: loading-bar-spinner 400ms linear infinite;
-ms-animation: loading-bar-spinner 400ms linear infinite;
-o-animation: loading-bar-spinner 400ms linear infinite;
animation: loading-bar-spinner 400ms linear infinite;
}
@-webkit-keyframes loading-bar-spinner {
0% { -webkit-transform: rotate(0deg); transform: rotate(0deg); }
100% { -webkit-transform: rotate(360deg); transform: rotate(360deg); }
}
@-moz-keyframes loading-bar-spinner {
0% { -moz-transform: rotate(0deg); transform: rotate(0deg); }
100% { -moz-transform: rotate(360deg); transform: rotate(360deg); }
}
@-o-keyframes loading-bar-spinner {
0% { -o-transform: rotate(0deg); transform: rotate(0deg); }
100% { -o-transform: rotate(360deg); transform: rotate(360deg); }
}
@-ms-keyframes loading-bar-spinner {
0% { -ms-transform: rotate(0deg); transform: rotate(0deg); }
100% { -ms-transform: rotate(360deg); transform: rotate(360deg); }
}
@keyframes loading-bar-spinner {
0% { transform: rotate(0deg); transform: rotate(0deg); }
100% { transform: rotate(360deg); transform: rotate(360deg); }
}

271
static/lib/loading-bar.js Executable file
View file

@ -0,0 +1,271 @@
/*
* angular-loading-bar
*
* intercepts XHR requests and creates a loading bar.
* Based on the excellent nprogress work by rstacruz (more info in readme)
*
* (c) 2013 Wes Cruver
* License: MIT
*/
(function() {
'use strict';
// Alias the loading bar so it can be included using a simpler
// (and maybe more professional) module name:
angular.module('angular-loading-bar', ['chieffancypants.loadingBar']);
/**
* loadingBarInterceptor service
*
* Registers itself as an Angular interceptor and listens for XHR requests.
*/
angular.module('chieffancypants.loadingBar', [])
.config(['$httpProvider', function ($httpProvider) {
var interceptor = ['$q', '$cacheFactory', 'cfpLoadingBar', function ($q, $cacheFactory, cfpLoadingBar) {
/**
* The total number of requests made
*/
var reqsTotal = 0;
/**
* The number of requests completed (either successfully or not)
*/
var reqsCompleted = 0;
/**
* calls cfpLoadingBar.complete() which removes the
* loading bar from the DOM.
*/
function setComplete() {
cfpLoadingBar.complete();
reqsCompleted = 0;
reqsTotal = 0;
}
/**
* Determine if the response has already been cached
* @param {Object} config the config option from the request
* @return {Boolean} retrns true if cached, otherwise false
*/
function isCached(config) {
var cache;
var defaults = $httpProvider.defaults;
if (config.method !== 'GET' || config.cache === false) {
config.cached = false;
return false;
}
if (config.cache === true && defaults.cache === undefined) {
cache = $cacheFactory.get('$http');
} else if (defaults.cache !== undefined) {
cache = defaults.cache;
} else {
cache = config.cache;
}
var cached = cache !== undefined ?
cache.get(config.url) !== undefined : false;
if (config.cached !== undefined && cached !== config.cached) {
return config.cached;
}
config.cached = cached;
return cached;
}
return {
'request': function(config) {
// Check to make sure this request hasn't already been cached and that
// the requester didn't explicitly ask us to ignore this request:
if (!config.ignoreLoadingBar && !isCached(config)) {
if (reqsTotal === 0) {
cfpLoadingBar.start();
}
reqsTotal++;
}
return config;
},
'response': function(response) {
if (!isCached(response.config)) {
reqsCompleted++;
if (reqsCompleted >= reqsTotal) {
setComplete();
} else {
cfpLoadingBar.set(reqsCompleted / reqsTotal);
}
}
return response;
},
'responseError': function(rejection) {
if (!isCached(rejection.config)) {
reqsCompleted++;
if (reqsCompleted >= reqsTotal) {
setComplete();
} else {
cfpLoadingBar.set(reqsCompleted / reqsTotal);
}
}
return $q.reject(rejection);
}
};
}];
$httpProvider.interceptors.push(interceptor);
}])
/**
* Loading Bar
*
* This service handles adding and removing the actual element in the DOM.
* Generally, best practices for DOM manipulation is to take place in a
* directive, but because the element itself is injected in the DOM only upon
* XHR requests, and it's likely needed on every view, the best option is to
* use a service.
*/
.provider('cfpLoadingBar', function() {
this.includeSpinner = true;
this.includeBar = true;
this.parentSelector = 'body';
this.$get = ['$document', '$timeout', '$animate', '$rootScope', function ($document, $timeout, $animate, $rootScope) {
var $parentSelector = this.parentSelector,
$parent = $document.find($parentSelector),
loadingBarContainer = angular.element('<div id="loading-bar"><div class="bar"><div class="peg"></div></div></div>'),
loadingBar = loadingBarContainer.find('div').eq(0),
spinner = angular.element('<div id="loading-bar-spinner"><div class="spinner-icon"></div></div>');
var incTimeout,
completeTimeout,
started = false,
status = 0;
var includeSpinner = this.includeSpinner;
var includeBar = this.includeBar;
/**
* Inserts the loading bar element into the dom, and sets it to 2%
*/
function _start() {
$timeout.cancel(completeTimeout);
// do not continually broadcast the started event:
if (started) {
return;
}
$rootScope.$broadcast('cfpLoadingBar:started');
started = true;
if (includeBar) {
$animate.enter(loadingBarContainer, $parent);
}
if (includeSpinner) {
$animate.enter(spinner, $parent);
}
_set(0.02);
}
/**
* Set the loading bar's width to a certain percent.
*
* @param n any value between 0 and 1
*/
function _set(n) {
if (!started) {
return;
}
var pct = (n * 100) + '%';
loadingBar.css('width', pct);
status = n;
// increment loadingbar to give the illusion that there is always
// progress but make sure to cancel the previous timeouts so we don't
// have multiple incs running at the same time.
$timeout.cancel(incTimeout);
incTimeout = $timeout(function() {
_inc();
}, 250);
}
/**
* Increments the loading bar by a random amount
* but slows down as it progresses
*/
function _inc() {
if (_status() >= 1) {
return;
}
var rnd = 0;
// TODO: do this mathmatically instead of through conditions
var stat = _status();
if (stat >= 0 && stat < 0.25) {
// Start out between 3 - 6% increments
rnd = (Math.random() * (5 - 3 + 1) + 3) / 100;
} else if (stat >= 0.25 && stat < 0.65) {
// increment between 0 - 3%
rnd = (Math.random() * 3) / 100;
} else if (stat >= 0.65 && stat < 0.9) {
// increment between 0 - 2%
rnd = (Math.random() * 2) / 100;
} else if (stat >= 0.9 && stat < 0.99) {
// finally, increment it .5 %
rnd = 0.005;
} else {
// after 99%, don't increment:
rnd = 0;
}
var pct = _status() + rnd;
_set(pct);
}
function _status() {
return status;
}
function _complete() {
$rootScope.$broadcast('cfpLoadingBar:completed');
_set(1);
// Attempt to aggregate any start/complete calls within 500ms:
completeTimeout = $timeout(function() {
$animate.leave(loadingBarContainer, function() {
status = 0;
started = false;
});
$animate.leave(spinner);
}, 500);
}
return {
start : _start,
set : _set,
status : _status,
inc : _inc,
complete : _complete,
includeSpinner : this.includeSpinner,
parentSelector : this.parentSelector
};
}]; //
}); // wtf javascript. srsly
})(); //

View file

@ -93,6 +93,15 @@ Email: my@email.com</pre>
</ul>
</div>
<h3>Deleting a tag <span class="label label-info">Requires Admin Access</span></h3>
<div class="container">
<div class="description-overview">
A specific tag and all its images can be deleted by right clicking on the tag in the repository history tree and choosing "Delete Tag". This will delete the tag and any images <b>unique to it</b>. Images will not be deleted until all tags sharing them are deleted.
</div>
</div>
<a name="#post-hook"></a>
<h3>Using push webhooks <span class="label label-info">Requires Admin Access</span></h3>
<div class="container">
@ -101,16 +110,16 @@ Email: my@email.com</pre>
as an HTTP <b>POST</b> to the specified URL, with a JSON body describing the push:<br><br>
<pre>
{
<span class="code-info" title="The number of images pushed" bs-tooltip="tooltip.title">"pushed_image_count"</span>: 2,
<span class="code-info" title="The name of the repository (without its namespace)" bs-tooltip="tooltip.title">"name"</span>: "ubuntu",
<span class="code-info" title="The full name of the repository" bs-tooltip="tooltip.title">"repository"</span>:"devtable/ubuntu",
<span class="code-info" title="The URL at which the repository can be pulled by Docker" bs-tooltip="tooltip.title">"docker_url"</span>: "quay.io/devtable/ubuntu",
<span class="code-info" title="Map of updated tag names to their latest image IDs" bs-tooltip="tooltip.title">"updated_tags"</span>: {
<span class="context-tooltip" title="The number of images pushed" bs-tooltip="tooltip.title">"pushed_image_count"</span>: 2,
<span class="context-tooltip" title="The name of the repository (without its namespace)" bs-tooltip="tooltip.title">"name"</span>: "ubuntu",
<span class="context-tooltip" title="The full name of the repository" bs-tooltip="tooltip.title">"repository"</span>:"devtable/ubuntu",
<span class="context-tooltip" title="The URL at which the repository can be pulled by Docker" bs-tooltip="tooltip.title">"docker_url"</span>: "quay.io/devtable/ubuntu",
<span class="context-tooltip" title="Map of updated tag names to their latest image IDs" bs-tooltip="tooltip.title">"updated_tags"</span>: {
"latest": "b750fe79269d2ec9a3c593ef05b4332b1d1a02a62b4accb2c21d589ff2f5f2dc"
},
<span class="code-info" title="The namespace of the repository" bs-tooltip="tooltip.title">"namespace"</span>: "devtable",
<span class="code-info" title="Whether the repository is public or private" bs-tooltip="tooltip.title">"visibility"</span>: "private",
<span class="code-info" title="The Quay URL for the repository" bs-tooltip="tooltip.title">"homepage"</span>: "https://quay.io/repository/devtable/ubuntu"
<span class="context-tooltip" title="The namespace of the repository" bs-tooltip="tooltip.title">"namespace"</span>: "devtable",
<span class="context-tooltip" title="Whether the repository is public or private" bs-tooltip="tooltip.title">"visibility"</span>: "private",
<span class="context-tooltip" title="The Quay URL for the repository" bs-tooltip="tooltip.title">"homepage"</span>: "https://quay.io/repository/devtable/ubuntu"
}
</pre>
</div>

View file

@ -1,97 +1,99 @@
<div class="container" ng-show="!loading && !image">
No image found
</div>
<div class="resource-view" resource="image" error-message="'No image found'">
<div class="container repo repo-image-view">
<div class="header">
<a href="{{ '/repository/' + repo.namespace + '/' + repo.name }}" class="back"><i class="fa fa-chevron-left"></i></a>
<h3>
<i class="fa fa-archive fa-lg" 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.value.id.substr(0, 12)}}</span>
</h3>
</div>
<div class="loading" ng-show="loading">
<i class="fa fa-spinner fa-spin fa-3x"></i>
</div>
<!-- Comment -->
<blockquote ng-show="image.value.comment">
<span class="markdown-view" content="image.value.comment"></span>
</blockquote>
<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="fa fa-chevron-left"></i></a>
<h3>
<i class="fa fa-archive fa-lg" 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>
<!-- Comment -->
<blockquote ng-show="image.comment">
<span class="markdown-view" content="image.comment"></span>
</blockquote>
<!-- 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="fa fa-copy"></i>
</span>
<!-- 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.value.id }}" readonly>
<span id="copyClipboard" class="input-group-addon" title="Copy to Clipboard" data-clipboard-target="full-id">
<i class="fa fa-copy"></i>
</span>
</div>
</div>
<div id="clipboardCopied" style="display: none">
Copied to clipboard
</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>
</dd>
<dt>Created</dt>
<dd am-time-ago="parseDate(image.value.created)"></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">{{ image.value.size | bytes }}</span>
</dd>
<!-- Changes tabs -->
<div ng-show="combinedChanges.length > 0">
<b>File Changes:</b>
<br>
<br>
<ul class="nav nav-tabs">
<li class="active"><a href="javascript:void(0)" data-toggle="tab" data-target="#filterable">Filterable View</a></li>
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#tree" ng-click="initializeTree()">Tree View</a></li>
</ul>
</div>
<dt ng-show="image.value.command && image.value.command.length">Command</dt>
<dd ng-show="image.value.command && image.value.command.length">
<pre class="formatted-command">{{ getFormattedCommand(image.value) }}</pre>
</dd>
</dl>
<!-- Changes tab content -->
<div class="tab-content" ng-show="combinedChanges.length > 0">
<!-- Filterable view -->
<div class="tab-pane active" id="filterable">
<div class="changes-container full-changes-container">
<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 style="height: 28px;"></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': 'fa fa-plus-square', 'removed': 'fa fa-minus-square', 'changed': 'fa fa-pencil-square'}[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>
<!-- Changes tabs -->
<div ng-show="combinedChanges.length > 0">
<b>File Changes:</b>
<br>
<br>
<ul class="nav nav-tabs">
<li class="active"><a href="javascript:void(0)" data-toggle="tab" data-target="#filterable">Filterable View</a></li>
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#tree" ng-click="initializeTree()">Tree View</a></li>
</ul>
</div>
<!-- Changes tab content -->
<div class="tab-content" ng-show="combinedChanges.length > 0">
<!-- Filterable view -->
<div class="tab-pane active" id="filterable">
<div class="changes-container full-changes-container">
<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 style="height: 28px;"></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': 'fa fa-plus-square', 'removed': 'fa fa-minus-square', 'changed': 'fa fa-pencil-square'}[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>
</div>
<!-- Tree view -->
<div class="tab-pane" id="tree">
<div id="changes-tree-container" class="changes-container" onresize="tree && tree.notifyResized()"></div>
</div>
<!-- Tree view -->
<div class="tab-pane" id="tree">
<div id="changes-tree-container" class="changes-container" onresize="tree && tree.notifyResized()"></div>
</div>
</div>
</div>
</div>

View file

@ -5,29 +5,32 @@
<div ng-show="user.anonymous">
<h1>Secure hosting for <b>private</b> Docker<a class="disclaimer-link" href="/disclaimer" target="_self">*</a> repositories</h1>
<h3>Use the Docker images <b>your team</b> needs with the safety of <b>private</b> repositories</h3>
<div class="sellcall"><a href="/plans/">Private repository plans starting at $7/mo</a></div>
<div class="sellcall"><a href="/plans/">Private repository plans starting at $12/mo</a></div>
</div>
<div ng-show="!user.anonymous">
<div ng-show="loadingmyrepos">
<i class="fa fa-spinner fa-spin fa-3x"></i>
</div>
<span class="namespace-selector" user="user" namespace="namespace" ng-show="!loadingmyrepos && user.organizations"></span>
<div ng-show="!loadingmyrepos && myrepos.length > 0">
<h2>Top Repositories</h2>
<div class="repo-listing" ng-repeat="repository in myrepos">
<span class="repo-circle no-background" repo="repository"></span>
<a ng-href="/repository/{{ repository.namespace }}/{{ repository.name }}">{{repository.namespace}}/{{repository.name}}</a>
<div class="markdown-view description" content="repository.description" first-line-only="true"></div>
<span class="namespace-selector" user="user" namespace="namespace" ng-show="user.organizations"></span>
<div class="resource-view" resource="my_repositories">
<!-- Repos -->
<div ng-show="my_repositories.value.length > 0">
<h2>Top Repositories</h2>
<div class="repo-listing" ng-repeat="repository in my_repositories.value">
<span class="repo-circle no-background" repo="repository"></span>
<a ng-href="/repository/{{ repository.namespace }}/{{ repository.name }}">{{repository.namespace}}/{{repository.name}}</a>
<div class="markdown-view description" content="repository.description" first-line-only="true"></div>
</div>
</div>
</div>
<div ng-show="!loadingmyrepos && myrepos.length == 0">
<div class="sub-message" style="margin-top: 20px">
<span ng-show="namespace != user.username">You don't have access to any repositories in this organization yet.</span>
<span ng-show="namespace == user.username">You don't have any repositories yet!</span>
<div class="options">
<a class="btn btn-primary" href="/repository/">Browse all repositories</a>
<a class="btn btn-success" href="/new/" ng-show="canCreateRepo(namespace)">Create a new repository</a>
<!-- No Repos -->
<div ng-show="my_repositories.value.length == 0">
<div class="sub-message" style="margin-top: 20px">
<span ng-show="namespace != user.username">You don't have access to any repositories in this organization yet.</span>
<span ng-show="namespace == user.username">You don't have any repositories yet!</span>
<div class="options">
<a class="btn btn-primary" href="/repository/">Browse all repositories</a>
<a class="btn btn-success" href="/new/" ng-show="canCreateRepo(namespace)">Create a new repository</a>
</div>
</div>
</div>
</div>
@ -41,8 +44,8 @@
<div ng-show="!user.anonymous" class="user-welcome">
<img class="gravatar" src="//www.gravatar.com/avatar/{{ user.gravatar }}?s=128&d=identicon" />
<div class="sub-message">Welcome <b>{{ user.username }}</b>!</div>
<a ng-show="myrepos" class="btn btn-primary" href="/repository/">Browse all repositories</a>
<a ng-show="myrepos" class="btn btn-success" href="/new/">Create a new repository</a>
<a ng-show="my_repositories.value" class="btn btn-primary" href="/repository/">Browse all repositories</a>
<a class="btn btn-success" href="/new/">Create a new repository</a>
</div>
</div> <!-- col -->
</div> <!-- row -->
@ -82,7 +85,7 @@
</div>
<div class="tour-section row">
<div class="col-md-7"><img src="/static/img/user-home.png" title="User Home - Quay" data-screenshot-url="https://quay.io/" class="img-responsive"></div>
<div class="col-md-7"><img src="/static/img/user-home.png" title="User Home - Quay.io" data-screenshot-url="https://quay.io/" class="img-responsive"></div>
<div class="col-md-5">
<div class="tour-section-title">Customized for you</div>
<div class="tour-section-description">
@ -93,7 +96,7 @@
</div>
<div class="tour-section row">
<div class="col-md-7 col-md-push-5"><img src="/static/img/repo-view.png" title="Repository View - Quay" data-screenshot-url="https://quay.io/repository/devtable/complex" class="img-responsive"></div>
<div class="col-md-7 col-md-push-5"><img src="/static/img/repo-view.png" title="Repository View - Quay.io" data-screenshot-url="https://quay.io/repository/devtable/complex" class="img-responsive"></div>
<div class="col-md-5 col-md-pull-7">
<div class="tour-section-title">Useful views of respositories</div>
<div class="tour-section-description">
@ -103,7 +106,7 @@
</div>
<div class="tour-section row">
<div class="col-md-7"><img src="/static/img/repo-changes.png" title="View Image - Quay" data-screenshot-url="https://quay.io/repository/devtable/image/..." class="img-responsive"></div>
<div class="col-md-7"><img src="/static/img/repo-changes.png" title="View Image - Quay.io" data-screenshot-url="https://quay.io/repository/devtable/image/..." class="img-responsive"></div>
<div class="col-md-5">
<div class="tour-section-title">Docker diff in the cloud</div>
<div class="tour-section-description">
@ -113,7 +116,7 @@
</div>
<div class="tour-section row">
<div class="col-md-7 col-md-push-5"><img src="/static/img/repo-admin.png" title="Repository Admin - Quay" data-screenshot-url="https://quay.io/repository/devtable/complex/admin" class="img-responsive"></div>
<div class="col-md-7 col-md-push-5"><img src="/static/img/repo-admin.png" title="Repository Admin - Quay.io" data-screenshot-url="https://quay.io/repository/devtable/complex/admin" class="img-responsive"></div>
<div class="col-md-5 col-md-pull-7">
<div class="tour-section-title">Share at your control</div>
<div class="tour-section-description">

View file

@ -1,8 +1,8 @@
<div class="loading" ng-show="loading || creating">
<i class="fa fa-spinner fa-spin fa-3x"></i>
<div class="loading" ng-show="creating">
<div class="quay-spinner"></div>
</div>
<div class="container create-org" ng-show="!loading && !creating">
<div class="container create-org" ng-show="!creating">
<div class="row header-row">
<div class="col-md-8 col-md-offset-1">
@ -72,7 +72,8 @@
</div>
<div class="button-bar">
<button class="btn btn-large btn-success" type="submit" ng-disabled="newOrgForm.$invalid || !currentPlan">
<button class="btn btn-large btn-success" type="submit" ng-disabled="newOrgForm.$invalid || !currentPlan"
analytics-on analytics-event="create_organization">
Create Organization
</button>
</div>

View file

@ -1,13 +1,15 @@
<div class="container" ng-show="user.anonymous">
<h3>Please <a href="/signin/">sign in</a></h3>
<div class="col-sm-6 col-sm-offset-3">
<div class="user-setup" redirect-url="'/new/'"></div>
</div>
</div>
<div class="container" ng-show="!user.anonymous && building">
<i class="fa fa-spinner fa-spin fa-3x"></i>
<div class="quay-spinner"></div>
</div>
<div class="container" ng-show="!user.anonymous && creating">
<i class="fa fa-spinner fa-spin fa-3x"></i>
<div class="quay-spinner"></div>
</div>
<div class="container" ng-show="!user.anonymous && uploading">
@ -72,12 +74,18 @@
<!-- Payment -->
<div class="required-plan" ng-show="repo.is_public == '0' && planRequired && isUserNamespace">
<div class="alert alert-warning">
In order to make this repository private, youll need to upgrade your plan from <b>{{ subscribedPlan.title }}</b> to <b>{{ planRequired.title }}</b>. This will cost $<span>{{ planRequired.price / 100 }}</span>/month.
In order to make this repository private, youll need to upgrade your plan to
<b style="border-bottom: 1px dotted black;" bs-tooltip="'<b>' + planRequired.title + '</b><br>' + planRequired.privateRepos + ' private repositories'">
{{ planRequired.title }}
</b>.
This will cost $<span>{{ planRequired.price / 100 }}</span>/month.
</div>
<a class="btn btn-primary" ng-click="upgradePlan()" ng-show="!planChanging">Upgrade now</a>
<i class="fa fa-spinner fa-spin fa-3x" ng-show="planChanging"></i>
<div class="quay-spinner" ng-show="planChanging"></div>
</div>
<div class="quay-spinner" ng-show="repo.is_public == '0' && checkingPlan"></div>
<div class="required-plan" ng-show="repo.is_public == '0' && planRequired && !isUserNamespace">
<div class="alert alert-warning">
This organization has reached its private repository limit. Please contact your administrator.
@ -112,7 +120,10 @@
<div class="row">
<div class="col-md-1"></div>
<div class="col-md-8">
<button class="btn btn-large btn-success" type="submit" ng-disabled="newRepoForm.$invalid || (repo.is_public == '0' && planRequired)">Create Repository</button>
<button class="btn btn-large btn-success" type="submit"
ng-disabled="newRepoForm.$invalid || (repo.is_public == '0' && (planRequired || checkingPlan))">
Create Repository
</button>
</div>
</div>

View file

@ -1,12 +1,5 @@
<div class="loading" ng-show="loading">
<i class="fa fa-spinner fa-spin fa-3x"></i>
</div>
<div class="loading" ng-show="!loading && !organization">
No matching organization found
</div>
<div class="org-admin container" ng-show="!loading && organization">
<div class="resource-view" resource="orgResource" error-message="'No organization found'"></div>
<div class="org-admin container" ng-show="organization">
<div class="organization-header" organization="organization" clickable="true"></div>
<div class="row">
@ -47,66 +40,12 @@
<!-- Billing History tab -->
<div id="billing" class="tab-pane">
<div ng-show="invoiceLoading">
<i class="fa fa-spinner fa-spin fa-3x"></i>
</div>
<div ng-show="!invoiceLoading && !invoices">
No invoices have been created
</div>
<div ng-show="!invoiceLoading && invoices">
<table class="table">
<thead>
<th>Billing Date/Time</th>
<th>Amount Due</th>
<th>Status</th>
<th></th>
</thead>
<tbody class="invoice" ng-repeat="invoice in invoices">
<tr class="invoice-title">
<td ng-click="toggleInvoice(invoice.id)"><span class="invoice-datetime">{{ invoice.date * 1000 | date:'medium' }}</span></td>
<td ng-click="toggleInvoice(invoice.id)"><span class="invoice-amount">{{ invoice.amount_due / 100 }}</span></td>
<td>
<span class="invoice-status">
<span class="success" ng-show="invoice.paid">Paid - Thank you!</span>
<span class="danger" ng-show="!invoice.paid && invoice.attempted && invoice.closed">Payment failed</span>
<span class="danger" ng-show="!invoice.paid && invoice.attempted && !invoice.closed">Payment failed - Will retry soon</span>
<span class="pending" ng-show="!invoice.paid && !invoice.attempted">Payment pending</span>
</span>
</td>
<td>
<a ng-show="invoice.paid" href="/receipt?id={{ invoice.id }}" download="receipt.pdf" target="_new">
<i class="fa fa-download" title="Download Receipt" bs-tooltip="tooltip.title"></i>
</a>
</td>
</tr>
<tr ng-class="invoiceExpanded[invoice.id] ? 'in' : 'out'" class="invoice-details panel-collapse collapse">
<td colspan="3">
<dl class="dl-normal">
<dt>Billing Period</dt>
<dd>
<span>{{ invoice.period_start * 1000 | date:'mediumDate' }}</span> -
<span>{{ invoice.period_end * 1000 | date:'mediumDate' }}</span>
</dd>
<dt>Plan</dt>
<dd>
<span>{{ invoice.plan ? plan_map[invoice.plan].title : '(N/A)' }}</span>
</dd>
</dl>
</td>
</tr>
</tbody>
</table>
</div>
<div class="billing-invoices" organization="organization" visible="invoicesShown"></div>
</div>
<!-- Members tab -->
<div id="members" class="tab-pane">
<i class="fa fa-spinner fa-spin fa-3x" ng-show="membersLoading"></i>
<div class="quay-spinner" ng-show="membersLoading"></div>
<div ng-show="!membersLoading">
<div class="side-controls">
<div class="result-count">

View file

@ -1,16 +1,6 @@
<div class="org-member-logs container" ng-show="loading">
<i class="fa fa-spinner fa-spin fa-3x"></i>
</div>
<div class="container" ng-show="!loading && !organization">
Organization not found
</div>
<div class="container" ng-show="!loading && !memberInfo">
Member not found
</div>
<div class="org-member-logs container" ng-show="!loading && organization && memberInfo">
<div class="organization-header" organization="organization" clickable="true"></div>
<div class="logs-view" organization="organization" performer="memberInfo" visible="organization && memberInfo && ready"></div>
<div class="resource-view" resource="memberResource" error-message="'Member not found'">
<div class="org-member-logs container">
<div class="organization-header" organization="organization" clickable="true"></div>
<div class="logs-view" organization="organization" performer="memberInfo" visible="organization && memberInfo && ready"></div>
</div>
</div>

View file

@ -1,51 +1,45 @@
<div class="loading" ng-show="loading">
<i class="fa fa-spinner fa-spin fa-3x"></i>
</div>
<div class="resource-view" resource="orgResource" error-message="'No matching organization found'">
<div class="org-view container">
<div class="organization-header" organization="organization">
<div class="header-buttons" ng-show="organization.is_admin">
<div class="loading" ng-show="!loading && !organization">
No matching organization found
</div>
<span class="popup-input-button" pattern="TEAM_PATTERN" placeholder="'Team Name'"
submitted="createTeam(value)">
<i class="fa fa-group"></i> Create Team
</span>
<div class="org-view container" ng-show="!loading && organization">
<div class="organization-header" organization="organization">
<div class="header-buttons" ng-show="organization.is_admin">
<span class="popup-input-button" pattern="TEAM_PATTERN" placeholder="'Team Name'"
submitted="createTeam(value)">
<i class="fa fa-group"></i> Create Team
</span>
<a class="btn btn-default" href="/organization/{{ organization.name }}/admin"><i class="fa fa-gear"></i> Settings</a>
<a class="btn btn-default" href="/organization/{{ organization.name }}/admin"><i class="fa fa-gear"></i> Settings</a>
</div>
</div>
</div>
<div class="row hidden-xs">
<div class="col-md-4 col-md-offset-8 col-sm-5 col-sm-offset-7 header-col" ng-show="organization.is_admin">
Team Permissions
<i class="info-icon fa fa-info-circle" data-placement="bottom" data-original-title="" title=""
data-content="Global permissions for the team and its members<br><br><dl><dt>Member</dt><dd>Permissions are assigned on a per repository basis</dd><dt>Creator</dt><dd>A team can create its own repositories</dd><dt>Admin</dt><dd>A team has full control of the organization</dd></dl>"></i>
<div class="row hidden-xs">
<div class="col-md-4 col-md-offset-8 col-sm-5 col-sm-offset-7 header-col" ng-show="organization.is_admin">
Team Permissions
<i class="info-icon fa fa-info-circle" data-placement="bottom" data-original-title="" title=""
data-content="Global permissions for the team and its members<br><br><dl><dt>Member</dt><dd>Permissions are assigned on a per repository basis</dd><dt>Creator</dt><dd>A team can create its own repositories</dd><dt>Admin</dt><dd>A team has full control of the organization</dd></dl>"></i>
</div>
</div>
</div>
<div class="team-listing" ng-repeat="(name, team) in organization.teams">
<div id="team-{{name}}" class="row">
<div class="col-sm-7 col-md-8">
<div class="team-title">
<i class="fa fa-group"></i>
<span ng-show="team.can_view">
<a href="/organization/{{ organization.name }}/teams/{{ team.name }}">{{ team.name }}</a>
</span>
<span ng-show="!team.can_view">
{{ team.name }}
</span>
<div class="team-listing" ng-repeat="(name, team) in organization.teams">
<div id="team-{{name}}" class="row">
<div class="col-sm-7 col-md-8">
<div class="team-title">
<i class="fa fa-group"></i>
<span ng-show="team.can_view">
<a href="/organization/{{ organization.name }}/teams/{{ team.name }}">{{ team.name }}</a>
</span>
<span ng-show="!team.can_view">
{{ team.name }}
</span>
</div>
<div class="team-description markdown-view" content="team.description" first-line-only="true"></div>
</div>
<div class="team-description markdown-view" content="team.description" first-line-only="true"></div>
</div>
<div class="col-sm-5 col-md-4 control-col" ng-show="organization.is_admin">
<span class="role-group" current-role="team.role" role-changed="setRole(role, team.name)" roles="teamRoles"></span>
<button class="btn btn-sm btn-danger" ng-click="askDeleteTeam(team.name)">Delete</button>
<div class="col-sm-5 col-md-4 control-col" ng-show="organization.is_admin">
<span class="role-group" current-role="team.role" role-changed="setRole(role, team.name)" roles="teamRoles"></span>
<button class="btn btn-sm btn-danger" ng-click="askDeleteTeam(team.name)">Delete</button>
</div>
</div>
</div>
</div>

View file

@ -1,6 +1,6 @@
<div class="container org-list conntent-container">
<div class="loading" ng-show="loading">
<i class="fa fa-spinner fa-spin fa-3x"></i>
<div class="loading" ng-show="!user">
<div class="quay-spinner"></div>
</div>
<div class="button-bar-right">
@ -43,7 +43,7 @@
<div class="tour-section row">
<div class="col-md-7"><img src="/static/img/org-repo-list.png" title="Repositories - Quay" data-screenshot-url="https://quay.io/repository/" class="img-responsive"></div>
<div class="col-md-7"><img src="/static/img/org-repo-list.png" title="Repositories - Quay.io" data-screenshot-url="https://quay.io/repository/" class="img-responsive"></div>
<div class="col-md-5">
<div class="tour-section-title">A central collection of repositories</div>
<div class="tour-section-description">
@ -57,7 +57,7 @@
</div>
<div class="tour-section row">
<div class="col-md-7 col-md-push-5"><img src="/static/img/org-admin.png" title="buynlarge Admin - Quay" data-screenshot-url="https://quay.io/organization/buynlarge/admin" class="img-responsive"></div>
<div class="col-md-7 col-md-push-5"><img src="/static/img/org-admin.png" title="buynlarge Admin - Quay.io" data-screenshot-url="https://quay.io/organization/buynlarge/admin" class="img-responsive"></div>
<div class="col-md-5 col-md-pull-7">
<div class="tour-section-title">Organization settings at a glance</div>
<div class="tour-section-description">
@ -73,8 +73,29 @@
</div>
<div class="tour-section row">
<div class="col-md-7"><img src="/static/img/org-teams.png" title="buynlarge - Quay" data-screenshot-url="https://quay.io/organization/buynlarge" class="img-responsive"></div>
<div class="col-md-7"><img src="/static/img/org-logs.png" title="buynlarge Admin - Quay.io" data-screenshot-url="https://quay.io/organization/buynlarge" class="img-responsive"></div>
<div class="col-md-5">
<div class="tour-section-title">Logging for comprehensive analysis</div>
<div class="tour-section-description">
Every time a user in your organization performs an action it is logged
and categorized in a way that allows for a complete understanding of
how your repositories have been accessed and modified. Each log entry
includes the action performed, the authorization which allowed the action
to occur, and additional relevant data such as the name of the item
which was modified or accessed.
</div>
<div class="tour-section-description">
For those times when you need full control when generating reports from
your logs, we also allow you to export your logs in JSON format. These
can be ingested by custom tooling solutions allowing you to visualize
reports in whatever format you require.
</div>
</div>
</div>
<div class="tour-section row">
<div class="col-md-7 col-md-push-5"><img src="/static/img/org-teams.png" title="buynlarge - Quay.io" data-screenshot-url="https://quay.io/organization/buynlarge" class="img-responsive"></div>
<div class="col-md-5 col-md-pull-7">
<div class="tour-section-title">Teams simplify access controls</div>
<div class="tour-section-description">
Teams allow your organization to delegate access to your namespace and
@ -94,8 +115,8 @@
</div>
<div class="tour-section row">
<div class="col-md-7 col-md-push-5"><img src="/static/img/org-repo-admin.png" title="buynlarge/orgrepo - Quay" data-screenshot-url="https://quay.io/repository/buynlarge/orgrepo" class="img-responsive"></div>
<div class="col-md-5 col-md-pull-7">
<div class="col-md-7"><img src="/static/img/org-repo-admin.png" title="buynlarge/orgrepo - Quay.io" data-screenshot-url="https://quay.io/repository/buynlarge/orgrepo" class="img-responsive"></div>
<div class="col-md-5">
<div class="tour-section-title">Fine-grained control of sharing</div>
<div class="tour-section-description">
Repositories that you create within your organization can be assigned

View file

@ -1,44 +1,107 @@
<div class="container plans content-container">
<div class="callout">
Plans &amp; Pricing
</div>
<div class="all-plans">
All plans include <span class="feature">unlimited public repositories</span> and <span class="feature">unlimited sharing</span>. All paid plans have a <span class="feature">14-day free trial</span>.
</div>
<div class="row plans-list">
<div class="col-xs-0 col-lg-1"></div>
<div class="col-lg-2 col-xs-4 plan-container" ng-repeat="plan in plans.user">
<div class="plan" ng-class="plan.stripeId">
<div class="plan-title">{{ plan.title }}</div>
<div class="plan-price">${{ plan.price/100 }}</div>
<div class="count"><b>{{ plan.privateRepos }}</b> private repositories</div>
<div class="description">{{ plan.audience }}</div>
<div class="smaller">SSL secured connections</div>
<button class="btn btn-primary btn-block" ng-click="buyNow(plan.stripeId)">Sign Up Now</button>
</div>
<div class="col-sm-2">
<div class="features-bar hidden-xs">
<div class="visible-lg" style="height: 50px"></div>
<div class="visible-md visible-sm" style="height: 70px"></div>
<div class="feature">
<span class="context-tooltip" bs-tooltip="tooltip.title" data-container="body" data-placement="right"
title="All plans have unlimited public repositories">
<span class="hidden-sm-inline">Public Repositories</span>
<span class="visible-sm-inline">Public Repos</span>
</span>
<i class="fa fa-hdd visible-lg"></i>
</div>
<div class="feature">
<span class="context-tooltip" bs-tooltip="tooltip.title" data-container="body" data-placement="right"
title="SSL encryption is enabled end-to-end for all operations">
SSL Encryption
</span>
<i class="fa fa-lock visible-lg"></i>
</div>
<div class="feature">
<span class="context-tooltip" bs-tooltip="tooltip.title" data-container="body" data-placement="right"
title="Allows users or organizations to grant permissions in multiple repositories to the same non-login-capable account">
Robot accounts
</span>
<i class="fa fa-wrench visible-lg"></i>
</div>
<div class="feature">
<span class="context-tooltip" bs-tooltip="tooltip.title" data-container="body" data-placement="right"
title="Repository images can be built directly from Dockerfiles">
Dockerfile Build
</span>
<i class="fa fa-upload visible-lg"></i>
</div>
<div class="feature">
<span class="context-tooltip" bs-tooltip="tooltip.title" data-container="body" data-placement="right"
title="Grant subsets of users in an organization their own permissions, either on a global basis or a per-repository basis">
Teams
</span>
<i class="fa fa-group visible-lg"></i>
</div>
<div class="feature">
<span class="context-tooltip" bs-tooltip="tooltip.title" data-container="body" data-placement="right"
title="Every action take within an organization is logged in detail, with the ability to visualize logs and download them">
Logging
</span>
<i class="fa fa-bar-chart-o visible-lg"></i>
</div>
<div class="feature">
<span class="context-tooltip" bs-tooltip="tooltip.title" data-container="body" data-placement="right"
title="Administrators can view and download the full invoice history for their organization">
Invoice History
</span>
<i class="fa fa-calendar visible-lg"></i>
</div>
<div class="feature">
<span class="context-tooltip" bs-tooltip="tooltip.title" data-container="body" data-placement="right"
title="All plans have a 14-day free trial">
<span class="hidden-sm-inline">14-Day Free Trial</span>
<span class="visible-sm-inline">14-Day Trial</span>
</span>
<i class="fa fa-clock-o visible-lg"></i>
</div>
</div>
</div>
</div>
<div class="col-sm-2 plan-container" ng-repeat="plan in plans" ng-show="plan.price > 0 && !plan.deprecated">
<div class="plan" ng-class="plan.stripeId + ' ' + (plan.bus_features ? 'business-plan' : '')">
<div class="plan-box">
<div class="plan-title">{{ plan.title }}</div>
<div class="plan-price">${{ plan.price/100 }}</div>
<div class="callout">
Business Plan Pricing
</div>
<div class="description">{{ plan.audience }}</div>
</div>
<div class="all-plans">
All business plans include all of the personal plan features, plus: <span class="business-feature">organizations</span> and <span class="business-feature">teams</span> with <span class="business-feature">delegated access</span> to the organization. All business plans have a <span class="business-feature">14-day free trial</span>.
</div>
<div class="features hidden-xs">
<div class="count"><b>{{ plan.privateRepos }}</b> private repositories</div>
<div class="feature present"></div>
<div class="feature present"></div>
<div class="feature present"></div>
<div class="feature present"></div>
<div class="feature" ng-class="plan.bus_features ? 'present' : ''"></div>
<div class="feature" ng-class="plan.bus_features ? 'present' : ''"></div>
<div class="feature" ng-class="plan.bus_features ? 'present' : ''"></div>
<div class="feature present"></div>
</div>
<div class="features visible-xs">
<div class="count"><b>{{ plan.privateRepos }}</b> private repositories</div>
<div class="feature present">Unlimited Public Repositories</div>
<div class="feature present">SSL Encryption</div>
<div class="feature present">Robot accounts</div>
<div class="feature present">Dockerfile Build</div>
<div class="feature" ng-class="plan.bus_features ? 'present' : 'notpresent'">Teams</div>
<div class="feature" ng-class="plan.bus_features ? 'present' : 'notpresent'">Logging</div>
<div class="feature" ng-class="plan.bus_features ? 'present' : 'notpresent'">Invoice History</div>
<div class="feature present">14-Day Free Trial</div>
</div>
<button class="btn btn-block" ng-class="plan.bus_features ? 'btn-success' : 'btn-primary'"
ng-click="buyNow(plan.stripeId)">Start <span class="hidden-sm-inline">Free</span> Trial</button>
<div class="row plans-list">
<div class="col-xs-0 col-lg-1"></div>
<div class="col-lg-2 col-xs-4 plan-container" ng-repeat="plan in plans.business">
<div class="plan business-plan" ng-class="plan.stripeId">
<div class="plan-title">{{ plan.title }}</div>
<div class="plan-price">${{ plan.price/100 }}</div>
<div class="count"><b>{{ plan.privateRepos }}</b> private repositories</div>
<div class="description">{{ plan.audience }}</div>
<div class="smaller">SSL secured connections</div>
<button class="btn btn-success btn-block" ng-click="createOrg(plan.stripeId)">Sign Up Now</button>
</div>
</div>
</div>
@ -82,7 +145,7 @@
<div class="user-setup" signed-in="signedIn()" redirect-url="'/plans/'"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal" ng-click="cancelNotedPlan()">Close</button>
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->

View file

@ -1,13 +1,5 @@
<div class="loading" ng-show="loading">
<i class="fa fa-spinner fa-spin fa-3x"></i>
</div>
<div class="container" ng-show="!loading && (!repo || !permissions)">
No repository found
</div>
<div class="container repo repo-admin" ng-show="!loading && repo && permissions">
<div class="resource-view" resource="repository" error-message="'No repository found'"></div>
<div class="container repo repo-admin" ng-show="repo">
<div class="header row">
<a href="{{ '/repository/' + repo.namespace + '/' + repo.name }}" class="back"><i class="fa fa-chevron-left"></i></a>
<h3>
@ -35,7 +27,7 @@
<div id="logs" class="tab-pane">
<div class="logs-view" repository="repo" visible="logsShown"></div>
</div>
<!-- Permissions tab -->
<div id="permissions" class="tab-pane active">
<!-- User Access Permissions -->
@ -137,7 +129,7 @@
<tr>
<td class="admin-search">
<input type="text" class="form-control" placeholder="New token description" ng-model="newToken.friendlyName"required>
<input type="text" class="form-control" placeholder="New token description" ng-model="newToken.friendlyName" required>
</td>
<td class="admin-search">
<button type="submit" ng-disabled="createTokenForm.$invalid" class="btn btn-sm btn-default">Create</button>
@ -156,39 +148,44 @@
<i class="info-icon fa fa-info-circle" data-placement="left" data-content="URLs which will be invoked with an HTTP POST and JSON payload when a successful push to the repository occurs."></i>
</div>
<div class="panel-body" ng-show="webhooksLoading">
Loading webhooks: <i class="fa fa-spinner fa-spin fa-2x" style="vertical-align: middle; margin-left: 4px"></i>
</div>
<div class="panel-body">
<div class="resource-view" resource="webhooksResource" error-message="'Could not load webhooks'">
<table class="permissions">
<thead>
<tr>
<td style="width: 500px;">Webhook URL</td>
<td></td>
</tr>
</thead>
<tbody>
<tr ng-repeat="webhook in webhooks">
<td>{{ webhook.parameters.url }}</td>
<td>
<span class="delete-ui" tabindex="0">
<span class="delete-ui-button" ng-click="deleteWebhook(webhook)"><button class="btn btn-danger">Delete</button></span>
<i class="fa fa-times" bs-tooltip="tooltip.title" data-placement="right" title="Delete Webhook"></i>
</span>
</td>
</tr>
</tbody>
</table>
</div>
<div class="panel-body" ng-show="!webhooksLoading">
<table class="permissions" ng-form="newWebhookForm">
<thead>
<tr>
<td style="width: 500px;">Webhook URL</td>
<td></td>
</tr>
</thead>
<tbody>
<tr ng-repeat="webhook in webhooks">
<td>{{ webhook.parameters.url }}</td>
<td>
<span class="delete-ui" tabindex="0">
<span class="delete-ui-button" ng-click="deleteWebhook(webhook)"><button class="btn btn-danger">Delete</button></span>
<i class="fa fa-times" bs-tooltip="tooltip.title" data-placement="right" title="Delete Webhook"></i>
</span>
</td>
</tr>
<tr>
<td>
<input type="url" class="form-control" placeholder="New webhook url..." ng-model="newWebhook.url" required>
</td>
<td>
<button class="btn btn-primary" type="submit" ng-click="createWebhook()">Create</button>
</td>
</tr>
</tbody>
</table>
<form name="createWebhookForm" ng-submit="createWebhook()">
<table class="permissions">
<tbody>
<tr>
<td style="width: 500px;">
<input type="url" class="form-control" placeholder="New webhook url..." ng-model="newWebhook.url" required>
</td>
<td>
<button class="btn btn-primary" type="submit" ng-disabled="createWebhookForm.$invalid">Create</button>
</td>
</tr>
</tbody>
</table>
</form>
<div class="right-info">
Quay will <b>POST</b> to these webhooks whenever a push occurs. See the <a href="/guide">User Guide</a> for more information.
@ -240,132 +237,136 @@
</div>
</div>
</div>
<div class="docker-auth-dialog" username="'$token'" token="shownToken.code"
shown="!!shownToken" counter="shownTokenCounter">
<i class="fa fa-key"></i> {{ shownToken.friendlyName }}
</div>
<!-- Modal message dialog -->
<div class="modal fade" id="cannotchangeModal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<h4 class="modal-title">Cannot change</h4>
</div>
<div class="modal-body">
The selected action could not be performed because you do not have that authority.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
<!-- Modal message dialog -->
<div class="modal fade" id="makepublicModal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<h4 class="modal-title">Make Repository Public</h4>
</div>
<div class="modal-body">
<div class="alert alert-warning">
Warning: This will allow <b>anyone</b> to pull from this repository
</div>
Are you sure you want to make this repository public?
</div>
<div class="modal-footer">
<button type="button" class="btn btn-danger" ng-click="changeAccess('public')">Make Public</button>
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
<!-- Modal message dialog -->
<div class="modal fade" id="makeprivateModal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<h4 class="modal-title">Make Repository Private</h4>
</div>
<div class="modal-body">
<div class="alert alert-warning">
Warning: Only users on the permissions list will be able to access this repository.
</div>
Are you sure you want to make this repository private?
</div>
<div class="modal-footer">
<button type="button" class="btn btn-success" ng-click="changeAccess('private')">Make Private</button>
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
<!-- Modal message dialog -->
<div class="modal fade" id="channgechangepermModal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<h4 class="modal-title">Cannot change permissions</h4>
</div>
<div class="modal-body">
<span ng-show="!changePermError">You do not have permission to change the permissions on the repository.</span>
<span ng-show="changePermError">{{ changePermError }}</span>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
<!-- Modal message dialog -->
<div class="modal fade" id="confirmdeleteModal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<h4 class="modal-title">Delete Repository?</h4>
</div>
<div class="modal-body">
Are you <b>absolutely, positively</b> sure you would like to delete this repository? This <b>cannot be undone</b>.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-danger" ng-click="deleteRepo()">Delete Repository</button>
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
</div>
</div>
<!-- Modal message dialog -->
<div class="modal fade" id="confirmaddoutsideModal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<h4 class="modal-title">Add User?</h4>
</div>
<div class="modal-body">
The selected user is outside of your organization. Are you sure you want to grant the user access to this repository?
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" ng-click="grantRole()">Yes, I'm sure</button>
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
<!-- Auth dialog -->
<div class="docker-auth-dialog" username="'$token'" token="shownToken.code"
shown="!!shownToken" counter="shownTokenCounter">
<i class="fa fa-key"></i> {{ shownToken.friendlyName }}
</div>
<!-- Modal message dialog -->
<div class="modal fade" id="cannotchangeModal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<h4 class="modal-title">Cannot change</h4>
</div>
<div class="modal-body">
The selected action could not be performed because you do not have that authority.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
<!-- Modal message dialog -->
<div class="modal fade" id="makepublicModal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<h4 class="modal-title">Make Repository Public</h4>
</div>
<div class="modal-body">
<div class="alert alert-warning">
Warning: This will allow <b>anyone</b> to pull from this repository
</div>
Are you sure you want to make this repository public?
</div>
<div class="modal-footer">
<button type="button" class="btn btn-danger" ng-click="changeAccess('public')">Make Public</button>
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
<!-- Modal message dialog -->
<div class="modal fade" id="makeprivateModal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<h4 class="modal-title">Make Repository Private</h4>
</div>
<div class="modal-body">
<div class="alert alert-warning">
Warning: Only users on the permissions list will be able to access this repository.
</div>
Are you sure you want to make this repository private?
</div>
<div class="modal-footer">
<button type="button" class="btn btn-success" ng-click="changeAccess('private')">Make Private</button>
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
<!-- Modal message dialog -->
<div class="modal fade" id="channgechangepermModal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<h4 class="modal-title">Cannot change permissions</h4>
</div>
<div class="modal-body">
<span ng-show="!changePermError">You do not have permission to change the permissions on the repository.</span>
<span ng-show="changePermError">{{ changePermError }}</span>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
<!-- Modal message dialog -->
<div class="modal fade" id="confirmdeleteModal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<h4 class="modal-title">Delete Repository?</h4>
</div>
<div class="modal-body">
Are you <b>absolutely, positively</b> sure you would like to delete this repository? This <b>cannot be undone</b>.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-danger" ng-click="deleteRepo()">Delete Repository</button>
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
<!-- Modal message dialog -->
<div class="modal fade" id="confirmaddoutsideModal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<h4 class="modal-title">Add User?</h4>
</div>
<div class="modal-body">
The selected user is outside of your organization. Are you sure you want to grant the user access to this repository?
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" ng-click="grantRole()">Yes, I'm sure</button>
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->

View file

@ -1,8 +1,4 @@
<div class="loading" ng-show="loading">
<i class="fa fa-spinner fa-spin fa-3x"></i>
</div>
<div class="container" ng-show="!loading">
<div class="container">
<div class="repo-list" ng-show="!user.anonymous">
<div ng-class="user.organizations.length ? 'section-header' : ''">
<div class="button-bar-right">
@ -26,31 +22,47 @@
<h3 ng-show="namespace == user.username">Your Repositories</h3>
<h3 ng-show="namespace != user.username">Repositories</h3>
<div ng-show="user_repositories.length > 0">
<div class="repo-listing" ng-repeat="repository in user_repositories">
<span class="repo-circle no-background" repo="repository"></span>
<a ng-href="/repository/{{repository.namespace}}/{{ repository.name }}">{{repository.namespace}}/{{repository.name}}</a>
<div class="description markdown-view" content="repository.description" first-line-only="true"></div>
<div class="resource-view" resource="user_repositories">
<!-- User/Org has repositories -->
<div ng-show="user_repositories.value.length > 0">
<div class="repo-listing" ng-repeat="repository in user_repositories.value">
<span class="repo-circle no-background" repo="repository"></span>
<a ng-href="/repository/{{repository.namespace}}/{{ repository.name }}">{{repository.namespace}}/{{repository.name}}</a>
<div class="description markdown-view" content="repository.description" first-line-only="true"></div>
</div>
</div>
<!-- User/Org has no repositories -->
<div ng-show="user_repositories.value.length == 0" style="padding:20px;">
<div class="alert alert-info">
<h4 ng-show="namespace == user.username">You don't have any repositories yet!</h4>
<h4 ng-show="namespace != user.username">This organization doesn't have any repositories, or you have not been provided access.</h4>
<a href="/guide"><b>Click here</b> to learn how to create a repository</a>
</div>
</div>
</div>
<div ng-show="user_repositories.length == 0" style="padding:20px;">
<div class="alert alert-info">
<h4 ng-show="namespace == user.username">You don't have any repositories yet!</h4>
<h4 ng-show="namespace != user.username">This organization doesn't have any repositories, or you have not been provided access.</h4>
<a href="/guide"><b>Click here</b> to learn how to create a repository</a>
</div>
</div>
</div>
<div class="repo-list">
<h3>Top Public Repositories</h3>
<div class="repo-listing" ng-repeat="repository in public_repositories">
<span class="repo-circle no-background" repo="repository"></span>
<a ng-href="/repository/{{repository.namespace}}/{{ repository.name }}">{{repository.namespace}}/{{repository.name}}</a>
<div class="description markdown-view" content="repository.description" first-line-only="true"></div>
<div class="resource-view" resource="public_repositories">
<div class="repo-listing" ng-repeat="repository in public_repositories.value">
<span class="repo-circle no-background" repo="repository"></span>
<a ng-href="/repository/{{repository.namespace}}/{{ repository.name }}">{{repository.namespace}}/{{repository.name}}</a>
<div class="description markdown-view" content="repository.description" first-line-only="true"></div>
</div>
<div class="page-controls">
<button class="btn btn-default" title="Previous Page" bs-tooltip="title" ng-show="page > 1"
ng-click="movePublicPage(-1)">
<i class="fa fa-chevron-left"></i>
</button>
<button class="btn btn-default" title="Next Page" bs-tooltip="title" ng-show="page < publicPageCount"
ng-click="movePublicPage(1)">
<i class="fa fa-chevron-right"></i>
</button>
</div>
</div>
</div>
</div>

View file

@ -1,47 +1,42 @@
<div class="loading" ng-show="loading">
<i class="fa fa-spinner fa-spin fa-3x"></i>
</div>
<div class="resource-view" resource="orgResource" error-message="'No matching organization'">
<div class="team-view container">
<div class="organization-header" organization="organization" team-name="teamname"></div>
<div class="resource-view" resource="membersResource" error-message="'No matching team found'">
<div class="description markdown-input" content="team.description" can-write="organization.is_admin"
content-changed="updateForDescription" field-title="'team description'"></div>
<div class="loading" ng-show="!loading && !organization">
No matching team found
</div>
<div class="team-view container" ng-show="!loading && organization">
<div class="organization-header" organization="organization" team-name="teamname"></div>
<div class="description markdown-input" content="team.description" can-write="organization.is_admin"
content-changed="updateForDescription" field-title="'team description'"></div>
<div class="panel panel-default">
<div class="panel-heading">Team Members
<i class="info-icon fa fa-info-circle" data-placement="left" data-content="Users that inherit all permissions delegated to this team"></i>
</div>
<div class="panel-body">
<table class="permissions">
<tr ng-repeat="(name, member) in members">
<td class="user entity">
<span class="entity-reference" name="member.username" isrobot="member.is_robot"></span>
</td>
<td>
<span class="delete-ui" tabindex="0" title="Remove User" ng-show="canEditMembers">
<span class="delete-ui-button" ng-click="removeMember(member.username)"><button class="btn btn-danger">Remove</button></span>
<i class="fa fa-times"></i>
</span>
</td>
</tr>
<tr ng-show="canEditMembers">
<td colspan="2">
<span class="entity-search" namespace="orgname" include-teams="false" input-title="'Add a user...'"
entity-selected="addNewMember" is-organization="true"></span>
</td>
</tr>
</table>
<div class="panel panel-default">
<div class="panel-heading">Team Members
<i class="info-icon fa fa-info-circle" data-placement="left" data-content="Users that inherit all permissions delegated to this team"></i>
</div>
<div class="panel-body">
<table class="permissions">
<tr ng-repeat="(name, member) in members">
<td class="user entity">
<span class="entity-reference" name="member.username" isrobot="member.is_robot"></span>
</td>
<td>
<span class="delete-ui" tabindex="0" title="Remove User" ng-show="canEditMembers">
<span class="delete-ui-button" ng-click="removeMember(member.username)"><button class="btn btn-danger">Remove</button></span>
<i class="fa fa-times"></i>
</span>
</td>
</tr>
<tr ng-show="canEditMembers">
<td colspan="2">
<span class="entity-search" namespace="orgname" include-teams="false" input-title="'Add a user...'"
entity-selected="addNewMember" is-organization="true"></span>
</td>
</tr>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Modal message dialog -->
<div class="modal fade" id="cannotChangeTeamModal">
<div class="modal-dialog">

View file

@ -1,12 +1,12 @@
<div class="loading" ng-show="loading">
<i class="fa fa-spinner fa-spin fa-3x"></i>
<div class="loading" ng-show="!user">
<div class="quay-spinner"></div>
</div>
<div class="loading" ng-show="!loading && !user">
<div class="loading" ng-show="user.anonymous">
No matching user found
</div>
<div class="user-admin container" ng-show="!loading && user">
<div class="user-admin container" ng-show="!user.anonymous">
<div class="row">
<div class="organization-header-element">
<img src="//www.gravatar.com/avatar/{{ user.gravatar }}?s=24&amp;d=identicon">
@ -27,16 +27,24 @@
<div class="col-md-2">
<ul class="nav nav-pills nav-stacked">
<li class="active"><a href="javascript:void(0)" data-toggle="tab" data-target="#plan">Plan and Usage</a></li>
<li ng-show="hasPaidPlan"><a href="javascript:void(0)" data-toggle="tab" data-target="#billing">Billing Options</a></li>
<li ng-show="hasPaidPlan"><a href="javascript:void(0)" data-toggle="tab" data-target="#billingoptions">Billing Options</a></li>
<li ng-show="hasPaidBusinessPlan"><a href="javascript:void(0)" data-toggle="tab" data-target="#billing" ng-click="loadInvoices()">Billing History</a></li>
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#robots">Robot Accounts</a></li>
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#password">Set Password</a></li>
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#password">Change Password</a></li>
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#github">GitHub Login</a></li>
<li ng-show="hasPaidBusinessPlan"><a href="javascript:void(0)" data-toggle="tab" data-target="#logs" ng-click="loadLogs()">Usage Logs</a></li>
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#migrate" id="migrateTab">Convert to Organization</a></li>
</ul>
</div>
<!-- Content -->
<div class="col-md-10">
<div class="tab-content">
<div class="tab-content">
<!-- Logs tab -->
<div id="logs" class="tab-pane">
<div class="logs-view" user="user" visible="logsShown"></div>
</div>
<!-- Plans tab -->
<div id="plan" class="tab-pane active">
<div class="plan-manager" user="user.username" ready-for-plan="readyForPlan()" plan-changed="planChanged(plan)"></div>
@ -45,18 +53,48 @@
<!-- Change password tab -->
<div id="password" class="tab-pane">
<div class="loading" ng-show="updatingUser">
<i class="fa fa-spinner fa-spin fa-3x"></i>
<div class="quay-spinner 3x"></div>
</div>
<div class="row">
<form class="form-change-pw col-md-6" name="changePasswordForm" ng-submit="changePassword()" data-trigger="manual"
data-content="{{ changePasswordError }}" data-placement="right" ng-show="!awaitingConfirmation && !registering">
<input type="password" class="form-control" placeholder="Your new password" ng-model="user.password" required>
<input type="password" class="form-control" placeholder="Verify your new password" ng-model="user.repeatPassword"
match="user.password" required>
<button class="btn btn-danger" ng-disabled="changePasswordForm.$invalid" type="submit"
analytics-on analytics-event="register">Change Password</button>
<div class="panel">
<div class="panel-title">Change Password</div>
<span class="help-block" ng-show="changePasswordSuccess">Password changed successfully</span>
</form>
<div ng-show="!updatingUser" class="panel-body">
<form class="form-change-pw col-md-6" name="changePasswordForm" ng-submit="changePassword()" data-trigger="manual"
data-content="{{ changePasswordError }}" data-placement="right" ng-show="!awaitingConfirmation && !registering">
<input type="password" class="form-control" placeholder="Your new password" ng-model="cuser.password" required>
<input type="password" class="form-control" placeholder="Verify your new password" ng-model="cuser.repeatPassword"
match="cuser.password" required>
<button class="btn btn-danger" ng-disabled="changePasswordForm.$invalid" type="submit"
analytics-on analytics-event="change_pass">Change Password</button>
</form>
</div>
</div>
</div>
</div>
<div id="github" class="tab-pane">
<div class="loading" ng-show="!cuser">
<div class="quay-spinner 3x"></div>
</div>
<div class="row" ng-show="cuser">
<div class="panel">
<div class="panel-title">GitHub Login</div>
<div class="panel-body">
<div ng-show="githubLogin" class="lead col-md-8">
<span class="fa-stack">
<i class="fa fa-circle fa-stack-2x check-green"></i>
<i class="fa fa-check fa-stack-1x fa-inverse"></i>
</span>
This account is connected with GitHub account: <b>{{githubLogin}}</b>
</div>
<div ng-show="!githubLogin" class="col-md-8">
<a href="https://github.com/login/oauth/authorize?client_id={{ githubClientId }}&scope=user:email{{ github_state_clause }}&redirect_uri={{ githubRedirectUri }}/attach" class="btn btn-primary"><i class="fa fa-github fa-lg"></i> Connect with GitHub</a>
</div>
</div>
</div>
</div>
</div>
@ -66,10 +104,15 @@
</div>
<!-- Billing options tab -->
<div id="billing" class="tab-pane">
<div id="billingoptions" class="tab-pane">
<div class="billing-options" user="user"></div>
</div>
<!-- Billing History tab -->
<div id="billing" class="tab-pane">
<div class="billing-invoices" user="user" visible="invoicesShown"></div>
</div>
<!-- Convert to organization tab -->
<div id="migrate" class="tab-pane">
<!-- Step 0 -->
@ -83,11 +126,11 @@
</div>
<div class="panel-body" ng-show="user.organizations.length == 0">
<div class="alert alert-danger">
Converting a user account into an organization <b>cannot be undone</b>.<br> Here be many fire-breathing dragons!
<div class="alert alert-warning">
Note: Converting a user account into an organization <b>cannot be undone</b>
</div>
<button class="btn btn-danger" ng-click="showConvertForm()">Start conversion process</button>
<button class="btn btn-primary" ng-click="showConvertForm()">Start conversion process</button>
</div>
</div>
@ -110,7 +153,7 @@
ng-model="org.adminUser" required autofocus>
<input id="adminPassword" name="adminPassword" type="password" class="form-control" placeholder="Admin Password"
ng-model="org.adminPassword" required>
<span class="description">The username and password for an <b>existing account</b> that will become administrator of the organization</span>
<span class="description">The username and password for the account that will become administrator of the organization</span>
</div>
<!-- Plans Table -->
@ -120,7 +163,8 @@
</div>
<div class="button-bar">
<button class="btn btn-large btn-danger" type="submit" ng-disabled="convertForm.$invalid || !org.plan">
<button class="btn btn-large btn-danger" type="submit" ng-disabled="convertForm.$invalid || !org.plan"
analytics-on analytics-event="convert_to_organization">
Convert To Organization
</button>
</div>

View file

@ -1,193 +1,276 @@
<div class="container" ng-show="!loading && !repo">
No repository found
<div id="tagContextMenu" class="dropdown clearfix" tabindex="-1">
<ul class="dropdown-menu" role="menu" aria-labelledby="dropdownMenu">
<li><a tabindex="-1" href="javascript:void(0)" ng-click="askDeleteTag(currentMenuTag)">Delete Tag</a></li>
</ul>
</div>
<div class="loading" ng-show="loading">
<i class="fa fa-spinner fa-spin fa-3x"></i>
</div>
<div class="resource-view" resource="repository" error-message="'No Repository Found'">
<div class="container repo">
<!-- Repo Header -->
<div class="header">
<h3>
<span class="repo-circle" repo="repo"></span>
<span style="color: #aaa;"> {{repo.namespace}}</span> <span style="color: #ccc">/</span> {{repo.name}}
<span class="settings-cog" ng-show="repo.can_admin" title="Repository Settings" bs-tooltip="tooltip.title" data-placement="bottom">
<a href="{{ '/repository/' + repo.namespace + '/' + repo.name + '/admin' }}">
<i class="fa fa-cog fa-lg"></i>
</a>
</span>
</h3>
<!-- Pull command -->
<div class="pull-command visible-md visible-lg" style="display: none;">
<span class="pull-command-title">Pull repository:</span>
<div class="pull-container">
<div class="input-group">
<input id="pull-text" type="text" class="form-control" value="{{ 'docker pull quay.io/' + repo.namespace + '/' + repo.name }}" readonly>
<span id="copyClipboard" class="input-group-addon" title="Copy to Clipboard" data-clipboard-target="pull-text">
<i class="fa fa-copy"></i>
</span>
</div>
</div>
<div class="container repo" ng-show="!loading && repo">
<!-- Repo Header -->
<div class="header">
<h3>
<span class="repo-circle" repo="repo"></span>
<span style="color: #aaa;"> {{repo.namespace}}</span> <span style="color: #ccc">/</span> {{repo.name}}
<span class="settings-cog" ng-show="repo.can_admin" title="Repository Settings" bs-tooltip="tooltip.title" data-placement="bottom">
<a href="{{ '/repository/' + repo.namespace + '/' + repo.name + '/admin' }}">
<i class="fa fa-cog fa-lg"></i>
</a>
</span>
</h3>
<!-- Pull command -->
<div class="pull-command visible-md visible-lg" style="display: none;">
<span class="pull-command-title">Pull repository:</span>
<div class="pull-container">
<div class="input-group">
<input id="pull-text" type="text" class="form-control" value="{{ 'docker pull quay.io/' + repo.namespace + '/' + repo.name }}" readonly>
<span id="copyClipboard" class="input-group-addon" title="Copy to Clipboard" data-clipboard-target="pull-text">
<i class="fa fa-copy"></i>
</span>
<div id="clipboardCopied" class="hovering" style="display: none">
Copied to clipboard
</div>
</div>
</div>
<div id="clipboardCopied" class="hovering" style="display: none">
Copied to clipboard
<!-- Status boxes -->
<div class="status-boxes">
<div id="buildInfoBox" class="status-box" ng-show="repo.is_building"
bs-popover="'static/partials/build-status-item.html'" data-placement="bottom">
<span class="title">
<span class="quay-spinner"></span>
<b>Building Images</b>
</span>
<span class="count" ng-class="buildsInfo ? 'visible' : ''"><span>{{ buildsInfo ? buildsInfo.length : '-' }}</span></span>
</div>
</div>
</div>
<!-- Status boxes -->
<div class="status-boxes">
<div id="buildInfoBox" class="status-box" ng-show="repo.is_building"
bs-popover="'static/partials/build-status-item.html'" data-placement="bottom">
<span class="title">
<i class="fa fa-spinner fa-spin"></i>
<b>Building Images</b>
</span>
<span class="count" ng-class="buildsInfo ? 'visible' : ''"><span>{{ buildsInfo ? buildsInfo.length : '-' }}</span></span>
</div>
</div>
<!-- Description -->
<div class="description markdown-input" content="repo.description" can-write="repo.can_write"
content-changed="updateForDescription" field-title="'repository description'"></div>
<!-- Description -->
<div class="description markdown-input" content="repo.description" can-write="repo.can_write"
content-changed="updateForDescription" field-title="'repository description'"></div>
<!-- Empty message -->
<div class="repo-content" ng-show="!currentTag.image && !currentImage && !repo.is_building">
<div class="empty-message">
This repository is empty
</div>
<!-- Empty message -->
<div class="repo-content" ng-show="!currentTag.image && !repo.is_building">
<div class="empty-message">
This repository is empty
</div>
<div class="empty-description" ng-show="repo.can_write">
To push images to this repository:<br><br>
<pre>sudo docker tag <i>0u123imageidgoeshere</i> quay.io/{{repo.namespace}}/{{repo.name}}
<div class="empty-description" ng-show="repo.can_write">
To push images to this repository:<br><br>
<pre>sudo docker tag <i>0u123imageidgoeshere</i> quay.io/{{repo.namespace}}/{{repo.name}}
sudo docker push quay.io/{{repo.namespace}}/{{repo.name}}</pre>
</div>
</div>
</div>
<div class="repo-content" ng-show="!currentTag.image && repo.is_building">
<div class="empty-message">Your build is currently processing, if this takes longer than an hour, please contact <a href="mailto:support@quay.io">Quay.io support</a></div>
</div>
<div class="repo-content" ng-show="!currentTag.image && repo.is_building">
<div class="empty-message">
Your build is currently processing, if this takes longer than an hour, please contact <a href="mailto:support@quay.io">Quay.io support</a>
</div>
</div>
<!-- Content view -->
<div class="repo-content" ng-show="currentTag.image">
<!-- Image History -->
<div id="image-history" style="max-height: 10px;">
<div class="row">
<!-- Tree View container -->
<div class="col-md-8">
<div class="panel panel-default">
<div class="panel-heading">
<!-- Tag dropdown -->
<div class="tag-dropdown dropdown" title="Tags" bs-tooltip="tooltip.title" data-placement="top">
<i class="fa fa-tag"><span class="tag-count">{{getTagCount(repo)}}</span></i>
<a href="javascript:void(0)" class="dropdown-toggle" data-toggle="dropdown">{{currentTag.name}} <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)">{{tag.name}}</a>
</li>
</ul>
<!-- Content view -->
<div class="repo-content" ng-show="currentTag.image || currentImage">
<!-- Image History -->
<div id="image-history" style="max-height: 10px;">
<div class="row">
<!-- Tree View container -->
<div class="col-md-8">
<div class="panel panel-default">
<!-- Image history tree -->
<div class="resource-view" resource="imageHistory">
<div id="image-history-container" onresize="tree.notifyResized()"></div>
</div>
</div>
</div>
<!-- Side Panel -->
<div class="col-md-4">
<div class="panel panel-default">
<div class="panel-heading">
<!-- Dropdown -->
<div 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>
<ul class="dropdown-menu">
<li ng-repeat="tag in repo.tags">
<a href="javascript:void(0)" ng-click="setTag(tag.name, true)">
<i class="fa fa-tag"></i>{{tag.name}}
</a>
</li>
<li class="divider"></li>
<li ng-repeat="image in imageHistory.value">
<a href="javascript:void(0)" ng-click="setImage(image.id, true)">
{{image.id.substr(0, 12)}}
</a>
</li>
</ul>
</div>
<span class="right-tag-controls">
<i class="fa fa-tag" title="Tags" bs-tooltip="title">
<span class="tag-count">{{getTagCount(repo)}}</span>
</i>
<i class="fa fa-archive" title="Images" bs-tooltip="title">
<span class="tag-count">{{imageHistory.value.length}}</span>
</i>
</span>
</div>
<span class="right-title">Tags</span>
</div>
<div class="panel-body">
<!-- Current Tag -->
<div id="current-tag" ng-show="currentTag">
<dl class="dl-normal">
<dt>Last Modified</dt>
<dd am-time-ago="parseDate(currentTag.image.created)"></dd>
<dt>Total Compressed 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">{{ getTotalSize(currentTag) | bytes }}</span>
</dd>
</dl>
<div class="tag-image-sizes">
<div class="tag-image-size" ng-repeat="image in getImagesForTagBySize(currentTag) | limitTo: 10">
<span class="size-limiter">
<span class="size-bar" style="{{ 'width:' + (image.size / getTotalSize(currentTag)) * 100 + '%' }}"
bs-tooltip="image.size | bytes"></span>
</span>
<span class="size-title"><a href="javascript:void(0)" ng-click="setImage(image.id, true)">{{ image.id.substr(0, 12) }}</a></span>
</div>
</div>
<!-- Image history loading -->
<div ng-hide="imageHistory" style="padding: 10px; text-align: center;">
<i class="fa fa-spinner fa-spin fa-3x"></i>
</div>
<div class="control-bar" ng-show="repo.can_admin">
<button class="btn btn-default" ng-click="askDeleteTag(currentTag.name)">
Delete Tag
</button>
</div>
</div>
<!-- Tree View itself -->
<div id="image-history-container" onresize="tree.notifyResized()"></div>
</div>
</div>
<!-- Current Image -->
<div id="current-image" ng-show="currentImage && !currentTag">
<div ng-show="currentImage.comment">
<blockquote style="margin-top: 10px;">
<span class="markdown-view" content="currentImage.comment"></span>
</blockquote>
</div>
<!-- Side Panel -->
<div class="col-md-4">
<div class="panel panel-default">
<div class="panel-heading">
<!-- Image dropdown -->
<div class="tag-dropdown dropdown" title="Images" bs-tooltip="tooltip.title" data-placement="top">
<i class="fa fa-archive"><span class="tag-count">{{imageHistory.length}}</span></i>
<a href="javascript:void(0)" class="dropdown-toggle" data-toggle="dropdown">{{currentImage.id.substr(0, 12)}} <b class="caret"></b></a>
<ul class="dropdown-menu">
<li ng-repeat="image in imageHistory">
<a href="javascript:void(0)" ng-click="setImage(image)">{{image.id.substr(0, 12)}}</a>
</li>
</ul>
<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>
<!-- Image changes loading -->
<div class="resource-view" resource="currentImageChangeResource">
<div class="changes-container small-changes-container"
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">
<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">
<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">
<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>
</div>
</div>
</div>
<span class="right-title">Image</span>
</div>
<div class="panel-body">
<div id="current-image">
<div ng-show="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>
</dl>
<!-- Image changes loading -->
<div ng-hide="currentImageChanges">
<i class="fa fa-spinner fa-spin fa-3x"></i>
</div>
<div class="changes-container small-changes-container"
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">
<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">
<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">
<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>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Modal message dialog -->
<div class="modal fade" id="confirmdeleteTagModal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<h4 class="modal-title">Delete tag
<span class="label tag" ng-class="tagToDelete == currentTag.name ? 'label-success' : 'label-default'">
{{ tagToDelete }}
</span>?
</h4>
</div>
<div class="modal-body">
Are you sure you want to delete tag
<span class="label tag" ng-class="tagToDelete == currentTag.name ? 'label-success' : 'label-default'">
{{ tagToDelete }}
</span>?
<div ng-show="tagSpecificImages(tagToDelete).length" style="margin-top: 20px">
The following images will also 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>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" ng-click="deleteTag(tagToDelete)">Delete Tag</button>
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->

View file

@ -34,6 +34,10 @@ class Storage(object):
namespace,
repository)
def image_path(self, namespace, repository, image_id):
return '{0}/{1}/{2}/{3}/'.format(self.images, namespace, repository,
image_id)
def image_json_path(self, namespace, repository, image_id):
return '{0}/{1}/{2}/{3}/json'.format(self.images, namespace,
repository, image_id)

View file

@ -11,6 +11,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/static/lib/loading-bar.css">
<link rel="stylesheet" href="//netdna.bootstrapcdn.com/font-awesome/4.0.0/css/font-awesome.css">
<link rel="stylesheet" href="//netdna.bootstrapcdn.com/bootstrap/3.0.0/css/bootstrap.no-icons.min.css">
<link href='//fonts.googleapis.com/css?family=Droid+Sans:400,700' rel='stylesheet' type='text/css'>
@ -40,13 +42,16 @@
<script src="//ajax.googleapis.com/ajax/libs/angularjs/1.2.1/angular.min.js"></script>
<script src="//ajax.googleapis.com/ajax/libs/angularjs/1.2.1/angular-route.min.js"></script>
<script src="//ajax.googleapis.com/ajax/libs/angularjs/1.2.1/angular-sanitize.min.js"></script>
<script src="//cdn.jsdelivr.net/underscorejs/1.5.2/underscore-min.js"></script>
<script src="//cdn.jsdelivr.net/restangular/1.1.3/restangular.js"></script>
<script src="//cdn.jsdelivr.net/restangular/1.2.0/restangular.min.js"></script>
<script src="static/lib/loading-bar.js"></script>
<script src="static/lib/angular-strap.min.js"></script>
<script src="static/lib/angulartics.js"></script>
<script src="static/lib/angulartics-mixpanel.js"></script>
<script src="static/lib/angulartics-google-analytics.js"></script>
<script src="static/lib/angular-moment.min.js"></script>
<script src="static/lib/angular-cookies.min.js"></script>
@ -61,6 +66,10 @@
{% endblock %}
<script type="text/javascript">
window.__endpoints = {{ route_data|safe }}.endpoints;
</script>
<script src="static/js/app.js"></script>
<script src="static/js/controllers.js"></script>
<script src="static/js/graphing.js"></script>
@ -72,6 +81,19 @@ var isProd = document.location.hostname === 'quay.io';
typeof d?c=b[d]=[]:d="mixpanel";c.people=c.people||[];c.toString=function(b){var a="mixpanel";"mixpanel"!==d&&(a+="."+d);b||(a+=" (stub)");return a};c.people.toString=function(){return c.toString(1)+".people (stub)"};i="disable track track_pageview track_links track_forms register register_once alias unregister identify name_tag set_config people.set people.set_once people.increment people.append people.track_charge people.clear_charges people.delete_user".split(" ");for(g=0;g<i.length;g++)f(c,i[g]);
b._i.push([a,e,d])};b.__SV=1.2}})(document,window.mixpanel||[]);
mixpanel.init(isProd ? "50ff2b2569faa3a51c8f5724922ffb7e" : "38014a0f27e7bdc3ff8cc7cc29c869f9", { track_pageview : false, debug: !isProd });</script><!-- end Mixpanel -->
<!-- start analytics --><script>
/*
var isProd = document.location.hostname === 'quay.io';
(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
})(window,document,'script','//www.google-analytics.com/analytics.js','ga');
ga('create', isProd ? 'UA-34988886-5' : 'UA-34988886-4', 'quay.io');
*/
</script>
</head>
<body>
<div ng-class="!fixFooter ? 'wrapper' : ''">
@ -85,27 +107,46 @@ mixpanel.init(isProd ? "50ff2b2569faa3a51c8f5724922ffb7e" : "38014a0f27e7bdc3ff8
</div>
<div class="footer-container" ng-class="fixFooter ? 'fixed' : ''">
<nav class="page-footer visible-lg visible-md">
<div class="row">
<div class="col-md-8">
<ul>
<li><span class="copyright">&copy;2013 DevTable, LLC</span></li>
<li><a href="http://blog.devtable.com/">Blog</a></li>
<li><a href="/tos" target="_self">Terms of Service</a></li>
<li><a href="/privacy" target="_self">Privacy Policy</a></li>
<li><a href="/security/">Security</a></li>
<li><b><a href="/contact/">Contact Us</a></b></li>
</ul>
</div>
<div class="page-footer-padder">
<nav class="page-footer visible-lg visible-md">
<div class="row">
<div class="col-md-8">
<ul>
<li><span class="copyright">&copy;2013 DevTable, LLC</span></li>
<li><a href="http://blog.devtable.com/">Blog</a></li>
<li><a href="/tos" target="_self">Terms of Service</a></li>
<li><a href="/privacy" target="_self">Privacy Policy</a></li>
<li><a href="/security/">Security</a></li>
<li><b><a href="/contact/">Contact Us</a></b></li>
</ul>
</div>
<div class="col-md-4 logo-container">
<a href="https://devtable.com"><img class="dt-logo" src="/static/img/dt-logo.png"></a>
</div>
</div> <!-- row -->
</nav>
<div class="col-md-5 logo-container">
<a href="https://devtable.com"><img class="dt-logo" src="/static/img/dt-logo.png"></a>
</div>
</div> <!-- row -->
</nav>
</div>
</div>
<!-- Modal message dialog -->
<div class="modal fade" id="couldnotloadModal" data-backdrop="static">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Uh Oh...</h4>
</div>
<div class="modal-body">
Something went wrong when trying to load Quay.io! Please report this to <a href="mailto:support@quay.io">support@quay.io</a>.
</div>
<div class="modal-footer">
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
<!-- begin olark code -->
{% if request.host == 'quay.io' %}
<script data-cfasync="false" type='text/javascript'>/*<![CDATA[*/window.olark||(function(c){var f=window,d=document,l=f.location.protocol=="https:"?"https:":"http:",z=c.name,r="load";var nt=function(){
f[z]=function(){
(a.s=a.s||[]).push(arguments)};var a=f[z]._={
@ -124,6 +165,7 @@ mixpanel.init(isProd ? "50ff2b2569faa3a51c8f5724922ffb7e" : "38014a0f27e7bdc3ff8
loader: "static.olark.com/jsclient/loader0.js",name:"olark",methods:["configure","extend","declare","identify"]});
/* custom configuration goes here (www.olark.com/documentation) */
olark.identify('1189-336-10-9918');/*]]>*/</script><noscript><a href="https://www.olark.com/site/1189-336-10-9918/contact" title="Contact us" target="_blank">Questions? Feedback?</a> powered by <a href="http://www.olark.com?welcome" title="Olark live chat software">Olark live chat software</a></noscript>
{% endif %}
<!-- end olark code -->
</body>

View file

@ -16,6 +16,8 @@
<div>
Please register using the <a href="/">registration form</a> to continue.
You will be able to connect your github account to your Quay.io account
in the user settings.
</div>
</div>
</div>

Binary file not shown.

View file

@ -105,20 +105,20 @@ def build_specs():
return [
TestSpec(url_for('welcome'), 200, 200, 200, 200),
TestSpec(url_for('plans_list'), 200, 200, 200, 200),
TestSpec(url_for('list_plans'), 200, 200, 200, 200),
TestSpec(url_for('get_logged_in_user'), 200, 200, 200, 200),
TestSpec(url_for('change_user_details'),
401, 200, 200, 200).set_method('PUT'),
TestSpec(url_for('create_user_api'), 201, 201, 201,
TestSpec(url_for('create_new_user'), 201, 201, 201,
201).set_method('POST').set_data_from_obj(NEW_USER_DETAILS),
TestSpec(url_for('signin_api'), 200, 200, 200,
TestSpec(url_for('signin_user'), 200, 200, 200,
200).set_method('POST').set_data_from_obj(SIGNIN_DETAILS),
TestSpec(url_for('send_recovery'), 201, 201, 201,
TestSpec(url_for('request_recovery_email'), 201, 201, 201,
201).set_method('POST').set_data_from_obj(SEND_RECOVERY_DETAILS),
TestSpec(url_for('get_matching_users', prefix='dev'), 401, 200, 200, 200),
@ -161,29 +161,29 @@ def build_specs():
teamname=ORG_READERS, membername=ORG_OWNER),
admin_code=400).set_method('DELETE'),
(TestSpec(url_for('create_repo_api'))
(TestSpec(url_for('create_repo'))
.set_method('POST')
.set_data_from_obj(NEW_ORG_REPO_DETAILS)),
TestSpec(url_for('match_repos_api'), 200, 200, 200, 200),
TestSpec(url_for('find_repos'), 200, 200, 200, 200),
TestSpec(url_for('list_repos_api'), 200, 200, 200, 200),
TestSpec(url_for('list_repos'), 200, 200, 200, 200),
TestSpec(url_for('update_repo_api', repository=PUBLIC_REPO),
TestSpec(url_for('update_repo', repository=PUBLIC_REPO),
admin_code=403).set_method('PUT'),
(TestSpec(url_for('update_repo_api', repository=ORG_REPO))
(TestSpec(url_for('update_repo', repository=ORG_REPO))
.set_method('PUT')
.set_data_from_obj(UPDATE_REPO_DETAILS)),
(TestSpec(url_for('update_repo_api', repository=PRIVATE_REPO))
(TestSpec(url_for('update_repo', repository=PRIVATE_REPO))
.set_method('PUT')
.set_data_from_obj(UPDATE_REPO_DETAILS)),
(TestSpec(url_for('change_repo_visibility_api', repository=PUBLIC_REPO),
(TestSpec(url_for('change_repo_visibility', repository=PUBLIC_REPO),
admin_code=403).set_method('POST')
.set_data_from_obj(CHANGE_VISIBILITY_DETAILS)),
(TestSpec(url_for('change_repo_visibility_api', repository=ORG_REPO))
(TestSpec(url_for('change_repo_visibility', repository=ORG_REPO))
.set_method('POST').set_data_from_obj(CHANGE_VISIBILITY_DETAILS)),
(TestSpec(url_for('change_repo_visibility_api', repository=PRIVATE_REPO))
(TestSpec(url_for('change_repo_visibility', repository=PRIVATE_REPO))
.set_method('POST').set_data_from_obj(CHANGE_VISIBILITY_DETAILS)),
TestSpec(url_for('delete_repository', repository=PUBLIC_REPO),
@ -193,11 +193,11 @@ def build_specs():
TestSpec(url_for('delete_repository', repository=PRIVATE_REPO),
admin_code=204).set_method('DELETE'),
TestSpec(url_for('get_repo_api', repository=PUBLIC_REPO),
TestSpec(url_for('get_repo', repository=PUBLIC_REPO),
200, 200, 200,200),
TestSpec(url_for('get_repo_api', repository=ORG_REPO),
TestSpec(url_for('get_repo', repository=ORG_REPO),
403, 403, 200, 200),
TestSpec(url_for('get_repo_api', repository=PRIVATE_REPO),
TestSpec(url_for('get_repo', repository=PRIVATE_REPO),
403, 403, 200, 200),
TestSpec(url_for('get_repo_builds', repository=PUBLIC_REPO),
@ -403,20 +403,20 @@ def build_specs():
TestSpec(url_for('delete_token', repository=PRIVATE_REPO,
code=FAKE_TOKEN), admin_code=400).set_method('DELETE'),
TestSpec(url_for('subscribe_api'), 401, 400, 400, 400).set_method('PUT'),
TestSpec(url_for('update_user_subscription'), 401, 400, 400, 400).set_method('PUT'),
TestSpec(url_for('subscribe_org_api', orgname=ORG),
TestSpec(url_for('update_org_subscription', orgname=ORG),
401, 403, 403, 400).set_method('PUT'),
TestSpec(url_for('get_subscription'), 401, 200, 200, 200),
TestSpec(url_for('get_user_subscription'), 401, 200, 200, 200),
TestSpec(url_for('get_org_subscription', orgname=ORG)),
TestSpec(url_for('repo_logs_api', repository=PUBLIC_REPO), admin_code=403),
TestSpec(url_for('repo_logs_api', repository=ORG_REPO)),
TestSpec(url_for('repo_logs_api', repository=PRIVATE_REPO)),
TestSpec(url_for('list_repo_logs', repository=PUBLIC_REPO), admin_code=403),
TestSpec(url_for('list_repo_logs', repository=ORG_REPO)),
TestSpec(url_for('list_repo_logs', repository=PRIVATE_REPO)),
TestSpec(url_for('org_logs_api', orgname=ORG)),
TestSpec(url_for('list_org_logs', orgname=ORG)),
]

View file

@ -0,0 +1,20 @@
from data.database import Image
from app import app
import json
store = app.config['STORAGE']
for image in Image.select():
if image.command == None:
image_json_path = store.image_json_path(image.repository.namespace,
image.repository.name,
image.docker_image_id)
if store.exists(image_json_path):
data = json.loads(store.get_content(image_json_path))
command_list = data.get('container_config', {}).get('Cmd', None)
command = json.dumps(command_list) if command_list else None
print 'Setting command to: %s' % command
image.command = command
image.save()

17
tools/backfillsizes.py Normal file
View file

@ -0,0 +1,17 @@
from data.database import Image
from app import app
store = app.config['STORAGE']
for image in Image.select():
if image.image_size == None:
image_path = store.image_layer_path(image.repository.namespace,
image.repository.name,
image.docker_image_id)
if store.exists(image_path):
size = store.get_size(image_path)
print 'Setting image %s size to: %s' % (image.docker_image_id, size)
image.image_size = size
image.save()

View file

@ -1,19 +1,36 @@
from app import stripe
from collections import defaultdict
EXCLUDE_CID = {'cus_2iVlmwz8CpHgOj'}
offset = 0
total_monthly_revenue = 0
def empty_tuple():
return (0, 0)
plan_revenue = defaultdict(empty_tuple)
batch = stripe.Customer.all(count=100, offset=offset)
while batch.data:
for cust in batch.data:
if cust.id not in EXCLUDE_CID and cust.subscription:
sub = cust.subscription
total_monthly_revenue += sub.plan.amount * sub.quantity
subscribers, revenue = plan_revenue[sub.plan.id]
plan_revenue[sub.plan.id] = (subscribers + 1,
revenue + sub.plan.amount * sub.quantity)
offset += len(batch.data)
batch = stripe.Customer.all(count=100, offset=offset)
dollars = total_monthly_revenue / 100
cents = total_monthly_revenue % 100
print 'Monthly revenue: $%d.%02d' % (dollars, cents)
def format_money(total_cents):
dollars = total_cents / 100
cents = total_cents % 100
return dollars, cents
for plan_id, (subs, rev) in plan_revenue.items():
d, c = format_money(rev)
print '%s: $%d.%02d(%s)' % (plan_id, d, c, subs)
d, c = format_money(total_monthly_revenue)
print 'Monthly revenue: $%d.%02d' % (d, c)

View file

@ -10,3 +10,12 @@ def cache_control(max_age=55):
return response
return add_max_age
return wrap
def no_cache(f):
@wraps(f)
def add_no_cache(*args, **kwargs):
response = f(*args, **kwargs)
response.headers['Cache-Control'] = 'no-cache'
return response
return add_no_cache

View file

@ -26,7 +26,8 @@ formatter = logging.Formatter(FORMAT)
logger = logging.getLogger(__name__)
BUILD_SERVER_CMD = ('docker run -d -lxc-conf="lxc.aa_profile=unconfined" ' +
BUILD_SERVER_CMD = ('docker run -d -p 5002:5002 ' +
'-lxc-conf="lxc.aa_profile=unconfined" ' +
'-privileged -e \'RESOURCE_URL=%s\' -e \'TAG=%s\' ' +
'-e \'TOKEN=%s\' quay.io/quay/buildserver')
@ -85,7 +86,7 @@ def babysit_builder(request):
api_key=do_api_key,
name=name,
region_id=regions.pop(),
image_id=1004145, # Docker on 13.04
image_id=app.config['DO_DOCKER_IMAGE'],
size_id=66, # 512MB,
backup_active=False)
retry_command(droplet.create, [],
@ -189,7 +190,7 @@ def babysit_builder(request):
retry_command(droplet.destroy)
repository_build.status_url = None
repository_build.build_node_id = None;
repository_build.build_node_id = None
repository_build.save()
return True