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: 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 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 git python-virtualenv python-dev phantomjs libjpeg8 libjpeg62-dev libfreetype6 libfreetype6-dev libevent-dev gdebi-core
sudo apt-get install -y nginx-full
``` ```
check out the code: check out the code:
``` ```
git clone https://bitbucket.org/yackob03/quay.git git clone https://bitbucket.org/yackob03/quay.git
cd quay
virtualenv --distribute venv virtualenv --distribute venv
source venv/bin/activate source venv/bin/activate
pip install -r requirements.txt pip install -r requirements.txt
sudo gdebi --n binary_dependencies/*.deb
``` ```
running: running:
``` ```
sudo nginx -c `pwd`/nginx.conf sudo mkdir -p /mnt/nginx/ && sudo /usr/local/nginx/sbin/nginx -c `pwd`/nginx.conf
STACK=prod gunicorn -D --workers 4 -b unix:/tmp/gunicorn.sock --worker-class gevent -t 2000 application:application STACK=prod gunicorn -c gunicorn_config.py application:application
``` ```
start the workers: start the workers:

View file

@ -34,7 +34,7 @@ def process_basic_auth(auth):
if len(credentials) != 2: if len(credentials) != 2:
logger.debug('Invalid basic auth credential format.') logger.debug('Invalid basic auth credential format.')
if credentials[0] == '$token': elif credentials[0] == '$token':
# Use as token auth # Use as token auth
try: try:
token = model.load_token_data(credentials[1]) 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. # We weren't able to authenticate via basic auth.
logger.debug('Basic auth present but could not be validated.') logger.debug('Basic auth present but could not be validated.')
abort(401)
def process_token(auth): 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 VOLUME /var/lib/docker
EXPOSE 5002:5002 EXPOSE 5002
CMD startserver CMD startserver

View file

@ -6,10 +6,9 @@ import re
import requests import requests
import json 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 zipfile import ZipFile
from tempfile import TemporaryFile, mkdtemp from tempfile import TemporaryFile, mkdtemp
from uuid import uuid4
from multiprocessing.pool import ThreadPool from multiprocessing.pool import ThreadPool
from base64 import b64encode from base64 import b64encode
@ -53,16 +52,24 @@ def prepare_dockerfile(request_file):
return build_dir 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): def build_image(build_dir, tag_name, num_steps, result_object):
try: try:
logger.debug('Starting build.') logger.debug('Starting build.')
docker_cl = docker.Client(version='1.5') docker_cl = docker.Client(timeout=1200)
result_object['status'] = 'building' 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 current_step = 0
built_image = None built_image = None
for status in build_status: for status in build_status:
# logger.debug('Status: %s', str(status))
step_increment = re.search(r'Step ([0-9]+) :', status) step_increment = re.search(r'Step ([0-9]+) :', status)
if step_increment: if step_increment:
current_step = int(step_increment.group(1)) 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.' result_object['message'] = 'Unable to build dockerfile.'
return return
history = docker_cl.history(built_image) history = json.loads(docker_cl.history(built_image))
num_images = len(history) num_images = len(history)
result_object['total_images'] = num_images result_object['total_images'] = num_images
result_object['status'] = 'pushing' result_object['status'] = 'pushing'
logger.debug('Pushing to tag name: %s' % tag_name) 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 for status_str in resp:
image_progress = 0 status = json.loads(status_str)
for status in resp: logger.debug('Status: %s', status_str)
if u'status' in status: if u'status' in status:
status_msg = status[u'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: if status_msg == 'Pushing':
percent = r'\(([0-9]+)%\)' if u'progressDetail' in status and u'id' in status:
match = re.search(percent, status[u'progress']) image_id = status[u'id']
if match: detail = status[u'progressDetail']
image_progress = int(match.group(1))
result_object['current_image'] = current_image if u'current' in detail and 'total' in detail:
result_object['image_completion_percent'] = image_progress images = result_object['image_completion']
images[image_id] = detail
result_object['push_completion'] = total_completion(images,
num_images)
elif u'errorDetail' in status: elif u'errorDetail' in status:
result_object['status'] = 'error' result_object['status'] = 'error'
if u'message' in status[u'errorDetail']: 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 return
result_object['status'] = 'complete' result_object['status'] = 'complete'
@ -133,15 +136,14 @@ MIME_PROCESSORS = {
'application/octet-stream': prepare_dockerfile, 'application/octet-stream': prepare_dockerfile,
} }
# If this format it should also be changed in the api method get_repo_builds
build = { build = {
'total_commands': None, 'total_commands': None,
'total_images': None,
'current_command': None, 'current_command': None,
'current_image': None, 'push_completion': 0.0,
'image_completion_percent': None,
'status': 'waiting', 'status': 'waiting',
'message': None, 'message': None,
'image_completion': {},
} }
pool = ThreadPool(1) pool = ThreadPool(1)

View file

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

View file

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

View file

@ -6,12 +6,14 @@ import operator
import json import json
from datetime import timedelta from datetime import timedelta
from database import * from database import *
from util.validation import * from util.validation import *
from util.names import format_robot_username from util.names import format_robot_username
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
store = app.config['STORAGE']
class DataModelException(Exception): class DataModelException(Exception):
@ -306,12 +308,18 @@ def create_federated_user(username, email, service_name, service_id):
return new_user 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): def verify_federated_login(service_name, service_id):
selected = FederatedLogin.select(FederatedLogin, User) selected = FederatedLogin.select(FederatedLogin, User)
with_service = selected.join(LoginService) with_service = selected.join(LoginService)
with_user = with_service.switch(FederatedLogin).join(User) with_user = with_service.switch(FederatedLogin).join(User)
found = with_user.where(FederatedLogin.service_ident == service_id, found = with_user.where(FederatedLogin.service_ident == service_id,
LoginService.name == service_name) LoginService.name == service_name)
found_list = list(found) found_list = list(found)
@ -321,14 +329,25 @@ def verify_federated_login(service_name, service_id):
return None 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): def create_confirm_email_code(user):
code = EmailConfirmation.create(user=user, email_confirm=True) code = EmailConfirmation.create(user=user, email_confirm=True)
return code return code
def confirm_user_email(code): def confirm_user_email(code):
code = EmailConfirmation.get(EmailConfirmation.code == code, try:
EmailConfirmation.email_confirm == True) code = EmailConfirmation.get(EmailConfirmation.code == code,
EmailConfirmation.email_confirm == True)
except EmailConfirmation.DoesNotExist:
raise DataModelException('Invalid email confirmation code.')
user = code.user user = code.user
user.verified = True user.verified = True
@ -345,6 +364,9 @@ def create_reset_password_email_code(email):
except User.DoesNotExist: except User.DoesNotExist:
raise InvalidEmailAddressException('Email address was not found.'); 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) code = EmailConfirmation.create(user=user, pw_reset=True)
return code return code
@ -383,7 +405,6 @@ def get_matching_teams(team_prefix, organization):
def get_matching_users(username_prefix, robot_namespace=None, def get_matching_users(username_prefix, robot_namespace=None,
organization=None): organization=None):
Org = User.alias()
direct_user_query = (User.username ** (username_prefix + '%') & direct_user_query = (User.username ** (username_prefix + '%') &
(User.organization == False) & (User.robot == False)) (User.organization == False) & (User.robot == False))
@ -393,14 +414,16 @@ def get_matching_users(username_prefix, robot_namespace=None,
(User.username ** (robot_prefix + '%') & (User.username ** (robot_prefix + '%') &
(User.robot == True))) (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: if organization:
with_team = query.join(TeamMember, JOIN_LEFT_OUTER).join(Team, query = (query
JOIN_LEFT_OUTER) .join(TeamMember, JOIN_LEFT_OUTER)
with_org = with_team.join(Org, JOIN_LEFT_OUTER, .join(Team, JOIN_LEFT_OUTER, on=((Team.id == TeamMember.team) &
on=(Org.id == Team.organization)) (Team.organization == organization))))
query = with_org.where((Org.id == organization) | (Org.id >> None))
class MatchingUserResult(object): class MatchingUserResult(object):
@ -408,7 +431,7 @@ def get_matching_users(username_prefix, robot_namespace=None,
self.username = args[0] self.username = args[0]
self.is_robot = args[2] self.is_robot = args[2]
if organization: if organization:
self.is_org_member = (args[1] == organization.username) self.is_org_member = (args[1] != None)
else: else:
self.is_org_member = None self.is_org_member = None
@ -489,13 +512,23 @@ def get_user_teams_within_org(username, organization):
User.username == username) 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): 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: if not username and not include_public:
return [] return []
query = (Repository query = (Repository
.select(Repository, Visibility) .select() # Note: We need to leave this blank for the get_count case. Otherwise, MySQL/RDS complains.
.distinct() .distinct()
.join(Visibility) .join(Visibility)
.switch(Repository) .switch(Repository)
@ -543,10 +576,19 @@ def get_visible_repositories(username=None, include_public=True, limit=None,
else: else:
where_clause = new_clause where_clause = new_clause
if limit: if sort:
query.limit(limit) 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): def get_matching_repositories(repo_term, username=None):
@ -702,8 +744,23 @@ def set_image_checksum(docker_image_id, repository, checksum):
return fetched 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, 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) joined = Image.select().join(Repository)
image_list = list(joined.where(Repository.name == repository_name, image_list = list(joined.where(Repository.name == repository_name,
Repository.namespace == namespace_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 = image_list[0]
fetched.created = dateutil.parser.parse(created_date_str) fetched.created = dateutil.parser.parse(created_date_str)
fetched.comment = comment fetched.comment = comment
fetched.command = command
if parent: if parent:
fetched.ancestors = '%s%s/' % (parent.ancestors, parent.id) 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, return with_image.where(Repository.name == repository_name,
Repository.namespace == namespace_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): def get_tag_image(namespace_name, repository_name, tag_name):
joined = Image.select().join(RepositoryTag).join(Repository) joined = Image.select().join(RepositoryTag).join(Repository)
@ -933,6 +1035,11 @@ def purge_repository(namespace_name, repository_name):
Repository.namespace == namespace_name) Repository.namespace == namespace_name)
fetched.delete_instance(recursive=True) 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): def get_private_repo_count(username):
joined = Repository.select().join(Visibility) joined = Repository.select().join(Visibility)

View file

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

View file

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

View file

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

View file

@ -1,19 +1,33 @@
import math
from random import SystemRandom from random import SystemRandom
from flask import jsonify, send_file from flask import jsonify
from app import app 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']) @app.route('/test/build/status', methods=['GET'])
def generate_random_build_status(): def generate_random_build_status():
response = { response = {
'id': 1, 'id': 1,
'total_commands': None, 'total_commands': None,
'total_images': None,
'current_command': None, 'current_command': None,
'current_image': None, 'push_completion': 0.0,
'image_completion_percent': None,
'status': None, 'status': None,
'message': None, 'message': None,
'image_completion': {},
} }
random = SystemRandom() random = SystemRandom()
@ -35,9 +49,8 @@ def generate_random_build_status():
'pushing': { 'pushing': {
'total_commands': 7, 'total_commands': 7,
'current_command': 7, 'current_command': 7,
'total_images': 11, 'push_completion': random.random(),
'current_image': random.randint(1, 11), 'image_completion': generate_image_completion(random),
'image_completion_percent': random.randint(0, 100),
}, },
} }

View file

@ -4,53 +4,33 @@ import stripe
from flask import (abort, redirect, request, url_for, render_template, from flask import (abort, redirect, request, url_for, render_template,
make_response, Response) make_response, Response)
from flask.ext.login import login_user, UserMixin from flask.ext.login import login_required, current_user
from flask.ext.principal import identity_changed
from urlparse import urlparse from urlparse import urlparse
from data import model from data import model
from app import app, login_manager, mixpanel from app import app, mixpanel
from auth.permissions import (QuayDeferredPermissionUser, from auth.permissions import AdministerOrganizationPermission
AdministerOrganizationPermission)
from util.invoice import renderInvoiceToPdf from util.invoice import renderInvoiceToPdf
from util.seo import render_snapshot 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__) logger = logging.getLogger(__name__)
class _LoginWrappedDBUser(UserMixin): def render_page_template(name, **kwargs):
def __init__(self, db_username, db_user=None): return make_response(render_template(name, route_data=get_route_data(),
**kwargs))
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)
@app.route('/', methods=['GET'], defaults={'path': ''}) @app.route('/', methods=['GET'], defaults={'path': ''})
@app.route('/repository/<path:path>', methods=['GET']) @app.route('/repository/<path:path>', methods=['GET'])
@app.route('/organization/<path:path>', methods=['GET']) @app.route('/organization/<path:path>', methods=['GET'])
@no_cache
def index(path): def index(path):
return render_template('index.html') return render_page_template('index.html')
@app.route('/snapshot', methods=['GET']) @app.route('/snapshot', methods=['GET'])
@ -67,26 +47,32 @@ def snapshot(path = ''):
@app.route('/plans/') @app.route('/plans/')
@no_cache
def plans(): def plans():
return index('') return index('')
@app.route('/guide/') @app.route('/guide/')
@no_cache
def guide(): def guide():
return index('') return index('')
@app.route('/organizations/') @app.route('/organizations/')
@app.route('/organizations/new/') @app.route('/organizations/new/')
@no_cache
def organizations(): def organizations():
return index('') return index('')
@app.route('/user/') @app.route('/user/')
@no_cache
def user(): def user():
return index('') return index('')
@app.route('/signin/') @app.route('/signin/')
@no_cache
def signin(): def signin():
return index('') return index('')
@ -97,76 +83,85 @@ def contact():
@app.route('/new/') @app.route('/new/')
@no_cache
def new(): def new():
return index('') return index('')
@app.route('/repository/') @app.route('/repository/')
@no_cache
def repository(): def repository():
return index('') return index('')
@app.route('/security/') @app.route('/security/')
@no_cache
def security(): def security():
return index('') return index('')
@app.route('/v1') @app.route('/v1')
@app.route('/v1/') @app.route('/v1/')
@no_cache
def v1(): def v1():
return index('') return index('')
@app.route('/status', methods=['GET']) @app.route('/status', methods=['GET'])
@no_cache
def status(): def status():
return make_response('Healthy') return make_response('Healthy')
@app.route('/tos', methods=['GET']) @app.route('/tos', methods=['GET'])
@no_cache
def tos(): def tos():
return render_template('tos.html') return render_page_template('tos.html')
@app.route('/disclaimer', methods=['GET']) @app.route('/disclaimer', methods=['GET'])
@no_cache
def disclaimer(): def disclaimer():
return render_template('disclaimer.html') return render_page_template('disclaimer.html')
@app.route('/privacy', methods=['GET']) @app.route('/privacy', methods=['GET'])
@no_cache
def privacy(): def privacy():
return render_template('privacy.html') return render_page_template('privacy.html')
@app.route('/receipt', methods=['GET']) @app.route('/receipt', methods=['GET'])
def receipt(): def receipt():
if not current_user.is_authenticated():
abort(401)
return
id = request.args.get('id') id = request.args.get('id')
if id: if id:
invoice = stripe.Invoice.retrieve(id) invoice = stripe.Invoice.retrieve(id)
if invoice: if invoice:
org = model.get_user_or_org_by_customer_id(invoice.customer) user_or_org = model.get_user_or_org_by_customer_id(invoice.customer)
if org and org.organization:
admin_org = AdministerOrganizationPermission(org.username) if user_or_org:
if admin_org.can(): if user_or_org.organization:
file_data = renderInvoiceToPdf(invoice, org) admin_org = AdministerOrganizationPermission(user_or_org.username)
return Response(file_data, if not admin_org.can():
mimetype="application/pdf", abort(404)
headers={"Content-Disposition": return
"attachment;filename=receipt.pdf"}) 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) 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
def exchange_github_code_for_token(code):
@app.route('/oauth2/github/callback', methods=['GET'])
def github_oauth_callback():
code = request.args.get('code') code = request.args.get('code')
payload = { payload = {
'client_id': app.config['GITHUB_CLIENT_ID'], 'client_id': app.config['GITHUB_CLIENT_ID'],
@ -181,19 +176,37 @@ def github_oauth_callback():
params=payload, headers=headers) params=payload, headers=headers)
token = get_access_token.json()['access_token'] token = get_access_token.json()['access_token']
return token
def get_github_user(token):
token_param = { token_param = {
'access_token': token, 'access_token': token,
} }
get_user = requests.get(app.config['GITHUB_USER_URL'], params=token_param) 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'] username = user_data['login']
github_id = user_data['id'] github_id = user_data['id']
v3_media_type = { v3_media_type = {
'Accept': 'application/vnd.github.v3' 'Accept': 'application/vnd.github.v3'
} }
token_param = {
'access_token': token,
}
get_email = requests.get(app.config['GITHUB_USER_EMAILS'], get_email = requests.get(app.config['GITHUB_USER_EMAILS'],
params=token_param, headers=v3_media_type) params=token_param, headers=v3_media_type)
@ -220,18 +233,33 @@ def github_oauth_callback():
mixpanel.alias(to_login.username, state) mixpanel.alias(to_login.username, state)
except model.DataModelException, ex: 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): if common_login(to_login):
return redirect(url_for('index')) 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']) @app.route('/confirm', methods=['GET'])
def confirm_email(): def confirm_email():
code = request.values['code'] 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) common_login(user)
@ -248,8 +276,3 @@ def confirm_recovery():
return redirect(url_for('user')) return redirect(url_for('user'))
else: else:
abort(403) abort(403)
@app.route('/reset', methods=['GET'])
def password_reset():
pass

View file

@ -1,19 +1,14 @@
import logging import logging
import requests
import stripe import stripe
from flask import (abort, redirect, request, url_for, render_template, from flask import request, make_response
make_response)
from flask.ext.login import login_user, UserMixin, login_required
from flask.ext.principal import identity_changed, Identity, AnonymousIdentity
from data import model from data import model
from app import app, login_manager, mixpanel from app import app
from auth.permissions import QuayDeferredPermissionUser
from data.plans import USER_PLANS, BUSINESS_PLANS, get_plan
from util.invoice import renderInvoiceToHtml from util.invoice import renderInvoiceToHtml
from util.email import send_invoice_email from util.email import send_invoice_email
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -33,10 +28,10 @@ def stripe_webhook():
# Find the user associated with the customer ID. # Find the user associated with the customer ID.
user = model.get_user_or_org_by_customer_id(customer_id) user = model.get_user_or_org_by_customer_id(customer_id)
if user and user.invoice_email: if user and user.invoice_email:
# Lookup the invoice. # Lookup the invoice.
invoice = stripe.Invoice.retrieve(invoice_id) invoice = stripe.Invoice.retrieve(invoice_id)
if invoice: if invoice:
invoice_html = renderInvoiceToHtml(invoice, user) invoice_html = renderInvoiceToHtml(invoice, user)
send_invoice_email(user.email, invoice_html) send_invoice_email(user.email, invoice_html)
return make_response('Okay') 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 logging
import string import json
import shutil
import os
import hashlib import hashlib
import random
from datetime import datetime, timedelta from datetime import datetime, timedelta
from flask import url_for
from peewee import SqliteDatabase, create_model_tables, drop_model_tables from peewee import SqliteDatabase, create_model_tables, drop_model_tables
from data.database import * from data.database import *
@ -18,6 +16,15 @@ store = app.config['STORAGE']
SAMPLE_DIFFS = ['test/data/sample/diffs/diffs%s.json' % i SAMPLE_DIFFS = ['test/data/sample/diffs/diffs%s.json' % i
for i in range(1, 10)] 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) REFERENCE_DATE = datetime(2013, 6, 23)
TEST_STRIPE_ID = 'cus_2tmnh3PkXQS8NG' TEST_STRIPE_ID = 'cus_2tmnh3PkXQS8NG'
@ -51,9 +58,14 @@ def __create_subtree(repo, structure, parent):
model.set_image_checksum(docker_image_id, repo, checksum) model.set_image_checksum(docker_image_id, repo, checksum)
creation_time = REFERENCE_DATE + timedelta(days=image_num) 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, new_image = model.set_image_metadata(docker_image_id, repo.namespace,
repo.name, str(creation_time), 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 # Populate the diff file
diff_path = store.image_file_diffs_path(repo.namespace, repo.name, 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='push_repo')
LogEntryKind.create(name='pull_repo') LogEntryKind.create(name='pull_repo')
LogEntryKind.create(name='delete_repo') LogEntryKind.create(name='delete_repo')
LogEntryKind.create(name='delete_tag')
LogEntryKind.create(name='add_repo_permission') LogEntryKind.create(name='add_repo_permission')
LogEntryKind.create(name='change_repo_permission') LogEntryKind.create(name='change_repo_permission')
LogEntryKind.create(name='delete_repo_permission') LogEntryKind.create(name='delete_repo_permission')
@ -160,6 +173,7 @@ def populate_database():
new_user_1 = model.create_user('devtable', 'password', new_user_1 = model.create_user('devtable', 'password',
'jschorr@devtable.com') 'jschorr@devtable.com')
new_user_1.verified = True new_user_1.verified = True
new_user_1.stripe_id = TEST_STRIPE_ID
new_user_1.save() new_user_1.save()
model.create_robot('dtrobot', new_user_1) 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, model.log_action('pull_repo', org.username, repository=org_repo, timestamp=today,
metadata={'token': 'sometoken', 'token_code': 'somecode', 'repo': 'orgrepo'}) 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__': if __name__ == '__main__':
logging.basicConfig(**app.config['LOGGING_CONFIG']) logging.basicConfig(**app.config['LOGGING_CONFIG'])
initialize_database() initialize_database()

View file

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

View file

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

View file

@ -10,20 +10,6 @@ var casper = require('casper').create({
logLevel: "debug" 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 options = casper.cli.options;
var isDebug = !!options['d']; var isDebug = !!options['d'];
@ -56,12 +42,18 @@ casper.thenClick('.form-signin button[type=submit]', function() {
this.waitForText('Top Repositories'); this.waitForText('Top Repositories');
}); });
disableOlark(); casper.then(function() {
this.log('Generating user home screenshot.');
});
casper.then(function() { casper.then(function() {
this.capture(outputDir + 'user-home.png'); 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() { casper.thenOpen(rootUrl + 'repository/devtable/' + repo + '?tag=v2.0', function() {
// Wait for the tree to initialize. // Wait for the tree to initialize.
this.waitForSelector('.image-tree', function() { this.waitForSelector('.image-tree', function() {
@ -70,12 +62,14 @@ casper.thenOpen(rootUrl + 'repository/devtable/' + repo + '?tag=v2.0', function(
}); });
}); });
disableOlark();
casper.then(function() { casper.then(function() {
this.capture(outputDir + 'repo-view.png'); this.capture(outputDir + 'repo-view.png');
}); });
casper.then(function() {
this.log('Generating repository changes screenshot.');
});
casper.thenClick('#current-image dd a', function() { casper.thenClick('#current-image dd a', function() {
this.waitForSelector('.result-count', function() { this.waitForSelector('.result-count', function() {
this.capture(outputDir + 'repo-changes.png', { 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() { casper.thenOpen(rootUrl + 'repository/devtable/' + repo + '/admin', function() {
this.waitForSelector('.repo-access-state'); this.waitForSelector('.repo-access-state');
}); });
disableOlark();
casper.then(function() { casper.then(function() {
this.capture(outputDir + 'repo-admin.png'); this.capture(outputDir + 'repo-admin.png');
}); });
casper.then(function() {
this.log('Generating organization repo list screenshot.');
});
casper.thenOpen(rootUrl + 'repository/?namespace=' + org, function() { casper.thenOpen(rootUrl + 'repository/?namespace=' + org, function() {
this.waitForText('Repositories'); this.waitForText('Repositories');
}); });
disableOlark();
casper.then(function() { casper.then(function() {
this.capture(outputDir + 'org-repo-list.png'); this.capture(outputDir + 'org-repo-list.png');
}); });
casper.then(function() {
this.log('Generating organization teams screenshot.');
});
casper.thenOpen(rootUrl + 'organization/' + org, function() { casper.thenOpen(rootUrl + 'organization/' + org, function() {
this.waitForSelector('.organization-name'); this.waitForSelector('.organization-name');
}); });
disableOlark();
casper.then(function() { casper.then(function() {
this.capture(outputDir + 'org-teams.png'); this.capture(outputDir + 'org-teams.png');
}); });
casper.then(function() {
this.log('Generating organization admin screenshot.');
});
casper.thenOpen(rootUrl + 'organization/' + org + '/admin', function() { casper.thenOpen(rootUrl + 'organization/' + org + '/admin', function() {
this.waitForSelector('#repository-usage-chart'); this.waitForSelector('#repository-usage-chart');
}); });
disableOlark();
casper.then(function() { casper.then(function() {
this.capture(outputDir + 'org-admin.png'); this.capture(outputDir + 'org-admin.png');
}); });
casper.then(function() {
this.log('Generating organization logs screenshot.');
});
casper.thenClick('a[data-target="#logs"]', function() { casper.thenClick('a[data-target="#logs"]', function() {
this.waitForSelector('svg > g', 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() { casper.thenOpen(rootUrl + 'repository/' + org + '/' + orgrepo + '/admin', function() {
this.waitForText('outsideorg') this.waitForText('outsideorg')
}); });
disableOlark();
casper.then(function() { casper.then(function() {
this.capture(outputDir + 'org-repo-admin.png'); this.capture(outputDir + 'org-repo-admin.png');
}); });

View file

@ -3,6 +3,76 @@
margin: 0; 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 { .entity-search-element input {
vertical-align: middle; vertical-align: middle;
} }
@ -48,10 +118,7 @@ html, body {
.tooltip { .tooltip {
word-break: normal !important; word-break: normal !important;
word-wrap: normal !important; word-wrap: normal !important;
} pointer-events: none;
.code-info {
border-bottom: 1px dashed #aaa;
} }
.toggle-icon { .toggle-icon {
@ -136,12 +203,11 @@ i.toggle-icon:hover {
min-height: 100%; min-height: 100%;
height: auto !important; height: auto !important;
height: 100%; height: 100%;
margin: 0 auto -136px; margin: 0 auto -176px;
} }
.footer-container, .push { .footer-container, .push {
height: 110px; height: 74px;
overflow: hidden;
} }
.footer-container.fixed { .footer-container.fixed {
@ -193,6 +259,10 @@ i.toggle-icon:hover {
margin-left: 10px; margin-left: 10px;
} }
.logs-view-element .logs-date-picker {
width: 122px;
}
.logs-view-element .header input { .logs-view-element .header input {
font-size: 14px; font-size: 14px;
text-align: center; text-align: center;
@ -608,49 +678,165 @@ i.toggle-icon:hover {
.plans-list .plan { .plans-list .plan {
vertical-align: top; vertical-align: top;
padding: 10px;
border: 1px solid #eee;
border-top: 4px solid #94C9F7;
font-size: 1.4em; 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 { .plans-list .plan.bus-small {
border: 1px solid #ddd; border-top: 6px solid #46ac39;
border-top: 4px solid #47A447; margin-top: -10px;
margin-top: 0px; }
font-size: 1.6em;
.plans-list .plan.bus-small .plan-box {
background: black !important;
} }
.plans-list .plan:last-child { .plans-list .plan:last-child {
margin-right: 0px; margin-right: 0px;
} }
.plans-list .plan .plan-box {
background: #444;
padding: 10px;
color: white;
}
.plans-list .plan .plan-title { .plans-list .plan .plan-title {
text-transform: uppercase;
padding-top: 25px;
padding-bottom: 20px;
margin-bottom: 10px; margin-bottom: 10px;
display: block;
font-weight: bold; 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 { .plan-price {
margin-bottom: 10px;
display: block; display: block;
font-weight: bold; font-weight: bold;
font-size: 1.8em; font-size: 1.8em;
position: relative; position: relative;
} }
@ -678,7 +864,8 @@ i.toggle-icon:hover {
.plans-list .plan .description { .plans-list .plan .description {
font-size: 1em; font-size: 1em;
font-size: 16px; font-size: 16px;
margin-bottom: 10px; height: 34px;
} }
.plans-list .plan .smaller { .plans-list .plan .smaller {
@ -829,12 +1016,20 @@ form input.ng-valid.ng-dirty,
} }
.page-footer { .page-footer {
padding: 10px;
padding-bottom: 0px;
border-top: 1px solid #eee;
}
.page-footer-padder {
margin-top: 76px;
background-color: white; background-color: white;
background-image: none; background-image: none;
padding: 10px;
padding-bottom: 40px; overflow: hidden;
margin-top: 52px; width: 100%;
border-top: 1px solid #eee; height: 80px;
padding-top: 24px;
} }
.page-footer .row { .page-footer .row {
@ -909,12 +1104,20 @@ form input.ng-valid.ng-dirty,
.entity-mini-listing { .entity-mini-listing {
margin: 2px; margin: 2px;
white-space: nowrap !important; white-space: nowrap !important;
position: relative;
} }
.entity-mini-listing i { .entity-mini-listing i {
margin-right: 8px; margin-right: 8px;
} }
.entity-mini-listing i.fa-exclamation-triangle {
position: absolute;
right: -16px;
top: 4px;
color: #c09853;
}
.entity-mini-listing .warning { .entity-mini-listing .warning {
margin-top: 6px; margin-top: 6px;
font-size: 10px; font-size: 10px;
@ -967,20 +1170,84 @@ p.editable:hover i {
font-size: 1.15em; 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 { .right-title {
display: inline-block; display: inline-block;
float: right; float: right;
padding: 4px;
font-size: 12px; font-size: 12px;
color: #aaa; 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; display: inline-block;
margin-left: 4px; margin-left: 4px;
margin-right: 6px; 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 { .tag-dropdown a {
@ -993,6 +1260,61 @@ p.editable:hover i {
border: 0px; 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 { .repo .header {
margin-bottom: 10px; margin-bottom: 10px;
position: relative; position: relative;
@ -1009,7 +1331,7 @@ p.editable:hover i {
.repo .description { .repo .description {
margin-top: 10px; margin-top: 10px;
margin-bottom: 40px; margin-bottom: 20px;
} }
.repo .empty-message { .repo .empty-message {
@ -1343,6 +1665,18 @@ p.editable:hover i {
padding-top: 4px; 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 { .repo .changes-count-container {
text-align: center; text-align: center;
} }
@ -1567,6 +1901,10 @@ p.editable:hover i {
margin-top: 10px; margin-top: 10px;
} }
.user-admin .check-green {
color: #46ac39;
}
#image-history-container { #image-history-container {
overflow: hidden; overflow: hidden;
min-height: 400px; min-height: 400px;
@ -1617,7 +1955,7 @@ p.editable:hover i {
text-align: center; text-align: center;
} }
#image-history-container .tags .tag { #image-history-container .tags .tag, #confirmdeleteTagModal .tag {
border-radius: 10px; border-radius: 10px;
margin-right: 4px; margin-right: 4px;
cursor: pointer; cursor: pointer;
@ -1864,28 +2202,28 @@ p.editable:hover i {
display: inline-block; display: inline-block;
} }
.org-admin .invoice-title { .billing-invoices-element .invoice-title {
padding: 6px; padding: 6px;
cursor: pointer; cursor: pointer;
} }
.org-admin .invoice-status .success { .billing-invoices-element .invoice-status .success {
color: green; color: green;
} }
.org-admin .invoice-status .pending { .billing-invoices-element .invoice-status .pending {
color: steelblue; color: steelblue;
} }
.org-admin .invoice-status .danger { .billing-invoices-element .invoice-status .danger {
color: red; color: red;
} }
.org-admin .invoice-amount:before { .billing-invoices-element .invoice-amount:before {
content: '$'; content: '$';
} }
.org-admin .invoice-details { .billing-invoices-element .invoice-details {
margin-left: 10px; margin-left: 10px;
margin-bottom: 10px; margin-bottom: 10px;
@ -1894,21 +2232,21 @@ p.editable:hover i {
border-left: 2px solid #eee !important; border-left: 2px solid #eee !important;
} }
.org-admin .invoice-details td { .billing-invoices-element .invoice-details td {
border: 0px solid transparent !important; border: 0px solid transparent !important;
} }
.org-admin .invoice-details dl { .billing-invoices-element .invoice-details dl {
margin: 0px; margin: 0px;
} }
.org-admin .invoice-details dd { .billing-invoices-element .invoice-details dd {
margin-left: 10px; margin-left: 10px;
padding: 6px; padding: 6px;
margin-bottom: 10px; margin-bottom: 10px;
} }
.org-admin .invoice-title:hover { .billing-invoices-element .invoice-title:hover {
color: steelblue; color: steelblue;
} }
@ -2034,6 +2372,14 @@ p.editable:hover i {
margin-bottom: 0px; 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 { .plans-table-element table {
margin: 20px; margin: 20px;
border: 1px solid #eee; border: 1px solid #eee;
@ -2179,16 +2525,37 @@ p.editable:hover i {
display: block; display: block;
} }
.d3-tip .created { .d3-tip .command {
font-size: 12px; font-size: 12px;
color: white; color: white;
display: block; 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 { .d3-tip .comment {
display: block; display: block;
font-size: 14px; 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; 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 Credit Card
</div> </div>
<div class="panel-body"> <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"> <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="no-card-outline" ng-show="!currentCard.last4"></span>
<span class="last4" ng-show="currentCard.last4">****-****-****-<b>{{ currentCard.last4 }}</b></span> <span class="last4" ng-show="currentCard.last4">****-****-****-<b>{{ currentCard.last4 }}</b></span>
@ -24,7 +24,7 @@
<div class="panel"> <div class="panel">
<div class="panel-title"> <div class="panel-title">
Billing Options Billing Options
<i class="fa fa-spinner fa-spin" ng-show="working"></i> <div class="quay-spinner" ng-show="working"></div>
</div> </div>
<div class="panel-body"> <div class="panel-body">
<div class="settings-option"> <div class="settings-option">

View file

@ -5,7 +5,7 @@
<span class="caret"></span> <span class="caret"></span>
</button> </button>
<ul class="dropdown-menu" role="menu" aria-labelledby="entityDropdownMenu"> <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" <li role="presentation" ng-repeat="team in teams" ng-show="!lazyLoading"
ng-click="setEntity(team.name, 'team', false)"> 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"> <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" /> <img src="//www.gravatar.com/avatar/{{ user.gravatar }}?s=32&d=identicon" />
{{ user.username }} {{ 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> <b class="caret"></b>
</a> </a>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
<li> <li>
<a href="/user/" target="{{ appLinkTarget() }}"> <a href="/user/" target="{{ appLinkTarget() }}">
Account Settings 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> </a>
</li> </li>
<li><a ng-href="/organizations/" target="{{ appLinkTarget() }}">Organizations</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 class="entity-reference" name="performer.username" isrobot="performer.is_robot" ng-show="performer"></span>
<span id="logs-range" class="mini"> <span id="logs-range" class="mini">
From 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> <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> </span>
<span class="right"> <span class="right">
@ -21,7 +21,7 @@
</div> </div>
<div ng-show="loading"> <div ng-show="loading">
<i class="fa fa-spinner fa-spin fa-3x"></i> <div class="quay-spinner 3x"></div>
</div> </div>
<div ng-show="!loading"> <div ng-show="!loading">
<div id="bar-chart" style="width: 800px; height: 500px;" ng-show="chartVisible"> <div id="bar-chart" style="width: 800px; height: 500px;" ng-show="chartVisible">

View file

@ -1,6 +1,6 @@
<div class="plan-manager-element"> <div class="plan-manager-element">
<!-- Loading/Changing --> <!-- 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 --> <!-- Alerts -->
<div class="alert alert-danger" ng-show="limit == 'over' && !planLoading"> <div class="alert alert-danger" ng-show="limit == 'over' && !planLoading">
@ -32,26 +32,35 @@
<td></td> <td></td>
</thead> </thead>
<tr ng-repeat="plan in plans" ng-class="(subscribedPlan.stripeId === plan.stripeId) ? getActiveSubClass() : ''"> <tr ng-repeat="plan in plans" ng-show="isPlanVisible(plan, subscribedPlan)"
<td>{{ plan.title }}</td> 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>{{ plan.privateRepos }}</td>
<td><div class="plan-price">${{ plan.price / 100 }}</div></td> <td><div class="plan-price">${{ plan.price / 100 }}</div></td>
<td class="controls"> <td class="controls">
<div ng-switch='plan.stripeId'> <div ng-switch='plan.deprecated'>
<div ng-switch-when='bus-free'> <div ng-switch-when='true'>
<button class="btn button-hidden">Hidden!</button> <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>
<div ng-switch-default> <div ng-switch-default>
<button class="btn" ng-show="subscribedPlan.stripeId !== plan.stripeId" <button class="btn" ng-show="subscribedPlan.stripeId !== plan.stripeId"
ng-class="subscribedPlan.price == 0 ? 'btn-primary' : 'btn-default'" ng-class="subscribedPlan.price == 0 ? 'btn-primary' : 'btn-default'"
ng-click="changeSubscription(plan.stripeId)"> 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">Change</span>
<span ng-show="!planChanging && subscribedPlan.price == 0">Subscribe</span> <span ng-show="!planChanging && subscribedPlan.price == 0">Subscribe</span>
</button> </button>
<button class="btn btn-danger" ng-show="subscription.plan === plan.stripeId && plan.price > 0" <button class="btn btn-danger" ng-show="subscription.plan === plan.stripeId && plan.price > 0"
ng-click="cancelSubscription()"> 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> <span ng-show="!planChanging">Cancel</span>
</button> </button>
</div> </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> <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"> <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="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"> <div class="container" ng-show="!loading">

View file

@ -19,7 +19,7 @@
</div> </div>
</form> </form>
<div ng-show="registering" style="text-align: center"> <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>
<div ng-show="awaitingConfirmation"> <div ng-show="awaitingConfirmation">
<div class="sub-message"> <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> <button class="btn btn-lg btn-primary btn-block" type="submit">Send Recovery Email</button>
</form> </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 class="alert alert-success" ng-show="sent">Account recovery email was sent.</div>
</div> </div>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Before After
Before After

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

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 208 KiB

After

Width:  |  Height:  |  Size: 196 KiB

Before After
Before After

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_HEIGHT = 100;
var DEPTH_WIDTH = 132; var DEPTH_WIDTH = 132;
/** /**
* Based off of http://mbostock.github.io/d3/talk/20111018/tree.html by Mike Bostock (@mbostock) * 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. * The namespace of the repo.
*/ */
@ -30,6 +57,11 @@ function ImageHistoryTree(namespace, name, images, formatComment, formatTime) {
*/ */
this.formatTime_ = formatTime; this.formatTime_ = formatTime;
/**
* Method to invoke to format the command for an image.
*/
this.formatCommand_ = formatCommand;
/** /**
* The current tag (if any). * The current tag (if any).
*/ */
@ -40,6 +72,11 @@ function ImageHistoryTree(namespace, name, images, formatComment, formatTime) {
*/ */
this.currentImage_ = null; this.currentImage_ = null;
/**
* The currently highlighted node (if any).
*/
this.currentNode_ = null;
/** /**
* Counter for creating unique IDs. * 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 cw = Math.max(document.getElementById(container).clientWidth, this.maxWidth_ * DEPTH_WIDTH);
var ch = this.maxHeight_ * (DEPTH_HEIGHT + 10); 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 m = [margin.top, margin.right, margin.bottom, margin.left];
var w = cw - m[1] - m[3]; var w = cw - m[1] - m[3];
var h = ch - m[0] - m[2]; 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. * Updates the dimensions of the tree.
*/ */
@ -86,17 +142,22 @@ ImageHistoryTree.prototype.updateDimensions_ = function() {
$('#' + container).removeOverscroll(); $('#' + container).removeOverscroll();
var viewportHeight = $(window).height(); var viewportHeight = $(window).height();
var boundingBox = document.getElementById(container).getBoundingClientRect(); var boundingBox = document.getElementById(container).getBoundingClientRect();
document.getElementById(container).style.maxHeight = (viewportHeight - boundingBox.top - 110) + 'px'; document.getElementById(container).style.maxHeight = (viewportHeight - boundingBox.top - 150) + 'px';
$('#' + container).overscroll();
this.setupOverscroll_();
// Update the tree. // Update the tree.
var rootSvg = this.rootSvg_; var rootSvg = this.rootSvg_;
var tree = this.tree_; var tree = this.tree_;
var vis = this.vis_; var vis = this.vis_;
var ow = w + m[1] + m[3];
var oh = h + m[0] + m[2];
rootSvg rootSvg
.attr("width", w + m[1] + m[3]) .attr("width", ow)
.attr("height", h + m[0] + m[2]); .attr("height", oh)
.attr("style", "width: " + ow + "px; height: " + oh + "px");
tree.size([w, h]); tree.size([w, h]);
vis.attr("transform", "translate(" + m[3] + "," + m[0] + ")"); vis.attr("transform", "translate(" + m[3] + "," + m[0] + ")");
@ -131,6 +192,8 @@ ImageHistoryTree.prototype.draw = function(container) {
var formatComment = this.formatComment_; var formatComment = this.formatComment_;
var formatTime = this.formatTime_; var formatTime = this.formatTime_;
var formatCommand = this.formatCommand_;
var tip = d3.tip() var tip = d3.tip()
.attr('class', 'd3-tip') .attr('class', 'd3-tip')
.offset([-1, 24]) .offset([-1, 24])
@ -156,8 +219,10 @@ ImageHistoryTree.prototype.draw = function(container) {
if (d.image.comment) { if (d.image.comment) {
html += '<span class="comment">' + formatComment(d.image.comment) + '</span>'; html += '<span class="comment">' + formatComment(d.image.comment) + '</span>';
} }
html += '<span class="created">' + formatTime(d.image.created) + '</span>'; if (d.image.command && d.image.command.length) {
html += '<span class="full-id">' + d.image.id + '</span>'; 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; return html;
}) })
@ -178,8 +243,7 @@ ImageHistoryTree.prototype.draw = function(container) {
this.root_.y0 = 0; this.root_.y0 = 0;
this.setTag_(this.currentTag_); this.setTag_(this.currentTag_);
this.setupOverscroll_();
$('#' + container).overscroll();
}; };
@ -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. * Returns the ancestors of the given image.
*/ */
@ -445,26 +526,15 @@ ImageHistoryTree.prototype.setTag_ = function(tagName) {
// Save the current tag. // Save the current tag.
var previousTagName = this.currentTag_; var previousTagName = this.currentTag_;
this.currentTag_ = tagName; this.currentTag_ = tagName;
this.currentImage_ = null;
// Update the state of each existing node to no longer be highlighted. // Update the path.
var previousImage = this.findImage_(function(image) { var tagImage = 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) {
return image.tags.indexOf(tagName || '(no tag specified)') >= 0; return image.tags.indexOf(tagName || '(no tag specified)') >= 0;
}); });
// Update the state of the new node path. if (tagImage) {
if (this.currentImage_) { this.setHighlightedPath_(tagImage);
var currentNode = imageByDBID[this.currentImage_.dbid];
this.markPath_(currentNode, true);
} }
// Ensure that the children are in the correct order. // Ensure that the children are in the correct order.
@ -508,7 +578,9 @@ ImageHistoryTree.prototype.setImage_ = function(imageId) {
return; return;
} }
this.setHighlightedPath_(newImage);
this.currentImage_ = newImage; this.currentImage_ = newImage;
this.currentTag_ = null;
this.update_(this.root_); this.update_(this.root_);
}; };
@ -637,7 +709,7 @@ ImageHistoryTree.prototype.update_ = function(source) {
if (tag == currentTag) { if (tag == currentTag) {
kind = 'success'; 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; return html;
}); });
@ -649,6 +721,19 @@ ImageHistoryTree.prototype.update_ = function(source) {
if (tag) { if (tag) {
that.changeTag_(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. // 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> </ul>
</div> </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> <a name="#post-hook"></a>
<h3>Using push webhooks <span class="label label-info">Requires Admin Access</span></h3> <h3>Using push webhooks <span class="label label-info">Requires Admin Access</span></h3>
<div class="container"> <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> as an HTTP <b>POST</b> to the specified URL, with a JSON body describing the push:<br><br>
<pre> <pre>
{ {
<span class="code-info" title="The number of images pushed" bs-tooltip="tooltip.title">"pushed_image_count"</span>: 2, <span class="context-tooltip" 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="context-tooltip" 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="context-tooltip" 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="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="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="Map of updated tag names to their latest image IDs" bs-tooltip="tooltip.title">"updated_tags"</span>: {
"latest": "b750fe79269d2ec9a3c593ef05b4332b1d1a02a62b4accb2c21d589ff2f5f2dc" "latest": "b750fe79269d2ec9a3c593ef05b4332b1d1a02a62b4accb2c21d589ff2f5f2dc"
}, },
<span class="code-info" title="The namespace of the repository" bs-tooltip="tooltip.title">"namespace"</span>: "devtable", <span class="context-tooltip" 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="context-tooltip" 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 Quay URL for the repository" bs-tooltip="tooltip.title">"homepage"</span>: "https://quay.io/repository/devtable/ubuntu"
} }
</pre> </pre>
</div> </div>

View file

@ -1,97 +1,99 @@
<div class="container" ng-show="!loading && !image"> <div class="resource-view" resource="image" error-message="'No image found'">
No image found <div class="container repo repo-image-view">
</div> <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"> <!-- Comment -->
<i class="fa fa-spinner fa-spin fa-3x"></i> <blockquote ng-show="image.value.comment">
</div> <span class="markdown-view" content="image.value.comment"></span>
</blockquote>
<div class="container repo repo-image-view" ng-show="!loading && image"> <!-- Information -->
<div class="header"> <dl class="dl-normal">
<a href="{{ '/repository/' + repo.namespace + '/' + repo.name }}" class="back"><i class="fa fa-chevron-left"></i></a> <dt>Full Image ID</dt>
<h3> <dd>
<i class="fa fa-archive fa-lg" style="color: #aaa; margin-right: 10px;"></i> <div>
<span style="color: #aaa;"> {{repo.namespace}}</span> <div class="id-container">
<span style="color: #ccc">/</span> <div class="input-group">
<span style="color: #666;">{{repo.name}}</span> <input id="full-id" type="text" class="form-control" value="{{ image.value.id }}" readonly>
<span style="color: #ccc">/</span> <span id="copyClipboard" class="input-group-addon" title="Copy to Clipboard" data-clipboard-target="full-id">
<span>{{image.id.substr(0, 12)}}</span> <i class="fa fa-copy"></i>
</h3> </span>
</div> </div>
</div>
<!-- Comment -->
<blockquote ng-show="image.comment"> <div id="clipboardCopied" style="display: none">
<span class="markdown-view" content="image.comment"></span> Copied to clipboard
</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>
</div> </div>
</div> </div>
</dd>
<div id="clipboardCopied" style="display: none"> <dt>Created</dt>
Copied to clipboard <dd am-time-ago="parseDate(image.value.created)"></dd>
</div> <dt>Compressed Image Size</dt>
</div> <dd><span class="context-tooltip"
</dd> title="The amount of data sent between Docker and Quay.io when pushing/pulling"
<dt>Created</dt> bs-tooltip="tooltip.title" data-container="body">{{ image.value.size | bytes }}</span>
<dd am-time-ago="parseDate(image.created)"></dd> </dd>
</dl>
<!-- Changes tabs --> <dt ng-show="image.value.command && image.value.command.length">Command</dt>
<div ng-show="combinedChanges.length > 0"> <dd ng-show="image.value.command && image.value.command.length">
<b>File Changes:</b> <pre class="formatted-command">{{ getFormattedCommand(image.value) }}</pre>
<br> </dd>
<br> </dl>
<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 --> <!-- Changes tabs -->
<div class="tab-content" ng-show="combinedChanges.length > 0"> <div ng-show="combinedChanges.length > 0">
<!-- Filterable view --> <b>File Changes:</b>
<div class="tab-pane active" id="filterable"> <br>
<div class="changes-container full-changes-container"> <br>
<div class="change-side-controls"> <ul class="nav nav-tabs">
<div class="result-count"> <li class="active"><a href="javascript:void(0)" data-toggle="tab" data-target="#filterable">Filterable View</a></li>
Showing {{(combinedChanges | filter:search | limitTo:50).length}} of {{(combinedChanges | filter:search).length}} results <li><a href="javascript:void(0)" data-toggle="tab" data-target="#tree" ng-click="initializeTree()">Tree View</a></li>
</div> </ul>
<div class="filter-input"> </div>
<input id="change-filter" class="form-control" placeholder="Filter Changes" type="text" ng-model="search.$">
</div> <!-- Changes tab content -->
</div> <div class="tab-content" ng-show="combinedChanges.length > 0">
<div style="height: 28px;"></div> <!-- Filterable view -->
<div class="changes-list well well-sm"> <div class="tab-pane active" id="filterable">
<div ng-show="(combinedChanges | filter:search | limitTo:1).length == 0"> <div class="changes-container full-changes-container">
No matching changes <div class="change-side-controls">
</div> <div class="result-count">
<div class="change" ng-repeat="change in combinedChanges | filter:search | limitTo:50"> Showing {{(combinedChanges | filter:search | limitTo:50).length}} of {{(combinedChanges | filter:search).length}} results
<i ng-class="{'added': 'fa fa-plus-square', 'removed': 'fa fa-minus-square', 'changed': 'fa fa-pencil-square'}[change.kind]"></i> </div>
<span title="{{change.file}}"> <div class="filter-input">
<span style="color: #888;"> <input id="change-filter" class="form-control" placeholder="Filter Changes" type="text" ng-model="search.$">
<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> </div>
</span> </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>
</div> </div>
</div>
<!-- Tree view --> <!-- Tree view -->
<div class="tab-pane" id="tree"> <div class="tab-pane" id="tree">
<div id="changes-tree-container" class="changes-container" onresize="tree && tree.notifyResized()"></div> <div id="changes-tree-container" class="changes-container" onresize="tree && tree.notifyResized()"></div>
</div> </div>
</div>
</div> </div>
</div> </div>

View file

@ -5,29 +5,32 @@
<div ng-show="user.anonymous"> <div ng-show="user.anonymous">
<h1>Secure hosting for <b>private</b> Docker<a class="disclaimer-link" href="/disclaimer" target="_self">*</a> repositories</h1> <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> <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>
<div ng-show="!user.anonymous"> <div ng-show="!user.anonymous">
<div ng-show="loadingmyrepos"> <span class="namespace-selector" user="user" namespace="namespace" ng-show="user.organizations"></span>
<i class="fa fa-spinner fa-spin fa-3x"></i>
</div> <div class="resource-view" resource="my_repositories">
<span class="namespace-selector" user="user" namespace="namespace" ng-show="!loadingmyrepos && user.organizations"></span> <!-- Repos -->
<div ng-show="!loadingmyrepos && myrepos.length > 0"> <div ng-show="my_repositories.value.length > 0">
<h2>Top Repositories</h2> <h2>Top Repositories</h2>
<div class="repo-listing" ng-repeat="repository in myrepos"> <div class="repo-listing" ng-repeat="repository in my_repositories.value">
<span class="repo-circle no-background" repo="repository"></span> <span class="repo-circle no-background" repo="repository"></span>
<a ng-href="/repository/{{ repository.namespace }}/{{ repository.name }}">{{repository.namespace}}/{{repository.name}}</a> <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 class="markdown-view description" content="repository.description" first-line-only="true"></div>
</div>
</div> </div>
</div>
<div ng-show="!loadingmyrepos && myrepos.length == 0"> <!-- No Repos -->
<div class="sub-message" style="margin-top: 20px"> <div ng-show="my_repositories.value.length == 0">
<span ng-show="namespace != user.username">You don't have access to any repositories in this organization yet.</span> <div class="sub-message" style="margin-top: 20px">
<span ng-show="namespace == user.username">You don't have any repositories yet!</span> <span ng-show="namespace != user.username">You don't have access to any repositories in this organization yet.</span>
<div class="options"> <span ng-show="namespace == user.username">You don't have any repositories yet!</span>
<a class="btn btn-primary" href="/repository/">Browse all repositories</a> <div class="options">
<a class="btn btn-success" href="/new/" ng-show="canCreateRepo(namespace)">Create a new repository</a> <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> </div>
</div> </div>
@ -41,8 +44,8 @@
<div ng-show="!user.anonymous" class="user-welcome"> <div ng-show="!user.anonymous" class="user-welcome">
<img class="gravatar" src="//www.gravatar.com/avatar/{{ user.gravatar }}?s=128&d=identicon" /> <img class="gravatar" src="//www.gravatar.com/avatar/{{ user.gravatar }}?s=128&d=identicon" />
<div class="sub-message">Welcome <b>{{ user.username }}</b>!</div> <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="my_repositories.value" 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 class="btn btn-success" href="/new/">Create a new repository</a>
</div> </div>
</div> <!-- col --> </div> <!-- col -->
</div> <!-- row --> </div> <!-- row -->
@ -82,7 +85,7 @@
</div> </div>
<div class="tour-section row"> <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="col-md-5">
<div class="tour-section-title">Customized for you</div> <div class="tour-section-title">Customized for you</div>
<div class="tour-section-description"> <div class="tour-section-description">
@ -93,7 +96,7 @@
</div> </div>
<div class="tour-section row"> <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="col-md-5 col-md-pull-7">
<div class="tour-section-title">Useful views of respositories</div> <div class="tour-section-title">Useful views of respositories</div>
<div class="tour-section-description"> <div class="tour-section-description">
@ -103,7 +106,7 @@
</div> </div>
<div class="tour-section row"> <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="col-md-5">
<div class="tour-section-title">Docker diff in the cloud</div> <div class="tour-section-title">Docker diff in the cloud</div>
<div class="tour-section-description"> <div class="tour-section-description">
@ -113,7 +116,7 @@
</div> </div>
<div class="tour-section row"> <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="col-md-5 col-md-pull-7">
<div class="tour-section-title">Share at your control</div> <div class="tour-section-title">Share at your control</div>
<div class="tour-section-description"> <div class="tour-section-description">

View file

@ -1,8 +1,8 @@
<div class="loading" ng-show="loading || creating"> <div class="loading" ng-show="creating">
<i class="fa fa-spinner fa-spin fa-3x"></i> <div class="quay-spinner"></div>
</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="row header-row">
<div class="col-md-8 col-md-offset-1"> <div class="col-md-8 col-md-offset-1">
@ -72,7 +72,8 @@
</div> </div>
<div class="button-bar"> <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 Create Organization
</button> </button>
</div> </div>

View file

@ -1,13 +1,15 @@
<div class="container" ng-show="user.anonymous"> <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>
<div class="container" ng-show="!user.anonymous && building"> <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>
<div class="container" ng-show="!user.anonymous && creating"> <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>
<div class="container" ng-show="!user.anonymous && uploading"> <div class="container" ng-show="!user.anonymous && uploading">
@ -72,12 +74,18 @@
<!-- Payment --> <!-- Payment -->
<div class="required-plan" ng-show="repo.is_public == '0' && planRequired && isUserNamespace"> <div class="required-plan" ng-show="repo.is_public == '0' && planRequired && isUserNamespace">
<div class="alert alert-warning"> <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> </div>
<a class="btn btn-primary" ng-click="upgradePlan()" ng-show="!planChanging">Upgrade now</a> <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>
<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="required-plan" ng-show="repo.is_public == '0' && planRequired && !isUserNamespace">
<div class="alert alert-warning"> <div class="alert alert-warning">
This organization has reached its private repository limit. Please contact your administrator. This organization has reached its private repository limit. Please contact your administrator.
@ -112,7 +120,10 @@
<div class="row"> <div class="row">
<div class="col-md-1"></div> <div class="col-md-1"></div>
<div class="col-md-8"> <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>
</div> </div>

View file

@ -1,12 +1,5 @@
<div class="loading" ng-show="loading"> <div class="resource-view" resource="orgResource" error-message="'No organization found'"></div>
<i class="fa fa-spinner fa-spin fa-3x"></i> <div class="org-admin container" ng-show="organization">
</div>
<div class="loading" ng-show="!loading && !organization">
No matching organization found
</div>
<div class="org-admin container" ng-show="!loading && organization">
<div class="organization-header" organization="organization" clickable="true"></div> <div class="organization-header" organization="organization" clickable="true"></div>
<div class="row"> <div class="row">
@ -47,66 +40,12 @@
<!-- Billing History tab --> <!-- Billing History tab -->
<div id="billing" class="tab-pane"> <div id="billing" class="tab-pane">
<div ng-show="invoiceLoading"> <div class="billing-invoices" organization="organization" visible="invoicesShown"></div>
<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> </div>
<!-- Members tab --> <!-- Members tab -->
<div id="members" class="tab-pane"> <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 ng-show="!membersLoading">
<div class="side-controls"> <div class="side-controls">
<div class="result-count"> <div class="result-count">

View file

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

View file

@ -1,51 +1,45 @@
<div class="loading" ng-show="loading"> <div class="resource-view" resource="orgResource" error-message="'No matching organization found'">
<i class="fa fa-spinner fa-spin fa-3x"></i> <div class="org-view container">
</div> <div class="organization-header" organization="organization">
<div class="header-buttons" ng-show="organization.is_admin">
<div class="loading" ng-show="!loading && !organization"> <span class="popup-input-button" pattern="TEAM_PATTERN" placeholder="'Team Name'"
No matching organization found submitted="createTeam(value)">
</div> <i class="fa fa-group"></i> Create Team
</span>
<div class="org-view container" ng-show="!loading && organization"> <a class="btn btn-default" href="/organization/{{ organization.name }}/admin"><i class="fa fa-gear"></i> Settings</a>
<div class="organization-header" organization="organization"> </div>
<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>
</div> </div>
</div>
<div class="row hidden-xs"> <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"> <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 Team Permissions
<i class="info-icon fa fa-info-circle" data-placement="bottom" data-original-title="" title="" <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> 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>
<div class="team-listing" ng-repeat="(name, team) in organization.teams"> <div class="team-listing" ng-repeat="(name, team) in organization.teams">
<div id="team-{{name}}" class="row"> <div id="team-{{name}}" class="row">
<div class="col-sm-7 col-md-8"> <div class="col-sm-7 col-md-8">
<div class="team-title"> <div class="team-title">
<i class="fa fa-group"></i> <i class="fa fa-group"></i>
<span ng-show="team.can_view"> <span ng-show="team.can_view">
<a href="/organization/{{ organization.name }}/teams/{{ team.name }}">{{ team.name }}</a> <a href="/organization/{{ organization.name }}/teams/{{ team.name }}">{{ team.name }}</a>
</span> </span>
<span ng-show="!team.can_view"> <span ng-show="!team.can_view">
{{ team.name }} {{ team.name }}
</span> </span>
</div>
<div class="team-description markdown-view" content="team.description" first-line-only="true"></div>
</div> </div>
<div class="team-description markdown-view" content="team.description" first-line-only="true"></div> <div class="col-sm-5 col-md-4 control-col" ng-show="organization.is_admin">
</div> <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"> </div>
<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>
</div> </div>

View file

@ -1,6 +1,6 @@
<div class="container org-list conntent-container"> <div class="container org-list conntent-container">
<div class="loading" ng-show="loading"> <div class="loading" ng-show="!user">
<i class="fa fa-spinner fa-spin fa-3x"></i> <div class="quay-spinner"></div>
</div> </div>
<div class="button-bar-right"> <div class="button-bar-right">
@ -43,7 +43,7 @@
<div class="tour-section row"> <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="col-md-5">
<div class="tour-section-title">A central collection of repositories</div> <div class="tour-section-title">A central collection of repositories</div>
<div class="tour-section-description"> <div class="tour-section-description">
@ -57,7 +57,7 @@
</div> </div>
<div class="tour-section row"> <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="col-md-5 col-md-pull-7">
<div class="tour-section-title">Organization settings at a glance</div> <div class="tour-section-title">Organization settings at a glance</div>
<div class="tour-section-description"> <div class="tour-section-description">
@ -73,8 +73,29 @@
</div> </div>
<div class="tour-section row"> <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="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-title">Teams simplify access controls</div>
<div class="tour-section-description"> <div class="tour-section-description">
Teams allow your organization to delegate access to your namespace and Teams allow your organization to delegate access to your namespace and
@ -94,8 +115,8 @@
</div> </div>
<div class="tour-section row"> <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-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 col-md-pull-7"> <div class="col-md-5">
<div class="tour-section-title">Fine-grained control of sharing</div> <div class="tour-section-title">Fine-grained control of sharing</div>
<div class="tour-section-description"> <div class="tour-section-description">
Repositories that you create within your organization can be assigned Repositories that you create within your organization can be assigned

View file

@ -1,44 +1,107 @@
<div class="container plans content-container"> <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="row plans-list">
<div class="col-xs-0 col-lg-1"></div> <div class="col-sm-2">
<div class="col-lg-2 col-xs-4 plan-container" ng-repeat="plan in plans.user"> <div class="features-bar hidden-xs">
<div class="plan" ng-class="plan.stripeId"> <div class="visible-lg" style="height: 50px"></div>
<div class="plan-title">{{ plan.title }}</div> <div class="visible-md visible-sm" style="height: 70px"></div>
<div class="plan-price">${{ plan.price/100 }}</div>
<div class="count"><b>{{ plan.privateRepos }}</b> private repositories</div> <div class="feature">
<div class="description">{{ plan.audience }}</div> <span class="context-tooltip" bs-tooltip="tooltip.title" data-container="body" data-placement="right"
<div class="smaller">SSL secured connections</div> title="All plans have unlimited public repositories">
<button class="btn btn-primary btn-block" ng-click="buyNow(plan.stripeId)">Sign Up Now</button> <span class="hidden-sm-inline">Public Repositories</span>
</div> <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> <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"> <div class="description">{{ plan.audience }}</div>
Business Plan Pricing </div>
</div>
<div class="all-plans"> <div class="features hidden-xs">
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 class="count"><b>{{ plan.privateRepos }}</b> private repositories</div>
</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> </div>
</div> </div>
@ -82,7 +145,7 @@
<div class="user-setup" signed-in="signedIn()" redirect-url="'/plans/'"></div> <div class="user-setup" signed-in="signedIn()" redirect-url="'/plans/'"></div>
</div> </div>
<div class="modal-footer"> <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>
</div><!-- /.modal-content --> </div><!-- /.modal-content -->
</div><!-- /.modal-dialog --> </div><!-- /.modal-dialog -->

View file

@ -1,13 +1,5 @@
<div class="resource-view" resource="repository" error-message="'No repository found'"></div>
<div class="loading" ng-show="loading"> <div class="container repo repo-admin" ng-show="repo">
<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="header row"> <div class="header row">
<a href="{{ '/repository/' + repo.namespace + '/' + repo.name }}" class="back"><i class="fa fa-chevron-left"></i></a> <a href="{{ '/repository/' + repo.namespace + '/' + repo.name }}" class="back"><i class="fa fa-chevron-left"></i></a>
<h3> <h3>
@ -35,7 +27,7 @@
<div id="logs" class="tab-pane"> <div id="logs" class="tab-pane">
<div class="logs-view" repository="repo" visible="logsShown"></div> <div class="logs-view" repository="repo" visible="logsShown"></div>
</div> </div>
<!-- Permissions tab --> <!-- Permissions tab -->
<div id="permissions" class="tab-pane active"> <div id="permissions" class="tab-pane active">
<!-- User Access Permissions --> <!-- User Access Permissions -->
@ -137,7 +129,7 @@
<tr> <tr>
<td class="admin-search"> <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>
<td class="admin-search"> <td class="admin-search">
<button type="submit" ng-disabled="createTokenForm.$invalid" class="btn btn-sm btn-default">Create</button> <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> <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>
<div class="panel-body" ng-show="webhooksLoading"> <div class="panel-body">
Loading webhooks: <i class="fa fa-spinner fa-spin fa-2x" style="vertical-align: middle; margin-left: 4px"></i> <div class="resource-view" resource="webhooksResource" error-message="'Could not load webhooks'">
</div> <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"> <form name="createWebhookForm" ng-submit="createWebhook()">
<table class="permissions" ng-form="newWebhookForm"> <table class="permissions">
<thead> <tbody>
<tr> <tr>
<td style="width: 500px;">Webhook URL</td> <td style="width: 500px;">
<td></td> <input type="url" class="form-control" placeholder="New webhook url..." ng-model="newWebhook.url" required>
</tr> </td>
</thead> <td>
<button class="btn btn-primary" type="submit" ng-disabled="createWebhookForm.$invalid">Create</button>
<tbody> </td>
<tr ng-repeat="webhook in webhooks"> </tr>
<td>{{ webhook.parameters.url }}</td> </tbody>
<td> </table>
<span class="delete-ui" tabindex="0"> </form>
<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>
<div class="right-info"> <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. 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>
</div> </div>
</div>
<div class="docker-auth-dialog" username="'$token'" token="shownToken.code" </div>
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 --> <!-- Auth dialog -->
<div class="modal fade" id="confirmaddoutsideModal"> <div class="docker-auth-dialog" username="'$token'" token="shownToken.code"
<div class="modal-dialog"> shown="!!shownToken" counter="shownTokenCounter">
<div class="modal-content"> <i class="fa fa-key"></i> {{ shownToken.friendlyName }}
<div class="modal-header"> </div>
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<h4 class="modal-title">Add User?</h4> <!-- Modal message dialog -->
</div> <div class="modal fade" id="cannotchangeModal">
<div class="modal-body"> <div class="modal-dialog">
The selected user is outside of your organization. Are you sure you want to grant the user access to this repository? <div class="modal-content">
</div> <div class="modal-header">
<div class="modal-footer"> <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<button type="button" class="btn btn-primary" ng-click="grantRole()">Yes, I'm sure</button> <h4 class="modal-title">Cannot change</h4>
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button> </div>
</div> <div class="modal-body">
</div><!-- /.modal-content --> The selected action could not be performed because you do not have that authority.
</div><!-- /.modal-dialog --> </div>
</div><!-- /.modal --> <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"> <div class="container">
<i class="fa fa-spinner fa-spin fa-3x"></i>
</div>
<div class="container" ng-show="!loading">
<div class="repo-list" ng-show="!user.anonymous"> <div class="repo-list" ng-show="!user.anonymous">
<div ng-class="user.organizations.length ? 'section-header' : ''"> <div ng-class="user.organizations.length ? 'section-header' : ''">
<div class="button-bar-right"> <div class="button-bar-right">
@ -26,31 +22,47 @@
<h3 ng-show="namespace == user.username">Your Repositories</h3> <h3 ng-show="namespace == user.username">Your Repositories</h3>
<h3 ng-show="namespace != user.username">Repositories</h3> <h3 ng-show="namespace != user.username">Repositories</h3>
<div ng-show="user_repositories.length > 0"> <div class="resource-view" resource="user_repositories">
<div class="repo-listing" ng-repeat="repository in user_repositories"> <!-- User/Org has repositories -->
<span class="repo-circle no-background" repo="repository"></span> <div ng-show="user_repositories.value.length > 0">
<a ng-href="/repository/{{repository.namespace}}/{{ repository.name }}">{{repository.namespace}}/{{repository.name}}</a> <div class="repo-listing" ng-repeat="repository in user_repositories.value">
<div class="description markdown-view" content="repository.description" first-line-only="true"></div> <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> </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>
<div class="repo-list"> <div class="repo-list">
<h3>Top Public Repositories</h3> <h3>Top Public Repositories</h3>
<div class="repo-listing" ng-repeat="repository in public_repositories"> <div class="resource-view" resource="public_repositories">
<span class="repo-circle no-background" repo="repository"></span> <div class="repo-listing" ng-repeat="repository in public_repositories.value">
<a ng-href="/repository/{{repository.namespace}}/{{ repository.name }}">{{repository.namespace}}/{{repository.name}}</a> <span class="repo-circle no-background" repo="repository"></span>
<div class="description markdown-view" content="repository.description" first-line-only="true"></div> <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> </div>
</div> </div>

View file

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

View file

@ -1,12 +1,12 @@
<div class="loading" ng-show="loading"> <div class="loading" ng-show="!user">
<i class="fa fa-spinner fa-spin fa-3x"></i> <div class="quay-spinner"></div>
</div> </div>
<div class="loading" ng-show="!loading && !user"> <div class="loading" ng-show="user.anonymous">
No matching user found No matching user found
</div> </div>
<div class="user-admin container" ng-show="!loading && user"> <div class="user-admin container" ng-show="!user.anonymous">
<div class="row"> <div class="row">
<div class="organization-header-element"> <div class="organization-header-element">
<img src="//www.gravatar.com/avatar/{{ user.gravatar }}?s=24&amp;d=identicon"> <img src="//www.gravatar.com/avatar/{{ user.gravatar }}?s=24&amp;d=identicon">
@ -27,16 +27,24 @@
<div class="col-md-2"> <div class="col-md-2">
<ul class="nav nav-pills nav-stacked"> <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 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="#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> <li><a href="javascript:void(0)" data-toggle="tab" data-target="#migrate" id="migrateTab">Convert to Organization</a></li>
</ul> </ul>
</div> </div>
<!-- Content --> <!-- Content -->
<div class="col-md-10"> <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 --> <!-- Plans tab -->
<div id="plan" class="tab-pane active"> <div id="plan" class="tab-pane active">
<div class="plan-manager" user="user.username" ready-for-plan="readyForPlan()" plan-changed="planChanged(plan)"></div> <div class="plan-manager" user="user.username" ready-for-plan="readyForPlan()" plan-changed="planChanged(plan)"></div>
@ -45,18 +53,48 @@
<!-- Change password tab --> <!-- Change password tab -->
<div id="password" class="tab-pane"> <div id="password" class="tab-pane">
<div class="loading" ng-show="updatingUser"> <div class="loading" ng-show="updatingUser">
<i class="fa fa-spinner fa-spin fa-3x"></i> <div class="quay-spinner 3x"></div>
</div> </div>
<div class="row"> <div class="row">
<form class="form-change-pw col-md-6" name="changePasswordForm" ng-submit="changePassword()" data-trigger="manual" <div class="panel">
data-content="{{ changePasswordError }}" data-placement="right" ng-show="!awaitingConfirmation && !registering"> <div class="panel-title">Change Password</div>
<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>
<span class="help-block" ng-show="changePasswordSuccess">Password changed successfully</span> <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>
</div> </div>
@ -66,10 +104,15 @@
</div> </div>
<!-- Billing options tab --> <!-- Billing options tab -->
<div id="billing" class="tab-pane"> <div id="billingoptions" class="tab-pane">
<div class="billing-options" user="user"></div> <div class="billing-options" user="user"></div>
</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 --> <!-- Convert to organization tab -->
<div id="migrate" class="tab-pane"> <div id="migrate" class="tab-pane">
<!-- Step 0 --> <!-- Step 0 -->
@ -83,11 +126,11 @@
</div> </div>
<div class="panel-body" ng-show="user.organizations.length == 0"> <div class="panel-body" ng-show="user.organizations.length == 0">
<div class="alert alert-danger"> <div class="alert alert-warning">
Converting a user account into an organization <b>cannot be undone</b>.<br> Here be many fire-breathing dragons! Note: Converting a user account into an organization <b>cannot be undone</b>
</div> </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>
</div> </div>
@ -110,7 +153,7 @@
ng-model="org.adminUser" required autofocus> ng-model="org.adminUser" required autofocus>
<input id="adminPassword" name="adminPassword" type="password" class="form-control" placeholder="Admin Password" <input id="adminPassword" name="adminPassword" type="password" class="form-control" placeholder="Admin Password"
ng-model="org.adminPassword" required> 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> </div>
<!-- Plans Table --> <!-- Plans Table -->
@ -120,7 +163,8 @@
</div> </div>
<div class="button-bar"> <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 Convert To Organization
</button> </button>
</div> </div>

View file

@ -1,193 +1,276 @@
<div class="container" ng-show="!loading && !repo"> <div id="tagContextMenu" class="dropdown clearfix" tabindex="-1">
No repository found <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>
<div class="loading" ng-show="loading"> <div class="resource-view" resource="repository" error-message="'No Repository Found'">
<i class="fa fa-spinner fa-spin fa-3x"></i> <div class="container repo">
</div> <!-- 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"> <div id="clipboardCopied" class="hovering" style="display: none">
<!-- Repo Header --> Copied to clipboard
<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> </div>
</div>
<div id="clipboardCopied" class="hovering" style="display: none"> <!-- Status boxes -->
Copied to clipboard <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> </div>
</div>
<!-- Status boxes --> <!-- Description -->
<div class="status-boxes"> <div class="description markdown-input" content="repo.description" can-write="repo.can_write"
<div id="buildInfoBox" class="status-box" ng-show="repo.is_building" content-changed="updateForDescription" field-title="'repository description'"></div>
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 --> <!-- Empty message -->
<div class="description markdown-input" content="repo.description" can-write="repo.can_write" <div class="repo-content" ng-show="!currentTag.image && !currentImage && !repo.is_building">
content-changed="updateForDescription" field-title="'repository description'"></div> <div class="empty-message">
This repository is empty
</div>
<!-- Empty message --> <div class="empty-description" ng-show="repo.can_write">
<div class="repo-content" ng-show="!currentTag.image && !repo.is_building"> To push images to this repository:<br><br>
<div class="empty-message"> <pre>sudo docker tag <i>0u123imageidgoeshere</i> quay.io/{{repo.namespace}}/{{repo.name}}
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}}
sudo docker push quay.io/{{repo.namespace}}/{{repo.name}}</pre> sudo docker push quay.io/{{repo.namespace}}/{{repo.name}}</pre>
</div>
</div> </div>
</div>
<div class="repo-content" ng-show="!currentTag.image && repo.is_building"> <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 class="empty-message">
</div> 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 --> <!-- Content view -->
<div class="repo-content" ng-show="currentTag.image"> <div class="repo-content" ng-show="currentTag.image || currentImage">
<!-- Image History --> <!-- Image History -->
<div id="image-history" style="max-height: 10px;"> <div id="image-history" style="max-height: 10px;">
<div class="row"> <div class="row">
<!-- Tree View container --> <!-- Tree View container -->
<div class="col-md-8"> <div class="col-md-8">
<div class="panel panel-default">
<div class="panel panel-default"> <!-- Image history tree -->
<div class="panel-heading"> <div class="resource-view" resource="imageHistory">
<!-- Tag dropdown --> <div id="image-history-container" onresize="tree.notifyResized()"></div>
<div class="tag-dropdown dropdown" title="Tags" bs-tooltip="tooltip.title" data-placement="top"> </div>
<i class="fa fa-tag"><span class="tag-count">{{getTagCount(repo)}}</span></i> </div>
<a href="javascript:void(0)" class="dropdown-toggle" data-toggle="dropdown">{{currentTag.name}} <b class="caret"></b></a> </div>
<ul class="dropdown-menu">
<li ng-repeat="tag in repo.tags"> <!-- Side Panel -->
<a href="javascript:void(0)" ng-click="setTag(tag.name)">{{tag.name}}</a> <div class="col-md-4">
</li> <div class="panel panel-default">
</ul> <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> </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 class="control-bar" ng-show="repo.can_admin">
<div ng-hide="imageHistory" style="padding: 10px; text-align: center;"> <button class="btn btn-default" ng-click="askDeleteTag(currentTag.name)">
<i class="fa fa-spinner fa-spin fa-3x"></i> Delete Tag
</div> </button>
</div>
</div>
<!-- Tree View itself --> <!-- Current Image -->
<div id="image-history-container" onresize="tree.notifyResized()"></div> <div id="current-image" ng-show="currentImage && !currentTag">
</div> <div ng-show="currentImage.comment">
</div> <blockquote style="margin-top: 10px;">
<span class="markdown-view" content="currentImage.comment"></span>
</blockquote>
</div>
<!-- Side Panel --> <dl class="dl-normal">
<div class="col-md-4"> <dt>Created</dt>
<div class="panel panel-default"> <dd am-time-ago="parseDate(currentImage.created)"></dd>
<div class="panel-heading"> <dt>Image ID</dt>
<!-- Image dropdown --> <dd><a href="{{'/repository/' + repo.namespace + '/' + repo.name + '/image/' + currentImage.id}}">{{ currentImage.id }}</a></dd>
<div class="tag-dropdown dropdown" title="Images" bs-tooltip="tooltip.title" data-placement="top"> <dt>Compressed Image Size</dt>
<i class="fa fa-archive"><span class="tag-count">{{imageHistory.length}}</span></i> <dd><span class="context-tooltip"
<a href="javascript:void(0)" class="dropdown-toggle" data-toggle="dropdown">{{currentImage.id.substr(0, 12)}} <b class="caret"></b></a> title="The amount of data sent between Docker and Quay.io when pushing/pulling"
<ul class="dropdown-menu"> bs-tooltip="tooltip.title" data-container="body">{{ currentImage.size | bytes }}</span>
<li ng-repeat="image in imageHistory"> </dd>
<a href="javascript:void(0)" ng-click="setImage(image)">{{image.id.substr(0, 12)}}</a> <dt ng-show="currentImage.command && currentImage.command.length">Command</dt>
</li> <dd ng-show="currentImage.command && currentImage.command.length" class="codetooltipcontainer">
</ul> <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> </div>
<span class="right-title">Image</span>
</div> </div>
</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>
</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, namespace,
repository) 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): def image_json_path(self, namespace, repository, image_id):
return '{0}/{1}/{2}/{3}/json'.format(self.images, namespace, return '{0}/{1}/{2}/{3}/json'.format(self.images, namespace,
repository, image_id) repository, image_id)

View file

@ -11,6 +11,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <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/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 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'> <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.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-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/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/angular-strap.min.js"></script>
<script src="static/lib/angulartics.js"></script> <script src="static/lib/angulartics.js"></script>
<script src="static/lib/angulartics-mixpanel.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-moment.min.js"></script>
<script src="static/lib/angular-cookies.min.js"></script> <script src="static/lib/angular-cookies.min.js"></script>
@ -61,6 +66,10 @@
{% endblock %} {% endblock %}
<script type="text/javascript">
window.__endpoints = {{ route_data|safe }}.endpoints;
</script>
<script src="static/js/app.js"></script> <script src="static/js/app.js"></script>
<script src="static/js/controllers.js"></script> <script src="static/js/controllers.js"></script>
<script src="static/js/graphing.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]); 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||[]); 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 --> 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> </head>
<body> <body>
<div ng-class="!fixFooter ? 'wrapper' : ''"> <div ng-class="!fixFooter ? 'wrapper' : ''">
@ -85,27 +107,46 @@ mixpanel.init(isProd ? "50ff2b2569faa3a51c8f5724922ffb7e" : "38014a0f27e7bdc3ff8
</div> </div>
<div class="footer-container" ng-class="fixFooter ? 'fixed' : ''"> <div class="footer-container" ng-class="fixFooter ? 'fixed' : ''">
<nav class="page-footer visible-lg visible-md"> <div class="page-footer-padder">
<div class="row"> <nav class="page-footer visible-lg visible-md">
<div class="col-md-8"> <div class="row">
<ul> <div class="col-md-8">
<li><span class="copyright">&copy;2013 DevTable, LLC</span></li> <ul>
<li><a href="http://blog.devtable.com/">Blog</a></li> <li><span class="copyright">&copy;2013 DevTable, LLC</span></li>
<li><a href="/tos" target="_self">Terms of Service</a></li> <li><a href="http://blog.devtable.com/">Blog</a></li>
<li><a href="/privacy" target="_self">Privacy Policy</a></li> <li><a href="/tos" target="_self">Terms of Service</a></li>
<li><a href="/security/">Security</a></li> <li><a href="/privacy" target="_self">Privacy Policy</a></li>
<li><b><a href="/contact/">Contact Us</a></b></li> <li><a href="/security/">Security</a></li>
</ul> <li><b><a href="/contact/">Contact Us</a></b></li>
</div> </ul>
</div>
<div class="col-md-4 logo-container"> <div class="col-md-5 logo-container">
<a href="https://devtable.com"><img class="dt-logo" src="/static/img/dt-logo.png"></a> <a href="https://devtable.com"><img class="dt-logo" src="/static/img/dt-logo.png"></a>
</div> </div>
</div> <!-- row --> </div> <!-- row -->
</nav> </nav>
</div>
</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 --> <!-- 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(){ <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(){ f[z]=function(){
(a.s=a.s||[]).push(arguments)};var a=f[z]._={ (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"]}); loader: "static.olark.com/jsclient/loader0.js",name:"olark",methods:["configure","extend","declare","identify"]});
/* custom configuration goes here (www.olark.com/documentation) */ /* 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> 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 --> <!-- end olark code -->
</body> </body>

View file

@ -16,6 +16,8 @@
<div> <div>
Please register using the <a href="/">registration form</a> to continue. 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> </div>
</div> </div>

Binary file not shown.

View file

@ -105,20 +105,20 @@ def build_specs():
return [ return [
TestSpec(url_for('welcome'), 200, 200, 200, 200), 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('get_logged_in_user'), 200, 200, 200, 200),
TestSpec(url_for('change_user_details'), TestSpec(url_for('change_user_details'),
401, 200, 200, 200).set_method('PUT'), 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), 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), 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), 201).set_method('POST').set_data_from_obj(SEND_RECOVERY_DETAILS),
TestSpec(url_for('get_matching_users', prefix='dev'), 401, 200, 200, 200), 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), teamname=ORG_READERS, membername=ORG_OWNER),
admin_code=400).set_method('DELETE'), admin_code=400).set_method('DELETE'),
(TestSpec(url_for('create_repo_api')) (TestSpec(url_for('create_repo'))
.set_method('POST') .set_method('POST')
.set_data_from_obj(NEW_ORG_REPO_DETAILS)), .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'), 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_method('PUT')
.set_data_from_obj(UPDATE_REPO_DETAILS)), .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_method('PUT')
.set_data_from_obj(UPDATE_REPO_DETAILS)), .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') admin_code=403).set_method('POST')
.set_data_from_obj(CHANGE_VISIBILITY_DETAILS)), .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)), .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)), .set_method('POST').set_data_from_obj(CHANGE_VISIBILITY_DETAILS)),
TestSpec(url_for('delete_repository', repository=PUBLIC_REPO), TestSpec(url_for('delete_repository', repository=PUBLIC_REPO),
@ -193,11 +193,11 @@ def build_specs():
TestSpec(url_for('delete_repository', repository=PRIVATE_REPO), TestSpec(url_for('delete_repository', repository=PRIVATE_REPO),
admin_code=204).set_method('DELETE'), 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), 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), 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), 403, 403, 200, 200),
TestSpec(url_for('get_repo_builds', repository=PUBLIC_REPO), TestSpec(url_for('get_repo_builds', repository=PUBLIC_REPO),
@ -403,20 +403,20 @@ def build_specs():
TestSpec(url_for('delete_token', repository=PRIVATE_REPO, TestSpec(url_for('delete_token', repository=PRIVATE_REPO,
code=FAKE_TOKEN), admin_code=400).set_method('DELETE'), 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'), 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('get_org_subscription', orgname=ORG)),
TestSpec(url_for('repo_logs_api', repository=PUBLIC_REPO), admin_code=403), TestSpec(url_for('list_repo_logs', repository=PUBLIC_REPO), admin_code=403),
TestSpec(url_for('repo_logs_api', repository=ORG_REPO)), TestSpec(url_for('list_repo_logs', repository=ORG_REPO)),
TestSpec(url_for('repo_logs_api', repository=PRIVATE_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 app import stripe
from collections import defaultdict
EXCLUDE_CID = {'cus_2iVlmwz8CpHgOj'} EXCLUDE_CID = {'cus_2iVlmwz8CpHgOj'}
offset = 0 offset = 0
total_monthly_revenue = 0 total_monthly_revenue = 0
def empty_tuple():
return (0, 0)
plan_revenue = defaultdict(empty_tuple)
batch = stripe.Customer.all(count=100, offset=offset) batch = stripe.Customer.all(count=100, offset=offset)
while batch.data: while batch.data:
for cust in batch.data: for cust in batch.data:
if cust.id not in EXCLUDE_CID and cust.subscription: if cust.id not in EXCLUDE_CID and cust.subscription:
sub = cust.subscription sub = cust.subscription
total_monthly_revenue += sub.plan.amount * sub.quantity 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) offset += len(batch.data)
batch = stripe.Customer.all(count=100, offset=offset) batch = stripe.Customer.all(count=100, offset=offset)
dollars = total_monthly_revenue / 100 def format_money(total_cents):
cents = total_monthly_revenue % 100 dollars = total_cents / 100
print 'Monthly revenue: $%d.%02d' % (dollars, cents) 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 response
return add_max_age return add_max_age
return wrap 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__) 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\' ' + '-privileged -e \'RESOURCE_URL=%s\' -e \'TAG=%s\' ' +
'-e \'TOKEN=%s\' quay.io/quay/buildserver') '-e \'TOKEN=%s\' quay.io/quay/buildserver')
@ -85,7 +86,7 @@ def babysit_builder(request):
api_key=do_api_key, api_key=do_api_key,
name=name, name=name,
region_id=regions.pop(), region_id=regions.pop(),
image_id=1004145, # Docker on 13.04 image_id=app.config['DO_DOCKER_IMAGE'],
size_id=66, # 512MB, size_id=66, # 512MB,
backup_active=False) backup_active=False)
retry_command(droplet.create, [], retry_command(droplet.create, [],
@ -189,7 +190,7 @@ def babysit_builder(request):
retry_command(droplet.destroy) retry_command(droplet.destroy)
repository_build.status_url = None repository_build.status_url = None
repository_build.build_node_id = None; repository_build.build_node_id = None
repository_build.save() repository_build.save()
return True return True